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.context.store import ContextStore
|
||||||
from src.core.criticality import CriticalityAssessor
|
from src.core.criticality import CriticalityAssessor
|
||||||
from src.core.kill_switch import KillSwitchOrchestrator
|
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.priority_queue import PriorityTaskQueue
|
||||||
from src.core.risk_manager import CircuitBreakerTripped, FatFingerRejected, RiskManager
|
from src.core.risk_manager import CircuitBreakerTripped, FatFingerRejected, RiskManager
|
||||||
from src.db import (
|
from src.db import (
|
||||||
@@ -1005,6 +1006,22 @@ async def trading_cycle(
|
|||||||
order_price = kr_round_down(current_price * 1.002)
|
order_price = kr_round_down(current_price * 1.002)
|
||||||
else:
|
else:
|
||||||
order_price = kr_round_down(current_price * 0.998)
|
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(
|
result = await broker.send_order(
|
||||||
stock_code=stock_code,
|
stock_code=stock_code,
|
||||||
order_type=decision.action,
|
order_type=decision.action,
|
||||||
@@ -1027,6 +1044,22 @@ async def trading_cycle(
|
|||||||
overseas_price = round(current_price * 1.002, _price_decimals)
|
overseas_price = round(current_price * 1.002, _price_decimals)
|
||||||
else:
|
else:
|
||||||
overseas_price = round(current_price * 0.998, _price_decimals)
|
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(
|
result = await overseas_broker.send_overseas_order(
|
||||||
exchange_code=market.exchange_code,
|
exchange_code=market.exchange_code,
|
||||||
stock_code=stock_code,
|
stock_code=stock_code,
|
||||||
@@ -1271,6 +1304,11 @@ async def handle_domestic_pending_orders(
|
|||||||
f"Invalid price ({last_price}) for {stock_code}"
|
f"Invalid price ({last_price}) for {stock_code}"
|
||||||
)
|
)
|
||||||
new_price = kr_round_down(last_price * 0.996)
|
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(
|
await broker.send_order(
|
||||||
stock_code=stock_code,
|
stock_code=stock_code,
|
||||||
order_type="SELL",
|
order_type="SELL",
|
||||||
@@ -1444,6 +1482,19 @@ async def handle_overseas_pending_orders(
|
|||||||
f"Invalid price ({last_price}) for {stock_code}"
|
f"Invalid price ({last_price}) for {stock_code}"
|
||||||
)
|
)
|
||||||
new_price = round(last_price * 0.996, 4)
|
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(
|
await overseas_broker.send_overseas_order(
|
||||||
exchange_code=order_exchange,
|
exchange_code=order_exchange,
|
||||||
stock_code=stock_code,
|
stock_code=stock_code,
|
||||||
@@ -2012,6 +2063,22 @@ async def run_daily_session(
|
|||||||
order_price = kr_round_down(
|
order_price = kr_round_down(
|
||||||
stock_data["current_price"] * 0.998
|
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(
|
result = await broker.send_order(
|
||||||
stock_code=stock_code,
|
stock_code=stock_code,
|
||||||
order_type=decision.action,
|
order_type=decision.action,
|
||||||
@@ -2024,6 +2091,22 @@ async def run_daily_session(
|
|||||||
order_price = round(stock_data["current_price"] * 1.005, 4)
|
order_price = round(stock_data["current_price"] * 1.005, 4)
|
||||||
else:
|
else:
|
||||||
order_price = stock_data["current_price"]
|
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(
|
result = await overseas_broker.send_overseas_order(
|
||||||
exchange_code=market.exchange_code,
|
exchange_code=market.exchange_code,
|
||||||
stock_code=stock_code,
|
stock_code=stock_code,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import pytest
|
|||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
from src.context.layer import ContextLayer
|
from src.context.layer import ContextLayer
|
||||||
from src.context.scheduler import ScheduleResult
|
from src.context.scheduler import ScheduleResult
|
||||||
|
from src.core.order_policy import OrderPolicyRejected
|
||||||
from src.core.risk_manager import CircuitBreakerTripped, FatFingerRejected
|
from src.core.risk_manager import CircuitBreakerTripped, FatFingerRejected
|
||||||
from src.db import init_db, log_trade
|
from src.db import init_db, log_trade
|
||||||
from src.evolution.scorecard import DailyScorecard
|
from src.evolution.scorecard import DailyScorecard
|
||||||
@@ -5116,3 +5117,75 @@ async def test_kill_switch_block_skips_actionable_order_execution() -> None:
|
|||||||
KILL_SWITCH.clear_block()
|
KILL_SWITCH.clear_block()
|
||||||
|
|
||||||
broker.send_order.assert_not_called()
|
broker.send_order.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_order_policy_rejection_skips_order_execution() -> None:
|
||||||
|
"""Order policy rejection must prevent order submission."""
|
||||||
|
db_conn = init_db(":memory:")
|
||||||
|
decision_logger = DecisionLogger(db_conn)
|
||||||
|
|
||||||
|
broker = MagicMock()
|
||||||
|
broker.get_current_price = AsyncMock(return_value=(100.0, 0.5, 0.0))
|
||||||
|
broker.get_balance = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"output1": [],
|
||||||
|
"output2": [
|
||||||
|
{
|
||||||
|
"tot_evlu_amt": "100000",
|
||||||
|
"dnca_tot_amt": "50000",
|
||||||
|
"pchs_amt_smtl_amt": "50000",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
broker.send_order = AsyncMock(return_value={"msg1": "OK"})
|
||||||
|
|
||||||
|
market = MagicMock()
|
||||||
|
market.name = "Korea"
|
||||||
|
market.code = "KR"
|
||||||
|
market.exchange_code = "KRX"
|
||||||
|
market.is_domestic = True
|
||||||
|
|
||||||
|
telegram = MagicMock()
|
||||||
|
telegram.notify_trade_execution = AsyncMock()
|
||||||
|
telegram.notify_fat_finger = AsyncMock()
|
||||||
|
telegram.notify_circuit_breaker = AsyncMock()
|
||||||
|
telegram.notify_scenario_matched = AsyncMock()
|
||||||
|
|
||||||
|
settings = MagicMock()
|
||||||
|
settings.POSITION_SIZING_ENABLED = False
|
||||||
|
settings.CONFIDENCE_THRESHOLD = 80
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.main.validate_order_policy",
|
||||||
|
side_effect=OrderPolicyRejected(
|
||||||
|
"rejected",
|
||||||
|
session_id="NXT_AFTER",
|
||||||
|
market_code="KR",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
await trading_cycle(
|
||||||
|
broker=broker,
|
||||||
|
overseas_broker=MagicMock(),
|
||||||
|
scenario_engine=MagicMock(evaluate=MagicMock(return_value=_make_buy_match())),
|
||||||
|
playbook=_make_playbook(),
|
||||||
|
risk=MagicMock(),
|
||||||
|
db_conn=db_conn,
|
||||||
|
decision_logger=decision_logger,
|
||||||
|
context_store=MagicMock(
|
||||||
|
get_latest_timeframe=MagicMock(return_value=None),
|
||||||
|
set_context=MagicMock(),
|
||||||
|
),
|
||||||
|
criticality_assessor=MagicMock(
|
||||||
|
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
|
||||||
|
get_timeout=MagicMock(return_value=5.0),
|
||||||
|
),
|
||||||
|
telegram=telegram,
|
||||||
|
market=market,
|
||||||
|
stock_code="005930",
|
||||||
|
scan_candidates={},
|
||||||
|
settings=settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
broker.send_order.assert_not_called()
|
||||||
|
|||||||
40
tests/test_order_policy.py
Normal file
40
tests/test_order_policy.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.core.order_policy import OrderPolicyRejected, classify_session_id, validate_order_policy
|
||||||
|
from src.markets.schedule import MARKETS
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_kr_nxt_after() -> None:
|
||||||
|
# 2026-02-26 16:00 KST == 07:00 UTC
|
||||||
|
now = datetime(2026, 2, 26, 7, 0, tzinfo=UTC)
|
||||||
|
assert classify_session_id(MARKETS["KR"], now) == "NXT_AFTER"
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_us_pre() -> None:
|
||||||
|
# 2026-02-26 19:00 KST == 10:00 UTC
|
||||||
|
now = datetime(2026, 2, 26, 10, 0, tzinfo=UTC)
|
||||||
|
assert classify_session_id(MARKETS["US_NASDAQ"], now) == "US_PRE"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reject_market_order_in_low_liquidity_session() -> None:
|
||||||
|
now = datetime(2026, 2, 26, 10, 0, tzinfo=UTC) # 19:00 KST -> US_PRE
|
||||||
|
with pytest.raises(OrderPolicyRejected):
|
||||||
|
validate_order_policy(
|
||||||
|
market=MARKETS["US_NASDAQ"],
|
||||||
|
order_type="BUY",
|
||||||
|
price=0.0,
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_allow_limit_order_in_low_liquidity_session() -> None:
|
||||||
|
now = datetime(2026, 2, 26, 10, 0, tzinfo=UTC) # 19:00 KST -> US_PRE
|
||||||
|
info = validate_order_policy(
|
||||||
|
market=MARKETS["US_NASDAQ"],
|
||||||
|
order_type="BUY",
|
||||||
|
price=100.0,
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
assert info.session_id == "US_PRE"
|
||||||
Reference in New Issue
Block a user