feat: 블랙아웃 복구 시 가격/세션 재검증 강화 (#328) #345
@@ -78,6 +78,8 @@ class Settings(BaseSettings):
|
||||
ORDER_BLACKOUT_ENABLED: bool = True
|
||||
ORDER_BLACKOUT_WINDOWS_KST: str = "23:30-00:10"
|
||||
ORDER_BLACKOUT_QUEUE_MAX: int = Field(default=500, ge=10, le=5000)
|
||||
BLACKOUT_RECOVERY_PRICE_REVALIDATION_ENABLED: bool = True
|
||||
BLACKOUT_RECOVERY_MAX_PRICE_DRIFT_PCT: float = Field(default=5.0, ge=0.0, le=100.0)
|
||||
|
||||
# Pre-Market Planner
|
||||
PRE_MARKET_MINUTES: int = Field(default=30, ge=10, le=120)
|
||||
|
||||
60
src/main.py
60
src/main.py
@@ -1004,6 +1004,7 @@ async def process_blackout_recovery_orders(
|
||||
broker: KISBroker,
|
||||
overseas_broker: OverseasBroker,
|
||||
db_conn: Any,
|
||||
settings: Settings | None = None,
|
||||
) -> None:
|
||||
intents = BLACKOUT_ORDER_MANAGER.pop_recovery_batch()
|
||||
if not intents:
|
||||
@@ -1035,6 +1036,63 @@ async def process_blackout_recovery_orders(
|
||||
continue
|
||||
|
||||
try:
|
||||
revalidation_enabled = bool(
|
||||
_resolve_market_setting(
|
||||
market=market,
|
||||
settings=settings,
|
||||
key="BLACKOUT_RECOVERY_PRICE_REVALIDATION_ENABLED",
|
||||
default=True,
|
||||
)
|
||||
)
|
||||
if revalidation_enabled:
|
||||
if market.is_domestic:
|
||||
current_price, _, _ = await _retry_connection(
|
||||
broker.get_current_price,
|
||||
intent.stock_code,
|
||||
label=f"recovery_price:{market.code}:{intent.stock_code}",
|
||||
)
|
||||
else:
|
||||
price_data = await _retry_connection(
|
||||
overseas_broker.get_overseas_price,
|
||||
market.exchange_code,
|
||||
intent.stock_code,
|
||||
label=f"recovery_price:{market.code}:{intent.stock_code}",
|
||||
)
|
||||
current_price = safe_float(price_data.get("output", {}).get("last"), 0.0)
|
||||
|
||||
queued_price = float(intent.price)
|
||||
max_drift_pct = float(
|
||||
_resolve_market_setting(
|
||||
market=market,
|
||||
settings=settings,
|
||||
key="BLACKOUT_RECOVERY_MAX_PRICE_DRIFT_PCT",
|
||||
default=5.0,
|
||||
)
|
||||
)
|
||||
if queued_price <= 0 or current_price <= 0:
|
||||
logger.info(
|
||||
"Drop queued intent by price revalidation (invalid price): %s %s (%s) queued=%.4f current=%.4f",
|
||||
intent.order_type,
|
||||
intent.stock_code,
|
||||
market.code,
|
||||
queued_price,
|
||||
current_price,
|
||||
)
|
||||
continue
|
||||
drift_pct = abs(current_price - queued_price) / queued_price * 100.0
|
||||
if drift_pct > max_drift_pct:
|
||||
logger.info(
|
||||
"Drop queued intent by price revalidation: %s %s (%s) queued=%.4f current=%.4f drift=%.2f%% max=%.2f%%",
|
||||
intent.order_type,
|
||||
intent.stock_code,
|
||||
market.code,
|
||||
queued_price,
|
||||
current_price,
|
||||
drift_pct,
|
||||
max_drift_pct,
|
||||
)
|
||||
continue
|
||||
|
||||
validate_order_policy(
|
||||
market=market,
|
||||
order_type=intent.order_type,
|
||||
@@ -2513,6 +2571,7 @@ async def run_daily_session(
|
||||
broker=broker,
|
||||
overseas_broker=overseas_broker,
|
||||
db_conn=db_conn,
|
||||
settings=settings,
|
||||
)
|
||||
# Use market-local date for playbook keying
|
||||
market_today = datetime.now(market.timezone).date()
|
||||
@@ -4051,6 +4110,7 @@ async def run(settings: Settings) -> None:
|
||||
broker=broker,
|
||||
overseas_broker=overseas_broker,
|
||||
db_conn=db_conn,
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
# Notify market open if it just opened
|
||||
|
||||
@@ -6340,6 +6340,7 @@ async def test_process_blackout_recovery_executes_valid_intents() -> None:
|
||||
"""Recovery must execute queued intents that pass revalidation."""
|
||||
db_conn = init_db(":memory:")
|
||||
broker = MagicMock()
|
||||
broker.get_current_price = AsyncMock(return_value=(100.0, 0.0, 0.0))
|
||||
broker.send_order = AsyncMock(return_value={"rt_cd": "0", "msg1": "OK"})
|
||||
overseas_broker = MagicMock()
|
||||
|
||||
@@ -6394,6 +6395,7 @@ async def test_process_blackout_recovery_drops_policy_rejected_intent() -> None:
|
||||
"""Policy-rejected queued intents must not be requeued."""
|
||||
db_conn = init_db(":memory:")
|
||||
broker = MagicMock()
|
||||
broker.get_current_price = AsyncMock(return_value=(100.0, 0.0, 0.0))
|
||||
broker.send_order = AsyncMock(return_value={"rt_cd": "0", "msg1": "OK"})
|
||||
overseas_broker = MagicMock()
|
||||
|
||||
@@ -6437,6 +6439,55 @@ async def test_process_blackout_recovery_drops_policy_rejected_intent() -> None:
|
||||
blackout_manager.requeue.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_blackout_recovery_drops_intent_on_excessive_price_drift() -> None:
|
||||
"""Queued intent is dropped when current market price drift exceeds threshold."""
|
||||
db_conn = init_db(":memory:")
|
||||
broker = MagicMock()
|
||||
broker.get_current_price = AsyncMock(return_value=(106.0, 0.0, 0.0))
|
||||
broker.send_order = AsyncMock(return_value={"rt_cd": "0", "msg1": "OK"})
|
||||
overseas_broker = MagicMock()
|
||||
|
||||
market = MagicMock()
|
||||
market.code = "KR"
|
||||
market.exchange_code = "KRX"
|
||||
market.is_domestic = True
|
||||
|
||||
intent = MagicMock()
|
||||
intent.market_code = "KR"
|
||||
intent.stock_code = "005930"
|
||||
intent.order_type = "BUY"
|
||||
intent.quantity = 1
|
||||
intent.price = 100.0
|
||||
intent.source = "test"
|
||||
intent.attempts = 0
|
||||
|
||||
blackout_manager = MagicMock()
|
||||
blackout_manager.pop_recovery_batch.return_value = [intent]
|
||||
|
||||
with (
|
||||
patch("src.main.BLACKOUT_ORDER_MANAGER", blackout_manager),
|
||||
patch("src.main.MARKETS", {"KR": market}),
|
||||
patch("src.main.get_open_position", return_value=None),
|
||||
patch("src.main.validate_order_policy") as validate_policy,
|
||||
):
|
||||
await process_blackout_recovery_orders(
|
||||
broker=broker,
|
||||
overseas_broker=overseas_broker,
|
||||
db_conn=db_conn,
|
||||
settings=Settings(
|
||||
KIS_APP_KEY="k",
|
||||
KIS_APP_SECRET="s",
|
||||
KIS_ACCOUNT_NO="12345678-01",
|
||||
GEMINI_API_KEY="g",
|
||||
BLACKOUT_RECOVERY_MAX_PRICE_DRIFT_PCT=5.0,
|
||||
),
|
||||
)
|
||||
|
||||
broker.send_order.assert_not_called()
|
||||
validate_policy.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_emergency_kill_switch_executes_operational_steps() -> None:
|
||||
"""Emergency kill switch should execute cancel/refresh/reduce/notify callbacks."""
|
||||
|
||||
Reference in New Issue
Block a user