diff --git a/run_live_paper.py b/run_live_paper.py index d2b2f2e..833f73c 100644 --- a/run_live_paper.py +++ b/run_live_paper.py @@ -483,7 +483,11 @@ 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")), + # TRADING_GAP_SQUEEZE_ENABLED=0 turns the >=150% squeeze exception off here too, so + # one switch covers the trigger book, the watcher pre-filter and the entry gate. + gap_squeeze_min=(float(os.environ.get("TRIGGER_GAP_SQUEEZE_MIN", "0")) + if os.environ.get("TRADING_GAP_SQUEEZE_ENABLED", "1").strip().lower() + not in {"0", "false", "no", "off"} else 0.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")), diff --git a/strategy/population.py b/strategy/population.py index ed1838c..1c2d2cd 100644 --- a/strategy/population.py +++ b/strategy/population.py @@ -38,6 +38,13 @@ class PopulationConfig: gap_dead_band_lo: float = 0.35 gap_dead_band_hi: float = 1.50 squeeze_rvol_min: float = 5.0 + # The >=150% "verified squeeze" exception. Env TRADING_GAP_SQUEEZE_ENABLED (default on). + # 2026-09-28: under the current exits, live-band ORB breaks with a >=150% gap lost in both + # halves (Jun-Jul -0.21R n=65, Aug-Sep -0.30R n=53), while the 10-35% band made +0.27R / + # +0.13R (n=342 / 327). Our own squeeze fills were -0.31R / -0.33R realized (n=16). + # These are the day's top gainers by the time their opening range breaks: the move has + # already happened. + squeeze_enabled: bool = True @classmethod def from_env(cls, env: dict | None = None) -> "PopulationConfig": @@ -60,6 +67,8 @@ class PopulationConfig: price_max=float(v.get("TRADING_PRICE_MAX", "20.0")), min_entry_gap_pct=_qfloat(v.get("TRADING_MIN_ENTRY_GAP_PCT", "0.0")), min_day_volume=float(v.get("TRADING_MIN_DAY_VOLUME", "0.0")), + squeeze_enabled=str(v.get("TRADING_GAP_SQUEEZE_ENABLED", "1")).strip().lower() + not in {"0", "false", "no", "off"}, ) @@ -93,6 +102,8 @@ def population_reject( # reverse-split fakes show rvol ~0.01). if cfg.gap_dead_band_lo < g < cfg.gap_dead_band_hi: return f"gap {g:.0%} in dead band 35-150%" + if g >= cfg.gap_dead_band_hi and not cfg.squeeze_enabled: + return f"gap {g:.0%} above band (squeeze exception off)" if g >= cfg.gap_dead_band_hi and (rvol is None or float(rvol) < cfg.squeeze_rvol_min): return f"gap {g:.0%} squeeze unverified (rvol {rvol})" except (TypeError, ValueError): diff --git a/tests/unit/test_population_gate.py b/tests/unit/test_population_gate.py index 006a84a..13c652d 100644 --- a/tests/unit/test_population_gate.py +++ b/tests/unit/test_population_gate.py @@ -87,3 +87,4 @@ def test_gap_gate_disabled_when_min_zero(): assert g(gap_pct=0.05) is None # below-floor not checked assert g(gap_pct=0.40) is None # dead band not checked assert g(gap_pct=1.60, rvol=1.0) is None # squeeze not checked + diff --git a/tests/unit/test_squeeze_switch.py b/tests/unit/test_squeeze_switch.py new file mode 100644 index 0000000..8fbea00 --- /dev/null +++ b/tests/unit/test_squeeze_switch.py @@ -0,0 +1,19 @@ +"""TRADING_GAP_SQUEEZE_ENABLED=0 turns the >=150% squeeze exception off everywhere.""" + +from strategy.population import PopulationConfig, population_reject +from trading_execution import ExecutionSettings + + +def test_population_config_switch(): + on = PopulationConfig.from_env({"TRADING_MIN_ENTRY_GAP_PCT": "0.10"}) + off = PopulationConfig.from_env({"TRADING_MIN_ENTRY_GAP_PCT": "0.10", "TRADING_GAP_SQUEEZE_ENABLED": "0"}) + assert on.squeeze_enabled and not off.squeeze_enabled + assert population_reject(on, gap_pct=2.0, price=5.0, volume=None, rvol=20.0) is None + reason = population_reject(off, gap_pct=2.0, price=5.0, volume=None, rvol=20.0) + assert reason and "squeeze exception off" in reason + assert population_reject(off, gap_pct=0.20, price=5.0, volume=None, rvol=3.0) is None # core band untouched + + +def test_execution_settings_carry_the_switch(): + assert ExecutionSettings.from_env({}).gap_squeeze_enabled is True + assert ExecutionSettings.from_env({"TRADING_GAP_SQUEEZE_ENABLED": "0"}).gap_squeeze_enabled is False diff --git a/trading_execution.py b/trading_execution.py index 3ccce29..8815f78 100644 --- a/trading_execution.py +++ b/trading_execution.py @@ -122,6 +122,7 @@ class ExecutionSettings: # 0 = disabled. Fail-open: a signal missing gap/volume data is never blocked. min_entry_gap_pct: float = 0.0 min_day_volume: float = 0.0 + gap_squeeze_enabled: bool = True # TRADING_GAP_SQUEEZE_ENABLED, see strategy/population.py # entries only during the regular session (ET). SIP prints extended-hours # trades, so without this gate after-hours price crosses fire real orders # (TDTH/SUNE resting entries submitted 18:53-20:09 ET on 2026-07-09). @@ -319,6 +320,7 @@ class ExecutionSettings: entry_confirm_by_minute=int(values.get("TRADING_ENTRY_CONFIRM_BY_MINUTE", "0")), min_entry_gap_pct=_qfloat(values.get("TRADING_MIN_ENTRY_GAP_PCT", "0.0")), min_day_volume=float(values.get("TRADING_MIN_DAY_VOLUME", "0.0")), + gap_squeeze_enabled=flag("TRADING_GAP_SQUEEZE_ENABLED", "1"), session_only=flag("TRADING_SESSION_ONLY", "1"), session_start=values.get("TRADING_SESSION_START", "09:30"), session_end=values.get("TRADING_SESSION_END", "15:55"), @@ -957,6 +959,7 @@ class TradingExecutionService: price_max=self.settings.price_band_max, min_entry_gap_pct=self.settings.min_entry_gap_pct, min_day_volume=self.settings.min_day_volume, + squeeze_enabled=self.settings.gap_squeeze_enabled, ) return population_reject( cfg, gap_pct=gap_pct, price=price, volume=cum_volume, rvol=rvol)