From 67e0e8df410b80db33f27dbd9aca7c98a73e4482 Mon Sep 17 00:00:00 2001 From: agentson Date: Wed, 25 Feb 2026 02:28:42 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20current=5Fprice=3D0=20stop-loss=20?= =?UTF-8?q?=EC=98=A4=EB=B0=9C=EB=8F=99=20=EB=B0=8F=20=ED=95=B4=EC=99=B8=20?= =?UTF-8?q?=EC=A3=BC=EB=AC=B8=20=EC=86=8C=EC=88=98=EC=A0=90=20=EC=B4=88?= =?UTF-8?q?=EA=B3=BC=20=EC=88=98=EC=A0=95=20(#251,=20#252)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. stop-loss/take-profit 가드에 current_price > 0 조건 추가 (#251) - 현재가 API 실패(0.0 반환) 시 loss_pct=-100% 계산으로 오발동되던 문제 수정 - if entry_price > 0 → if entry_price > 0 and current_price > 0 - LLY '주문구분 입력오류'는 이 오발동의 연쇄 결과(overseas_price=0 → ORD_DVSN='01') 2. 해외 주문 가격 소수점을 $1 이상은 2자리로 제한 (#252) - round(x, 4) → $1+ 종목은 round(x, 2), 페니스탁은 round(x, 4) 유지 - KIS '1$이상 소수점 2자리까지만 가능' 오류(TQQQ) 수정 테스트: - test_stop_loss_not_triggered_when_current_price_is_zero 추가 - test_overseas_buy_price_rounded_to_2_decimals_for_dollar_plus_stock 추가 - test_overseas_penny_stock_price_keeps_4_decimals 추가 - 기존 overseas limit price 테스트 expected_price 2자리로 갱신 Co-Authored-By: Claude Sonnet 4.6 --- src/main.py | 9 +- tests/test_main.py | 212 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 216 insertions(+), 5 deletions(-) diff --git a/src/main.py b/src/main.py index daaebd2..2d52ae2 100644 --- a/src/main.py +++ b/src/main.py @@ -730,7 +730,7 @@ async def trading_cycle( open_position = get_open_position(db_conn, stock_code, market.code) if open_position: entry_price = safe_float(open_position.get("price"), 0.0) - if entry_price > 0: + if entry_price > 0 and current_price > 0: loss_pct = (current_price - entry_price) / entry_price * 100 stop_loss_threshold = -2.0 take_profit_threshold = 3.0 @@ -925,10 +925,13 @@ async def trading_cycle( # - SELL: -0.2% below last price — ensures fill even when price dips slightly # (placing at exact last price risks no-fill if the bid is just below). overseas_price: float + # KIS requires at most 2 decimal places for prices >= $1 (≥1달러 소수점 2자리 제한). + # Penny stocks (< $1) keep 4 decimal places to preserve price precision. + _price_decimals = 2 if current_price >= 1.0 else 4 if decision.action == "BUY": - overseas_price = round(current_price * 1.002, 4) + overseas_price = round(current_price * 1.002, _price_decimals) else: - overseas_price = round(current_price * 0.998, 4) + overseas_price = round(current_price * 0.998, _price_decimals) result = await overseas_broker.send_overseas_order( exchange_code=market.exchange_code, stock_code=stock_code, diff --git a/tests/test_main.py b/tests/test_main.py index d6fb3f5..0480c46 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1246,7 +1246,8 @@ class TestOverseasBalanceParsing: mock_overseas_broker_with_buy_scenario.send_overseas_order.assert_called_once() call_kwargs = mock_overseas_broker_with_buy_scenario.send_overseas_order.call_args sent_price = call_kwargs[1].get("price") or call_kwargs[0][4] - expected_price = round(182.5 * 1.002, 4) # 0.2% premium for BUY limit orders + # KIS requires max 2 decimal places for prices >= $1 (#252) + expected_price = round(182.5 * 1.002, 2) # 0.2% premium for BUY limit orders assert sent_price == expected_price, ( f"Expected limit price {expected_price} (182.5 * 1.002) but got {sent_price}. " "BUY uses +0.2% to improve fill rate while minimising overpayment (#211)." @@ -1325,12 +1326,133 @@ class TestOverseasBalanceParsing: overseas_broker.send_overseas_order.assert_called_once() call_kwargs = overseas_broker.send_overseas_order.call_args sent_price = call_kwargs[1].get("price") or call_kwargs[0][4] - expected_price = round(sell_price * 0.998, 4) # -0.2% for SELL limit orders + # KIS requires max 2 decimal places for prices >= $1 (#252) + expected_price = round(sell_price * 0.998, 2) # -0.2% for SELL limit orders assert sent_price == expected_price, ( f"Expected SELL limit price {expected_price} (182.5 * 0.998) but got {sent_price}. " "SELL uses -0.2% to ensure fill even when price dips slightly (#211)." ) + @pytest.mark.asyncio + async def test_overseas_buy_price_rounded_to_2_decimals_for_dollar_plus_stock( + self, + mock_domestic_broker: MagicMock, + mock_playbook: DayPlaybook, + mock_risk: MagicMock, + mock_db: MagicMock, + mock_decision_logger: MagicMock, + mock_context_store: MagicMock, + mock_criticality_assessor: MagicMock, + mock_telegram: MagicMock, + mock_overseas_market: MagicMock, + ) -> None: + """BUY price for $1+ stocks is rounded to 2 decimal places (issue #252). + + KIS rejects prices with more than 2 decimal places for stocks priced >= $1. + current_price=50.1234 * 1.002 = 50.22... should be sent as 50.22, not 50.2236. + """ + overseas_broker = MagicMock() + overseas_broker.get_overseas_balance = AsyncMock( + return_value={ + "output1": [], + "output2": [{"frcr_evlu_tota": "0", "frcr_dncl_amt_2": "10000", "frcr_buy_amt_smtl": "0"}], + } + ) + overseas_broker.get_overseas_price = AsyncMock( + return_value={"output": {"last": "50.1234", "rate": "0"}} + ) + overseas_broker.send_overseas_order = AsyncMock( + return_value={"rt_cd": None, "msg1": "주문접수"} + ) + + db_conn = init_db(":memory:") + decision_logger = DecisionLogger(db_conn) + + engine = MagicMock(spec=ScenarioEngine) + engine.evaluate = MagicMock(return_value=_make_buy_match()) + + await trading_cycle( + broker=mock_domestic_broker, + overseas_broker=overseas_broker, + scenario_engine=engine, + playbook=mock_playbook, + risk=mock_risk, + db_conn=db_conn, + decision_logger=decision_logger, + context_store=mock_context_store, + criticality_assessor=mock_criticality_assessor, + telegram=mock_telegram, + market=mock_overseas_market, + stock_code="TQQQ", + scan_candidates={}, + ) + + overseas_broker.send_overseas_order.assert_called_once() + sent_price = overseas_broker.send_overseas_order.call_args[1].get("price") or \ + overseas_broker.send_overseas_order.call_args[0][4] + # 50.1234 * 1.002 = 50.2235... rounded to 2 decimals = 50.22 + assert sent_price == round(50.1234 * 1.002, 2), ( + f"Expected 2-decimal price {round(50.1234 * 1.002, 2)} but got {sent_price} (#252)" + ) + + @pytest.mark.asyncio + async def test_overseas_penny_stock_price_keeps_4_decimals( + self, + mock_domestic_broker: MagicMock, + mock_playbook: DayPlaybook, + mock_risk: MagicMock, + mock_db: MagicMock, + mock_decision_logger: MagicMock, + mock_context_store: MagicMock, + mock_criticality_assessor: MagicMock, + mock_telegram: MagicMock, + mock_overseas_market: MagicMock, + ) -> None: + """BUY price for penny stocks (< $1) uses 4 decimal places (issue #252).""" + overseas_broker = MagicMock() + overseas_broker.get_overseas_balance = AsyncMock( + return_value={ + "output1": [], + "output2": [{"frcr_evlu_tota": "0", "frcr_dncl_amt_2": "10000", "frcr_buy_amt_smtl": "0"}], + } + ) + overseas_broker.get_overseas_price = AsyncMock( + return_value={"output": {"last": "0.5678", "rate": "0"}} + ) + overseas_broker.send_overseas_order = AsyncMock( + return_value={"rt_cd": None, "msg1": "주문접수"} + ) + + db_conn = init_db(":memory:") + decision_logger = DecisionLogger(db_conn) + + engine = MagicMock(spec=ScenarioEngine) + engine.evaluate = MagicMock(return_value=_make_buy_match()) + + await trading_cycle( + broker=mock_domestic_broker, + overseas_broker=overseas_broker, + scenario_engine=engine, + playbook=mock_playbook, + risk=mock_risk, + db_conn=db_conn, + decision_logger=decision_logger, + context_store=mock_context_store, + criticality_assessor=mock_criticality_assessor, + telegram=mock_telegram, + market=mock_overseas_market, + stock_code="PENNYX", + scan_candidates={}, + ) + + overseas_broker.send_overseas_order.assert_called_once() + sent_price = overseas_broker.send_overseas_order.call_args[1].get("price") or \ + overseas_broker.send_overseas_order.call_args[0][4] + # 0.5678 * 1.002 = 0.56893... rounded to 4 decimals = 0.5689 + assert sent_price == round(0.5678 * 1.002, 4), ( + f"Expected 4-decimal price {round(0.5678 * 1.002, 4)} but got {sent_price} (#252)" + ) + class TestScenarioEngineIntegration: """Test scenario engine integration in trading_cycle.""" @@ -2124,6 +2246,92 @@ 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_stop_loss_not_triggered_when_current_price_is_zero() -> None: + """HOLD must stay HOLD when current_price=0 even if entry_price is set (issue #251). + + A price API failure that returns 0.0 must not cause a false -100% stop-loss. + """ + 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, # valid entry price + market="KR", + exchange_code="KRX", + decision_id=buy_decision_id, + ) + + broker = MagicMock() + # Price API returns 0.0 — simulates API failure or pre-market unavailability + broker.get_current_price = AsyncMock(return_value=(0.0, 0.0, 0.0)) + broker.get_balance = AsyncMock( + return_value={ + "output2": [ + { + "tot_evlu_amt": "100000", + "dnca_tot_amt": "10000", + "pchs_amt_smtl_amt": "90000", + } + ] + } + ) + broker.send_order = AsyncMock(return_value={"msg1": "OK"}) + + 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=MagicMock(evaluate=MagicMock(return_value=_make_hold_match())), + playbook=_make_playbook("KR"), + 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={}, + ) + + # No SELL order must be placed — current_price=0 must suppress stop-loss + 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.