From 4da22b10eb15a3af9e8b81b32d5403cc15f5c50f Mon Sep 17 00:00:00 2001 From: agentson Date: Sat, 21 Feb 2026 00:32:11 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20SELL=20=EC=A3=BC=EB=AC=B8=EC=97=90?= =?UTF-8?q?=EC=84=9C=20Fat=20Finger=20=EC=98=A4=ED=83=90=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=E2=80=94=20=EC=86=90=EC=A0=88/=EC=9D=B5=EC=A0=88?= =?UTF-8?q?=20=EC=B0=A8=EB=8B=A8=20=EB=B2=84=EA=B7=B8=20(#187)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SELL 주문은 현금을 소비하지 않고 받는 것이므로 Fat Finger 체크 대상이 아님. 포지션 가치가 잔여 현금의 30%를 초과해도 SELL은 정상 실행돼야 함. - realtime/daily 사이클 두 곳 모두 수정 - SELL: check_circuit_breaker만 호출 (Fat Finger 스킵) - BUY: 기존대로 validate_order 호출 (Fat Finger + Circuit Breaker) - 테스트 2개 추가: SELL Fat Finger 스킵, SELL 서킷브레이커 적용 확인 재현 사례 (2026-02-21): JELD stop-loss -6.20% → FAT FINGER: 49,548 is 99.1% of cash 50,000 RXT take-profit +46.13% → FAT FINGER: 88,676 is 177.4% of cash 50,000 Co-Authored-By: Claude Sonnet 4.6 --- src/main.py | 30 ++++++++---- tests/test_main.py | 113 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 10 deletions(-) diff --git a/src/main.py b/src/main.py index 95b3c06..03eadcb 100644 --- a/src/main.py +++ b/src/main.py @@ -660,12 +660,17 @@ async def trading_cycle( return # 5a. Risk check BEFORE order + # SELL orders do not consume cash (they receive it), so fat-finger check + # is skipped for SELLs — only circuit breaker applies. try: - risk.validate_order( - current_pnl_pct=pnl_pct, - order_amount=order_amount, - total_cash=total_cash, - ) + if decision.action == "SELL": + risk.check_circuit_breaker(pnl_pct) + else: + risk.validate_order( + current_pnl_pct=pnl_pct, + order_amount=order_amount, + total_cash=total_cash, + ) except FatFingerRejected as exc: try: await telegram.notify_fat_finger( @@ -1123,12 +1128,17 @@ async def run_daily_session( continue # Risk check + # SELL orders do not consume cash (they receive it), so fat-finger + # check is skipped for SELLs — only circuit breaker applies. try: - risk.validate_order( - current_pnl_pct=pnl_pct, - order_amount=order_amount, - total_cash=total_cash, - ) + if decision.action == "SELL": + risk.check_circuit_breaker(pnl_pct) + else: + risk.validate_order( + current_pnl_pct=pnl_pct, + order_amount=order_amount, + total_cash=total_cash, + ) except FatFingerRejected as exc: try: await telegram.notify_fat_finger( diff --git a/tests/test_main.py b/tests/test_main.py index 2e009ae..272345e 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -631,6 +631,119 @@ class TestTradingCycleTelegramIntegration: # Verify no trade notification sent mock_telegram.notify_trade_execution.assert_not_called() + @pytest.mark.asyncio + async def test_sell_skips_fat_finger_check( + self, + mock_broker: MagicMock, + mock_overseas_broker: MagicMock, + mock_scenario_engine: 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_market: MagicMock, + ) -> None: + """SELL orders must not be blocked by fat-finger check. + + Even if position value > 30% of cash (e.g. stop-loss on a large holding + with low remaining cash), the SELL should proceed — only circuit breaker + applies to SELLs. + """ + # SELL decision with held qty=100 shares @ 50,000 = 5,000,000 + # cash = 5,000,000 → ratio = 100% which would normally trigger fat finger + mock_scenario_engine.evaluate = MagicMock(return_value=_make_sell_match()) + mock_broker.get_balance = AsyncMock( + return_value={ + "output1": [{"pdno": "005930", "ord_psbl_qty": "100"}], + "output2": [ + { + "tot_evlu_amt": "10000000", + "dnca_tot_amt": "5000000", + "pchs_amt_smtl_amt": "5000000", + } + ], + } + ) + + with patch("src.main.log_trade"): + await trading_cycle( + broker=mock_broker, + overseas_broker=mock_overseas_broker, + scenario_engine=mock_scenario_engine, + playbook=mock_playbook, + risk=mock_risk, + db_conn=mock_db, + decision_logger=mock_decision_logger, + context_store=mock_context_store, + criticality_assessor=mock_criticality_assessor, + telegram=mock_telegram, + market=mock_market, + stock_code="005930", + scan_candidates={}, + ) + + # validate_order (which includes fat finger) must NOT be called for SELL + mock_risk.validate_order.assert_not_called() + # check_circuit_breaker MUST be called for SELL + mock_risk.check_circuit_breaker.assert_called_once() + + @pytest.mark.asyncio + async def test_sell_circuit_breaker_still_applies( + self, + mock_broker: MagicMock, + mock_overseas_broker: MagicMock, + mock_scenario_engine: 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_market: MagicMock, + ) -> None: + """SELL orders must still respect the circuit breaker.""" + mock_scenario_engine.evaluate = MagicMock(return_value=_make_sell_match()) + mock_broker.get_balance = AsyncMock( + return_value={ + "output1": [{"pdno": "005930", "ord_psbl_qty": "100"}], + "output2": [ + { + "tot_evlu_amt": "10000000", + "dnca_tot_amt": "5000000", + "pchs_amt_smtl_amt": "5000000", + } + ], + } + ) + mock_risk.check_circuit_breaker.side_effect = CircuitBreakerTripped( + pnl_pct=-4.0, threshold=-3.0 + ) + + with patch("src.main.log_trade"): + with pytest.raises(CircuitBreakerTripped): + await trading_cycle( + broker=mock_broker, + overseas_broker=mock_overseas_broker, + scenario_engine=mock_scenario_engine, + playbook=mock_playbook, + risk=mock_risk, + db_conn=mock_db, + decision_logger=mock_decision_logger, + context_store=mock_context_store, + criticality_assessor=mock_criticality_assessor, + telegram=mock_telegram, + market=mock_market, + stock_code="005930", + scan_candidates={}, + ) + + mock_risk.check_circuit_breaker.assert_called_once() + mock_risk.validate_order.assert_not_called() + class TestRunFunctionTelegramIntegration: """Test telegram notifications in run function."""