fix: apply staged exit semantics in runtime paths (#304)
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,
|
||||
)
|
||||
_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,
|
||||
):
|
||||
|
||||
@@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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) 커밋 분리 필요
|
||||
|
||||
Reference in New Issue
Block a user