fix: address reviewer feedback for kill-switch enforcement and observability (#275)
Some checks are pending
CI / test (pull_request) Waiting to run
Some checks are pending
CI / test (pull_request) Waiting to run
This commit is contained in:
45
src/main.py
45
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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user