Files
momentum-system/tests/unit/test_exits.py
T
momentum devandClaude Fable 5 5597250ff1 fix: marketable entries price from live last + time-of-day rvol arms fast path
Day-22 forensic (BIYA session): 6 entry submissions, 0 fills — IREX missed
+1.7R, IRE +1.65R by one cent. Two root causes fixed:

1. Slow-path limit rested AT the trigger while price ran 2% past it, and
   retries reused the same stale price. Marketable model now prices from
   max(trigger, live last) via price_provider; the anti-chase extension
   gate gets the live ref too (was an entry_ref==trigger no-op on this
   path). TRADING_ENTRY_FILL_MODEL flipped to marketable.

2. Fast trigger path armed nothing all day: legacy rvol (cum-so-far /
   full-day MEAN) is ~0.1-0.3 minutes after the open for a normal heavy
   name and its mean is poisoned by reverse-split/prior-squeeze days
   (BIYA legacy 1.27 vs real activity 481x median). New rvol_tod =
   cum / (MEDIAN(20d) * elapsed-session fraction) drives arm eligibility
   and the scanner keep-filter; legacy rvol untouched for validated
   boundaries (gap-squeeze rvol>=5).

Also: stagnation time-stop (TRADING_EXIT_STAGNATION_MIN/_R, default OFF)
in simulate_exit + manage_live with sweep_exits.py variants for
validation before any live enable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 15:40:15 -05:00

208 lines
9.0 KiB
Python

"""Shared exit logic: simulate_exit (backtest) and manage_live must agree.
Locks in the rule behaviours we sweep and then run live: fixed target, move to
breakeven, trail under prior lows / by percent, scale-out, and first-red exit.
"""
import pandas as pd
from strategy.exits import (
ExitConfig,
TRAIL_PCT,
TRAIL_PRIOR_LOW,
_trail_stop,
catastrophe_triggered,
manage_live,
parse_profit_tiers,
simulate_exit,
)
def test_parse_profit_tiers():
assert parse_profit_tiers("8:3,15:9") == [(0.08, 0.03), (0.15, 0.09)]
assert parse_profit_tiers("") == []
def test_catastrophe_pct_arm():
# 10% pct floor, no known stop -> the only thing protecting a naked position
assert catastrophe_triggered(1.00, 0.89, None, 0.10, 1.5) is True # -11%, fires
assert catastrophe_triggered(1.00, 0.92, None, 0.10, 1.5) is False # -8%, safe
# NIVF: entry 0.9936 ran to 0.76 (-23.4%) with NO stop -> would have fired
assert catastrophe_triggered(0.9936, 0.7608, None, 0.10, 1.5) is True
def test_catastrophe_risk_arm_fires_before_pct():
# tight 5% stop: -1.5R = -7.5% < the 10% pct floor, so the risk arm catches it sooner
assert catastrophe_triggered(1.00, 0.92, 0.95, 0.10, 1.5) is True # past -1.5R (-1.6R)
assert catastrophe_triggered(1.00, 0.93, 0.95, 0.10, 1.5) is False # -1.4R, not yet
# a stop at/above entry is not a usable risk reference -> only the pct arm applies
assert catastrophe_triggered(1.00, 0.94, 1.00, 0.10, 1.5) is False
def test_catastrophe_off_and_in_profit():
assert catastrophe_triggered(1.00, 0.50, None, 0.0, 1.5) is False # pct arm disabled, no stop
assert catastrophe_triggered(1.00, 1.20, 0.95, 0.10, 1.5) is False # in profit
assert catastrophe_triggered(1.00, 0.0, None, 0.10, 1.5) is False # bad price -> no-op
def test_profit_lock_keeps_minimum_gain():
# entry 10, stop 9. price runs to +12% (11.2) then fades; +8%->lock+3% tier
# pins the stop to 10.30, so the fade exits in profit instead of at the stop.
bars = _bars([(10.5, 9.9, 10.4), (11.2, 10.6, 11.0), (10.4, 9.5, 9.6)])
cfg = ExitConfig(target_r=20.0, profit_lock_tiers=[(0.08, 0.03)])
r = simulate_exit(10.0, 9.0, bars, cfg)
assert r.fills[-1].price >= 10.30 - 1e-6 # sold at/above the +3% lock
assert r.r_multiple > 0 # a locked-in WIN, not a loss
def test_profit_lock_live_desired_stop():
bars = _bars([(11.2, 10.6, 11.0)]) # high-water +12%
d = manage_live(10.0, 9.0, bars, ExitConfig(profit_lock_tiers=[(0.08, 0.03), (0.15, 0.09)]))
assert round(d.desired_stop, 2) == 10.30 # +3% locked (only +8% tier reached)
def test_pct_breakeven_live_moves_stop_to_entry():
# entry 10, stop 9; once price tags +5% (10.5) the % breakeven pins stop to entry
d = manage_live(10.0, 9.0, _bars([(10.5, 10.1, 10.4)]), ExitConfig(breakeven_at_pct=0.05))
assert round(d.desired_stop, 2) == 10.0
def test_pct_breakeven_not_reached_holds_initial_stop():
# only +3% high-water, under the 5% threshold -> stop stays at the initial 9.0
d = manage_live(10.0, 9.0, _bars([(10.3, 10.0, 10.2)]), ExitConfig(breakeven_at_pct=0.05))
assert round(d.desired_stop, 2) == 9.0
def test_pct_breakeven_saves_a_reversing_winner():
# pops +6% then collapses below the original stop; the % breakeven turns a
# -1R loss into a 0R scratch (the "winner round-trips to a loss" failure)
bars = _bars([(10.6, 10.2, 10.5), (10.1, 8.5, 8.6)])
cfg = ExitConfig(target_r=20.0, breakeven_at_pct=0.05)
r = simulate_exit(10.0, 9.0, bars, cfg)
assert r.fills[-1].price >= 10.0 - 1e-6 # exited at breakeven, not the 9.0 stop
assert r.reason == "breakeven"
assert abs(r.r_multiple) < 1e-6 # scratch, not a loss
def _bars(seq):
return pd.DataFrame([{"high": h, "low": lo, "close": c} for h, lo, c in seq])
# runs 10 -> 12.2, then collapses to 9.4 on the last bar
RUN_THEN_DROP = _bars([(10.5, 9.8, 10.4), (11.0, 10.2, 10.9),
(12.2, 11.5, 12.0), (12.0, 9.4, 9.6)])
def test_static_target_hits_2r():
r = simulate_exit(10.0, 9.0, RUN_THEN_DROP, ExitConfig(target_r=2.0))
assert r.reason == "target"
assert round(r.r_multiple, 2) == 2.0
def test_breakeven_saves_a_runner_from_full_loss():
# far target so it can't take profit; breakeven@1R moves stop to entry,
# so the final collapse exits at ~0R instead of -1R
r = simulate_exit(10.0, 9.0, RUN_THEN_DROP,
ExitConfig(target_r=5.0, breakeven_at_r=1.0))
assert r.reason == "breakeven"
assert abs(r.r_multiple) < 1e-6
def test_trail_prior_low_captures_partial_gain():
r = simulate_exit(10.0, 9.0, RUN_THEN_DROP,
ExitConfig(target_r=5.0, trail_mode=TRAIL_PRIOR_LOW, trail_after_r=1.0))
assert r.reason == "trail_stop"
assert r.r_multiple > 1.0 # locked in more than breakeven
def test_step_trail_ratchet_levels():
# entry 10, risk 1 -> reached_r in R == price - 10. Verify the ladder.
cfg = ExitConfig(target_r=5.0, trail_r_step=0.25, breakeven_at_r=0.0,
breakeven_at_pct=0.0)
f = lambda hw, rr, stop=9.0: _trail_stop(stop, 10.0, hw, 9.5, rr, cfg)
assert f(10.20, 0.20) == 9.0 # below the first +0.25R step -> no ratchet
assert f(10.25, 0.25) == 10.00 # +0.25R -> breakeven
assert f(10.50, 0.50) == 10.25 # +0.5R -> +0.25R
assert f(10.75, 0.75) == 10.50 # +0.75R -> +0.5R
assert f(10.75, 0.75, stop=10.60) == 10.60 # only ever ratchets UP
def test_step_trail_locks_a_small_pop():
# +0.5R pop then fade. step-trail 0.25R ratchets the stop to +0.25R, so the
# fade exits at +0.25R instead of round-tripping (the SOFI failure mode).
pop_then_fade = _bars([(10.5, 10.0, 10.4), # +0.5R high -> stop locks at 10.25
(10.3, 9.5, 9.6)]) # fades; low 9.5 hits 10.25
r = simulate_exit(10.0, 9.0, pop_then_fade, ExitConfig(target_r=5.0, trail_r_step=0.25))
assert round(r.r_multiple, 2) == 0.25
# without it, the same fade round-trips to a loss (rides to the 9.6 close)
r0 = simulate_exit(10.0, 9.0, pop_then_fade, ExitConfig(target_r=5.0))
assert r0.r_multiple < 0
def test_scale_out_blends_realized_r():
r = simulate_exit(10.0, 9.0, RUN_THEN_DROP,
ExitConfig(target_r=3.0, scale_out_r=1.0, scale_out_pct=0.5))
# half booked at +1R, the rest rides to session end (close 9.6 = -0.4R)
assert any(f.reason == "scale_out" for f in r.fills)
assert round(r.r_multiple, 2) == round(0.5 * 1.0 + 0.5 * -0.4, 2)
def test_first_red_exits_on_break_of_prior_low():
bars = _bars([(10.5, 10.1, 10.4), (11.0, 10.6, 10.9), (10.8, 9.9, 10.0)])
# bar 3 closes 10.0 < bar 2 low 10.6 -> first red exit
r = simulate_exit(10.0, 9.0, bars, ExitConfig(target_r=9.0, first_red_exit=True))
assert r.reason == "first_red"
def test_stop_is_conservative_intrabar():
# a bar whose range spans BOTH stop and target -> assume stop fills first
bars = _bars([(12.5, 8.9, 10.0)])
r = simulate_exit(10.0, 9.0, bars, ExitConfig(target_r=2.0))
assert r.reason == "stop_loss"
assert round(r.r_multiple, 2) == -1.0
def test_manage_live_ratchets_stop_to_breakeven():
bars = _bars([(11.2, 10.1, 11.0)]) # reached +1.2R
d = manage_live(10.0, 9.0, bars, ExitConfig(breakeven_at_r=1.0))
assert d.desired_stop >= 10.0 # moved up to (at least) entry
def test_manage_live_pct_trail():
bars = _bars([(12.0, 10.5, 11.8)]) # high-water 12
d = manage_live(10.0, 9.0, bars,
ExitConfig(trail_mode=TRAIL_PCT, trail_pct=0.05, trail_after_r=1.0))
assert round(d.desired_stop, 2) == round(12.0 * 0.95, 2)
def test_stagnation_exit_fires_when_flat():
# entry 10, stop 9 (risk 1). 5 flat bars: never reaches +0.25R by minute 5
cfg = ExitConfig(target_r=0, stagnation_minutes=5, stagnation_min_r=0.25)
bars = _bars([(10.1, 9.9, 10.05)] * 8)
res = simulate_exit(10.0, 9.0, bars, cfg)
assert res.reason == "stagnation"
assert res.exit_index == 4 # fired ON minute 5
assert abs(res.r_multiple - 0.05) < 1e-9 # left with the tiny drift
def test_stagnation_not_triggered_when_move_came():
cfg = ExitConfig(target_r=0, stagnation_minutes=5, stagnation_min_r=0.25)
bars = _bars([(10.30, 9.95, 10.25)] + [(10.1, 9.9, 10.05)] * 10)
res = simulate_exit(10.0, 9.0, bars, cfg) # +0.3R on bar 1 -> armed off
assert res.reason != "stagnation"
def test_stagnation_off_by_default():
bars = _bars([(10.1, 9.9, 10.05)] * 30)
res = simulate_exit(10.0, 9.0, bars, ExitConfig(target_r=0))
assert res.reason == "session_end"
def test_manage_live_stagnation_exits_flat_position():
cfg = ExitConfig(target_r=0, stagnation_minutes=5, stagnation_min_r=0.25)
bars = _bars([(10.1, 9.9, 10.05)] * 5)
d = manage_live(10.0, 9.0, bars, cfg)
assert d.exit_now and d.reason == "stagnation"
d2 = manage_live(10.0, 9.0, _bars([(10.1, 9.9, 10.05)] * 3), cfg)
assert not d2.exit_now # not enough bars yet