From 1242794fc49389e2841880ee227603cb431dbd0e Mon Sep 17 00:00:00 2001 From: agentson Date: Mon, 23 Feb 2026 05:52:35 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=95=B4=EC=99=B8=EC=A3=BC=EC=8B=9D=20?= =?UTF-8?q?=EB=AF=B8=EC=B2=B4=EA=B2=B0=20SELL=20=EC=8B=9C=20=EC=9D=B4?= =?UTF-8?q?=EC=A4=91=20=EB=A7=A4=EC=88=98=20=EB=B0=A9=EC=A7=80=20(#195)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KIS VTS는 SELL 지정가 주문을 접수 즉시 rt_cd=0으로 반환하지만 실제 체결은 시장가 도달 시까지 지연된다. 이 기간 동안 DB는 포지션을 "종료"로 기록해 다음 사이클에서 이중 매수가 발생할 수 있었다. - trading_cycle(): BUY 게이팅에 브로커 잔고 추가 확인 로직 삽입 - run_daily_session(): 동일 패턴의 BUY 중복 방지 로직 추가 - 두 함수 모두 이미 fetch된 balance_data 재사용 (추가 API 호출 없음) - TestOverseasBrokerIntegration 클래스에 테스트 2개 추가 Co-Authored-By: Claude Sonnet 4.6 --- src/main.py | 35 +++++++++ tests/test_main.py | 182 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 217 insertions(+) diff --git a/src/main.py b/src/main.py index 3a06b13..b91c03d 100644 --- a/src/main.py +++ b/src/main.py @@ -524,6 +524,14 @@ async def trading_cycle( # BUY 결정 전 기존 포지션 체크 (중복 매수 방지) if decision.action == "BUY": existing_position = get_open_position(db_conn, stock_code, market.code) + if not existing_position and not market.is_domestic: + # SELL 지정가 접수 후 미체결 시 DB는 종료로 기록되나 브로커는 여전히 보유 중. + # 이중 매수 방지를 위해 라이브 브로커 잔고를 authoritative source로 사용. + broker_qty = _extract_held_qty_from_balance( + balance_data, stock_code, is_domestic=False + ) + if broker_qty > 0: + existing_position = {"price": 0.0, "quantity": broker_qty} if existing_position: decision = TradeDecision( action="HOLD", @@ -1076,6 +1084,33 @@ async def run_daily_session( decision.confidence, ) + # BUY 중복 방지: 브로커 잔고 기반 (미체결 SELL 리밋 주문 보호) + if decision.action == "BUY": + daily_existing = get_open_position(db_conn, stock_code, market.code) + if not daily_existing and not market.is_domestic: + # SELL 지정가 접수 후 미체결 시 DB는 종료로 기록되나 브로커는 여전히 보유 중. + # 이중 매수 방지를 위해 라이브 브로커 잔고를 authoritative source로 사용. + broker_qty = _extract_held_qty_from_balance( + balance_data, stock_code, is_domestic=False + ) + if broker_qty > 0: + daily_existing = {"price": 0.0, "quantity": broker_qty} + if daily_existing: + decision = TradeDecision( + action="HOLD", + confidence=decision.confidence, + rationale=( + f"Already holding {stock_code} " + f"(entry={daily_existing['price']:.4f}, " + f"qty={daily_existing['quantity']})" + ), + ) + logger.info( + "BUY suppressed for %s (%s): already holding open position", + stock_code, + market.name, + ) + # Log decision context_snapshot = { "L1": { diff --git a/tests/test_main.py b/tests/test_main.py index 76dad55..f00467f 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -3001,3 +3001,185 @@ async def test_buy_proceeds_when_no_open_position() -> None: # 포지션이 없으므로 해외 주문이 실행되어야 함 overseas_broker.send_overseas_order.assert_called_once() + + +class TestOverseasBrokerIntegration: + """Test overseas broker live-balance gating for double-buy prevention. + + Issue #195: KIS VTS SELL limit orders are accepted (rt_cd=0) immediately + but may not fill until the market price reaches the limit. During this window, + the DB records the position as closed, causing the next cycle to BUY again. + These tests verify that live broker balance is used as the authoritative source. + """ + + @pytest.mark.asyncio + async def test_overseas_buy_suppressed_by_broker_balance_when_db_shows_closed( + self, + ) -> None: + """BUY must be suppressed when broker still holds shares even if DB says closed. + + Scenario: SELL limit order was accepted (DB shows closed), but hasn't + filled yet — broker balance still shows 10 AAPL shares. + Expected: send_overseas_order is NOT called. + """ + db_conn = init_db(":memory:") + # DB: BUY then SELL recorded → get_open_position returns None (closed) + log_trade( + conn=db_conn, + stock_code="AAPL", + action="BUY", + confidence=90, + rationale="entry", + quantity=10, + price=180.0, + market="US_NASDAQ", + exchange_code="NASD", + ) + log_trade( + conn=db_conn, + stock_code="AAPL", + action="SELL", + confidence=90, + rationale="sell order accepted", + quantity=10, + price=182.0, + market="US_NASDAQ", + exchange_code="NASD", + ) + + overseas_broker = MagicMock() + overseas_broker.get_overseas_price = AsyncMock( + return_value={"output": {"last": "182.50"}} + ) + # 브로커: 여전히 AAPL 10주 보유 중 (SELL 미체결) + overseas_broker.get_overseas_balance = AsyncMock( + return_value={ + "output1": [{"ovrs_pdno": "AAPL", "ovrs_cblc_qty": "10"}], + "output2": [ + { + "frcr_dncl_amt_2": "50000.00", + "frcr_evlu_tota": "60000.00", + "frcr_buy_amt_smtl": "50000.00", + } + ], + } + ) + overseas_broker.send_overseas_order = AsyncMock(return_value={"msg1": "주문접수"}) + + engine = MagicMock(spec=ScenarioEngine) + engine.evaluate = MagicMock(return_value=_make_buy_match("AAPL")) + + market = MagicMock() + market.name = "NASDAQ" + market.code = "US_NASDAQ" + market.exchange_code = "NASD" + market.is_domestic = False + + telegram = MagicMock() + telegram.notify_trade_execution = AsyncMock() + telegram.notify_fat_finger = AsyncMock() + telegram.notify_circuit_breaker = AsyncMock() + telegram.notify_scenario_matched = AsyncMock() + + decision_logger = MagicMock() + decision_logger.log_decision = MagicMock(return_value="decision-id") + + await trading_cycle( + broker=MagicMock(), + overseas_broker=overseas_broker, + scenario_engine=engine, + playbook=_make_playbook(market="US"), + 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="AAPL", + scan_candidates={}, + ) + + # 브로커 잔고에 보유 중이므로 BUY 주문이 억제되어야 함 (이중 매수 방지) + overseas_broker.send_overseas_order.assert_not_called() + + @pytest.mark.asyncio + async def test_overseas_buy_proceeds_when_broker_shows_no_holding( + self, + ) -> None: + """BUY must proceed when both DB and broker confirm no existing holding. + + Scenario: No prior trades in DB and broker balance shows no AAPL. + Expected: send_overseas_order IS called (normal buy flow). + """ + db_conn = init_db(":memory:") + # DB: 레코드 없음 (신규 포지션) + + overseas_broker = MagicMock() + overseas_broker.get_overseas_price = AsyncMock( + return_value={"output": {"last": "182.50"}} + ) + # 브로커: AAPL 미보유 + overseas_broker.get_overseas_balance = AsyncMock( + return_value={ + "output1": [], + "output2": [ + { + "frcr_dncl_amt_2": "50000.00", + "frcr_evlu_tota": "50000.00", + "frcr_buy_amt_smtl": "0.00", + } + ], + } + ) + overseas_broker.send_overseas_order = AsyncMock(return_value={"msg1": "주문접수"}) + + engine = MagicMock(spec=ScenarioEngine) + engine.evaluate = MagicMock(return_value=_make_buy_match("AAPL")) + + market = MagicMock() + market.name = "NASDAQ" + market.code = "US_NASDAQ" + market.exchange_code = "NASD" + market.is_domestic = False + + telegram = MagicMock() + telegram.notify_trade_execution = AsyncMock() + telegram.notify_fat_finger = AsyncMock() + telegram.notify_circuit_breaker = AsyncMock() + telegram.notify_scenario_matched = AsyncMock() + + decision_logger = MagicMock() + decision_logger.log_decision = MagicMock(return_value="decision-id") + + with patch("src.main.log_trade"): + await trading_cycle( + broker=MagicMock(), + overseas_broker=overseas_broker, + scenario_engine=engine, + playbook=_make_playbook(market="US"), + 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="AAPL", + scan_candidates={}, + ) + + # DB도 브로커도 보유 없음 → BUY 주문이 실행되어야 함 (회귀 테스트) + overseas_broker.send_overseas_order.assert_called_once()