From 5050a4cf849adacee4b1327c43bfac11024f22e0 Mon Sep 17 00:00:00 2001 From: agentson Date: Thu, 26 Feb 2026 23:46:02 +0900 Subject: [PATCH] fix: address reviewer feedback for kill-switch enforcement and observability (#275) --- src/main.py | 45 +++++++++++++++++++-------- tests/test_main.py | 77 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 12 deletions(-) diff --git a/src/main.py b/src/main.py index eaa2642..f1679a4 100644 --- a/src/main.py +++ b/src/main.py @@ -897,6 +897,15 @@ async def trading_cycle( trade_price = current_price trade_pnl = 0.0 if decision.action in ("BUY", "SELL"): + if KILL_SWITCH.new_orders_blocked: + logger.critical( + "KillSwitch block active: skip %s order for %s (%s)", + decision.action, + stock_code, + market.name, + ) + return + broker_held_qty = ( _extract_held_qty_from_balance( balance_data, stock_code, is_domestic=market.is_domestic @@ -966,7 +975,7 @@ async def trading_cycle( logger.warning("Fat finger notification failed: %s", notify_exc) raise # Re-raise to prevent trade except CircuitBreakerTripped as exc: - await KILL_SWITCH.trigger( + ks_report = await KILL_SWITCH.trigger( reason=f"circuit_breaker:{market.code}:{stock_code}:{exc.pnl_pct:.2f}", snapshot_state=lambda: logger.critical( "KillSwitch snapshot %s/%s pnl=%.2f threshold=%.2f", @@ -976,16 +985,13 @@ async def trading_cycle( exc.threshold, ), ) - try: - await telegram.notify_circuit_breaker( - pnl_pct=exc.pnl_pct, - threshold=exc.threshold, + if ks_report.errors: + logger.critical( + "KillSwitch step errors for %s/%s: %s", + market.code, + stock_code, + "; ".join(ks_report.errors), ) - except Exception as notify_exc: - logger.warning( - "Circuit breaker notification failed: %s", notify_exc - ) - KILL_SWITCH.clear_block() raise # 5. Send order @@ -1888,6 +1894,15 @@ async def run_daily_session( trade_pnl = 0.0 order_succeeded = True if decision.action in ("BUY", "SELL"): + if KILL_SWITCH.new_orders_blocked: + logger.critical( + "KillSwitch block active: skip %s order for %s (%s)", + decision.action, + stock_code, + market.name, + ) + continue + daily_broker_held_qty = ( _extract_held_qty_from_balance( balance_data, stock_code, is_domestic=market.is_domestic @@ -1954,7 +1969,7 @@ async def run_daily_session( logger.warning("Fat finger notification failed: %s", notify_exc) continue # Skip this order except CircuitBreakerTripped as exc: - await KILL_SWITCH.trigger( + ks_report = await KILL_SWITCH.trigger( reason=f"daily_circuit_breaker:{market.code}:{stock_code}:{exc.pnl_pct:.2f}", snapshot_state=lambda: logger.critical( "Daily KillSwitch snapshot %s/%s pnl=%.2f threshold=%.2f", @@ -1974,7 +1989,13 @@ async def run_daily_session( logger.warning( "Circuit breaker notification failed: %s", notify_exc ) - KILL_SWITCH.clear_block() + if ks_report.errors: + logger.critical( + "Daily KillSwitch step errors for %s/%s: %s", + market.code, + stock_code, + "; ".join(ks_report.errors), + ) raise # Send order diff --git a/tests/test_main.py b/tests/test_main.py index 7a5e74c..8d7cb33 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -13,6 +13,7 @@ from src.db import init_db, log_trade from src.evolution.scorecard import DailyScorecard from src.logging.decision_logger import DecisionLogger from src.main import ( + KILL_SWITCH, _apply_dashboard_flag, _determine_order_quantity, _extract_avg_price_from_balance, @@ -77,6 +78,14 @@ def _make_sell_match(stock_code: str = "005930") -> ScenarioMatch: ) +@pytest.fixture(autouse=True) +def _reset_kill_switch_state() -> None: + """Prevent cross-test leakage from global kill-switch state.""" + KILL_SWITCH.clear_block() + yield + KILL_SWITCH.clear_block() + + class TestExtractAvgPriceFromBalance: """Tests for _extract_avg_price_from_balance() (issue #249).""" @@ -5039,3 +5048,71 @@ class TestOverseasGhostPositionClose: and "[ghost-close]" in (c.kwargs.get("rationale") or "") ] assert not ghost_close_calls, "Ghost-close must NOT be triggered for non-잔고없음 errors" + + +@pytest.mark.asyncio +async def test_kill_switch_block_skips_actionable_order_execution() -> None: + """Active kill-switch must prevent actionable order execution.""" + db_conn = init_db(":memory:") + decision_logger = DecisionLogger(db_conn) + + broker = MagicMock() + broker.get_current_price = AsyncMock(return_value=(100.0, 0.5, 0.0)) + broker.get_balance = AsyncMock( + return_value={ + "output1": [], + "output2": [ + { + "tot_evlu_amt": "100000", + "dnca_tot_amt": "50000", + "pchs_amt_smtl_amt": "50000", + } + ], + } + ) + 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() + + settings = MagicMock() + settings.POSITION_SIZING_ENABLED = False + settings.CONFIDENCE_THRESHOLD = 80 + + try: + KILL_SWITCH.new_orders_blocked = True + await trading_cycle( + broker=broker, + overseas_broker=MagicMock(), + scenario_engine=MagicMock(evaluate=MagicMock(return_value=_make_buy_match())), + playbook=_make_playbook(), + 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={}, + settings=settings, + ) + finally: + KILL_SWITCH.clear_block() + + broker.send_order.assert_not_called()