From 2e5346e035deb0f79e61e8e4ebb512bccc60a179 Mon Sep 17 00:00:00 2001 From: momentum dev Date: Mon, 6 Jul 2026 16:13:04 -0400 Subject: [PATCH] Trigger book: mega-gapper squeeze exception (the LHSW/NXTS fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LHSW gapped +278% on 100M shares, was rank 2 in the trigger book at 4:49am, ran +346%, and was the source trader's entire day — the bot's TRIGGER_GAP_MAX=35 capped it out, same as NXTS two weeks ago, while the bot spent its entries on the shadow round-trips (SEER/ELAB/PLUG/YRD). Year-scale replay (n=2,879 ORB entries, 14 months, reverse-split artifacts excluded by requiring a real volume surge): the 35-150% band is dead (+0.02R / +0.00R — the cap earns its keep there), but REAL >150% squeezes (n=82) ran +0.63R with 8/14 months >= 0, 4x the core band's edge. New gap_squeeze_min/gap_squeeze_rvol re-admit gaps >=150% only when rvol >= 5 (split repricings show rvol ~0.01 and stay out). Live: TRIGGER_GAP_SQUEEZE_MIN=150, TRIGGER_GAP_SQUEEZE_RVOL=5.0. Existing guards (trigger/day extension, halt, BE@+5% ratchet, sizing) still apply to these names. Co-Authored-By: Claude Opus 4.8 --- run_live_paper.py | 2 ++ runtime/triggers.py | 16 +++++++++++++++- tests/unit/test_triggers.py | 23 +++++++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/run_live_paper.py b/run_live_paper.py index edaa782..0f042e7 100644 --- a/run_live_paper.py +++ b/run_live_paper.py @@ -357,6 +357,8 @@ def build_runtime(args: argparse.Namespace) -> dict: max_armed=int(os.environ.get("TRIGGER_MAX_ARMED", "6")), gap_min=float(os.environ.get("TRIGGER_GAP_MIN", "3.0")), gap_max=float(os.environ.get("TRIGGER_GAP_MAX", "1e9")), + gap_squeeze_min=float(os.environ.get("TRIGGER_GAP_SQUEEZE_MIN", "0")), + gap_squeeze_rvol=float(os.environ.get("TRIGGER_GAP_SQUEEZE_RVOL", "5.0")), rvol_min=float(os.environ.get("TRIGGER_RVOL_MIN", "2.0")), min_range_pct=float(os.environ.get("TRIGGER_MIN_RANGE_PCT", "0.004")), min_dollar_vol=float(os.environ.get("TRIGGER_MIN_DOLLAR_VOL", "0")), diff --git a/runtime/triggers.py b/runtime/triggers.py index 5833f84..2e5c99b 100644 --- a/runtime/triggers.py +++ b/runtime/triggers.py @@ -101,6 +101,13 @@ class ArmedTriggerBook: max_armed: int = 6 gap_min: float = 3.0 # min overnight gap % to be eligible to fire gap_max: float = 1e9 # max gap % — skip blow-off gappers (they fade) + # mega-gapper exception: re-admit gaps >= this % DESPITE gap_max, but only on a + # real volume surge (rvol floor below) so reverse-split repricings stay out. + # Year-scale replay 2026-07-06 (n=2,879, split artifacts removed): the 35-150% + # band is dead (+0.02/+0.00R — gap_max earns its keep there), but REAL >150% + # squeezes (n=82, the LHSW/NXTS class) ran +0.63R with 8/14 months >= 0. 0 = off. + gap_squeeze_min: float = 0.0 + gap_squeeze_rvol: float = 5.0 # rvol floor for the exception (split fakes ~0.01) rvol_min: float = 2.0 # min relative volume to be eligible to fire min_range_pct: float = 0.004 # opening range must be at least this wide # min recent $-volume to fire. rvol is RELATIVE, so 2x of near-nothing still @@ -114,12 +121,19 @@ class ArmedTriggerBook: triggers: dict[str, ArmedTrigger] = field(default_factory=dict) _lock: object = field(default_factory=threading.RLock, repr=False, compare=False) + def _gap_ok(self, t: ArmedTrigger) -> bool: + if self.gap_min <= t.gap_pct <= self.gap_max: + return True + return (self.gap_squeeze_min > 0 + and t.gap_pct >= self.gap_squeeze_min + and t.rvol >= self.gap_squeeze_rvol) + def _eligible(self, t: ArmedTrigger) -> bool: return ( t.trigger is not None and t.stop is not None and t.stop < t.trigger - and self.gap_min <= t.gap_pct <= self.gap_max + and self._gap_ok(t) and t.rvol >= self.rvol_min and t.range_pct >= self.min_range_pct and t.dollar_vol >= self.min_dollar_vol diff --git a/tests/unit/test_triggers.py b/tests/unit/test_triggers.py index a797f96..9c2571b 100644 --- a/tests/unit/test_triggers.py +++ b/tests/unit/test_triggers.py @@ -363,3 +363,26 @@ def test_guard_grace_window_protects_just_armed_entry(): "armed_at": datetime.now(), "checks": 0} # armed just now assert svc.expire_stale_entries() == [] # grace -> not cancelled assert "o1" in svc._armed and client.canceled == [] + + +def test_gap_cap_blocks_mid_band_but_squeeze_exception_readmits_real_monsters(): + """2026-07-06 (LHSW +278%, the trader's only real profit, capped out at 4:49am): + gap_max still blocks the dead 35-150% band, but a REAL mega-gapper — huge gap + AND a genuine volume surge — re-arms via the squeeze exception. A reverse-split + repricing (huge "gap", no volume) must stay excluded.""" + b = ArmedTriggerBook(gap_min=10, gap_max=35, gap_squeeze_min=150, + gap_squeeze_rvol=5.0, rvol_min=2) + b.arm([ + _cand("MID", gap=80.0, rvol=20.0), # dead band: capped + _cand("LHSW", gap=278.0, rvol=40.0), # real monster: admitted + _cand("SPLIT", gap=278.0, rvol=0.4), # reverse-split fake: excluded + ]) + assert b.triggers["MID"].state == WEAK + assert b.triggers["LHSW"].state == ARMED + assert b.triggers["SPLIT"].state == WEAK + + +def test_squeeze_exception_off_by_default(): + b = ArmedTriggerBook(gap_min=10, gap_max=35, rvol_min=2) + b.arm([_cand("LHSW", gap=278.0, rvol=40.0)]) + assert b.triggers["LHSW"].state == WEAK