Merge pull request 'fix: runtime staged exit semantics in trading_cycle and run_daily_session (#304)' (#312) from feature/issue-304-runtime-staged-exit-semantics into feature/v3-session-policy-stream
Some checks failed
Gitea CI / test (push) Has been cancelled
Some checks failed
Gitea CI / test (push) Has been cancelled
This commit was merged in pull request #312.
This commit is contained in:
245
src/main.py
245
src/main.py
@@ -68,6 +68,8 @@ BLACKOUT_ORDER_MANAGER = BlackoutOrderManager(
|
|||||||
max_queue_size=500,
|
max_queue_size=500,
|
||||||
)
|
)
|
||||||
_SESSION_CLOSE_WINDOWS = {"NXT_AFTER", "US_AFTER"}
|
_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:
|
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
|
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(
|
async def build_overseas_symbol_universe(
|
||||||
db_conn: Any,
|
db_conn: Any,
|
||||||
overseas_broker: OverseasBroker,
|
overseas_broker: OverseasBroker,
|
||||||
@@ -977,6 +1091,11 @@ async def trading_cycle(
|
|||||||
"foreigner_net": foreigner_net,
|
"foreigner_net": foreigner_net,
|
||||||
"price_change_pct": price_change_pct,
|
"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
|
# Enrich market_data with scanner metrics for scenario engine
|
||||||
market_candidates = scan_candidates.get(market.code, {})
|
market_candidates = scan_candidates.get(market.code, {})
|
||||||
@@ -1175,82 +1294,36 @@ async def trading_cycle(
|
|||||||
|
|
||||||
if decision.action == "HOLD":
|
if decision.action == "HOLD":
|
||||||
open_position = get_open_position(db_conn, stock_code, market.code)
|
open_position = get_open_position(db_conn, stock_code, market.code)
|
||||||
if open_position:
|
if not open_position:
|
||||||
entry_price = safe_float(open_position.get("price"), 0.0)
|
_clear_runtime_exit_cache_for_symbol(
|
||||||
if entry_price > 0 and current_price > 0:
|
market_code=market.code,
|
||||||
loss_pct = (current_price - entry_price) / entry_price * 100
|
stock_code=stock_code,
|
||||||
stop_loss_threshold = -2.0
|
)
|
||||||
take_profit_threshold = 3.0
|
decision = _apply_staged_exit_override_for_hold(
|
||||||
if stock_playbook and stock_playbook.scenarios:
|
decision=decision,
|
||||||
stop_loss_threshold = stock_playbook.scenarios[0].stop_loss_pct
|
market=market,
|
||||||
take_profit_threshold = stock_playbook.scenarios[0].take_profit_pct
|
stock_code=stock_code,
|
||||||
|
open_position=open_position,
|
||||||
exit_eval = evaluate_exit(
|
market_data=market_data,
|
||||||
current_state=PositionState.HOLDING,
|
stock_playbook=stock_playbook,
|
||||||
config=ExitRuleConfig(
|
)
|
||||||
hard_stop_pct=stop_loss_threshold,
|
if open_position and decision.action == "HOLD" and _should_force_exit_for_overnight(
|
||||||
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(
|
|
||||||
market=market,
|
market=market,
|
||||||
settings=settings,
|
settings=settings,
|
||||||
):
|
):
|
||||||
decision = TradeDecision(
|
decision = TradeDecision(
|
||||||
action="SELL",
|
action="SELL",
|
||||||
confidence=max(decision.confidence, 85),
|
confidence=max(decision.confidence, 85),
|
||||||
rationale=(
|
rationale=(
|
||||||
"Forced exit by overnight policy"
|
"Forced exit by overnight policy"
|
||||||
" (session close window / kill switch priority)"
|
" (session close window / kill switch priority)"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Overnight policy override for %s (%s): HOLD -> SELL",
|
"Overnight policy override for %s (%s): HOLD -> SELL",
|
||||||
stock_code,
|
stock_code,
|
||||||
market.name,
|
market.name,
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Decision for %s (%s): %s (confidence=%d)",
|
"Decision for %s (%s): %s (confidence=%d)",
|
||||||
stock_code,
|
stock_code,
|
||||||
@@ -2190,6 +2263,14 @@ async def run_daily_session(
|
|||||||
"foreigner_net": foreigner_net,
|
"foreigner_net": foreigner_net,
|
||||||
"price_change_pct": price_change_pct,
|
"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
|
# Enrich with scanner metrics
|
||||||
cand = candidate_map.get(stock_code)
|
cand = candidate_map.get(stock_code)
|
||||||
if cand:
|
if cand:
|
||||||
@@ -2317,6 +2398,7 @@ async def run_daily_session(
|
|||||||
)
|
)
|
||||||
for stock_data in stocks_data:
|
for stock_data in stocks_data:
|
||||||
stock_code = stock_data["stock_code"]
|
stock_code = stock_data["stock_code"]
|
||||||
|
stock_playbook = playbook.get_stock_playbook(stock_code)
|
||||||
match = scenario_engine.evaluate(
|
match = scenario_engine.evaluate(
|
||||||
playbook, stock_code, stock_data, portfolio_data,
|
playbook, stock_code, stock_data, portfolio_data,
|
||||||
)
|
)
|
||||||
@@ -2362,7 +2444,20 @@ async def run_daily_session(
|
|||||||
)
|
)
|
||||||
if decision.action == "HOLD":
|
if decision.action == "HOLD":
|
||||||
daily_open = get_open_position(db_conn, stock_code, market.code)
|
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,
|
market=market,
|
||||||
settings=settings,
|
settings=settings,
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ from src.evolution.scorecard import DailyScorecard
|
|||||||
from src.logging.decision_logger import DecisionLogger
|
from src.logging.decision_logger import DecisionLogger
|
||||||
from src.main import (
|
from src.main import (
|
||||||
KILL_SWITCH,
|
KILL_SWITCH,
|
||||||
|
_RUNTIME_EXIT_PEAKS,
|
||||||
|
_RUNTIME_EXIT_STATES,
|
||||||
_should_force_exit_for_overnight,
|
_should_force_exit_for_overnight,
|
||||||
_should_block_overseas_buy_for_fx_buffer,
|
_should_block_overseas_buy_for_fx_buffer,
|
||||||
_trigger_emergency_kill_switch,
|
_trigger_emergency_kill_switch,
|
||||||
@@ -42,6 +44,7 @@ from src.strategy.models import (
|
|||||||
StockCondition,
|
StockCondition,
|
||||||
StockScenario,
|
StockScenario,
|
||||||
)
|
)
|
||||||
|
from src.strategy.position_state_machine import PositionState
|
||||||
from src.strategy.scenario_engine import ScenarioEngine, ScenarioMatch
|
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:
|
def _reset_kill_switch_state() -> None:
|
||||||
"""Prevent cross-test leakage from global kill-switch state."""
|
"""Prevent cross-test leakage from global kill-switch state."""
|
||||||
KILL_SWITCH.clear_block()
|
KILL_SWITCH.clear_block()
|
||||||
|
_RUNTIME_EXIT_STATES.clear()
|
||||||
|
_RUNTIME_EXIT_PEAKS.clear()
|
||||||
yield
|
yield
|
||||||
KILL_SWITCH.clear_block()
|
KILL_SWITCH.clear_block()
|
||||||
|
_RUNTIME_EXIT_STATES.clear()
|
||||||
|
_RUNTIME_EXIT_PEAKS.clear()
|
||||||
|
|
||||||
|
|
||||||
class TestExtractAvgPriceFromBalance:
|
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()
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_stop_loss_not_triggered_when_current_price_is_zero() -> None:
|
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).
|
"""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
|
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)
|
# sync_positions_from_broker — startup DB sync tests (issue #206)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -41,3 +41,11 @@
|
|||||||
- next_ticket: #304
|
- next_ticket: #304
|
||||||
- process_gate_checked: process_ticket=#306,#308 merged_to_feature_branch=yes
|
- process_gate_checked: process_ticket=#306,#308 merged_to_feature_branch=yes
|
||||||
- risks_or_notes: process-change-first 실행 게이트를 문서+스크립트로 강화
|
- 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) 커밋 분리 필요
|
||||||
|
|||||||
Reference in New Issue
Block a user