diff --git a/src/config.py b/src/config.py index 34a886a..0e60e32 100644 --- a/src/config.py +++ b/src/config.py @@ -60,6 +60,7 @@ class Settings(BaseSettings): # This value is used as a fallback when the balance API returns 0 in paper mode. PAPER_OVERSEAS_CASH: float = Field(default=50000.0, ge=0.0) USD_BUFFER_MIN: float = Field(default=1000.0, ge=0.0) + OVERNIGHT_EXCEPTION_ENABLED: bool = True # Trading frequency mode (daily = batch API calls, realtime = per-stock calls) TRADE_MODE: str = Field(default="daily", pattern="^(daily|realtime)$") diff --git a/src/main.py b/src/main.py index 6d8e8bb..fd80768 100644 --- a/src/main.py +++ b/src/main.py @@ -33,7 +33,11 @@ from src.core.blackout_manager import ( parse_blackout_windows_kst, ) from src.core.kill_switch import KillSwitchOrchestrator -from src.core.order_policy import OrderPolicyRejected, validate_order_policy +from src.core.order_policy import ( + OrderPolicyRejected, + get_session_info, + validate_order_policy, +) from src.core.priority_queue import PriorityTaskQueue from src.core.risk_manager import CircuitBreakerTripped, FatFingerRejected, RiskManager from src.db import ( @@ -63,6 +67,7 @@ BLACKOUT_ORDER_MANAGER = BlackoutOrderManager( windows=[], max_queue_size=500, ) +_SESSION_CLOSE_WINDOWS = {"NXT_AFTER", "US_AFTER"} def safe_float(value: str | float | None, default: float = 0.0) -> float: @@ -449,6 +454,21 @@ def _should_block_overseas_buy_for_fx_buffer( return remaining < required, remaining, required +def _should_force_exit_for_overnight( + *, + market: MarketInfo, + settings: Settings | None, +) -> bool: + session_id = get_session_info(market).session_id + if session_id not in _SESSION_CLOSE_WINDOWS: + return False + if KILL_SWITCH.new_orders_blocked: + return True + if settings is None: + return False + return not settings.OVERNIGHT_EXCEPTION_ENABLED + + async def build_overseas_symbol_universe( db_conn: Any, overseas_broker: OverseasBroker, @@ -1214,6 +1234,23 @@ async def trading_cycle( loss_pct, take_profit_threshold, ) + if decision.action == "HOLD" and _should_force_exit_for_overnight( + market=market, + settings=settings, + ): + decision = TradeDecision( + action="SELL", + confidence=max(decision.confidence, 85), + rationale=( + "Forced exit by overnight policy" + " (session close window / kill switch priority)" + ), + ) + logger.info( + "Overnight policy override for %s (%s): HOLD -> SELL", + stock_code, + market.name, + ) logger.info( "Decision for %s (%s): %s (confidence=%d)", stock_code, @@ -1274,7 +1311,7 @@ async def trading_cycle( trade_price = current_price trade_pnl = 0.0 if decision.action in ("BUY", "SELL"): - if KILL_SWITCH.new_orders_blocked: + if KILL_SWITCH.new_orders_blocked and decision.action == "BUY": logger.critical( "KillSwitch block active: skip %s order for %s (%s)", decision.action, @@ -2323,6 +2360,25 @@ async def run_daily_session( stock_code, market.name, ) + if decision.action == "HOLD": + daily_open = get_open_position(db_conn, stock_code, market.code) + if daily_open and _should_force_exit_for_overnight( + market=market, + settings=settings, + ): + decision = TradeDecision( + action="SELL", + confidence=max(decision.confidence, 85), + rationale=( + "Forced exit by overnight policy" + " (session close window / kill switch priority)" + ), + ) + logger.info( + "Daily overnight policy override for %s (%s): HOLD -> SELL", + stock_code, + market.name, + ) # Log decision context_snapshot = { @@ -2363,7 +2419,7 @@ async def run_daily_session( trade_pnl = 0.0 order_succeeded = True if decision.action in ("BUY", "SELL"): - if KILL_SWITCH.new_orders_blocked: + if KILL_SWITCH.new_orders_blocked and decision.action == "BUY": logger.critical( "KillSwitch block active: skip %s order for %s (%s)", decision.action, diff --git a/tests/test_main.py b/tests/test_main.py index 61887a0..f7a7213 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -15,6 +15,7 @@ from src.evolution.scorecard import DailyScorecard from src.logging.decision_logger import DecisionLogger from src.main import ( KILL_SWITCH, + _should_force_exit_for_overnight, _should_block_overseas_buy_for_fx_buffer, _trigger_emergency_kill_switch, _apply_dashboard_flag, @@ -5310,6 +5311,88 @@ async def test_order_policy_rejection_skips_order_execution() -> None: broker.send_order.assert_not_called() +def test_overnight_policy_prioritizes_killswitch_over_exception() -> None: + market = MagicMock() + with patch("src.main.get_session_info", return_value=MagicMock(session_id="US_AFTER")): + settings = MagicMock() + settings.OVERNIGHT_EXCEPTION_ENABLED = True + try: + KILL_SWITCH.new_orders_blocked = True + assert _should_force_exit_for_overnight(market=market, settings=settings) + finally: + KILL_SWITCH.clear_block() + + +@pytest.mark.asyncio +async def test_kill_switch_block_does_not_block_sell_reduction() -> None: + """KillSwitch should block BUY entries, but allow SELL risk reduction orders.""" + 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": [{"pdno": "005930", "ord_psbl_qty": "3"}], + "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 + settings.OVERNIGHT_EXCEPTION_ENABLED = True + settings.MODE = "paper" + + try: + KILL_SWITCH.new_orders_blocked = True + await trading_cycle( + broker=broker, + overseas_broker=MagicMock(), + scenario_engine=MagicMock(evaluate=MagicMock(return_value=_make_sell_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, + ) + finally: + KILL_SWITCH.clear_block() + + broker.send_order.assert_called_once() + + @pytest.mark.asyncio async def test_blackout_queues_order_and_skips_submission() -> None: """When blackout is active, order submission is replaced by queueing."""