diff --git a/run_live_paper.py b/run_live_paper.py index 08caf50..0121e6d 100644 --- a/run_live_paper.py +++ b/run_live_paper.py @@ -587,10 +587,13 @@ def main(argv: list[str] | None = None) -> int: # recent $-volume (last 5 regular-hours bars) — the liquidity gate. # rvol is relative; this is the absolute "can it absorb an order" check. dollar_vol = 0.0 + day_open = None try: rth = bars[bars["is_regular_hours"]] if "is_regular_hours" in bars else bars tail = rth.tail(5) 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 dollar_vol = 0.0 candidates.append({ @@ -603,6 +606,7 @@ def main(argv: list[str] | None = None) -> int: "cum_volume": g.cumulative_volume, "dollar_vol": dollar_vol, "catalyst": catalyst, + "day_open": day_open, # a fresh catalyst is WHY a small-cap runs — boost it up the rank; # times an optional manual per-symbol weight (default 1.0) "_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( t.symbol, t.trigger, t.stop, last_price=t.price, cum_volume=t.cum_volume, halted=_recently_halted(t.symbol), + day_open=t.day_open, ) if res.get("ok"): book.mark_fired(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 # re-check every 4s; routine dedup skips stay quiet entirely) key = f"{t.symbol}:{res['skipped']}" diff --git a/runtime/exit_manager.py b/runtime/exit_manager.py index 4be5f41..74b6398 100644 --- a/runtime/exit_manager.py +++ b/runtime/exit_manager.py @@ -35,7 +35,8 @@ class _Tracked: 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.profit_lock_tiers)) diff --git a/runtime/triggers.py b/runtime/triggers.py index 013f627..5833f84 100644 --- a/runtime/triggers.py +++ b/runtime/triggers.py @@ -63,6 +63,7 @@ class ArmedTrigger: cum_volume: float = 0.0 # session cumulative volume (for liquidity sizing) dollar_vol: float = 0.0 # recent $-volume (liquidity gate: skip thin spikes) 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 rank: int = 0 @@ -155,6 +156,7 @@ class ArmedTriggerBook: t.cum_volume = float(c.get("cum_volume") or 0.0) t.dollar_vol = float(c.get("dollar_vol") or 0.0) t.catalyst = c.get("catalyst") or "" + t.day_open = c.get("day_open") t.rank = rank if not c.get("complete") or t.trigger is None: t.state = WAITING diff --git a/strategy/exits.py b/strategy/exits.py index 135118e..8e6809d 100644 --- a/strategy/exits.py +++ b/strategy/exits.py @@ -55,6 +55,11 @@ def parse_profit_tiers(raw: str) -> list[tuple[float, float]]: class ExitConfig: 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) + # 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_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 @@ -84,6 +89,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"), 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"), @@ -97,6 +103,8 @@ class ExitConfig: parts = [f"target {self.target_r:g}R"] if self.breakeven_at_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: 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") @@ -131,6 +139,11 @@ def _trail_stop(stop: float, entry: float, high_water: float, new = stop if cfg.breakeven_at_r and reached_r >= cfg.breakeven_at_r: 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 cfg.trail_mode == TRAIL_PRIOR_LOW: new = max(new, bar_low) diff --git a/tests/unit/test_exits.py b/tests/unit/test_exits.py index bb962b4..038c466 100644 --- a/tests/unit/test_exits.py +++ b/tests/unit/test_exits.py @@ -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) +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]) diff --git a/tests/unit/test_triggers.py b/tests/unit/test_triggers.py index d7e4534..a797f96 100644 --- a/tests/unit/test_triggers.py +++ b/tests/unit/test_triggers.py @@ -239,6 +239,38 @@ def test_breakout_now_rejects_nonpositive_price(): 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(): """A clean break crosses the trigger smoothly (+3%, well under the 15% ceiling) and still fires — the guard must not kill normal breakouts.""" diff --git a/trading_execution.py b/trading_execution.py index 845a57d..e53b9af 100644 --- a/trading_execution.py +++ b/trading_execution.py @@ -143,6 +143,14 @@ class ExecutionSettings: # ICCM @64%/26%, BIRD @30%). 15% sits in that dead zone — it blocks the # spike-top chases and passes every normal breakout. 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 # 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 @@ -207,6 +215,9 @@ class ExecutionSettings: extension_max_pct=float( 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_max_bar_gap_min=float( values.get("TRADING_HALT_MAX_BAR_GAP_MIN", "3") @@ -670,6 +681,7 @@ class TradingExecutionService: reason: str = "orb_live_break", cum_volume: float = 0.0, halted: bool = False, + day_open: float | None = None, ) -> dict: """Fire a breakout entry immediately on a LIVE price cross. @@ -720,6 +732,13 @@ class TradingExecutionService: ext_max = self.settings.extension_max_pct if ext_max > 0 and entry_ref > trigger_f * (1.0 + ext_max): 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 # stop on resume — don't enter a name the caller flagged as halted. if halted and self.settings.halt_guard_enabled: