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:
1 parent
87186be949
commit
559083b4ab
7 files changed
+98
-2
No files matched your search
+7
-1
@@ -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']}"
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in new issue
Block a user