diff --git a/src/main.py b/src/main.py index cf168e7..cc158a2 100644 --- a/src/main.py +++ b/src/main.py @@ -68,6 +68,8 @@ BLACKOUT_ORDER_MANAGER = BlackoutOrderManager( max_queue_size=500, ) _SESSION_CLOSE_WINDOWS = {"NXT_AFTER", "US_AFTER"} +_RUNTIME_EXIT_STATES: dict[str, PositionState] = {} +_RUNTIME_EXIT_PEAKS: dict[str, float] = {} def safe_float(value: str | float | None, default: float = 0.0) -> float: @@ -469,6 +471,118 @@ def _should_force_exit_for_overnight( return not settings.OVERNIGHT_EXCEPTION_ENABLED +def _build_runtime_position_key( + *, + market_code: str, + stock_code: str, + open_position: dict[str, Any], +) -> str: + decision_id = str(open_position.get("decision_id") or "") + timestamp = str(open_position.get("timestamp") or "") + return f"{market_code}:{stock_code}:{decision_id}:{timestamp}" + + +def _clear_runtime_exit_cache_for_symbol(*, market_code: str, stock_code: str) -> None: + prefix = f"{market_code}:{stock_code}:" + stale_keys = [key for key in _RUNTIME_EXIT_STATES if key.startswith(prefix)] + for key in stale_keys: + _RUNTIME_EXIT_STATES.pop(key, None) + _RUNTIME_EXIT_PEAKS.pop(key, None) + + +def _apply_staged_exit_override_for_hold( + *, + decision: TradeDecision, + market: MarketInfo, + stock_code: str, + open_position: dict[str, Any] | None, + market_data: dict[str, Any], + stock_playbook: Any | None, +) -> TradeDecision: + """Apply v2 staged exit semantics for HOLD positions using runtime state.""" + if decision.action != "HOLD" or not open_position: + return decision + + entry_price = safe_float(open_position.get("price"), 0.0) + current_price = safe_float(market_data.get("current_price"), 0.0) + if entry_price <= 0 or current_price <= 0: + return decision + + stop_loss_threshold = -2.0 + take_profit_threshold = 3.0 + if stock_playbook and stock_playbook.scenarios: + stop_loss_threshold = stock_playbook.scenarios[0].stop_loss_pct + take_profit_threshold = stock_playbook.scenarios[0].take_profit_pct + + runtime_key = _build_runtime_position_key( + market_code=market.code, + stock_code=stock_code, + open_position=open_position, + ) + current_state = _RUNTIME_EXIT_STATES.get(runtime_key, PositionState.HOLDING) + prev_peak = _RUNTIME_EXIT_PEAKS.get(runtime_key, 0.0) + peak_hint = max( + safe_float(market_data.get("peak_price"), 0.0), + safe_float(market_data.get("session_high_price"), 0.0), + ) + peak_price = max(entry_price, current_price, prev_peak, peak_hint) + + exit_eval = evaluate_exit( + current_state=current_state, + config=ExitRuleConfig( + hard_stop_pct=stop_loss_threshold, + be_arm_pct=max(0.5, take_profit_threshold * 0.4), + arm_pct=take_profit_threshold, + ), + inp=ExitRuleInput( + current_price=current_price, + entry_price=entry_price, + peak_price=peak_price, + atr_value=safe_float(market_data.get("atr_value"), 0.0), + pred_down_prob=safe_float(market_data.get("pred_down_prob"), 0.0), + liquidity_weak=safe_float(market_data.get("volume_ratio"), 1.0) < 1.0, + ), + ) + _RUNTIME_EXIT_STATES[runtime_key] = exit_eval.state + _RUNTIME_EXIT_PEAKS[runtime_key] = peak_price + + if not exit_eval.should_exit: + return decision + + pnl_pct = (current_price - entry_price) / entry_price * 100.0 + if exit_eval.reason == "hard_stop": + rationale = ( + f"Stop-loss triggered ({pnl_pct:.2f}% <= " + f"{stop_loss_threshold:.2f}%)" + ) + elif exit_eval.reason == "arm_take_profit": + rationale = ( + f"Take-profit triggered ({pnl_pct:.2f}% >= " + f"{take_profit_threshold:.2f}%)" + ) + elif exit_eval.reason == "atr_trailing_stop": + rationale = "ATR trailing-stop triggered" + elif exit_eval.reason == "be_lock_threat": + rationale = "Break-even lock threat detected" + elif exit_eval.reason == "model_liquidity_exit": + rationale = "Model/liquidity exit triggered" + else: + rationale = f"Exit rule triggered ({exit_eval.reason})" + + logger.info( + "Staged exit override for %s (%s): HOLD -> SELL (reason=%s, state=%s)", + stock_code, + market.name, + exit_eval.reason, + exit_eval.state.value, + ) + return TradeDecision( + action="SELL", + confidence=max(decision.confidence, 90), + rationale=rationale, + ) + + async def build_overseas_symbol_universe( db_conn: Any, overseas_broker: OverseasBroker, @@ -977,6 +1091,11 @@ async def trading_cycle( "foreigner_net": foreigner_net, "price_change_pct": price_change_pct, } + session_high_price = safe_float( + price_output.get("high") or price_output.get("ovrs_hgpr") or price_output.get("stck_hgpr") + ) + if session_high_price > 0: + market_data["session_high_price"] = session_high_price # Enrich market_data with scanner metrics for scenario engine market_candidates = scan_candidates.get(market.code, {}) @@ -1175,82 +1294,36 @@ async def trading_cycle( if decision.action == "HOLD": open_position = get_open_position(db_conn, stock_code, market.code) - if open_position: - entry_price = safe_float(open_position.get("price"), 0.0) - if entry_price > 0 and current_price > 0: - loss_pct = (current_price - entry_price) / entry_price * 100 - stop_loss_threshold = -2.0 - take_profit_threshold = 3.0 - if stock_playbook and stock_playbook.scenarios: - stop_loss_threshold = stock_playbook.scenarios[0].stop_loss_pct - take_profit_threshold = stock_playbook.scenarios[0].take_profit_pct - - exit_eval = evaluate_exit( - current_state=PositionState.HOLDING, - config=ExitRuleConfig( - hard_stop_pct=stop_loss_threshold, - be_arm_pct=max(0.5, take_profit_threshold * 0.4), - arm_pct=take_profit_threshold, - ), - inp=ExitRuleInput( - current_price=current_price, - entry_price=entry_price, - peak_price=max(entry_price, current_price), - atr_value=0.0, - pred_down_prob=0.0, - liquidity_weak=market_data.get("volume_ratio", 1.0) < 1.0, - ), - ) - - if exit_eval.reason == "hard_stop": - decision = TradeDecision( - action="SELL", - confidence=95, - rationale=( - f"Stop-loss triggered ({loss_pct:.2f}% <= " - f"{stop_loss_threshold:.2f}%)" - ), - ) - logger.info( - "Stop-loss override for %s (%s): %.2f%% <= %.2f%%", - stock_code, - market.name, - loss_pct, - stop_loss_threshold, - ) - elif exit_eval.reason == "arm_take_profit": - decision = TradeDecision( - action="SELL", - confidence=90, - rationale=( - f"Take-profit triggered ({loss_pct:.2f}% >= " - f"{take_profit_threshold:.2f}%)" - ), - ) - logger.info( - "Take-profit override for %s (%s): %.2f%% >= %.2f%%", - stock_code, - market.name, - loss_pct, - take_profit_threshold, - ) - if decision.action == "HOLD" and _should_force_exit_for_overnight( + if not open_position: + _clear_runtime_exit_cache_for_symbol( + market_code=market.code, + stock_code=stock_code, + ) + decision = _apply_staged_exit_override_for_hold( + decision=decision, + market=market, + stock_code=stock_code, + open_position=open_position, + market_data=market_data, + stock_playbook=stock_playbook, + ) + if open_position and 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, - ) + ): + 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, @@ -2190,6 +2263,14 @@ async def run_daily_session( "foreigner_net": foreigner_net, "price_change_pct": price_change_pct, } + if not market.is_domestic: + session_high_price = safe_float( + price_data.get("output", {}).get("high") + or price_data.get("output", {}).get("ovrs_hgpr") + or price_data.get("output", {}).get("stck_hgpr") + ) + if session_high_price > 0: + stock_data["session_high_price"] = session_high_price # Enrich with scanner metrics cand = candidate_map.get(stock_code) if cand: @@ -2317,6 +2398,7 @@ async def run_daily_session( ) for stock_data in stocks_data: stock_code = stock_data["stock_code"] + stock_playbook = playbook.get_stock_playbook(stock_code) match = scenario_engine.evaluate( playbook, stock_code, stock_data, portfolio_data, ) @@ -2362,7 +2444,20 @@ async def run_daily_session( ) if decision.action == "HOLD": daily_open = get_open_position(db_conn, stock_code, market.code) - if daily_open and _should_force_exit_for_overnight( + if not daily_open: + _clear_runtime_exit_cache_for_symbol( + market_code=market.code, + stock_code=stock_code, + ) + decision = _apply_staged_exit_override_for_hold( + decision=decision, + market=market, + stock_code=stock_code, + open_position=daily_open, + market_data=stock_data, + stock_playbook=stock_playbook, + ) + if daily_open and decision.action == "HOLD" and _should_force_exit_for_overnight( market=market, settings=settings, ): diff --git a/tests/test_main.py b/tests/test_main.py index f7a7213..63ee0da 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -15,6 +15,8 @@ from src.evolution.scorecard import DailyScorecard from src.logging.decision_logger import DecisionLogger from src.main import ( KILL_SWITCH, + _RUNTIME_EXIT_PEAKS, + _RUNTIME_EXIT_STATES, _should_force_exit_for_overnight, _should_block_overseas_buy_for_fx_buffer, _trigger_emergency_kill_switch, @@ -42,6 +44,7 @@ from src.strategy.models import ( StockCondition, StockScenario, ) +from src.strategy.position_state_machine import PositionState from src.strategy.scenario_engine import ScenarioEngine, ScenarioMatch @@ -87,8 +90,12 @@ def _make_sell_match(stock_code: str = "005930") -> ScenarioMatch: def _reset_kill_switch_state() -> None: """Prevent cross-test leakage from global kill-switch state.""" KILL_SWITCH.clear_block() + _RUNTIME_EXIT_STATES.clear() + _RUNTIME_EXIT_PEAKS.clear() yield KILL_SWITCH.clear_block() + _RUNTIME_EXIT_STATES.clear() + _RUNTIME_EXIT_PEAKS.clear() class TestExtractAvgPriceFromBalance: @@ -2337,6 +2344,218 @@ async def test_hold_not_overridden_when_between_stop_loss_and_take_profit() -> N broker.send_order.assert_not_called() +@pytest.mark.asyncio +async def test_hold_overridden_to_sell_on_be_lock_threat_after_state_arms() -> None: + """Staged exit must use runtime state (BE_LOCK -> be_lock_threat -> SELL).""" + db_conn = init_db(":memory:") + decision_logger = DecisionLogger(db_conn) + + buy_decision_id = decision_logger.log_decision( + stock_code="005930", + market="KR", + exchange_code="KRX", + action="BUY", + confidence=90, + rationale="entry", + context_snapshot={}, + input_data={}, + ) + log_trade( + conn=db_conn, + stock_code="005930", + action="BUY", + confidence=90, + rationale="entry", + quantity=1, + price=100.0, + market="KR", + exchange_code="KRX", + decision_id=buy_decision_id, + ) + + broker = MagicMock() + broker.get_current_price = AsyncMock(side_effect=[(102.0, 2.0, 0.0), (99.0, -1.0, 0.0)]) + broker.get_balance = AsyncMock( + return_value={ + "output1": [{"pdno": "005930", "ord_psbl_qty": "1"}], + "output2": [ + { + "tot_evlu_amt": "100000", + "dnca_tot_amt": "10000", + "pchs_amt_smtl_amt": "90000", + } + ], + } + ) + broker.send_order = AsyncMock(return_value={"msg1": "OK"}) + + scenario = StockScenario( + condition=StockCondition(rsi_below=30), + action=ScenarioAction.BUY, + confidence=88, + stop_loss_pct=-5.0, + take_profit_pct=3.0, + rationale="staged exit policy", + ) + playbook = DayPlaybook( + date=date(2026, 2, 8), + market="KR", + stock_playbooks=[ + {"stock_code": "005930", "stock_name": "Samsung", "scenarios": [scenario]} + ], + ) + engine = MagicMock(spec=ScenarioEngine) + engine.evaluate = MagicMock(return_value=_make_hold_match()) + + 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() + + for _ in range(2): + await trading_cycle( + broker=broker, + overseas_broker=MagicMock(), + scenario_engine=engine, + playbook=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={}, + ) + + broker.send_order.assert_called_once() + assert broker.send_order.call_args.kwargs["order_type"] == "SELL" + + +@pytest.mark.asyncio +async def test_runtime_exit_cache_cleared_when_position_closed() -> None: + """Runtime staged-exit cache must be cleared when no open position exists.""" + db_conn = init_db(":memory:") + decision_logger = DecisionLogger(db_conn) + + buy_decision_id = decision_logger.log_decision( + stock_code="005930", + market="KR", + exchange_code="KRX", + action="BUY", + confidence=90, + rationale="entry", + context_snapshot={}, + input_data={}, + ) + log_trade( + conn=db_conn, + stock_code="005930", + action="BUY", + confidence=90, + rationale="entry", + quantity=1, + price=100.0, + market="KR", + exchange_code="KRX", + decision_id=buy_decision_id, + ) + + broker = MagicMock() + broker.get_current_price = AsyncMock(return_value=(100.0, 0.0, 0.0)) + broker.get_balance = AsyncMock( + return_value={ + "output1": [{"pdno": "005930", "ord_psbl_qty": "1"}], + "output2": [ + { + "tot_evlu_amt": "100000", + "dnca_tot_amt": "10000", + "pchs_amt_smtl_amt": "90000", + } + ], + } + ) + 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() + + _RUNTIME_EXIT_STATES[f"{market.code}:005930:{buy_decision_id}:dummy-ts"] = PositionState.BE_LOCK + _RUNTIME_EXIT_PEAKS[f"{market.code}:005930:{buy_decision_id}:dummy-ts"] = 120.0 + + # Close position first so trading_cycle observes no open position. + sell_decision_id = decision_logger.log_decision( + stock_code="005930", + market="KR", + exchange_code="KRX", + action="SELL", + confidence=90, + rationale="manual close", + context_snapshot={}, + input_data={}, + ) + log_trade( + conn=db_conn, + stock_code="005930", + action="SELL", + confidence=90, + rationale="manual close", + quantity=1, + price=100.0, + market="KR", + exchange_code="KRX", + decision_id=sell_decision_id, + ) + + await trading_cycle( + broker=broker, + overseas_broker=MagicMock(), + scenario_engine=MagicMock(evaluate=MagicMock(return_value=_make_hold_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={}, + ) + + assert not [k for k in _RUNTIME_EXIT_STATES if k.startswith("KR:005930:")] + assert not [k for k in _RUNTIME_EXIT_PEAKS if k.startswith("KR:005930:")] + + @pytest.mark.asyncio async def test_stop_loss_not_triggered_when_current_price_is_zero() -> None: """HOLD must stay HOLD when current_price=0 even if entry_price is set (issue #251). @@ -4135,6 +4354,130 @@ class TestDailyCBBaseline: assert result == 55000.0 +@pytest.mark.asyncio +async def test_run_daily_session_applies_staged_exit_override_on_hold() -> None: + """run_daily_session must apply HOLD staged exit semantics (issue #304).""" + from src.analysis.smart_scanner import ScanCandidate + + db_conn = init_db(":memory:") + log_trade( + conn=db_conn, + stock_code="005930", + action="BUY", + confidence=90, + rationale="entry", + quantity=1, + price=100.0, + market="KR", + exchange_code="KRX", + decision_id="buy-d1", + ) + + settings = Settings( + KIS_APP_KEY="k", + KIS_APP_SECRET="s", + KIS_ACCOUNT_NO="12345678-01", + GEMINI_API_KEY="g", + MODE="paper", + ) + + broker = MagicMock() + broker.get_balance = AsyncMock( + return_value={ + "output1": [{"pdno": "005930", "ord_psbl_qty": "1"}], + "output2": [ + { + "tot_evlu_amt": "100000", + "dnca_tot_amt": "10000", + "pchs_amt_smtl_amt": "90000", + } + ], + } + ) + broker.get_current_price = AsyncMock(return_value=(95.0, -5.0, 0.0)) + broker.send_order = AsyncMock(return_value={"msg1": "OK"}) + + market = MagicMock() + market.name = "Korea" + market.code = "KR" + market.exchange_code = "KRX" + market.is_domestic = True + market.timezone = __import__("zoneinfo").ZoneInfo("Asia/Seoul") + + scenario = StockScenario( + condition=StockCondition(rsi_below=30), + action=ScenarioAction.BUY, + confidence=88, + stop_loss_pct=-2.0, + take_profit_pct=3.0, + rationale="stop loss policy", + ) + playbook = DayPlaybook( + date=date(2026, 2, 8), + market="KR", + stock_playbooks=[ + {"stock_code": "005930", "stock_name": "Samsung", "scenarios": [scenario]} + ], + ) + playbook_store = MagicMock() + playbook_store.load = MagicMock(return_value=playbook) + + smart_scanner = MagicMock() + smart_scanner.scan = AsyncMock( + return_value=[ + ScanCandidate( + stock_code="005930", + name="Samsung", + price=95.0, + volume=1_000_000.0, + volume_ratio=2.0, + rsi=42.0, + signal="momentum", + score=80.0, + ) + ] + ) + + scenario_engine = MagicMock(spec=ScenarioEngine) + scenario_engine.evaluate = MagicMock(return_value=_make_hold_match("005930")) + + risk = MagicMock() + risk.check_circuit_breaker = MagicMock() + risk.validate_order = MagicMock() + + decision_logger = MagicMock() + decision_logger.log_decision = MagicMock(return_value="d1") + + telegram = MagicMock() + telegram.notify_trade_execution = AsyncMock() + telegram.notify_scenario_matched = AsyncMock() + + async def _passthrough(fn, *a, label: str = "", **kw): # type: ignore[override] + return await fn(*a, **kw) + + with patch("src.main.get_open_markets", return_value=[market]), \ + patch("src.main._retry_connection", new=_passthrough): + await run_daily_session( + broker=broker, + overseas_broker=MagicMock(), + scenario_engine=scenario_engine, + playbook_store=playbook_store, + pre_market_planner=MagicMock(), + risk=risk, + db_conn=db_conn, + decision_logger=decision_logger, + context_store=MagicMock(), + criticality_assessor=MagicMock(), + telegram=telegram, + settings=settings, + smart_scanner=smart_scanner, + daily_start_eval=0.0, + ) + + broker.send_order.assert_called_once() + assert broker.send_order.call_args.kwargs["order_type"] == "SELL" + + # --------------------------------------------------------------------------- # sync_positions_from_broker — startup DB sync tests (issue #206) # --------------------------------------------------------------------------- diff --git a/workflow/session-handover.md b/workflow/session-handover.md index 68085f6..ea9f9cd 100644 --- a/workflow/session-handover.md +++ b/workflow/session-handover.md @@ -41,3 +41,11 @@ - next_ticket: #304 - process_gate_checked: process_ticket=#306,#308 merged_to_feature_branch=yes - risks_or_notes: process-change-first 실행 게이트를 문서+스크립트로 강화 + +### 2026-02-27 | session=codex-handover-start-2 +- branch: feature/issue-304-runtime-staged-exit-semantics +- docs_checked: docs/workflow.md, docs/commands.md, docs/agent-constraints.md +- open_issues_reviewed: #304, #305 +- next_ticket: #304 +- process_gate_checked: process_ticket=#306,#308 merged_to_feature_branch=yes +- risks_or_notes: handover 재시작 요청으로 세션 엔트리 추가, 미추적 산출물(AMS/NAS/NYS, DB, lock, xlsx) 커밋 분리 필요