diff --git a/src/core/order_policy.py b/src/core/order_policy.py new file mode 100644 index 0000000..5fbb43a --- /dev/null +++ b/src/core/order_policy.py @@ -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 diff --git a/src/main.py b/src/main.py index f1679a4..c9f36b9 100644 --- a/src/main.py +++ b/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, diff --git a/tests/test_main.py b/tests/test_main.py index 8d7cb33..8c540ca 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -8,6 +8,7 @@ import pytest from src.config import Settings from src.context.layer import ContextLayer from src.context.scheduler import ScheduleResult +from src.core.order_policy import OrderPolicyRejected from src.core.risk_manager import CircuitBreakerTripped, FatFingerRejected from src.db import init_db, log_trade 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() 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() diff --git a/tests/test_order_policy.py b/tests/test_order_policy.py new file mode 100644 index 0000000..0f25aba --- /dev/null +++ b/tests/test_order_policy.py @@ -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"