feat: implement phase1 state machine, composite exits, and kill-switch orchestration (#275)
Some checks failed
CI / test (pull_request) Has been cancelled
Some checks failed
CI / test (pull_request) Has been cancelled
This commit is contained in:
104
src/strategy/exit_rules.py
Normal file
104
src/strategy/exit_rules.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""Composite exit rules: hard stop, break-even lock, ATR trailing, model assist."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from src.strategy.position_state_machine import PositionState, StateTransitionInput, promote_state
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExitRuleConfig:
|
||||
hard_stop_pct: float = -2.0
|
||||
be_arm_pct: float = 1.2
|
||||
arm_pct: float = 3.0
|
||||
atr_multiplier_k: float = 2.2
|
||||
model_prob_threshold: float = 0.62
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExitRuleInput:
|
||||
current_price: float
|
||||
entry_price: float
|
||||
peak_price: float
|
||||
atr_value: float = 0.0
|
||||
pred_down_prob: float = 0.0
|
||||
liquidity_weak: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExitEvaluation:
|
||||
state: PositionState
|
||||
should_exit: bool
|
||||
reason: str
|
||||
unrealized_pnl_pct: float
|
||||
trailing_stop_price: float | None
|
||||
|
||||
|
||||
def evaluate_exit(
|
||||
*,
|
||||
current_state: PositionState,
|
||||
config: ExitRuleConfig,
|
||||
inp: ExitRuleInput,
|
||||
) -> ExitEvaluation:
|
||||
"""Evaluate composite exit logic and return updated state."""
|
||||
if inp.entry_price <= 0 or inp.current_price <= 0:
|
||||
return ExitEvaluation(
|
||||
state=current_state,
|
||||
should_exit=False,
|
||||
reason="invalid_price",
|
||||
unrealized_pnl_pct=0.0,
|
||||
trailing_stop_price=None,
|
||||
)
|
||||
|
||||
unrealized = (inp.current_price - inp.entry_price) / inp.entry_price * 100.0
|
||||
hard_stop_hit = unrealized <= config.hard_stop_pct
|
||||
take_profit_hit = unrealized >= config.arm_pct
|
||||
|
||||
trailing_stop_price: float | None = None
|
||||
trailing_stop_hit = False
|
||||
if inp.atr_value > 0 and inp.peak_price > 0:
|
||||
trailing_stop_price = inp.peak_price - (config.atr_multiplier_k * inp.atr_value)
|
||||
trailing_stop_hit = inp.current_price <= trailing_stop_price
|
||||
|
||||
be_lock_threat = current_state in (PositionState.BE_LOCK, PositionState.ARMED) and (
|
||||
inp.current_price <= inp.entry_price
|
||||
)
|
||||
model_exit_signal = inp.pred_down_prob >= config.model_prob_threshold and inp.liquidity_weak
|
||||
|
||||
next_state = promote_state(
|
||||
current=current_state,
|
||||
inp=StateTransitionInput(
|
||||
unrealized_pnl_pct=unrealized,
|
||||
be_arm_pct=config.be_arm_pct,
|
||||
arm_pct=config.arm_pct,
|
||||
hard_stop_hit=hard_stop_hit,
|
||||
trailing_stop_hit=trailing_stop_hit,
|
||||
model_exit_signal=model_exit_signal,
|
||||
be_lock_threat=be_lock_threat,
|
||||
),
|
||||
)
|
||||
|
||||
if hard_stop_hit:
|
||||
reason = "hard_stop"
|
||||
elif trailing_stop_hit:
|
||||
reason = "atr_trailing_stop"
|
||||
elif be_lock_threat:
|
||||
reason = "be_lock_threat"
|
||||
elif model_exit_signal:
|
||||
reason = "model_liquidity_exit"
|
||||
elif take_profit_hit:
|
||||
# Backward-compatible immediate profit-taking path.
|
||||
reason = "arm_take_profit"
|
||||
else:
|
||||
reason = "hold"
|
||||
|
||||
should_exit = next_state == PositionState.EXITED or take_profit_hit
|
||||
|
||||
return ExitEvaluation(
|
||||
state=next_state,
|
||||
should_exit=should_exit,
|
||||
reason=reason,
|
||||
unrealized_pnl_pct=unrealized,
|
||||
trailing_stop_price=trailing_stop_price,
|
||||
)
|
||||
Reference in New Issue
Block a user