"""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