feat: revalidate blackout recovery orders by price/session context (#328)
This commit is contained in:
@@ -78,6 +78,8 @@ class Settings(BaseSettings):
|
|||||||
ORDER_BLACKOUT_ENABLED: bool = True
|
ORDER_BLACKOUT_ENABLED: bool = True
|
||||||
ORDER_BLACKOUT_WINDOWS_KST: str = "23:30-00:10"
|
ORDER_BLACKOUT_WINDOWS_KST: str = "23:30-00:10"
|
||||||
ORDER_BLACKOUT_QUEUE_MAX: int = Field(default=500, ge=10, le=5000)
|
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 Planner
|
||||||
PRE_MARKET_MINUTES: int = Field(default=30, ge=10, le=120)
|
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,
|
broker: KISBroker,
|
||||||
overseas_broker: OverseasBroker,
|
overseas_broker: OverseasBroker,
|
||||||
db_conn: Any,
|
db_conn: Any,
|
||||||
|
settings: Settings | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
intents = BLACKOUT_ORDER_MANAGER.pop_recovery_batch()
|
intents = BLACKOUT_ORDER_MANAGER.pop_recovery_batch()
|
||||||
if not intents:
|
if not intents:
|
||||||
@@ -1035,6 +1036,63 @@ async def process_blackout_recovery_orders(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
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(
|
validate_order_policy(
|
||||||
market=market,
|
market=market,
|
||||||
order_type=intent.order_type,
|
order_type=intent.order_type,
|
||||||
@@ -2513,6 +2571,7 @@ async def run_daily_session(
|
|||||||
broker=broker,
|
broker=broker,
|
||||||
overseas_broker=overseas_broker,
|
overseas_broker=overseas_broker,
|
||||||
db_conn=db_conn,
|
db_conn=db_conn,
|
||||||
|
settings=settings,
|
||||||
)
|
)
|
||||||
# Use market-local date for playbook keying
|
# Use market-local date for playbook keying
|
||||||
market_today = datetime.now(market.timezone).date()
|
market_today = datetime.now(market.timezone).date()
|
||||||
@@ -4051,6 +4110,7 @@ async def run(settings: Settings) -> None:
|
|||||||
broker=broker,
|
broker=broker,
|
||||||
overseas_broker=overseas_broker,
|
overseas_broker=overseas_broker,
|
||||||
db_conn=db_conn,
|
db_conn=db_conn,
|
||||||
|
settings=settings,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Notify market open if it just opened
|
# 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."""
|
"""Recovery must execute queued intents that pass revalidation."""
|
||||||
db_conn = init_db(":memory:")
|
db_conn = init_db(":memory:")
|
||||||
broker = MagicMock()
|
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"})
|
broker.send_order = AsyncMock(return_value={"rt_cd": "0", "msg1": "OK"})
|
||||||
overseas_broker = MagicMock()
|
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."""
|
"""Policy-rejected queued intents must not be requeued."""
|
||||||
db_conn = init_db(":memory:")
|
db_conn = init_db(":memory:")
|
||||||
broker = MagicMock()
|
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"})
|
broker.send_order = AsyncMock(return_value={"rt_cd": "0", "msg1": "OK"})
|
||||||
overseas_broker = MagicMock()
|
overseas_broker = MagicMock()
|
||||||
|
|
||||||
@@ -6437,6 +6439,55 @@ async def test_process_blackout_recovery_drops_policy_rejected_intent() -> None:
|
|||||||
blackout_manager.requeue.assert_not_called()
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_trigger_emergency_kill_switch_executes_operational_steps() -> None:
|
async def test_trigger_emergency_kill_switch_executes_operational_steps() -> None:
|
||||||
"""Emergency kill switch should execute cancel/refresh/reduce/notify callbacks."""
|
"""Emergency kill switch should execute cancel/refresh/reduce/notify callbacks."""
|
||||||
|
|||||||
Reference in New Issue
Block a user