feat: add session-aware order policy guard for low-liquidity market-order rejection (#279)
This commit is contained in:
93
src/core/order_policy.py
Normal file
93
src/core/order_policy.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Session-aware order policy guards.
|
||||
|
||||
Default policy:
|
||||
- Low-liquidity sessions must reject market orders (price <= 0).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, time
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from src.markets.schedule import MarketInfo
|
||||
|
||||
_LOW_LIQUIDITY_SESSIONS = {"NXT_AFTER", "US_PRE", "US_DAY", "US_AFTER"}
|
||||
|
||||
|
||||
class OrderPolicyRejected(Exception):
|
||||
"""Raised when an order violates session policy."""
|
||||
|
||||
def __init__(self, message: str, *, session_id: str, market_code: str) -> None:
|
||||
super().__init__(message)
|
||||
self.session_id = session_id
|
||||
self.market_code = market_code
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionInfo:
|
||||
session_id: str
|
||||
is_low_liquidity: bool
|
||||
|
||||
|
||||
def classify_session_id(market: MarketInfo, now: datetime | None = None) -> str:
|
||||
"""Classify current session by KST schedule used in v3 docs."""
|
||||
now = now or datetime.now(UTC)
|
||||
# v3 session tables are explicitly defined in KST perspective.
|
||||
kst_time = now.astimezone(ZoneInfo("Asia/Seoul")).timetz().replace(tzinfo=None)
|
||||
|
||||
if market.code == "KR":
|
||||
if time(8, 0) <= kst_time < time(8, 50):
|
||||
return "NXT_PRE"
|
||||
if time(9, 0) <= kst_time < time(15, 30):
|
||||
return "KRX_REG"
|
||||
if time(15, 30) <= kst_time < time(20, 0):
|
||||
return "NXT_AFTER"
|
||||
return "KR_OFF"
|
||||
|
||||
if market.code.startswith("US"):
|
||||
if time(10, 0) <= kst_time < time(18, 0):
|
||||
return "US_DAY"
|
||||
if time(18, 0) <= kst_time < time(23, 30):
|
||||
return "US_PRE"
|
||||
if time(23, 30) <= kst_time or kst_time < time(6, 0):
|
||||
return "US_REG"
|
||||
if time(6, 0) <= kst_time < time(7, 0):
|
||||
return "US_AFTER"
|
||||
return "US_OFF"
|
||||
|
||||
return "GENERIC_REG"
|
||||
|
||||
|
||||
def get_session_info(market: MarketInfo, now: datetime | None = None) -> SessionInfo:
|
||||
session_id = classify_session_id(market, now)
|
||||
return SessionInfo(session_id=session_id, is_low_liquidity=session_id in _LOW_LIQUIDITY_SESSIONS)
|
||||
|
||||
|
||||
def validate_order_policy(
|
||||
*,
|
||||
market: MarketInfo,
|
||||
order_type: str,
|
||||
price: float,
|
||||
now: datetime | None = None,
|
||||
) -> SessionInfo:
|
||||
"""Validate order against session policy and return resolved session info."""
|
||||
info = get_session_info(market, now)
|
||||
|
||||
is_market_order = price <= 0
|
||||
if info.is_low_liquidity and is_market_order:
|
||||
raise OrderPolicyRejected(
|
||||
f"Market order is forbidden in low-liquidity session ({info.session_id})",
|
||||
session_id=info.session_id,
|
||||
market_code=market.code,
|
||||
)
|
||||
|
||||
# Guard against accidental unsupported actions.
|
||||
if order_type not in {"BUY", "SELL"}:
|
||||
raise OrderPolicyRejected(
|
||||
f"Unsupported order_type={order_type}",
|
||||
session_id=info.session_id,
|
||||
market_code=market.code,
|
||||
)
|
||||
|
||||
return info
|
||||
83
src/main.py
83
src/main.py
@@ -28,6 +28,7 @@ from src.context.scheduler import ContextScheduler
|
||||
from src.context.store import ContextStore
|
||||
from src.core.criticality import CriticalityAssessor
|
||||
from src.core.kill_switch import KillSwitchOrchestrator
|
||||
from src.core.order_policy import OrderPolicyRejected, validate_order_policy
|
||||
from src.core.priority_queue import PriorityTaskQueue
|
||||
from src.core.risk_manager import CircuitBreakerTripped, FatFingerRejected, RiskManager
|
||||
from src.db import (
|
||||
@@ -1005,6 +1006,22 @@ async def trading_cycle(
|
||||
order_price = kr_round_down(current_price * 1.002)
|
||||
else:
|
||||
order_price = kr_round_down(current_price * 0.998)
|
||||
try:
|
||||
validate_order_policy(
|
||||
market=market,
|
||||
order_type=decision.action,
|
||||
price=float(order_price),
|
||||
)
|
||||
except OrderPolicyRejected as exc:
|
||||
logger.warning(
|
||||
"Order policy rejected %s %s (%s): %s [session=%s]",
|
||||
decision.action,
|
||||
stock_code,
|
||||
market.name,
|
||||
exc,
|
||||
exc.session_id,
|
||||
)
|
||||
return
|
||||
result = await broker.send_order(
|
||||
stock_code=stock_code,
|
||||
order_type=decision.action,
|
||||
@@ -1027,6 +1044,22 @@ async def trading_cycle(
|
||||
overseas_price = round(current_price * 1.002, _price_decimals)
|
||||
else:
|
||||
overseas_price = round(current_price * 0.998, _price_decimals)
|
||||
try:
|
||||
validate_order_policy(
|
||||
market=market,
|
||||
order_type=decision.action,
|
||||
price=float(overseas_price),
|
||||
)
|
||||
except OrderPolicyRejected as exc:
|
||||
logger.warning(
|
||||
"Order policy rejected %s %s (%s): %s [session=%s]",
|
||||
decision.action,
|
||||
stock_code,
|
||||
market.name,
|
||||
exc,
|
||||
exc.session_id,
|
||||
)
|
||||
return
|
||||
result = await overseas_broker.send_overseas_order(
|
||||
exchange_code=market.exchange_code,
|
||||
stock_code=stock_code,
|
||||
@@ -1271,6 +1304,11 @@ async def handle_domestic_pending_orders(
|
||||
f"Invalid price ({last_price}) for {stock_code}"
|
||||
)
|
||||
new_price = kr_round_down(last_price * 0.996)
|
||||
validate_order_policy(
|
||||
market=MARKETS["KR"],
|
||||
order_type="SELL",
|
||||
price=float(new_price),
|
||||
)
|
||||
await broker.send_order(
|
||||
stock_code=stock_code,
|
||||
order_type="SELL",
|
||||
@@ -1444,6 +1482,19 @@ async def handle_overseas_pending_orders(
|
||||
f"Invalid price ({last_price}) for {stock_code}"
|
||||
)
|
||||
new_price = round(last_price * 0.996, 4)
|
||||
market_info = next(
|
||||
(
|
||||
m for m in MARKETS.values()
|
||||
if m.exchange_code == order_exchange and not m.is_domestic
|
||||
),
|
||||
None,
|
||||
)
|
||||
if market_info is not None:
|
||||
validate_order_policy(
|
||||
market=market_info,
|
||||
order_type="SELL",
|
||||
price=float(new_price),
|
||||
)
|
||||
await overseas_broker.send_overseas_order(
|
||||
exchange_code=order_exchange,
|
||||
stock_code=stock_code,
|
||||
@@ -2012,6 +2063,22 @@ async def run_daily_session(
|
||||
order_price = kr_round_down(
|
||||
stock_data["current_price"] * 0.998
|
||||
)
|
||||
try:
|
||||
validate_order_policy(
|
||||
market=market,
|
||||
order_type=decision.action,
|
||||
price=float(order_price),
|
||||
)
|
||||
except OrderPolicyRejected as exc:
|
||||
logger.warning(
|
||||
"Order policy rejected %s %s (%s): %s [session=%s]",
|
||||
decision.action,
|
||||
stock_code,
|
||||
market.name,
|
||||
exc,
|
||||
exc.session_id,
|
||||
)
|
||||
continue
|
||||
result = await broker.send_order(
|
||||
stock_code=stock_code,
|
||||
order_type=decision.action,
|
||||
@@ -2024,6 +2091,22 @@ async def run_daily_session(
|
||||
order_price = round(stock_data["current_price"] * 1.005, 4)
|
||||
else:
|
||||
order_price = stock_data["current_price"]
|
||||
try:
|
||||
validate_order_policy(
|
||||
market=market,
|
||||
order_type=decision.action,
|
||||
price=float(order_price),
|
||||
)
|
||||
except OrderPolicyRejected as exc:
|
||||
logger.warning(
|
||||
"Order policy rejected %s %s (%s): %s [session=%s]",
|
||||
decision.action,
|
||||
stock_code,
|
||||
market.name,
|
||||
exc,
|
||||
exc.session_id,
|
||||
)
|
||||
continue
|
||||
result = await overseas_broker.send_overseas_order(
|
||||
exchange_code=market.exchange_code,
|
||||
stock_code=stock_code,
|
||||
|
||||
Reference in New Issue
Block a user