Compare commits

..

1 Commits

Author SHA1 Message Date
agentson
1adb85926d fix: use actual held quantity for SELL orders instead of hardcoded 1 (#164)
Some checks failed
CI / test (pull_request) Has been cancelled
_determine_order_quantity()에서 SELL 시 항상 1을 반환하던 버그 수정.
DB에서 실제 보유 수량을 조회해 전량 청산이 가능하도록 변경.

- _determine_order_quantity에 open_position 파라미터 추가
- SELL 시 open_position["quantity"] 반환, 포지션 없으면 0 반환
- trading_cycle 및 run_daily_session 호출 지점 모두 수정
- _determine_order_quantity 임포트 및 유닛 테스트 클래스 추가 (6개)
- SELL 실제 수량 사용 통합 테스트 추가 (quantity=5 검증)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 03:03:50 +09:00
2 changed files with 49 additions and 544 deletions

View File

@@ -106,82 +106,6 @@ def _extract_symbol_from_holding(item: dict[str, Any]) -> str:
return ""
def _extract_held_codes_from_balance(
balance_data: dict[str, Any],
*,
is_domestic: bool,
) -> list[str]:
"""Return stock codes with a positive orderable quantity from a balance response.
Uses the broker's live output1 as the source of truth so that partial fills
and manual external trades are always reflected correctly.
"""
output1 = balance_data.get("output1", [])
if isinstance(output1, dict):
output1 = [output1]
if not isinstance(output1, list):
return []
codes: list[str] = []
for holding in output1:
if not isinstance(holding, dict):
continue
code_key = "pdno" if is_domestic else "ovrs_pdno"
code = str(holding.get(code_key, "")).strip().upper()
if not code:
continue
if is_domestic:
qty = int(holding.get("ord_psbl_qty") or holding.get("hldg_qty") or 0)
else:
qty = int(holding.get("ovrs_cblc_qty") or holding.get("hldg_qty") or 0)
if qty > 0:
codes.append(code)
return codes
def _extract_held_qty_from_balance(
balance_data: dict[str, Any],
stock_code: str,
*,
is_domestic: bool,
) -> int:
"""Extract the broker-confirmed orderable quantity for a stock.
Uses the broker's live balance response (output1) as the source of truth
rather than the local DB, because DB records reflect order quantity which
may differ from actual fill quantity due to partial fills.
Domestic fields (VTTC8434R output1):
pdno — 종목코드
ord_psbl_qty — 주문가능수량 (preferred: excludes unsettled)
hldg_qty — 보유수량 (fallback)
Overseas fields (output1):
ovrs_pdno — 종목코드
ovrs_cblc_qty — 해외잔고수량 (preferred)
hldg_qty — 보유수량 (fallback)
"""
output1 = balance_data.get("output1", [])
if isinstance(output1, dict):
output1 = [output1]
if not isinstance(output1, list):
return 0
for holding in output1:
if not isinstance(holding, dict):
continue
code_key = "pdno" if is_domestic else "ovrs_pdno"
held_code = str(holding.get(code_key, "")).strip().upper()
if held_code != stock_code.strip().upper():
continue
if is_domestic:
qty = int(holding.get("ord_psbl_qty") or holding.get("hldg_qty") or 0)
else:
qty = int(holding.get("ovrs_cblc_qty") or holding.get("hldg_qty") or 0)
return qty
return 0
def _determine_order_quantity(
*,
action: str,
@@ -189,40 +113,19 @@ def _determine_order_quantity(
total_cash: float,
candidate: ScanCandidate | None,
settings: Settings | None,
broker_held_qty: int = 0,
playbook_allocation_pct: float | None = None,
scenario_confidence: int = 80,
open_position: dict[str, Any] | None = None,
) -> int:
"""Determine order quantity using volatility-aware position sizing.
Priority:
1. playbook_allocation_pct (AI-specified) scaled by scenario_confidence
2. Fallback: volatility-score-based allocation from scanner candidate
"""
"""Determine order quantity using volatility-aware position sizing."""
if action == "SELL":
return broker_held_qty
if open_position is None:
return 0
return int(open_position.get("quantity") or 0)
if current_price <= 0 or total_cash <= 0:
return 0
if settings is None or not settings.POSITION_SIZING_ENABLED:
return 1
# Use AI-specified allocation_pct if available
if playbook_allocation_pct is not None:
# Confidence scaling: confidence 80 → 1.0x, confidence 95 → 1.19x
confidence_scale = scenario_confidence / 80.0
effective_pct = min(
settings.POSITION_MAX_ALLOCATION_PCT,
max(
settings.POSITION_MIN_ALLOCATION_PCT,
playbook_allocation_pct * confidence_scale,
),
)
budget = total_cash * (effective_pct / 100.0)
quantity = int(budget // current_price)
return max(0, quantity)
# Fallback: volatility-score-based allocation
target_score = max(1.0, settings.POSITION_VOLATILITY_TARGET_SCORE)
observed_score = candidate.score if candidate else target_score
observed_score = max(1.0, min(100.0, observed_score))
@@ -487,10 +390,8 @@ async def trading_cycle(
if entry_price > 0:
loss_pct = (current_price - entry_price) / entry_price * 100
stop_loss_threshold = -2.0
take_profit_threshold = 3.0
if stock_playbook and stock_playbook.scenarios:
stop_loss_threshold = stock_playbook.scenarios[0].stop_loss_pct
take_profit_threshold = stock_playbook.scenarios[0].take_profit_pct
if loss_pct <= stop_loss_threshold:
decision = TradeDecision(
@@ -508,22 +409,6 @@ async def trading_cycle(
loss_pct,
stop_loss_threshold,
)
elif loss_pct >= take_profit_threshold:
decision = TradeDecision(
action="SELL",
confidence=90,
rationale=(
f"Take-profit triggered ({loss_pct:.2f}% >= "
f"{take_profit_threshold:.2f}%)"
),
)
logger.info(
"Take-profit override for %s (%s): %.2f%% >= %.2f%%",
stock_code,
market.name,
loss_pct,
take_profit_threshold,
)
logger.info(
"Decision for %s (%s): %s (confidence=%d)",
stock_code,
@@ -584,23 +469,18 @@ async def trading_cycle(
trade_price = current_price
trade_pnl = 0.0
if decision.action in ("BUY", "SELL"):
broker_held_qty = (
_extract_held_qty_from_balance(
balance_data, stock_code, is_domestic=market.is_domestic
)
sell_position = (
get_open_position(db_conn, stock_code, market.code)
if decision.action == "SELL"
else 0
else None
)
matched_scenario = match.matched_scenario
quantity = _determine_order_quantity(
action=decision.action,
current_price=current_price,
total_cash=total_cash,
candidate=candidate,
settings=settings,
broker_held_qty=broker_held_qty,
playbook_allocation_pct=matched_scenario.allocation_pct if matched_scenario else None,
scenario_confidence=match.confidence,
open_position=sell_position,
)
if quantity <= 0:
logger.info(
@@ -1020,12 +900,10 @@ async def run_daily_session(
trade_pnl = 0.0
order_succeeded = True
if decision.action in ("BUY", "SELL"):
daily_broker_held_qty = (
_extract_held_qty_from_balance(
balance_data, stock_code, is_domestic=market.is_domestic
)
daily_sell_position = (
get_open_position(db_conn, stock_code, market.code)
if decision.action == "SELL"
else 0
else None
)
quantity = _determine_order_quantity(
action=decision.action,
@@ -1033,7 +911,7 @@ async def run_daily_session(
total_cash=total_cash,
candidate=candidate_map.get(stock_code),
settings=settings,
broker_held_qty=daily_broker_held_qty,
open_position=daily_sell_position,
)
if quantity <= 0:
logger.info(
@@ -2000,38 +1878,8 @@ async def run(settings: Settings) -> None:
except Exception as exc:
logger.error("Smart Scanner failed for %s: %s", market.name, exc)
# Get active stocks from scanner (dynamic, no static fallback).
# Also include currently-held positions so stop-loss /
# take-profit can fire even when a holding drops off the
# scanner. Broker balance is the source of truth here —
# unlike the local DB it reflects actual fills and any
# manual trades done outside the bot.
scanner_codes = active_stocks.get(market.code, [])
try:
if market.is_domestic:
held_balance = await broker.get_balance()
else:
held_balance = await overseas_broker.get_overseas_balance(
market.exchange_code
)
held_codes = _extract_held_codes_from_balance(
held_balance, is_domestic=market.is_domestic
)
except Exception as exc:
logger.warning(
"Failed to fetch holdings for %s: %s — skipping holdings merge",
market.name, exc,
)
held_codes = []
stock_codes = list(dict.fromkeys(scanner_codes + held_codes))
extra_held = [c for c in held_codes if c not in set(scanner_codes)]
if extra_held:
logger.info(
"Holdings added to loop for %s (not in scanner): %s",
market.name, extra_held,
)
# Get active stocks from scanner (dynamic, no static fallback)
stock_codes = active_stocks.get(market.code, [])
if not stock_codes:
logger.debug("No active stocks for market %s", market.code)
continue

View File

@@ -15,8 +15,6 @@ from src.logging.decision_logger import DecisionLogger
from src.main import (
_apply_dashboard_flag,
_determine_order_quantity,
_extract_held_codes_from_balance,
_extract_held_qty_from_balance,
_handle_market_close,
_run_context_scheduler,
_run_evolution_loop,
@@ -71,104 +69,49 @@ def _make_sell_match(stock_code: str = "005930") -> ScenarioMatch:
)
class TestExtractHeldQtyFromBalance:
"""Tests for _extract_held_qty_from_balance()."""
def _domestic_balance(self, stock_code: str, ord_psbl_qty: int) -> dict:
return {
"output1": [{"pdno": stock_code, "ord_psbl_qty": str(ord_psbl_qty)}],
"output2": [{"dnca_tot_amt": "1000000"}],
}
def test_domestic_returns_ord_psbl_qty(self) -> None:
balance = self._domestic_balance("005930", 7)
assert _extract_held_qty_from_balance(balance, "005930", is_domestic=True) == 7
def test_domestic_fallback_to_hldg_qty(self) -> None:
balance = {"output1": [{"pdno": "005930", "hldg_qty": "3"}]}
assert _extract_held_qty_from_balance(balance, "005930", is_domestic=True) == 3
def test_domestic_returns_zero_when_not_found(self) -> None:
balance = self._domestic_balance("005930", 5)
assert _extract_held_qty_from_balance(balance, "000660", is_domestic=True) == 0
def test_domestic_returns_zero_when_output1_empty(self) -> None:
balance = {"output1": [], "output2": [{}]}
assert _extract_held_qty_from_balance(balance, "005930", is_domestic=True) == 0
def test_overseas_returns_ovrs_cblc_qty(self) -> None:
balance = {"output1": [{"ovrs_pdno": "AAPL", "ovrs_cblc_qty": "10"}]}
assert _extract_held_qty_from_balance(balance, "AAPL", is_domestic=False) == 10
def test_overseas_fallback_to_hldg_qty(self) -> None:
balance = {"output1": [{"ovrs_pdno": "AAPL", "hldg_qty": "4"}]}
assert _extract_held_qty_from_balance(balance, "AAPL", is_domestic=False) == 4
def test_case_insensitive_match(self) -> None:
balance = {"output1": [{"pdno": "005930", "ord_psbl_qty": "2"}]}
assert _extract_held_qty_from_balance(balance, "005930", is_domestic=True) == 2
class TestExtractHeldCodesFromBalance:
"""Tests for _extract_held_codes_from_balance()."""
def test_returns_codes_with_positive_qty(self) -> None:
balance = {
"output1": [
{"pdno": "005930", "ord_psbl_qty": "5"},
{"pdno": "000660", "ord_psbl_qty": "3"},
]
}
result = _extract_held_codes_from_balance(balance, is_domestic=True)
assert set(result) == {"005930", "000660"}
def test_excludes_zero_qty_holdings(self) -> None:
balance = {
"output1": [
{"pdno": "005930", "ord_psbl_qty": "0"},
{"pdno": "000660", "ord_psbl_qty": "2"},
]
}
result = _extract_held_codes_from_balance(balance, is_domestic=True)
assert "005930" not in result
assert "000660" in result
def test_returns_empty_when_output1_missing(self) -> None:
balance: dict = {}
assert _extract_held_codes_from_balance(balance, is_domestic=True) == []
def test_overseas_uses_ovrs_pdno(self) -> None:
balance = {"output1": [{"ovrs_pdno": "AAPL", "ovrs_cblc_qty": "3"}]}
result = _extract_held_codes_from_balance(balance, is_domestic=False)
assert result == ["AAPL"]
class TestDetermineOrderQuantity:
"""Test _determine_order_quantity() — SELL uses broker_held_qty."""
"""Test _determine_order_quantity() helper function."""
def test_sell_returns_broker_held_qty(self) -> None:
def test_sell_returns_position_quantity(self) -> None:
"""SELL action should return actual held quantity from open_position."""
open_pos = {"decision_id": "abc", "price": 100.0, "quantity": 7}
result = _determine_order_quantity(
action="SELL",
current_price=105.0,
total_cash=50000.0,
candidate=None,
settings=None,
broker_held_qty=7,
open_position=open_pos,
)
assert result == 7
def test_sell_returns_zero_when_broker_qty_zero(self) -> None:
def test_sell_without_position_returns_zero(self) -> None:
"""SELL with no open_position should return 0 (no shares to sell)."""
result = _determine_order_quantity(
action="SELL",
current_price=105.0,
total_cash=50000.0,
candidate=None,
settings=None,
broker_held_qty=0,
open_position=None,
)
assert result == 0
def test_sell_with_zero_quantity_returns_zero(self) -> None:
"""SELL with position quantity=0 should return 0."""
open_pos = {"decision_id": "abc", "price": 100.0, "quantity": 0}
result = _determine_order_quantity(
action="SELL",
current_price=105.0,
total_cash=50000.0,
candidate=None,
settings=None,
open_position=open_pos,
)
assert result == 0
def test_buy_without_position_sizing_returns_one(self) -> None:
"""BUY with no settings should return 1 (default)."""
result = _determine_order_quantity(
action="BUY",
current_price=50000.0,
@@ -179,6 +122,7 @@ class TestDetermineOrderQuantity:
assert result == 1
def test_buy_with_zero_cash_returns_zero(self) -> None:
"""BUY with no cash should return 0."""
result = _determine_order_quantity(
action="BUY",
current_price=50000.0,
@@ -189,97 +133,22 @@ class TestDetermineOrderQuantity:
assert result == 0
def test_buy_with_position_sizing_calculates_correctly(self) -> None:
"""BUY with position sizing should calculate quantity from budget."""
settings = MagicMock(spec=Settings)
settings.POSITION_SIZING_ENABLED = True
settings.POSITION_VOLATILITY_TARGET_SCORE = 50.0
settings.POSITION_BASE_ALLOCATION_PCT = 10.0
settings.POSITION_MAX_ALLOCATION_PCT = 30.0
settings.POSITION_MIN_ALLOCATION_PCT = 1.0
# 1,000,000 * 10% = 100,000 budget // 50,000 price = 2 shares
result = _determine_order_quantity(
action="BUY",
current_price=50000.0,
total_cash=1000000.0,
candidate=None,
settings=settings,
)
assert result == 2
def test_determine_order_quantity_uses_playbook_allocation_pct(self) -> None:
"""playbook_allocation_pct should take priority over volatility-based sizing."""
settings = MagicMock(spec=Settings)
settings.POSITION_SIZING_ENABLED = True
settings.POSITION_MAX_ALLOCATION_PCT = 30.0
settings.POSITION_MIN_ALLOCATION_PCT = 1.0
# playbook says 20%, confidence 80 → scale=1.0 → 20%
# 1,000,000 * 20% = 200,000 // 50,000 price = 4 shares
# total_cash=1,000,000 * 10% = 100,000 budget
# 100,000 // 50,000 = 2 shares
result = _determine_order_quantity(
action="BUY",
current_price=50000.0,
total_cash=1000000.0,
candidate=None,
settings=settings,
playbook_allocation_pct=20.0,
scenario_confidence=80,
)
assert result == 4
def test_determine_order_quantity_confidence_scales_allocation(self) -> None:
"""Higher confidence should produce a larger allocation (up to max)."""
settings = MagicMock(spec=Settings)
settings.POSITION_SIZING_ENABLED = True
settings.POSITION_MAX_ALLOCATION_PCT = 30.0
settings.POSITION_MIN_ALLOCATION_PCT = 1.0
# confidence 96 → scale=1.2 → 10% * 1.2 = 12%
# 1,000,000 * 12% = 120,000 // 50,000 price = 2 shares
result = _determine_order_quantity(
action="BUY",
current_price=50000.0,
total_cash=1000000.0,
candidate=None,
settings=settings,
playbook_allocation_pct=10.0,
scenario_confidence=96,
)
# scale = 96/80 = 1.2 → effective_pct = 12.0
# budget = 1_000_000 * 0.12 = 120_000 → qty = 120_000 // 50_000 = 2
assert result == 2
def test_determine_order_quantity_confidence_clamped_to_max(self) -> None:
"""Confidence scaling should not exceed POSITION_MAX_ALLOCATION_PCT."""
settings = MagicMock(spec=Settings)
settings.POSITION_SIZING_ENABLED = True
settings.POSITION_MAX_ALLOCATION_PCT = 15.0
settings.POSITION_MIN_ALLOCATION_PCT = 1.0
# playbook 20% * scale 1.5 = 30% → clamped to 15%
# 1,000,000 * 15% = 150,000 // 50,000 price = 3 shares
result = _determine_order_quantity(
action="BUY",
current_price=50000.0,
total_cash=1000000.0,
candidate=None,
settings=settings,
playbook_allocation_pct=20.0,
scenario_confidence=120, # extreme → scale = 1.5
)
assert result == 3
def test_determine_order_quantity_fallback_when_no_playbook(self) -> None:
"""Without playbook_allocation_pct, falls back to volatility-based sizing."""
settings = MagicMock(spec=Settings)
settings.POSITION_SIZING_ENABLED = True
settings.POSITION_VOLATILITY_TARGET_SCORE = 50.0
settings.POSITION_BASE_ALLOCATION_PCT = 10.0
settings.POSITION_MAX_ALLOCATION_PCT = 30.0
settings.POSITION_MIN_ALLOCATION_PCT = 1.0
# Same as test_buy_with_position_sizing_calculates_correctly (no playbook)
result = _determine_order_quantity(
action="BUY",
current_price=50000.0,
total_cash=1000000.0,
candidate=None,
settings=settings,
playbook_allocation_pct=None, # explicit None → fallback
)
assert result == 2
@@ -1456,14 +1325,13 @@ async def test_sell_updates_original_buy_decision_outcome() -> None:
broker.get_current_price = AsyncMock(return_value=(120.0, 0.0, 0.0))
broker.get_balance = AsyncMock(
return_value={
"output1": [{"pdno": "005930", "ord_psbl_qty": "1"}],
"output2": [
{
"tot_evlu_amt": "100000",
"dnca_tot_amt": "10000",
"pchs_amt_smtl_amt": "90000",
}
],
]
}
)
broker.send_order = AsyncMock(return_value={"msg1": "OK"})
@@ -1547,14 +1415,13 @@ async def test_hold_overridden_to_sell_when_stop_loss_triggered() -> None:
broker.get_current_price = AsyncMock(return_value=(95.0, -5.0, 0.0))
broker.get_balance = AsyncMock(
return_value={
"output1": [{"pdno": "005930", "ord_psbl_qty": "1"}],
"output2": [
{
"tot_evlu_amt": "100000",
"dnca_tot_amt": "10000",
"pchs_amt_smtl_amt": "90000",
}
],
]
}
)
broker.send_order = AsyncMock(return_value={"msg1": "OK"})
@@ -1615,8 +1482,8 @@ async def test_hold_overridden_to_sell_when_stop_loss_triggered() -> None:
@pytest.mark.asyncio
async def test_hold_overridden_to_sell_when_take_profit_triggered() -> None:
"""HOLD decision should be overridden to SELL when take-profit threshold is reached."""
async def test_sell_order_uses_actual_held_quantity() -> None:
"""SELL order should use the actual quantity held, not hardcoded 1."""
db_conn = init_db(":memory:")
decision_logger = DecisionLogger(db_conn)
@@ -1630,13 +1497,14 @@ async def test_hold_overridden_to_sell_when_take_profit_triggered() -> None:
context_snapshot={},
input_data={},
)
# Bought 5 shares at 100.0
log_trade(
conn=db_conn,
stock_code="005930",
action="BUY",
confidence=90,
rationale="entry",
quantity=1,
quantity=5,
price=100.0,
market="KR",
exchange_code="KRX",
@@ -1644,110 +1512,7 @@ async def test_hold_overridden_to_sell_when_take_profit_triggered() -> None:
)
broker = MagicMock()
# Current price 106.0 → +6% gain, above take_profit_pct=3.0
broker.get_current_price = AsyncMock(return_value=(106.0, 6.0, 0.0))
broker.get_balance = AsyncMock(
return_value={
"output1": [{"pdno": "005930", "ord_psbl_qty": "1"}],
"output2": [
{
"tot_evlu_amt": "100000",
"dnca_tot_amt": "10000",
"pchs_amt_smtl_amt": "90000",
}
],
}
)
broker.send_order = AsyncMock(return_value={"msg1": "OK"})
scenario = StockScenario(
condition=StockCondition(rsi_below=30),
action=ScenarioAction.BUY,
confidence=88,
stop_loss_pct=-2.0,
take_profit_pct=3.0,
rationale="take profit policy",
)
playbook = DayPlaybook(
date=date(2026, 2, 8),
market="KR",
stock_playbooks=[
{"stock_code": "005930", "stock_name": "Samsung", "scenarios": [scenario]}
],
)
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=_make_hold_match())
market = MagicMock()
market.name = "Korea"
market.code = "KR"
market.exchange_code = "KRX"
market.is_domestic = True
telegram = MagicMock()
telegram.notify_trade_execution = AsyncMock()
telegram.notify_fat_finger = AsyncMock()
telegram.notify_circuit_breaker = AsyncMock()
telegram.notify_scenario_matched = AsyncMock()
await trading_cycle(
broker=broker,
overseas_broker=MagicMock(),
scenario_engine=engine,
playbook=playbook,
risk=MagicMock(),
db_conn=db_conn,
decision_logger=decision_logger,
context_store=MagicMock(
get_latest_timeframe=MagicMock(return_value=None),
set_context=MagicMock(),
),
criticality_assessor=MagicMock(
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
get_timeout=MagicMock(return_value=5.0),
),
telegram=telegram,
market=market,
stock_code="005930",
scan_candidates={},
)
broker.send_order.assert_called_once()
assert broker.send_order.call_args.kwargs["order_type"] == "SELL"
@pytest.mark.asyncio
async def test_hold_not_overridden_when_between_stop_loss_and_take_profit() -> None:
"""HOLD should remain HOLD when P&L is within stop-loss and take-profit bounds."""
db_conn = init_db(":memory:")
decision_logger = DecisionLogger(db_conn)
buy_decision_id = decision_logger.log_decision(
stock_code="005930",
market="KR",
exchange_code="KRX",
action="BUY",
confidence=90,
rationale="entry",
context_snapshot={},
input_data={},
)
log_trade(
conn=db_conn,
stock_code="005930",
action="BUY",
confidence=90,
rationale="entry",
quantity=1,
price=100.0,
market="KR",
exchange_code="KRX",
decision_id=buy_decision_id,
)
broker = MagicMock()
# Current price 101.0 → +1% gain, within [-2%, +3%] range
broker.get_current_price = AsyncMock(return_value=(101.0, 1.0, 0.0))
broker.get_current_price = AsyncMock(return_value=(95.0, -5.0, 0.0))
broker.get_balance = AsyncMock(
return_value={
"output2": [
@@ -1761,113 +1526,6 @@ async def test_hold_not_overridden_when_between_stop_loss_and_take_profit() -> N
)
broker.send_order = AsyncMock(return_value={"msg1": "OK"})
scenario = StockScenario(
condition=StockCondition(rsi_below=30),
action=ScenarioAction.BUY,
confidence=88,
stop_loss_pct=-2.0,
take_profit_pct=3.0,
rationale="within range policy",
)
playbook = DayPlaybook(
date=date(2026, 2, 8),
market="KR",
stock_playbooks=[
{"stock_code": "005930", "stock_name": "Samsung", "scenarios": [scenario]}
],
)
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=_make_hold_match())
market = MagicMock()
market.name = "Korea"
market.code = "KR"
market.exchange_code = "KRX"
market.is_domestic = True
telegram = MagicMock()
telegram.notify_trade_execution = AsyncMock()
telegram.notify_fat_finger = AsyncMock()
telegram.notify_circuit_breaker = AsyncMock()
telegram.notify_scenario_matched = AsyncMock()
await trading_cycle(
broker=broker,
overseas_broker=MagicMock(),
scenario_engine=engine,
playbook=playbook,
risk=MagicMock(),
db_conn=db_conn,
decision_logger=decision_logger,
context_store=MagicMock(
get_latest_timeframe=MagicMock(return_value=None),
set_context=MagicMock(),
),
criticality_assessor=MagicMock(
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
get_timeout=MagicMock(return_value=5.0),
),
telegram=telegram,
market=market,
stock_code="005930",
scan_candidates={},
)
broker.send_order.assert_not_called()
@pytest.mark.asyncio
async def test_sell_order_uses_broker_balance_qty_not_db() -> None:
"""SELL quantity must come from broker balance output1, not DB.
The DB records order quantity which may differ from actual fill quantity.
This test verifies that we use the broker-confirmed orderable quantity.
"""
db_conn = init_db(":memory:")
decision_logger = DecisionLogger(db_conn)
buy_decision_id = decision_logger.log_decision(
stock_code="005930",
market="KR",
exchange_code="KRX",
action="BUY",
confidence=90,
rationale="entry",
context_snapshot={},
input_data={},
)
# DB records 10 shares ordered — but only 5 actually filled (partial fill scenario)
log_trade(
conn=db_conn,
stock_code="005930",
action="BUY",
confidence=90,
rationale="entry",
quantity=10, # ordered quantity (may differ from fill)
price=100.0,
market="KR",
exchange_code="KRX",
decision_id=buy_decision_id,
)
broker = MagicMock()
# Stop-loss triggers (price dropped below -2%)
broker.get_current_price = AsyncMock(return_value=(95.0, -5.0, 0.0))
broker.get_balance = AsyncMock(
return_value={
# Broker confirms only 5 shares are actually orderable (partial fill)
"output1": [{"pdno": "005930", "ord_psbl_qty": "5"}],
"output2": [
{
"tot_evlu_amt": "100000",
"dnca_tot_amt": "10000",
"pchs_amt_smtl_amt": "90000",
}
],
}
)
broker.send_order = AsyncMock(return_value={"msg1": "OK"})
scenario = StockScenario(
condition=StockCondition(rsi_below=30),
action=ScenarioAction.BUY,
@@ -1922,8 +1580,7 @@ async def test_sell_order_uses_broker_balance_qty_not_db() -> None:
broker.send_order.assert_called_once()
call_kwargs = broker.send_order.call_args.kwargs
assert call_kwargs["order_type"] == "SELL"
# Must use broker-confirmed qty (5), NOT DB-recorded ordered qty (10)
assert call_kwargs["quantity"] == 5
assert call_kwargs["quantity"] == 5 # actual held quantity, not 1
@pytest.mark.asyncio