From 305120f599c58dcc3f12d65cba373397a139a69e Mon Sep 17 00:00:00 2001 From: agentson Date: Fri, 20 Feb 2026 07:40:45 +0900 Subject: [PATCH] fix: use broker balance API as source of truth for SELL qty and holdings (#164 #165) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DB의 주문 수량 기록은 실제 체결 수량과 다를 수 있음(부분 체결, 외부 수동 거래). 브로커 잔고 API(output1)를 source of truth로 사용하도록 수정. ## 변경 사항 ### SELL 수량 (#164) - _extract_held_qty_from_balance() 추가 - 국내: output1의 ord_psbl_qty (→ hldg_qty fallback) - 해외: output1의 ovrs_cblc_qty (→ hldg_qty fallback) - _determine_order_quantity()에 broker_held_qty 파라미터 추가 - SELL 시 broker_held_qty 반환 (0이면 주문 스킵) - trading_cycle / run_daily_session 양쪽 호출 지점 수정 - 이미 fetch된 balance_data에서 수량 추출 (추가 API 호출 없음) ### 보유 종목 루프 (#165) - _extract_held_codes_from_balance() 추가 - ord_psbl_qty > 0인 종목 코드 목록 반환 - 실시간 루프에서 스캔 시점에 get_balance() 호출해 보유 종목 병합 - 스캐너 후보 + 실제 보유 종목 union으로 trading_cycle 순회 - 실패 시 경고 로그 후 스캐너 후보만으로 계속 진행 ### 테스트 - TestExtractHeldQtyFromBalance: 7개 (국내/해외/fallback/미보유) - TestExtractHeldCodesFromBalance: 4개 (qty>0 포함, qty=0 제외 등) - TestDetermineOrderQuantity: 5개 (SELL qty, BUY sizing) - test_sell_order_uses_broker_balance_qty_not_db: DB 10주 기록 vs 브로커 5주 확인 → 브로커 값(5) 사용 검증 - 기존 SELL/stop-loss/take-profit 테스트에 output1 mock 추가 Co-Authored-By: Claude Sonnet 4.6 --- src/main.py | 131 ++++++++++++++++++++++- tests/test_main.py | 257 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 381 insertions(+), 7 deletions(-) diff --git a/src/main.py b/src/main.py index d138ff0..58bd045 100644 --- a/src/main.py +++ b/src/main.py @@ -106,6 +106,82 @@ 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, @@ -113,10 +189,11 @@ def _determine_order_quantity( total_cash: float, candidate: ScanCandidate | None, settings: Settings | None, + broker_held_qty: int = 0, ) -> int: """Determine order quantity using volatility-aware position sizing.""" - if action != "BUY": - return 1 + if action == "SELL": + return broker_held_qty if current_price <= 0 or total_cash <= 0: return 0 @@ -484,12 +561,20 @@ 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 + ) + if decision.action == "SELL" + else 0 + ) 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, ) if quantity <= 0: logger.info( @@ -909,12 +994,20 @@ 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 + ) + if decision.action == "SELL" + else 0 + ) quantity = _determine_order_quantity( action=decision.action, current_price=stock_data["current_price"], total_cash=total_cash, candidate=candidate_map.get(stock_code), settings=settings, + broker_held_qty=daily_broker_held_qty, ) if quantity <= 0: logger.info( @@ -1881,8 +1974,38 @@ 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) - stock_codes = active_stocks.get(market.code, []) + # 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, + ) + if not stock_codes: logger.debug("No active stocks for market %s", market.code) continue diff --git a/tests/test_main.py b/tests/test_main.py index 36ef390..ef95c14 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -14,6 +14,9 @@ from src.evolution.scorecard import DailyScorecard 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, @@ -68,6 +71,141 @@ 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.""" + + def test_sell_returns_broker_held_qty(self) -> None: + result = _determine_order_quantity( + action="SELL", + current_price=105.0, + total_cash=50000.0, + candidate=None, + settings=None, + broker_held_qty=7, + ) + assert result == 7 + + def test_sell_returns_zero_when_broker_qty_zero(self) -> None: + result = _determine_order_quantity( + action="SELL", + current_price=105.0, + total_cash=50000.0, + candidate=None, + settings=None, + broker_held_qty=0, + ) + assert result == 0 + + def test_buy_without_position_sizing_returns_one(self) -> None: + result = _determine_order_quantity( + action="BUY", + current_price=50000.0, + total_cash=1000000.0, + candidate=None, + settings=None, + ) + assert result == 1 + + def test_buy_with_zero_cash_returns_zero(self) -> None: + result = _determine_order_quantity( + action="BUY", + current_price=50000.0, + total_cash=0.0, + candidate=None, + settings=None, + ) + assert result == 0 + + def test_buy_with_position_sizing_calculates_correctly(self) -> None: + 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 + + class TestSafeFloat: """Test safe_float() helper function.""" @@ -1240,13 +1378,14 @@ 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"}) @@ -1330,13 +1469,14 @@ 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"}) @@ -1430,13 +1570,14 @@ async def test_hold_overridden_to_sell_when_take_profit_triggered() -> None: 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"}) @@ -1597,6 +1738,116 @@ async def test_hold_not_overridden_when_between_stop_loss_and_take_profit() -> N 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, + confidence=88, + stop_loss_pct=-2.0, + rationale="stop loss 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() + 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 + + @pytest.mark.asyncio async def test_handle_market_close_runs_daily_review_flow() -> None: """Market close should aggregate, create scorecard, lessons, and notify."""