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_price = current_price
|
||||||
trade_pnl = 0.0
|
trade_pnl = 0.0
|
||||||
if decision.action in ("BUY", "SELL"):
|
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 = (
|
broker_held_qty = (
|
||||||
_extract_held_qty_from_balance(
|
_extract_held_qty_from_balance(
|
||||||
balance_data, stock_code, is_domestic=market.is_domestic
|
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)
|
logger.warning("Fat finger notification failed: %s", notify_exc)
|
||||||
raise # Re-raise to prevent trade
|
raise # Re-raise to prevent trade
|
||||||
except CircuitBreakerTripped as exc:
|
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}",
|
reason=f"circuit_breaker:{market.code}:{stock_code}:{exc.pnl_pct:.2f}",
|
||||||
snapshot_state=lambda: logger.critical(
|
snapshot_state=lambda: logger.critical(
|
||||||
"KillSwitch snapshot %s/%s pnl=%.2f threshold=%.2f",
|
"KillSwitch snapshot %s/%s pnl=%.2f threshold=%.2f",
|
||||||
@@ -976,16 +985,13 @@ async def trading_cycle(
|
|||||||
exc.threshold,
|
exc.threshold,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
try:
|
if ks_report.errors:
|
||||||
await telegram.notify_circuit_breaker(
|
logger.critical(
|
||||||
pnl_pct=exc.pnl_pct,
|
"KillSwitch step errors for %s/%s: %s",
|
||||||
threshold=exc.threshold,
|
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
|
raise
|
||||||
|
|
||||||
# 5. Send order
|
# 5. Send order
|
||||||
@@ -1888,6 +1894,15 @@ async def run_daily_session(
|
|||||||
trade_pnl = 0.0
|
trade_pnl = 0.0
|
||||||
order_succeeded = True
|
order_succeeded = True
|
||||||
if decision.action in ("BUY", "SELL"):
|
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 = (
|
daily_broker_held_qty = (
|
||||||
_extract_held_qty_from_balance(
|
_extract_held_qty_from_balance(
|
||||||
balance_data, stock_code, is_domestic=market.is_domestic
|
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)
|
logger.warning("Fat finger notification failed: %s", notify_exc)
|
||||||
continue # Skip this order
|
continue # Skip this order
|
||||||
except CircuitBreakerTripped as exc:
|
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}",
|
reason=f"daily_circuit_breaker:{market.code}:{stock_code}:{exc.pnl_pct:.2f}",
|
||||||
snapshot_state=lambda: logger.critical(
|
snapshot_state=lambda: logger.critical(
|
||||||
"Daily KillSwitch snapshot %s/%s pnl=%.2f threshold=%.2f",
|
"Daily KillSwitch snapshot %s/%s pnl=%.2f threshold=%.2f",
|
||||||
@@ -1974,7 +1989,13 @@ async def run_daily_session(
|
|||||||
logger.warning(
|
logger.warning(
|
||||||
"Circuit breaker notification failed: %s", notify_exc
|
"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
|
raise
|
||||||
|
|
||||||
# Send order
|
# Send order
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from src.db import init_db, log_trade
|
|||||||
from src.evolution.scorecard import DailyScorecard
|
from src.evolution.scorecard import DailyScorecard
|
||||||
from src.logging.decision_logger import DecisionLogger
|
from src.logging.decision_logger import DecisionLogger
|
||||||
from src.main import (
|
from src.main import (
|
||||||
|
KILL_SWITCH,
|
||||||
_apply_dashboard_flag,
|
_apply_dashboard_flag,
|
||||||
_determine_order_quantity,
|
_determine_order_quantity,
|
||||||
_extract_avg_price_from_balance,
|
_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:
|
class TestExtractAvgPriceFromBalance:
|
||||||
"""Tests for _extract_avg_price_from_balance() (issue #249)."""
|
"""Tests for _extract_avg_price_from_balance() (issue #249)."""
|
||||||
|
|
||||||
@@ -5039,3 +5048,71 @@ class TestOverseasGhostPositionClose:
|
|||||||
and "[ghost-close]" in (c.kwargs.get("rationale") or "")
|
and "[ghost-close]" in (c.kwargs.get("rationale") or "")
|
||||||
]
|
]
|
||||||
assert not ghost_close_calls, "Ghost-close must NOT be triggered for non-잔고없음 errors"
|
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