From 0ceb2dfdc9e7879fd9cb4a9a15b1c6f8203d3cae Mon Sep 17 00:00:00 2001 From: agentson Date: Sun, 1 Mar 2026 09:33:28 +0900 Subject: [PATCH 1/2] feat: revalidate blackout recovery orders by price/session context (#328) --- src/config.py | 2 ++ src/main.py | 60 ++++++++++++++++++++++++++++++++++++++++++++++ tests/test_main.py | 51 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+) diff --git a/src/config.py b/src/config.py index d12a346..671b95b 100644 --- a/src/config.py +++ b/src/config.py @@ -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) diff --git a/src/main.py b/src/main.py index 6248817..da6f3e9 100644 --- a/src/main.py +++ b/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 diff --git a/tests/test_main.py b/tests/test_main.py index 86e67d1..c958354 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -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.""" -- 2.49.1 From 5fae9765e791b3f3556d1a02babcf0e4a1566337 Mon Sep 17 00:00:00 2001 From: agentson Date: Sun, 1 Mar 2026 09:40:00 +0900 Subject: [PATCH 2/2] test: add blackout recovery overseas/failure revalidation coverage (#328) --- tests/test_main.py | 94 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/tests/test_main.py b/tests/test_main.py index c958354..6bba964 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -6488,6 +6488,100 @@ async def test_process_blackout_recovery_drops_intent_on_excessive_price_drift() validate_policy.assert_not_called() +@pytest.mark.asyncio +async def test_process_blackout_recovery_drops_overseas_intent_on_excessive_price_drift() -> None: + """Overseas queued intent is dropped when price drift exceeds threshold.""" + db_conn = init_db(":memory:") + broker = MagicMock() + broker.send_order = AsyncMock(return_value={"rt_cd": "0", "msg1": "OK"}) + overseas_broker = MagicMock() + overseas_broker.get_overseas_price = AsyncMock(return_value={"output": {"last": "106.0"}}) + overseas_broker.send_overseas_order = AsyncMock(return_value={"rt_cd": "0", "msg1": "OK"}) + + market = MagicMock() + market.code = "US_NASDAQ" + market.exchange_code = "NASD" + market.is_domestic = False + + intent = MagicMock() + intent.market_code = "US_NASDAQ" + intent.stock_code = "AAPL" + 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", {"US_NASDAQ": 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, + ), + ) + + overseas_broker.send_overseas_order.assert_not_called() + validate_policy.assert_not_called() + + +@pytest.mark.asyncio +async def test_process_blackout_recovery_requeues_intent_when_price_lookup_fails() -> None: + """Price lookup failure must requeue intent for a later retry.""" + db_conn = init_db(":memory:") + broker = MagicMock() + broker.get_current_price = AsyncMock(side_effect=ConnectionError("price API down")) + 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, + ) + + broker.send_order.assert_not_called() + validate_policy.assert_not_called() + blackout_manager.requeue.assert_called_once_with(intent) + assert intent.attempts == 1 + + @pytest.mark.asyncio async def test_trigger_emergency_kill_switch_executes_operational_steps() -> None: """Emergency kill switch should execute cancel/refresh/reduce/notify callbacks.""" -- 2.49.1