[RISK-EMERGENCY][SCN-FAIL-003] TKT-P0-002 Kill Switch 순서 강제 검증 자동화 #284

Merged
agentson merged 2 commits from feature/issue-tkt-p0-002-killswitch-ordering into feature/v3-session-policy-stream 2026-02-27 00:42:16 +09:00
2 changed files with 76 additions and 5 deletions
Showing only changes of commit 0a4e69d40c - Show all commits

View File

@@ -663,6 +663,7 @@ async def _cancel_pending_orders_for_kill_switch(
overseas_broker: OverseasBroker, overseas_broker: OverseasBroker,
markets: list[MarketInfo], markets: list[MarketInfo],
) -> None: ) -> None:
failures: list[str] = []
domestic = [m for m in markets if m.is_domestic] domestic = [m for m in markets if m.is_domestic]
overseas = [m for m in markets if not m.is_domestic] overseas = [m for m in markets if not m.is_domestic]
@@ -673,21 +674,28 @@ async def _cancel_pending_orders_for_kill_switch(
logger.warning("KillSwitch: failed to fetch domestic pending orders: %s", exc) logger.warning("KillSwitch: failed to fetch domestic pending orders: %s", exc)
orders = [] orders = []
for order in orders: for order in orders:
stock_code = str(order.get("pdno", ""))
try: try:
stock_code = order.get("pdno", "")
orgn_odno = order.get("orgn_odno", "") orgn_odno = order.get("orgn_odno", "")
krx_fwdg_ord_orgno = order.get("ord_gno_brno", "") krx_fwdg_ord_orgno = order.get("ord_gno_brno", "")
psbl_qty = int(order.get("psbl_qty", "0") or "0") psbl_qty = int(order.get("psbl_qty", "0") or "0")
if not stock_code or not orgn_odno or psbl_qty <= 0: if not stock_code or not orgn_odno or psbl_qty <= 0:
continue continue
await broker.cancel_domestic_order( cancel_result = await broker.cancel_domestic_order(
stock_code=stock_code, stock_code=stock_code,
orgn_odno=orgn_odno, orgn_odno=orgn_odno,
krx_fwdg_ord_orgno=krx_fwdg_ord_orgno, krx_fwdg_ord_orgno=krx_fwdg_ord_orgno,
qty=psbl_qty, qty=psbl_qty,
) )
if cancel_result.get("rt_cd") != "0":
failures.append(
"domestic cancel failed for"
f" {stock_code}: rt_cd={cancel_result.get('rt_cd')}"
f" msg={cancel_result.get('msg1')}"
)
except Exception as exc: except Exception as exc:
logger.warning("KillSwitch: domestic cancel failed: %s", exc) logger.warning("KillSwitch: domestic cancel failed: %s", exc)
failures.append(f"domestic cancel exception for {stock_code}: {exc}")
us_exchanges = frozenset({"NASD", "NYSE", "AMEX"}) us_exchanges = frozenset({"NASD", "NYSE", "AMEX"})
exchange_codes: list[str] = [] exchange_codes: list[str] = []
@@ -712,21 +720,33 @@ async def _cancel_pending_orders_for_kill_switch(
) )
continue continue
for order in orders: for order in orders:
stock_code = str(order.get("pdno", ""))
order_exchange = str(order.get("ovrs_excg_cd") or exchange_code)
try: try:
stock_code = order.get("pdno", "")
odno = order.get("odno", "") odno = order.get("odno", "")
nccs_qty = int(order.get("nccs_qty", "0") or "0") nccs_qty = int(order.get("nccs_qty", "0") or "0")
order_exchange = order.get("ovrs_excg_cd") or exchange_code
if not stock_code or not odno or nccs_qty <= 0: if not stock_code or not odno or nccs_qty <= 0:
continue continue
await overseas_broker.cancel_overseas_order( cancel_result = await overseas_broker.cancel_overseas_order(
exchange_code=order_exchange, exchange_code=order_exchange,
stock_code=stock_code, stock_code=stock_code,
odno=odno, odno=odno,
qty=nccs_qty, qty=nccs_qty,
) )
if cancel_result.get("rt_cd") != "0":
failures.append(
"overseas cancel failed for"
f" {order_exchange}/{stock_code}: rt_cd={cancel_result.get('rt_cd')}"
f" msg={cancel_result.get('msg1')}"
)
except Exception as exc: except Exception as exc:
logger.warning("KillSwitch: overseas cancel failed: %s", exc) logger.warning("KillSwitch: overseas cancel failed: %s", exc)
failures.append(
f"overseas cancel exception for {order_exchange}/{stock_code}: {exc}"
)
if failures:
raise RuntimeError("; ".join(failures[:3]))
async def _refresh_order_state_for_kill_switch( async def _refresh_order_state_for_kill_switch(

View File

@@ -5414,3 +5414,54 @@ async def test_trigger_emergency_kill_switch_executes_operational_steps() -> Non
pnl_pct=-3.2, pnl_pct=-3.2,
threshold=-3.0, threshold=-3.0,
) )
@pytest.mark.asyncio
async def test_trigger_emergency_kill_switch_records_cancel_failure() -> None:
"""Cancel API rejection should be captured in kill switch errors."""
broker = MagicMock()
broker.get_domestic_pending_orders = AsyncMock(
return_value=[
{
"pdno": "005930",
"orgn_odno": "1",
"ord_gno_brno": "01",
"psbl_qty": "3",
}
]
)
broker.cancel_domestic_order = AsyncMock(return_value={"rt_cd": "1", "msg1": "fail"})
broker.get_balance = AsyncMock(return_value={"output1": [], "output2": []})
overseas_broker = MagicMock()
overseas_broker.get_overseas_pending_orders = AsyncMock(return_value=[])
overseas_broker.get_overseas_balance = AsyncMock(return_value={"output1": [], "output2": []})
telegram = MagicMock()
telegram.notify_circuit_breaker = AsyncMock()
settings = MagicMock()
settings.enabled_market_list = ["KR"]
market = MagicMock()
market.code = "KR"
market.exchange_code = "KRX"
market.is_domestic = True
with (
patch("src.main.MARKETS", {"KR": market}),
patch("src.main.BLACKOUT_ORDER_MANAGER.clear", return_value=0),
):
report = await _trigger_emergency_kill_switch(
reason="test-fail",
broker=broker,
overseas_broker=overseas_broker,
telegram=telegram,
settings=settings,
current_market=market,
stock_code="005930",
pnl_pct=-3.2,
threshold=-3.0,
)
assert any(err.startswith("cancel_pending_orders:") for err in report.errors)