Day-extension entry guard + percentage breakeven stop

Day-extension guard (the H1 gap from the prior review): the trigger-extension
ceiling only sees distance above the ORB high, so a name up huge intraday whose
break is itself CLEAN slips through — real example ATPC, entered +1.7% above the
trigger yet +32% above the session open, now a loser. New guard blocks when the
entry is > day_extension_max_pct above the SESSION OPEN. Default 0.30 calibrated
on 22 real fires: normal entries are <=14% above the open, the parabolic tail is
20-83% (ICCM +83/+41, BIRD +36, ATPC +34/+32, NEOV +29). day_open is computed in
step_arm (first RTH bar open), carried on ArmedTrigger, checked in
submit_breakout_now. Env TRADING_DAY_EXTENSION_MAX_PCT; 0 = off.

Percentage breakeven (the "ratchet to a better stop once it works" request): the
broker-side stop ratchet already exists (exit_manager replace_order), but its
earliest move was +1R — a big move for a wide opening-range stop. breakeven_at_pct
moves the stop to entry once price is +this fraction above entry, BEFORE 1R, so a
pop that reverses can't round-trip to a loss (the documented small-cap failure).
Lives in the shared strategy/exits._trail_stop (backtest + live agree), only ever
ratchets up. Default 0.05 live (TRADING_EXIT_BREAKEVEN_PCT); _is_active updated so
the manager runs when it's the only rule set.

tests/unit: 160 passed (+ day-ext block/allow/no-open, + pct-breakeven move/hold/
saves-reverser).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
momentum devandClaude Opus 4.8 committed 2026-06-19 07:49:27 -04:00
1 parent 87186be949
commit 559083b4ab
7 files changed
+98 -2

No files matched your search

+7 -1
View File
@@ -587,10 +587,13 @@ def main(argv: list[str] | None = None) -> int:
# recent $-volume (last 5 regular-hours bars) — the liquidity gate. # recent $-volume (last 5 regular-hours bars) — the liquidity gate.
# rvol is relative; this is the absolute "can it absorb an order" check. # rvol is relative; this is the absolute "can it absorb an order" check.
dollar_vol = 0.0 dollar_vol = 0.0
day_open = None
try: try:
rth = bars[bars["is_regular_hours"]] if "is_regular_hours" in bars else bars rth = bars[bars["is_regular_hours"]] if "is_regular_hours" in bars else bars
tail = rth.tail(5) tail = rth.tail(5)
dollar_vol = float((tail["close"] * tail["volume"]).sum()) dollar_vol = float((tail["close"] * tail["volume"]).sum())
if len(rth): # session open = first regular-hours bar's open
day_open = float(rth.iloc[0]["open"])
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
dollar_vol = 0.0 dollar_vol = 0.0
candidates.append({ candidates.append({
@@ -603,6 +606,7 @@ def main(argv: list[str] | None = None) -> int:
"cum_volume": g.cumulative_volume, "cum_volume": g.cumulative_volume,
"dollar_vol": dollar_vol, "dollar_vol": dollar_vol,
"catalyst": catalyst, "catalyst": catalyst,
"day_open": day_open,
# a fresh catalyst is WHY a small-cap runs — boost it up the rank; # a fresh catalyst is WHY a small-cap runs — boost it up the rank;
# times an optional manual per-symbol weight (default 1.0) # times an optional manual per-symbol weight (default 1.0)
"_score": (g.gap_pct * max(g.relative_volume, 0.1) "_score": (g.gap_pct * max(g.relative_volume, 0.1)
@@ -668,11 +672,13 @@ def main(argv: list[str] | None = None) -> int:
res = rt["execution"].submit_breakout_now( res = rt["execution"].submit_breakout_now(
t.symbol, t.trigger, t.stop, last_price=t.price, t.symbol, t.trigger, t.stop, last_price=t.price,
cum_volume=t.cum_volume, halted=_recently_halted(t.symbol), cum_volume=t.cum_volume, halted=_recently_halted(t.symbol),
day_open=t.day_open,
) )
if res.get("ok"): if res.get("ok"):
book.mark_fired(t.symbol) book.mark_fired(t.symbol)
fired.append(t.symbol) fired.append(t.symbol)
elif res.get("skipped") in ("over_extended", "halted_symbol"): elif res.get("skipped") in ("over_extended", "over_extended_day",
"halted_symbol"):
# surface the anti-chase / halt skips once per symbol (they # surface the anti-chase / halt skips once per symbol (they
# re-check every 4s; routine dedup skips stay quiet entirely) # re-check every 4s; routine dedup skips stay quiet entirely)
key = f"{t.symbol}:{res['skipped']}" key = f"{t.symbol}:{res['skipped']}"
+2 -1
View File
@@ -35,7 +35,8 @@ class _Tracked:
def _is_active(cfg: ExitConfig) -> bool: def _is_active(cfg: ExitConfig) -> bool:
return bool(cfg and (cfg.breakeven_at_r or cfg.trail_mode != TRAIL_NONE return bool(cfg and (cfg.breakeven_at_r or cfg.breakeven_at_pct
or cfg.trail_mode != TRAIL_NONE
or cfg.scale_out_r or cfg.first_red_exit or cfg.scale_out_r or cfg.first_red_exit
or cfg.profit_lock_tiers)) or cfg.profit_lock_tiers))
+2
View File
@@ -63,6 +63,7 @@ class ArmedTrigger:
cum_volume: float = 0.0 # session cumulative volume (for liquidity sizing) cum_volume: float = 0.0 # session cumulative volume (for liquidity sizing)
dollar_vol: float = 0.0 # recent $-volume (liquidity gate: skip thin spikes) dollar_vol: float = 0.0 # recent $-volume (liquidity gate: skip thin spikes)
catalyst: str = "" # fresh news headline (if any) — why it's hot catalyst: str = "" # fresh news headline (if any) — why it's hot
day_open: float | None = None # session-open price (for the day-extension gate)
state: str = WAITING state: str = WAITING
rank: int = 0 rank: int = 0
@@ -155,6 +156,7 @@ class ArmedTriggerBook:
t.cum_volume = float(c.get("cum_volume") or 0.0) t.cum_volume = float(c.get("cum_volume") or 0.0)
t.dollar_vol = float(c.get("dollar_vol") or 0.0) t.dollar_vol = float(c.get("dollar_vol") or 0.0)
t.catalyst = c.get("catalyst") or "" t.catalyst = c.get("catalyst") or ""
t.day_open = c.get("day_open")
t.rank = rank t.rank = rank
if not c.get("complete") or t.trigger is None: if not c.get("complete") or t.trigger is None:
t.state = WAITING t.state = WAITING
+13
View File
@@ -55,6 +55,11 @@ def parse_profit_tiers(raw: str) -> list[tuple[float, float]]:
class ExitConfig: class ExitConfig:
target_r: float = 2.0 # final take-profit in R (0 = no fixed target) target_r: float = 2.0 # final take-profit in R (0 = no fixed target)
breakeven_at_r: float = 0.0 # move stop to entry once +this R is reached (0 = off) breakeven_at_r: float = 0.0 # move stop to entry once +this R is reached (0 = off)
# move stop to entry once price is +this FRACTION above entry (0 = off). A
# 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).
breakeven_at_pct: float = 0.0
trail_mode: str = TRAIL_NONE # none | prior_low | pct trail_mode: str = TRAIL_NONE # none | prior_low | pct
trail_pct: float = 0.0 # pct trail: stop = high_water * (1 - trail_pct) trail_pct: float = 0.0 # pct trail: stop = high_water * (1 - trail_pct)
trail_after_r: float = 1.0 # only start trailing once +this R is reached trail_after_r: float = 1.0 # only start trailing once +this R is reached
@@ -84,6 +89,7 @@ class ExitConfig:
return cls( return cls(
target_r=f("TRADING_REWARD_MULTIPLE", "2.0"), target_r=f("TRADING_REWARD_MULTIPLE", "2.0"),
breakeven_at_r=f("TRADING_EXIT_BREAKEVEN_R", "0.0"), breakeven_at_r=f("TRADING_EXIT_BREAKEVEN_R", "0.0"),
breakeven_at_pct=f("TRADING_EXIT_BREAKEVEN_PCT", "0.05"),
trail_mode=v.get("TRADING_EXIT_TRAIL_MODE", TRAIL_NONE).strip().lower(), trail_mode=v.get("TRADING_EXIT_TRAIL_MODE", TRAIL_NONE).strip().lower(),
trail_pct=f("TRADING_EXIT_TRAIL_PCT", "0.0"), trail_pct=f("TRADING_EXIT_TRAIL_PCT", "0.0"),
trail_after_r=f("TRADING_EXIT_TRAIL_AFTER_R", "1.0"), trail_after_r=f("TRADING_EXIT_TRAIL_AFTER_R", "1.0"),
@@ -97,6 +103,8 @@ class ExitConfig:
parts = [f"target {self.target_r:g}R"] parts = [f"target {self.target_r:g}R"]
if self.breakeven_at_r: if self.breakeven_at_r:
parts.append(f"BE@{self.breakeven_at_r:g}R") parts.append(f"BE@{self.breakeven_at_r:g}R")
if self.breakeven_at_pct:
parts.append(f"BE@+{self.breakeven_at_pct:.0%}")
if self.trail_mode != TRAIL_NONE: if self.trail_mode != TRAIL_NONE:
how = f"{self.trail_pct:.1%}" if self.trail_mode == TRAIL_PCT else "prior-low" how = f"{self.trail_pct:.1%}" if self.trail_mode == TRAIL_PCT else "prior-low"
parts.append(f"trail {how} after {self.trail_after_r:g}R") parts.append(f"trail {how} after {self.trail_after_r:g}R")
@@ -131,6 +139,11 @@ def _trail_stop(stop: float, entry: float, high_water: float,
new = stop new = stop
if cfg.breakeven_at_r and reached_r >= cfg.breakeven_at_r: if cfg.breakeven_at_r and reached_r >= cfg.breakeven_at_r:
new = max(new, entry) new = max(new, entry)
# percentage breakeven: once the move clears +breakeven_at_pct above entry,
# the trade can't go red (stop to entry). Fires off the high-water mark, so
# it latches once reached and only ratchets the stop up, never down.
if cfg.breakeven_at_pct and entry > 0 and high_water >= entry * (1.0 + cfg.breakeven_at_pct):
new = max(new, entry)
if reached_r >= cfg.trail_after_r: if reached_r >= cfg.trail_after_r:
if cfg.trail_mode == TRAIL_PRIOR_LOW: if cfg.trail_mode == TRAIL_PRIOR_LOW:
new = max(new, bar_low) new = max(new, bar_low)
+23
View File
@@ -37,6 +37,29 @@ def test_profit_lock_live_desired_stop():
assert round(d.desired_stop, 2) == 10.30 # +3% locked (only +8% tier reached) 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): def _bars(seq):
return pd.DataFrame([{"high": h, "low": lo, "close": c} for h, lo, c in seq]) return pd.DataFrame([{"high": h, "low": lo, "close": c} for h, lo, c in seq])
+32
View File
@@ -239,6 +239,38 @@ def test_breakout_now_rejects_nonpositive_price():
assert client.last is None assert client.last is None
def test_breakout_now_blocks_over_extended_on_day():
"""Day ceiling: a CLEAN trigger break (+3.8% above trigger, passes the trigger
ceiling) of a name already +35% above the session open is a parabolic chase —
the real ATPC case (clean break, +32% on the day, lost). Blocked by the day
ceiling that the trigger ceiling can't see."""
client = _FakeClient()
store, svc = _svc(client)
res = svc.submit_breakout_now("AAA", trigger=13.0, stop=12.5,
last_price=13.5, day_open=10.0)
assert res["ok"] is False and res["skipped"] == "over_extended_day"
assert client.last is None
def test_breakout_now_allows_modest_day_extension():
"""+10% above the session open (a normal gapper) is under the 30% day ceiling."""
client = _FakeClient()
store, svc = _svc(client)
res = svc.submit_breakout_now("AAA", trigger=10.0, stop=9.5,
last_price=11.0, day_open=10.0)
assert res["ok"] is True
def test_day_extension_skipped_without_day_open():
"""No day_open (e.g. pre-open / missing bars) -> the day ceiling can't apply,
entry proceeds under the other gates."""
client = _FakeClient()
store, svc = _svc(client)
res = svc.submit_breakout_now("AAA", trigger=10.0, stop=9.5,
last_price=11.0, day_open=None)
assert res["ok"] is True
def test_breakout_now_allows_clean_break_within_ceiling(): def test_breakout_now_allows_clean_break_within_ceiling():
"""A clean break crosses the trigger smoothly (+3%, well under the 15% """A clean break crosses the trigger smoothly (+3%, well under the 15%
ceiling) and still fires — the guard must not kill normal breakouts.""" ceiling) and still fires — the guard must not kill normal breakouts."""
+19
View File
@@ -143,6 +143,14 @@ class ExecutionSettings:
# ICCM @64%/26%, BIRD @30%). 15% sits in that dead zone — it blocks the # ICCM @64%/26%, BIRD @30%). 15% sits in that dead zone — it blocks the
# spike-top chases and passes every normal breakout. # spike-top chases and passes every normal breakout.
extension_max_pct: float = 0.15 extension_max_pct: float = 0.15
# Day-extension ceiling: block when the entry is more than this fraction ABOVE
# the SESSION OPEN — i.e. the stock is already parabolic on the day. This
# catches what extension_max_pct can't: a name up huge intraday whose ORB-high
# break is itself clean (small trigger-extension) but is a chase of a parabolic
# (e.g. ATPC entered +1.7% above trigger yet +32% above the open, and lost).
# Default 0.30 calibrated on real fires: normal entries are <=14% above the
# open, the parabolic tail is 20-83%. 0 = off. Needs day_open from the trigger.
day_extension_max_pct: float = 0.30
# Skip entries on a name that looks HALTED: in a trading halt you can't exit # Skip entries on a name that looks HALTED: in a trading halt you can't exit
# and it gaps through your stop on resume. The caller passes a per-symbol # and it gaps through your stop on resume. The caller passes a per-symbol
# halt flag (a bar-gap heuristic over the liquid gappers we trade). This flag # halt flag (a bar-gap heuristic over the liquid gappers we trade). This flag
@@ -207,6 +215,9 @@ class ExecutionSettings:
extension_max_pct=float( extension_max_pct=float(
values.get("TRADING_EXTENSION_MAX_PCT", "0.15") values.get("TRADING_EXTENSION_MAX_PCT", "0.15")
), ),
day_extension_max_pct=float(
values.get("TRADING_DAY_EXTENSION_MAX_PCT", "0.30")
),
halt_guard_enabled=flag("TRADING_HALT_GUARD_ENABLED", "1"), halt_guard_enabled=flag("TRADING_HALT_GUARD_ENABLED", "1"),
halt_max_bar_gap_min=float( halt_max_bar_gap_min=float(
values.get("TRADING_HALT_MAX_BAR_GAP_MIN", "3") values.get("TRADING_HALT_MAX_BAR_GAP_MIN", "3")
@@ -670,6 +681,7 @@ class TradingExecutionService:
reason: str = "orb_live_break", reason: str = "orb_live_break",
cum_volume: float = 0.0, cum_volume: float = 0.0,
halted: bool = False, halted: bool = False,
day_open: float | None = None,
) -> dict: ) -> dict:
"""Fire a breakout entry immediately on a LIVE price cross. """Fire a breakout entry immediately on a LIVE price cross.
@@ -720,6 +732,13 @@ class TradingExecutionService:
ext_max = self.settings.extension_max_pct ext_max = self.settings.extension_max_pct
if ext_max > 0 and entry_ref > trigger_f * (1.0 + ext_max): if ext_max > 0 and entry_ref > trigger_f * (1.0 + ext_max):
return {"ok": False, "skipped": "over_extended"} return {"ok": False, "skipped": "over_extended"}
# Day-extension ceiling: the stock is already parabolic on the DAY (far
# above the session open) — chasing that is the "+100% then halts down"
# trap even when the trigger break itself looks clean.
day_max = self.settings.day_extension_max_pct
if (day_max > 0 and day_open is not None and float(day_open) > 0
and entry_ref > float(day_open) * (1.0 + day_max)):
return {"ok": False, "skipped": "over_extended_day"}
# Halt guard: you can't exit during a trading halt and it gaps through the # Halt guard: you can't exit during a trading halt and it gaps through the
# stop on resume — don't enter a name the caller flagged as halted. # stop on resume — don't enter a name the caller flagged as halted.
if halted and self.settings.halt_guard_enabled: if halted and self.settings.halt_guard_enabled: