fix: stop the exit stack amputating every winner

The live exit ran FOUR independent stop ratchets at once: a +5% breakeven
inherited silently from the env default, a 0.25R step-trail, a prior-low
trail, and profit-lock tiers. Stacked, they pinned the stop at or just
above entry within the first minute of any real move, so winners were
capped near +1R while losers still ran to the full stop.

Measured over 62 live Tradier + 172 Alpaca paper round trips:

  - trades whose stock ran >=20% after entry (avg +63.7%) kept 9.3% of it
  - median hold 10 min for winners vs 19 min for losers -- inverted
  - PCLA 2026-09-11: entered 12.8233, stopped 12.82, a 0.03% stop

breakeven_at_pct was the only exit lever in ExitConfig that defaulted ON
from the env (0.05) while its dataclass default and every sibling lever
defaulted to off, so the live stack ran it without ever setting it. Turn
the env default off so "not configured" means "not applied".

Replayed through simulate_exit -- same entries, same initial stops, only
the management varying -- with each stack split 70/30 by date. A 10%
trail running from entry looked best in-sample and went negative out-of-
sample; what survives is a trail armed only after +1R. The 8-20% range
all beat the old config in all four train/test cells (mean R 0.20-0.25 vs
0.125), so the live value of 12% is a plateau pick rather than a fitted
one.

Live config_overrides updated separately (market closed, no open
positions); this commit is the code default and the regression guard.
Risk per trade is unchanged and both safety backstops -- the -10%
catastrophe exit and naked-stop enforcement -- are untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2LepdJfPCfK5Q8uznjFBM
This commit is contained in:
momentum devandClaude Opus 5 committed 2026-09-13 12:00:29 -05:00
1 parent 40e51c8dd5
commit aae7269538
2 files changed
+103 -1

No files matched your search

+10 -1
View File
@@ -59,6 +59,15 @@ class ExitConfig:
# percentage breakeven that fires once the breakout confirms — sooner than
# +1R for a wide opening-range stop — so a winner can't round-trip to a loss
# (the documented failure mode: small-cap pops then reverses through entry).
#
# OFF BY DEFAULT (2026-09-13). It used to default to 0.05 from the env even
# though every other lever here defaults to off, so the live stack ran it
# without ever setting it. On a gapper that is going to run 60%+, +5% is
# reached inside the first minute, which pins the stop to entry and hands the
# trade to the next routine pullback. Measured over 62 live + 172 paper
# trades: winners were held a median 10 min vs 19 min for losers, and only
# 9.3% of the available move was captured on trades that ran >=20%. Enable
# only with an explicit TRADING_EXIT_BREAKEVEN_PCT and a sweep behind it.
breakeven_at_pct: float = 0.0
trail_mode: str = TRAIL_NONE # none | prior_low | pct
trail_pct: float = 0.0 # pct trail: stop = high_water * (1 - trail_pct)
@@ -122,7 +131,7 @@ class ExitConfig:
return cls(
target_r=f("TRADING_REWARD_MULTIPLE", "2.0"),
breakeven_at_r=f("TRADING_EXIT_BREAKEVEN_R", "0.0"),
breakeven_at_pct=f("TRADING_EXIT_BREAKEVEN_PCT", "0.05"),
breakeven_at_pct=f("TRADING_EXIT_BREAKEVEN_PCT", "0.0"),
trail_mode=v.get("TRADING_EXIT_TRAIL_MODE", TRAIL_NONE).strip().lower(),
trail_pct=f("TRADING_EXIT_TRAIL_PCT", "0.0"),
trail_after_r=f("TRADING_EXIT_TRAIL_AFTER_R", "1.0"),
@@ -0,0 +1,93 @@
"""Regression guard for the 2026-09-13 exit retune.
The live stack ran FOUR independent stop ratchets at once — a +5% breakeven
(inherited silently from the env default), a 0.25R step-trail, a prior-low
trail, and profit-lock tiers. Stacked, they pinned the stop at or just above
entry within the first minute of any real move, so every winner was capped near
+1R while losers still ran to the full stop. Measured over 62 live + 172 paper
trades: median hold 10 min for winners vs 19 min for losers, and only 9.3% of
the available move captured on trades that ran >=20%.
These tests lock the two things that must not silently come back:
1. TRADING_EXIT_BREAKEVEN_PCT is OFF unless explicitly set.
2. A runner survives its first pullback under the retuned config.
"""
import pandas as pd
from strategy.exits import ExitConfig, TRAIL_PCT, TRAIL_PRIOR_LOW, simulate_exit
def _bars(seq):
return pd.DataFrame([{"high": h, "low": lo, "close": c} for h, lo, c in seq])
# entry 10.00, stop 9.00 -> risk 1.00, so +1R == +10%.
ENTRY, STOP = 10.0, 9.0
# Pops +6%, takes a routine pullback, then runs to +62% — the gapper shape the
# strategy exists to catch (JLHL 8/5 ran +107%, XPON 8/26 +80%, MIMI 8/27 +29%).
RUNNER = _bars([
(10.60, 10.00, 10.50), # +6% high-water — old config pins the stop here
(10.70, 10.10, 10.20), # routine pullback — old config is stopped out on it
(11.20, 10.15, 11.10), # +12% = 1.2R, the retuned trail arms here
(12.00, 11.00, 11.90),
(16.00, 11.80, 15.80), # +60%
(16.20, 14.50, 14.80),
])
OLD = ExitConfig(
target_r=10.0, breakeven_at_r=1.0, breakeven_at_pct=0.05,
trail_mode=TRAIL_PRIOR_LOW, trail_after_r=1.0, trail_r_step=0.25,
profit_lock_tiers=[(.08, .03), (.15, .09), (.25, .18), (.40, .30)],
)
NEW = ExitConfig(
target_r=10.0, breakeven_at_r=1.0,
trail_mode=TRAIL_PCT, trail_pct=0.12, trail_after_r=1.0,
)
def test_breakeven_pct_is_off_unless_explicitly_set():
"""The footgun: this was the only exit lever that defaulted ON from the env."""
assert ExitConfig.from_env({}).breakeven_at_pct == 0.0
assert ExitConfig().breakeven_at_pct == 0.0
# still settable, for a sweep that earns it
assert ExitConfig.from_env({"TRADING_EXIT_BREAKEVEN_PCT": "0.05"}).breakeven_at_pct == 0.05
def test_old_stack_amputated_the_runner_at_the_first_pullback():
"""Documents the defect: a +62% move was surrendered for +0.25R."""
r = simulate_exit(ENTRY, STOP, RUNNER, OLD)
assert r.exit_index == 1 # out on the second bar
assert r.r_multiple < 0.5 # ~+0.25R of a 6R move
def test_retuned_config_rides_the_runner_through_its_pullback():
r = simulate_exit(ENTRY, STOP, RUNNER, NEW)
assert r.exit_index > 1 # survives the pullback that killed OLD
assert r.r_multiple > 3.0 # keeps a real share of the move
def test_retuned_config_beats_the_old_stack_on_the_same_runner():
assert (simulate_exit(ENTRY, STOP, RUNNER, NEW).r_multiple
> simulate_exit(ENTRY, STOP, RUNNER, OLD).r_multiple)
def test_retuned_config_still_gives_back_a_bounded_amount():
"""A 12% giveback must cap the round-trip, not let a winner go red."""
reverse = _bars([
(11.20, 10.10, 11.10), # +12% = 1.2R, trail arms -> stop >= entry (BE@1R)
(11.30, 10.05, 10.20),
(10.30, 8.00, 8.10), # collapse straight through entry
])
r = simulate_exit(ENTRY, STOP, reverse, NEW)
assert r.r_multiple >= 0.0 # never a loss once +1R was tagged
assert r.fills[-1].price >= ENTRY - 1e-9
def test_full_loser_still_takes_the_original_stop():
"""The retune must not widen risk — an immediate failure still costs 1R."""
loser = _bars([(10.05, 9.80, 9.90), (9.95, 8.90, 8.95)])
r = simulate_exit(ENTRY, STOP, loser, NEW)
assert round(r.r_multiple, 6) == -1.0
assert r.reason == "stop_loss"