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()