TRADING_GAP_SQUEEZE_ENABLED=0 rejects >=150% gappers in the population gate (watcher pre-filter and entry gate) and zeroes the trigger book's squeeze arm. One switch covers all three paths. Why: while testing the trader's "trade the leading gainers" idea on 1,093 live-band v1 ORB breaks (Jun-Sep 2026, current exits), top-3 gainers at the break did WORSE (diff -0.48R Jun-Jul, -0.40R Aug-Sep, 90% CIs excluding 0). That turned out to be almost entirely the >=150% squeeze-exception names: gap 10-35% Jun-Jul +0.27R (n=342) Aug-Sep +0.13R (n=327) gap >=150% Jun-Jul -0.21R (n=65) Aug-Sep -0.30R (n=53) Our own squeeze fills: -0.31R / -0.33R realized (n=16). By the time a squeeze name breaks its opening range the move has happened; the trader catches these names premarket, which the bot does not. This finding was post-hoc (not the pre-registered direction), but it held in both halves and in the real fills. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E3S5EFKThFVrjmN4HHsHMN
127 lines
5.6 KiB
Python
127 lines
5.6 KiB
Python
"""strategy/population.py — the ONE definition of the tradeable population.
|
|
|
|
Every consumer (the live gate in trading_execution, the nightly tuner via
|
|
learn_params, the whole-market eod_replay, and future experiment harnesses)
|
|
screens sessions through ``population_reject`` so a backtest, a tune, and a live
|
|
entry all agree on which names are inside the +EV universe.
|
|
|
|
UNIT CONTRACT: ``gap_pct`` is a FRACTION (0.12 == 12%), matching the live call
|
|
site (``signal.get("gap_pct")``). ``as_of`` documents that every input must be
|
|
knowable at evaluation time — ``"open"`` means the static overnight gap
|
|
(session open vs prev close) and premarket/at-entry volume. Day-end fields
|
|
(full-day volume, close price) are FORBIDDEN: feeding them in re-creates the
|
|
outcome-time look-ahead this module exists to kill. The convention is enforced
|
|
by review, not at runtime.
|
|
|
|
Import-clean: no env reads at import time (env parsing lives in ``from_env``),
|
|
matching the ``ExitConfig.from_env()`` convention in strategy/exits.py.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from dataclasses import dataclass
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PopulationConfig:
|
|
price_min: float = 2.0
|
|
price_max: float = 20.0
|
|
# FRACTION (0.10 == 10%); env TRADING_MIN_ENTRY_GAP_PCT. Code default 0.0 —
|
|
# the whole gap gate is disabled at 0, matching the live default. Production
|
|
# sets 0.10.
|
|
min_entry_gap_pct: float = 0.0
|
|
min_day_volume: float = 0.0 # shares; env TRADING_MIN_DAY_VOLUME
|
|
# Hardcoded in the live gate — module CONSTANTS, no env read. (The
|
|
# TRIGGER_GAP_MAX / TRIGGER_GAP_SQUEEZE_* env keys configure the SEPARATE
|
|
# ArmedTriggerBook watcher gate in runtime/triggers.py, NOT this function.)
|
|
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":
|
|
"""Build from environment — SAME keys + defaults as trading_execution's
|
|
ExecutionSettings.from_env today (incl. the [0,1] clamp on the gap floor).
|
|
Reads ONLY the four population keys; the dead-band / squeeze constants are
|
|
never env-driven."""
|
|
v = dict(os.environ)
|
|
if env:
|
|
v.update(env)
|
|
|
|
def _qfloat(val: str) -> float: # mirrors trading_execution._qfloat
|
|
try:
|
|
return max(0.0, min(1.0, float(val)))
|
|
except (TypeError, ValueError):
|
|
return 0.0
|
|
|
|
return cls(
|
|
price_min=float(v.get("TRADING_PRICE_MIN", "2.0")),
|
|
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"},
|
|
)
|
|
|
|
|
|
def population_reject(
|
|
cfg: PopulationConfig,
|
|
*,
|
|
gap_pct,
|
|
price,
|
|
volume,
|
|
rvol,
|
|
as_of: str = "open",
|
|
) -> str | None:
|
|
"""Return None if inside the tradeable population, else the reject reason.
|
|
|
|
UNIT CONTRACT: ``gap_pct`` is a FRACTION (0.12 == 12%), matching the live
|
|
call site (``signal.get("gap_pct")``). CONTRACT: every input must be
|
|
knowable at evaluation time (``as_of="open"`` = static open gap,
|
|
premarket/at-entry volume). Day-end fields are forbidden (see module doc).
|
|
|
|
Fail-open: missing values never block (except the dead band, which requires
|
|
a VERIFIED squeeze to re-admit). Logic moved verbatim from the live gate
|
|
``TradingExecutionService._population_reject``.
|
|
"""
|
|
if cfg.min_entry_gap_pct > 0 and gap_pct is not None:
|
|
try:
|
|
g = float(gap_pct)
|
|
if g < cfg.min_entry_gap_pct:
|
|
return f"gap {g:.1%} < {cfg.min_entry_gap_pct:.0%}"
|
|
# dead band (35-150%): +0.02R at year scale — excluded. Above 150%
|
|
# only VOLUME-VERIFIED squeezes re-enter (real ones ran +0.63R;
|
|
# 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):
|
|
pass
|
|
if price is not None:
|
|
try:
|
|
px = float(price)
|
|
lo, hi = cfg.price_min, cfg.price_max
|
|
if px > 0 and not (lo <= px <= hi):
|
|
return f"price {px:.2f} outside ${lo:g}-{hi:g} band"
|
|
except (TypeError, ValueError):
|
|
pass
|
|
if cfg.min_day_volume > 0 and volume is not None:
|
|
try:
|
|
if float(volume) < cfg.min_day_volume:
|
|
return (f"day volume {float(volume):,.0f} < "
|
|
f"{cfg.min_day_volume:,.0f}")
|
|
except (TypeError, ValueError):
|
|
pass
|
|
return None
|