From 7e9a573390722fbde115260f96e9340edc881182 Mon Sep 17 00:00:00 2001 From: agentson Date: Sat, 21 Feb 2026 09:35:39 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20BUY=20=EA=B2=B0=EC=A0=95=20=EC=A0=84=20?= =?UTF-8?q?=EA=B8=B0=EC=A1=B4=20=ED=8F=AC=EC=A7=80=EC=85=98=20=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=20=EC=B6=94=EA=B0=80=20=E2=80=94=20=EC=A4=91=EB=B3=B5?= =?UTF-8?q?=20=EB=A7=A4=EC=88=98=20=EB=B0=A9=EC=A7=80=20(#191)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 어제(2026-02-20) 거래 로그에서 NP 7번, KNRX 5번 중복 매수 발생. trading_cycle()의 BUY 브랜치에 get_open_position() 체크를 추가하여 이미 보유 중인 종목은 HOLD로 전환, 재매수를 차단함. - src/main.py: BUY 결정 직후 기존 포지션 확인 → 있으면 HOLD 변환 - tests/test_main.py: 테스트 2개 추가 - test_buy_suppressed_when_open_position_exists - test_buy_proceeds_when_no_open_position Co-Authored-By: Claude Sonnet 4.6 --- src/main.py | 19 ++++++ tests/test_main.py | 153 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+) diff --git a/src/main.py b/src/main.py index 03eadcb..3399fbe 100644 --- a/src/main.py +++ b/src/main.py @@ -510,6 +510,25 @@ async def trading_cycle( ), ) + # BUY 결정 전 기존 포지션 체크 (중복 매수 방지) + if decision.action == "BUY": + existing_position = get_open_position(db_conn, stock_code, market.code) + if existing_position: + decision = TradeDecision( + action="HOLD", + confidence=decision.confidence, + rationale=( + f"Already holding {stock_code} " + f"(entry={existing_position['price']:.4f}, " + f"qty={existing_position['quantity']})" + ), + ) + logger.info( + "BUY suppressed for %s (%s): already holding open position", + stock_code, + market.name, + ) + if decision.action == "HOLD": open_position = get_open_position(db_conn, stock_code, market.code) if open_position: diff --git a/tests/test_main.py b/tests/test_main.py index 272345e..76dad55 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -2848,3 +2848,156 @@ class TestMarketOutlookConfidenceThreshold: call_args = decision_logger.log_decision.call_args assert call_args is not None assert call_args.kwargs["action"] == "BUY" + + +@pytest.mark.asyncio +async def test_buy_suppressed_when_open_position_exists() -> None: + """BUY should be suppressed when an open position already exists for the stock.""" + db_conn = init_db(":memory:") + decision_logger = DecisionLogger(db_conn) + + # 기존 BUY 포지션 DB에 기록 (중복 매수 상황) + buy_decision_id = decision_logger.log_decision( + stock_code="NP", + market="US", + exchange_code="AMS", + action="BUY", + confidence=90, + rationale="initial entry", + context_snapshot={}, + input_data={}, + ) + log_trade( + conn=db_conn, + stock_code="NP", + action="BUY", + confidence=90, + rationale="initial entry", + quantity=10, + price=50.0, + market="US", + exchange_code="AMS", + decision_id=buy_decision_id, + ) + + broker = MagicMock() + broker.send_order = AsyncMock(return_value={"msg1": "OK"}) + + overseas_broker = MagicMock() + overseas_broker.get_overseas_price = AsyncMock( + return_value={"output": {"last": "51.0", "rate": "2.0", "high": "52.0", "low": "50.0", "tvol": "1000000"}} + ) + overseas_broker.get_overseas_balance = AsyncMock( + return_value={ + "output1": [], + "output2": [{"frcr_dncl_amt_2": "10000", "frcr_evlu_tota": "10000", "frcr_buy_amt_smtl": "0"}], + } + ) + overseas_broker.send_overseas_order = AsyncMock(return_value={"msg1": "OK"}) + + engine = MagicMock(spec=ScenarioEngine) + engine.evaluate = MagicMock(return_value=_make_buy_match(stock_code="NP")) + + market = MagicMock() + market.name = "United States" + market.code = "US" + market.exchange_code = "AMS" + 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() + + await trading_cycle( + broker=broker, + 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="NP", + scan_candidates={}, + ) + + # 이미 보유 중이므로 주문이 실행되지 않아야 함 + broker.send_order.assert_not_called() + overseas_broker.send_overseas_order.assert_not_called() + + +@pytest.mark.asyncio +async def test_buy_proceeds_when_no_open_position() -> None: + """BUY should proceed normally when no open position exists for the stock.""" + db_conn = init_db(":memory:") + decision_logger = DecisionLogger(db_conn) + # DB가 비어있는 상태 — 기존 포지션 없음 + + broker = MagicMock() + broker.send_order = AsyncMock(return_value={"msg1": "OK"}) + + overseas_broker = MagicMock() + overseas_broker.get_overseas_price = AsyncMock( + return_value={"output": {"last": "100.0", "rate": "1.0", "high": "101.0", "low": "99.0", "tvol": "500000"}} + ) + overseas_broker.get_overseas_balance = AsyncMock( + return_value={ + "output1": [], + "output2": [{"frcr_dncl_amt_2": "50000", "frcr_evlu_tota": "50000", "frcr_buy_amt_smtl": "0"}], + } + ) + overseas_broker.send_overseas_order = AsyncMock(return_value={"msg1": "OK"}) + + engine = MagicMock(spec=ScenarioEngine) + engine.evaluate = MagicMock(return_value=_make_buy_match(stock_code="KNRX")) + + market = MagicMock() + market.name = "United States" + market.code = "US" + market.exchange_code = "NAS" + market.is_domestic = False + + risk = MagicMock() + risk.validate_order = MagicMock() + + 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=overseas_broker, + scenario_engine=engine, + playbook=_make_playbook(market="US"), + risk=risk, + 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="KNRX", + scan_candidates={}, + ) + + # 포지션이 없으므로 해외 주문이 실행되어야 함 + overseas_broker.send_overseas_order.assert_called_once()