fix: SELL 주문에서 Fat Finger 오탐 수정 — 손절/익절 차단 버그 (#187)
Some checks failed
CI / test (pull_request) Has been cancelled
Some checks failed
CI / test (pull_request) Has been cancelled
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 <noreply@anthropic.com>
This commit is contained in:
30
src/main.py
30
src/main.py
@@ -660,12 +660,17 @@ async def trading_cycle(
|
|||||||
return
|
return
|
||||||
|
|
||||||
# 5a. Risk check BEFORE order
|
# 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:
|
try:
|
||||||
risk.validate_order(
|
if decision.action == "SELL":
|
||||||
current_pnl_pct=pnl_pct,
|
risk.check_circuit_breaker(pnl_pct)
|
||||||
order_amount=order_amount,
|
else:
|
||||||
total_cash=total_cash,
|
risk.validate_order(
|
||||||
)
|
current_pnl_pct=pnl_pct,
|
||||||
|
order_amount=order_amount,
|
||||||
|
total_cash=total_cash,
|
||||||
|
)
|
||||||
except FatFingerRejected as exc:
|
except FatFingerRejected as exc:
|
||||||
try:
|
try:
|
||||||
await telegram.notify_fat_finger(
|
await telegram.notify_fat_finger(
|
||||||
@@ -1123,12 +1128,17 @@ async def run_daily_session(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Risk check
|
# Risk check
|
||||||
|
# SELL orders do not consume cash (they receive it), so fat-finger
|
||||||
|
# check is skipped for SELLs — only circuit breaker applies.
|
||||||
try:
|
try:
|
||||||
risk.validate_order(
|
if decision.action == "SELL":
|
||||||
current_pnl_pct=pnl_pct,
|
risk.check_circuit_breaker(pnl_pct)
|
||||||
order_amount=order_amount,
|
else:
|
||||||
total_cash=total_cash,
|
risk.validate_order(
|
||||||
)
|
current_pnl_pct=pnl_pct,
|
||||||
|
order_amount=order_amount,
|
||||||
|
total_cash=total_cash,
|
||||||
|
)
|
||||||
except FatFingerRejected as exc:
|
except FatFingerRejected as exc:
|
||||||
try:
|
try:
|
||||||
await telegram.notify_fat_finger(
|
await telegram.notify_fat_finger(
|
||||||
|
|||||||
@@ -631,6 +631,119 @@ class TestTradingCycleTelegramIntegration:
|
|||||||
# Verify no trade notification sent
|
# Verify no trade notification sent
|
||||||
mock_telegram.notify_trade_execution.assert_not_called()
|
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:
|
class TestRunFunctionTelegramIntegration:
|
||||||
"""Test telegram notifications in run function."""
|
"""Test telegram notifications in run function."""
|
||||||
|
|||||||
Reference in New Issue
Block a user