fix: address reviewer feedback for kill-switch enforcement and observability (#275)
Some checks are pending
CI / test (pull_request) Waiting to run

This commit is contained in:
agentson
2026-02-26 23:46:02 +09:00
parent 4987b6393a
commit 5050a4cf84
2 changed files with 110 additions and 12 deletions

View File

@@ -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

View File

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