"""VolBreakout52w — honest, look-ahead-safe descendant of the retired topgainer family. Buy at next_open T when T-1 close is a 52-week high with volume confirmation; hold to next-day close. Designed to NEVER repeat the topgainer v1-v54 lookahead bug (see memory: project_topgainer_phase1_lookahead_2026-05-05.md): Phase-1 pre-screen used today's daily_high → +267% Sharpe 13.73 collapsed to -4.3% Sharpe -1.04 once removed. Trigger conditions (ALL evaluated using only data ending T-1): 1. close_T-1 > max(high[T-252..T-2]) 2. volume_T-1 >= 2 * median_volume_20d_T-2 3. ATR_14_T-1 / close_T-1 in [0.015, 0.06] Entry: T next_open. Skip if pre-open implied gap > +4% (when gap data available). Exit: -3% intraday stop, +5% target, max_holding_days = 2 (mandatory MOC day 2). Architectural choice (a) — synthetic Candidate emission into the existing ``_scheduled_delayed_entries`` queue, mirroring EarningsRunup / PeerSympathy. Production path (b) — pre-compute features into the snapshot — left as a follow-up. Look-ahead defenses (NON-NEGOTIABLE): * BarHistoryProvider boundary returns bars STRICTLY before decision_date. * ``_assert_features_strictly_before_decision_open`` checks every feature timestamp against 09:30 ET on the decision day. * Defence-in-depth: ``evaluate_trigger`` re-asserts ``last_bar_date < decision_date`` so a future maintainer cannot accidentally introduce a T+0 feature path. * ``FrozenT1Features`` typed wrapper raises ``LookaheadViolationError`` on construction if any field's source date >= decision_date. """ from __future__ import annotations import datetime as dt import math import statistics from dataclasses import dataclass, field from typing import Any, Iterable, Protocol from libs.backtest.domain import ( Candidate, LookaheadViolationError, StrategyEngineConfig, ) from libs.common.logging import get_logger logger = get_logger(__name__) VOL_BREAKOUT_52W_EVENT_TYPE = "vol_breakout_52w" # Eastern-time market open used as the leakage cutoff. _ET_MARKET_OPEN = dt.time(9, 30) _ET_OFFSET = dt.timedelta(hours=-5) # EST; DST irrelevant for an ordering bound # Forbidden field substrings at the screener level — any column whose name # encodes the entry-day's intraday/EOD data is a categorical look-ahead. _FORBIDDEN_T0_FIELD_SUBSTRINGS: tuple[str, ...] = ( "daily_high", "daily_low", "daily_close", "intraday_high", "intraday_low", ) # --------------------------------------------------------------------------- # Provider Protocols # --------------------------------------------------------------------------- class BarHistoryProvider(Protocol): """Returns chronologically-ordered (date, bar_dict) pairs for ``symbol`` strictly before ``as_of_date``.""" def get_bars_before( self, symbol: str, as_of_date: dt.date, lookback_days: int, ) -> list[tuple[dt.date, dict[str, Any]]]: ... class PreOpenGapProvider(Protocol): """Returns the implied pre-open gap for ``symbol`` on ``decision_date``'s next session. ``None`` if data is unavailable for that symbol/date. Implementations MUST use only premarket data observed before 09:30 ET on the next trading day. """ def get_pre_open_gap_pct( self, symbol: str, next_trading_date: dt.date, prev_close: float, ) -> float | None: ... # --------------------------------------------------------------------------- # Lookahead defense — cutoff and assertions # --------------------------------------------------------------------------- def _decision_cutoff_utc(decision_date: dt.date) -> dt.datetime: """09:30 ET on decision_date, expressed as a UTC-aware timestamp.""" et_naive = dt.datetime.combine(decision_date, _ET_MARKET_OPEN) utc_naive = et_naive - _ET_OFFSET return utc_naive.replace(tzinfo=dt.timezone.utc) def _assert_features_strictly_before_decision_open( symbol: str, decision_date: dt.date, feature_timestamps: Iterable[dt.datetime], ) -> None: """Raise LookaheadViolationError if any feature timestamp >= 09:30 ET on decision_date.""" cutoff = _decision_cutoff_utc(decision_date) for ts in feature_timestamps: if ts is None: continue if ts.tzinfo is None: raise LookaheadViolationError( f"VolBreakout52w feature timestamp for {symbol} is naive ({ts.isoformat()}); " "all timestamps must be timezone-aware to compare against the cutoff" ) if ts >= cutoff: raise LookaheadViolationError( f"VolBreakout52w feature timestamp {ts.isoformat()} for {symbol} is " f">= decision_date cutoff {cutoff.isoformat()}; this is a look-ahead violation" ) def _bar_close_timestamp(bar_date: dt.date) -> dt.datetime: """Timestamp the daily-close bar at 16:00 ET on its trading day, in UTC.""" et_naive = dt.datetime.combine(bar_date, dt.time(16, 0)) utc_naive = et_naive - _ET_OFFSET return utc_naive.replace(tzinfo=dt.timezone.utc) # --------------------------------------------------------------------------- # FrozenT1Features — typed wrapper that refuses to hold T+0 data # --------------------------------------------------------------------------- @dataclass(frozen=True) class FrozenT1Features: """Strict T-1 (or earlier) feature bundle. Construction validates that every source date is strictly before ``decision_date`` AND that no field name encodes entry-day data (``daily_high``, ``daily_low``, ``daily_close``, ...). Either raises ``LookaheadViolationError`` immediately. This is the categorical defense against the topgainer v1-v54 bug — even if the BarHistoryProvider were leaky, this wrapper refuses to carry forward any T+0 information into the screener. """ symbol: str decision_date: dt.date last_bar_date: dt.date last_close: float high_252d_max: float # max(high[T-252..T-2]); excludes last_bar_date by construction high_252d_max_window: list[dt.date] = field(default_factory=list) volume_t_minus_1: float = 0.0 median_volume_20d_t_minus_2: float = 0.0 atr_14_t_minus_1: float = 0.0 atr_normalized_t_minus_1: float = 0.0 avg_dollar_volume_20d: float = 0.0 last_bar_timestamp: dt.datetime | None = None # tz-aware extra: dict[str, Any] = field(default_factory=dict) def __post_init__(self) -> None: # 1) Forbidden field substrings on user-supplied extras. for k in self.extra.keys(): kl = str(k).lower() for forbidden in _FORBIDDEN_T0_FIELD_SUBSTRINGS: if forbidden in kl: raise LookaheadViolationError( f"FrozenT1Features for {self.symbol}: field {k!r} contains " f"forbidden substring {forbidden!r} — these encode entry-day " "data and constitute a categorical look-ahead" ) # 2) last_bar_date must be strictly before decision_date. if self.last_bar_date >= self.decision_date: raise LookaheadViolationError( f"FrozenT1Features for {self.symbol}: last_bar_date {self.last_bar_date.isoformat()} " f"is not strictly before decision_date {self.decision_date.isoformat()}" ) # 3) high_252d_max_window dates must be strictly before decision_date. for d in self.high_252d_max_window: if d >= self.decision_date: raise LookaheadViolationError( f"FrozenT1Features for {self.symbol}: 252d window includes " f"{d.isoformat()} which is not strictly before " f"{self.decision_date.isoformat()}" ) # 4) last_bar_timestamp (if provided) must be strictly before 09:30 ET on decision_date. if self.last_bar_timestamp is not None: _assert_features_strictly_before_decision_open( self.symbol, self.decision_date, [self.last_bar_timestamp] ) def __getattr__(self, item: str) -> Any: # pragma: no cover - defensive # Only invoked if normal attribute lookup fails, but we want to be # explicit about forbidden access patterns even on dynamic getattr. kl = item.lower() for forbidden in _FORBIDDEN_T0_FIELD_SUBSTRINGS: if forbidden in kl: raise LookaheadViolationError( f"FrozenT1Features for {self.symbol}: access to {item!r} blocked — " f"contains forbidden substring {forbidden!r}" ) raise AttributeError(item) # --------------------------------------------------------------------------- # Pure trigger feature computations # --------------------------------------------------------------------------- def compute_52w_high_breakout( bars: list[tuple[dt.date, dict[str, Any]]], *, lookback_days: int = 252, ) -> tuple[bool, float, float, list[dt.date]]: """Return (is_breakout, last_close, prior_max_high, used_window_dates). ``bars`` must be chronologically ordered AND strictly before the decision_date. The "prior 252-day high" is computed over the [-(lookback+1) .. -2] slice — i.e. the 252 days BEFORE T-1 — so T-1's own high never enters the max. Returns ``(False, last_close, 0.0, [])`` on insufficient history. """ if len(bars) < 2: return False, 0.0, 0.0, [] last_date, last_bar = bars[-1] last_close = float(last_bar.get("close", 0.0)) if last_close <= 0: return False, 0.0, 0.0, [] # Window = the 252 bars BEFORE T-1 (excludes T-1 itself). prior_window = bars[-(lookback_days + 1):-1] if len(prior_window) < max(20, lookback_days // 4): # Need at least a minimal window to claim a 52w high. return False, last_close, 0.0, [] used_dates = [d for d, _ in prior_window] prior_max_high = max(float(b.get("high", 0.0)) for _, b in prior_window) is_breakout = last_close > prior_max_high return bool(is_breakout), last_close, float(prior_max_high), used_dates def compute_volume_ratio( bars: list[tuple[dt.date, dict[str, Any]]], *, median_window: int = 20, ) -> tuple[float | None, float | None]: """Return (volume_T-1, median_volume_20d_T-2). Median is computed over the 20 bars BEFORE T-1 — i.e. ending at T-2. Returns (None, None) on insufficient data. """ if len(bars) < median_window + 1: return None, None last_volume = float(bars[-1][1].get("volume", 0.0)) prior_window = bars[-(median_window + 1):-1] prior_volumes = [float(b.get("volume", 0.0)) for _, b in prior_window] if not prior_volumes: return None, None median_vol = float(statistics.median(prior_volumes)) return last_volume, median_vol def compute_atr_normalized( bars: list[tuple[dt.date, dict[str, Any]]], *, window: int = 14, ) -> float | None: """Compute ATR_14 / close_T-1 from the last 15 bars (need T-15..T-1).""" if len(bars) < window + 1: return None recent = bars[-(window + 1):] trs: list[float] = [] prev_close = float(recent[0][1].get("close", 0.0)) for d, bar in recent[1:]: high = float(bar.get("high", 0.0)) low = float(bar.get("low", 0.0)) close = float(bar.get("close", 0.0)) tr = max(high - low, abs(high - prev_close), abs(low - prev_close)) trs.append(tr) prev_close = close if not trs: return None atr = statistics.fmean(trs) last_close = float(bars[-1][1].get("close", 0.0)) if last_close <= 0: return None return atr / last_close def compute_avg_dollar_volume_20d( bars: list[tuple[dt.date, dict[str, Any]]], ) -> float: """Mean(close * volume) over the last 20 bars.""" if not bars: return 0.0 tail = bars[-20:] if not tail: return 0.0 return statistics.fmean( float(b.get("close", 0.0)) * float(b.get("volume", 0.0)) for _, b in tail ) # --------------------------------------------------------------------------- # Trigger evaluation # --------------------------------------------------------------------------- @dataclass(frozen=True) class VolBreakout52wTriggerInputs: """Bundle of T-1-or-earlier inputs for one (symbol, decision_date) trigger. NOTE: ``last_bar_date`` MUST be strictly before ``decision_date``. The builder enforces this and ``evaluate_trigger`` re-asserts as defence in depth. """ symbol: str decision_date: dt.date next_trading_date: dt.date last_bar_date: dt.date last_bar_timestamp: dt.datetime # tz-aware last_close: float prior_252d_max_high: float is_52w_breakout: bool volume_t_minus_1: float median_volume_20d_t_minus_2: float atr_normalized_t_minus_1: float avg_dollar_volume_20d: float pre_open_gap_pct: float | None # may be None when missing-data path is taken def evaluate_trigger( inputs: VolBreakout52wTriggerInputs, engine: StrategyEngineConfig, ) -> tuple[bool, str | None]: """Pure trigger check. Returns (passes, reject_reason). Defence-in-depth: re-assert ``last_bar_date < decision_date`` so any future code path that bypasses the BarHistoryProvider boundary still trips here. """ if inputs.last_bar_date >= inputs.decision_date: raise LookaheadViolationError( f"VolBreakout52w {inputs.symbol}: last_bar_date {inputs.last_bar_date.isoformat()} " f"is not strictly before decision_date {inputs.decision_date.isoformat()}" ) # Universe gates — ADV and price. adv_min = float(getattr(engine, "vol_breakout_52w_min_avg_dollar_volume", 10_000_000.0) or 0.0) if adv_min > 0 and inputs.avg_dollar_volume_20d < adv_min: return False, ( f"avg_dollar_volume_20d {inputs.avg_dollar_volume_20d:,.0f} " f"< min {adv_min:,.0f}" ) price_min = float(getattr(engine, "vol_breakout_52w_min_price", 5.0) or 0.0) if price_min > 0 and inputs.last_close < price_min: return False, f"last_close {inputs.last_close:.2f} < min price {price_min:.2f}" # 1) 52-week breakout. if not inputs.is_52w_breakout: return False, ( f"close {inputs.last_close:.4f} <= prior 252d max high " f"{inputs.prior_252d_max_high:.4f}" ) # 2) Volume confirmation. vol_ratio_min = float(getattr(engine, "vol_breakout_52w_volume_ratio_min", 2.0) or 0.0) if inputs.median_volume_20d_t_minus_2 <= 0: return False, "median_volume_20d_t_minus_2 <= 0" actual_ratio = inputs.volume_t_minus_1 / inputs.median_volume_20d_t_minus_2 if actual_ratio < vol_ratio_min: return False, ( f"volume_ratio {actual_ratio:.3f} < min {vol_ratio_min:.3f}" ) # 3) ATR / close band — filter parabolics AND too-quiet stocks. atr_min = float(getattr(engine, "vol_breakout_52w_atr_normalized_min", 0.015) or 0.0) atr_max = float(getattr(engine, "vol_breakout_52w_atr_normalized_max", 0.06) or 1.0) if inputs.atr_normalized_t_minus_1 < atr_min: return False, ( f"atr_normalized {inputs.atr_normalized_t_minus_1:.4f} < min {atr_min:.4f}" ) if inputs.atr_normalized_t_minus_1 > atr_max: return False, ( f"atr_normalized {inputs.atr_normalized_t_minus_1:.4f} > max {atr_max:.4f}" ) # 4) Pre-open gap fade guard. gap_max = float(getattr(engine, "vol_breakout_52w_pre_open_gap_max", 0.04) or 0.0) if inputs.pre_open_gap_pct is not None and gap_max > 0: if inputs.pre_open_gap_pct > gap_max: return False, ( f"pre_open_gap_pct {inputs.pre_open_gap_pct:.4f} > max {gap_max:.4f}" ) # If pre_open_gap_pct is None, the missing-data path was already chosen at the # builder level (skip-with-warning vs. hard-fail). return True, None # --------------------------------------------------------------------------- # Public entry point # --------------------------------------------------------------------------- def build_candidates( decision_date: dt.date, next_trading_date: dt.date, universe_symbols: Iterable[str], engine: StrategyEngineConfig, bar_provider: BarHistoryProvider, pre_open_gap_provider: PreOpenGapProvider | None = None, *, _missing_gap_warned: dict[str, bool] | None = None, ) -> list[Candidate]: """Construct synthetic VolBreakout52w candidates for ``next_trading_date`` execution. Decision logic runs at T-1 close (=decision_date close); orders fill at T+1 next_open. Every input must satisfy ``timestamp < decision_date 09:30 ET``. ``pre_open_gap_provider`` is optional. When None, behavior depends on ``engine.vol_breakout_52w_skip_if_no_gap_data``: * True → skip the gap guard (no enforcement) and log a one-shot warning. * False → do not enforce (no enforcement) and log a one-shot warning. Either way the engine emits candidates without the gap guard. A loud one-shot log surfaces the missing infra. """ if not getattr(engine, "vol_breakout_52w_enabled", False): return [] if next_trading_date <= decision_date: raise LookaheadViolationError( f"VolBreakout52w next_trading_date {next_trading_date.isoformat()} must be " f"strictly after decision_date {decision_date.isoformat()}" ) lookback = int(getattr(engine, "vol_breakout_52w_lookback_days", 252) or 252) median_window = int(getattr(engine, "vol_breakout_52w_volume_median_window", 20) or 20) skip_if_no_gap = bool(getattr(engine, "vol_breakout_52w_skip_if_no_gap_data", False)) # One-shot missing-gap warning aggregation. Caller may pass a shared dict. warned = _missing_gap_warned if _missing_gap_warned is not None else {} if pre_open_gap_provider is None and not warned.get("logged"): if skip_if_no_gap: logger.warning( "vol_breakout_52w_pre_open_gap_provider_missing", action="skip_gap_guard", detail=( "PreOpenGapProvider not wired; the +4% gap-fade guard is INACTIVE. " "Backtest results will under-penalize gap-up days. Mark all derived " "PnL as 'missing pre-open guard'." ), ) else: logger.warning( "vol_breakout_52w_pre_open_gap_provider_missing", action="no_enforcement_no_skip", detail="PreOpenGapProvider not wired and skip flag is False — gap guard inactive.", ) warned["logged"] = True candidates: list[Candidate] = [] seen_symbols: set[str] = set() # Need enough bars for both the 252d window and the 20d volume median. fetch_lookback = max(lookback + 5, median_window + 5) for raw_symbol in universe_symbols: symbol = str(raw_symbol).strip().upper() if not symbol or symbol in seen_symbols: continue seen_symbols.add(symbol) bars = bar_provider.get_bars_before(symbol, decision_date, lookback_days=fetch_lookback) if not bars: continue # Strict T-1 check: most recent allowed bar must be < decision_date. last_bar_date, last_bar = bars[-1] if last_bar_date >= decision_date: raise LookaheadViolationError( f"VolBreakout52w bar for {symbol} on {last_bar_date.isoformat()} is not " f"strictly before decision_date {decision_date.isoformat()}" ) last_close = float(last_bar.get("close", 0.0)) if last_close <= 0: continue # --- Cheap universe gates first to short-circuit before the 252d scan --- adv_min = float(getattr(engine, "vol_breakout_52w_min_avg_dollar_volume", 10_000_000.0) or 0.0) price_min = float(getattr(engine, "vol_breakout_52w_min_price", 5.0) or 0.0) if price_min > 0 and last_close < price_min: continue adv_20d = compute_avg_dollar_volume_20d(bars) if adv_min > 0 and adv_20d < adv_min: continue is_breakout, _last_close_check, prior_max_high, used_dates = compute_52w_high_breakout( bars, lookback_days=lookback ) # Defence-in-depth: every used date in the 252d window must be strictly < decision_date. for d in used_dates: if d >= decision_date: raise LookaheadViolationError( f"VolBreakout52w 252d window for {symbol} includes {d.isoformat()} " f"which is not strictly before decision_date {decision_date.isoformat()}" ) if not is_breakout: continue vol_t1, median_vol_t2 = compute_volume_ratio(bars, median_window=median_window) if vol_t1 is None or median_vol_t2 is None or median_vol_t2 <= 0: continue atr_norm = compute_atr_normalized(bars, window=14) if atr_norm is None: continue # Pre-open gap (optional). pre_open_gap_pct: float | None = None if pre_open_gap_provider is not None: try: pre_open_gap_pct = pre_open_gap_provider.get_pre_open_gap_pct( symbol=symbol, next_trading_date=next_trading_date, prev_close=last_close, ) except Exception as exc: # noqa: BLE001 logger.debug( "vol_breakout_52w_pre_open_gap_provider_error", symbol=symbol, error=str(exc), ) pre_open_gap_pct = None last_bar_ts = _bar_close_timestamp(last_bar_date) # Hot-path lookahead assertion. _assert_features_strictly_before_decision_open( symbol, decision_date, [last_bar_ts] ) inputs = VolBreakout52wTriggerInputs( symbol=symbol, decision_date=decision_date, next_trading_date=next_trading_date, last_bar_date=last_bar_date, last_bar_timestamp=last_bar_ts, last_close=last_close, prior_252d_max_high=prior_max_high, is_52w_breakout=is_breakout, volume_t_minus_1=vol_t1, median_volume_20d_t_minus_2=median_vol_t2, atr_normalized_t_minus_1=atr_norm, avg_dollar_volume_20d=adv_20d, pre_open_gap_pct=pre_open_gap_pct, ) passes, reason = evaluate_trigger(inputs, engine) if not passes: logger.debug( "vol_breakout_52w_trigger_skipped", symbol=symbol, decision_date=decision_date.isoformat(), reason=reason, ) continue candidate = _build_candidate_from_inputs(inputs, engine) candidates.append(candidate) return candidates # --------------------------------------------------------------------------- # Candidate construction # --------------------------------------------------------------------------- def _build_candidate_from_inputs( inputs: VolBreakout52wTriggerInputs, engine: StrategyEngineConfig, ) -> Candidate: # Map pct exits onto the existing ATR-multiplier / R-multiple machinery. synthetic_atr = max(inputs.last_close * 0.02, 0.01) stop_pct = float(getattr(engine, "vol_breakout_52w_stop_pct", 0.03) or 0.03) target_pct = float(getattr(engine, "vol_breakout_52w_target_pct", 0.05) or 0.05) stop_mult = stop_pct / 0.02 if stop_pct > 0 else 1.5 target_r = target_pct / stop_pct if stop_pct > 0 else 1.67 max_holding_days = max(1, int(getattr(engine, "vol_breakout_52w_max_holding_days", 2) or 2)) # Score: deterministic function of the volume spike — higher conviction at higher ratio. if inputs.median_volume_20d_t_minus_2 > 0: vol_ratio = inputs.volume_t_minus_1 / inputs.median_volume_20d_t_minus_2 else: vol_ratio = 1.0 score = 0.5 + 0.05 * (vol_ratio - 2.0) score = max(0.0, min(0.99, score)) score_bucket = ( "high" if score >= 0.8 else "medium_high" if score >= 0.6 else "medium" ) event_id = ( f"synth_vol_breakout_52w_{inputs.symbol.lower()}_" f"{inputs.decision_date.isoformat()}" ) features = { "vol_breakout_52w_decision_date": inputs.decision_date.isoformat(), "vol_breakout_52w_last_close": inputs.last_close, "vol_breakout_52w_prior_252d_max_high": inputs.prior_252d_max_high, "vol_breakout_52w_volume_t_minus_1": inputs.volume_t_minus_1, "vol_breakout_52w_median_volume_20d_t_minus_2": inputs.median_volume_20d_t_minus_2, "vol_breakout_52w_volume_ratio": round(vol_ratio, 4), "vol_breakout_52w_atr_normalized_t_minus_1": round(inputs.atr_normalized_t_minus_1, 6), "vol_breakout_52w_avg_dollar_volume_20d": inputs.avg_dollar_volume_20d, "vol_breakout_52w_pre_open_gap_pct": inputs.pre_open_gap_pct, "vol_breakout_52w_stop_pct": stop_pct, "vol_breakout_52w_target_pct": target_pct, "vol_breakout_52w_max_holding_days": max_holding_days, } return Candidate( event_id=event_id, symbol=inputs.symbol, source_symbol=inputs.symbol, score=score, sector="UNKNOWN", event_type=VOL_BREAKOUT_52W_EVENT_TYPE, event_timestamp=inputs.last_bar_timestamp, event_date=inputs.decision_date, filing_time_bucket="post_market", timing_class="after_close", reaction_date=inputs.decision_date, execution_date=inputs.next_trading_date, entry_price_est=inputs.last_close, avg_dollar_volume=inputs.avg_dollar_volume_20d, atr_14=synthetic_atr, score_bucket=score_bucket, engine_id=engine.engine_id, entry_timing_policy="next_open", trade_direction="long", engine_max_holding_days=max_holding_days, engine_risk_budget_pct=engine.engine_risk_budget_pct, engine_capital_bucket_id=( (engine.capital_bucket_id or engine.engine_id) if engine.capital_bucket_allocation_pct is not None else None ), engine_capital_bucket_allocation_pct=engine.capital_bucket_allocation_pct, engine_per_trade_risk_pct=engine.per_trade_risk_pct_override, engine_target_1_r=target_r, engine_target_1_fraction=1.0, engine_trailing_model=engine.trailing_model_override, engine_trailing_warmup_days=engine.trailing_warmup_days_override, engine_stop_atr_multiplier=stop_mult, engine_next_open_gap_cap_pct=engine.next_open_gap_cap_pct, engine_use_reaction_day_low_stop=False, engine_early_failure_close_below_entry_and_reaction_close=False, engine_early_failure_no_progress_days=engine.early_failure_no_progress_days_override, engine_early_failure_no_progress_r=engine.early_failure_no_progress_r_override, engine_early_failure_no_progress_fraction=engine.early_failure_no_progress_fraction_override, shadow_only=engine.shadow_only, features=features, ) # --------------------------------------------------------------------------- # Adapters: bridge BacktestRunner state to the Protocols above. # --------------------------------------------------------------------------- @dataclass class _SnapshotStoreBarAdapter: """Adapt SnapshotStore (or any bars-by-symbol-by-date dict) to BarHistoryProvider. Caches the sorted (date, bar) list per symbol so the per-day loop does not re-sort O(B) bars on each call. This is the hot path for VolBreakout52w because the universe is scanned daily, unlike event-triggered engines. """ bars_by_symbol: dict[str, dict[dt.date, dict[str, Any]]] _sorted_cache: dict[str, list[tuple[dt.date, dict[str, Any]]]] = field(default_factory=dict) def _sorted_for(self, symbol: str) -> list[tuple[dt.date, dict[str, Any]]]: sym_upper = symbol.upper() cached = self._sorted_cache.get(sym_upper) if cached is not None: return cached sym_bars = self.bars_by_symbol.get(sym_upper) if not sym_bars: self._sorted_cache[sym_upper] = [] return self._sorted_cache[sym_upper] ordered = sorted(sym_bars.items(), key=lambda kv: kv[0]) self._sorted_cache[sym_upper] = ordered return ordered def get_bars_before( self, symbol: str, as_of_date: dt.date, lookback_days: int, ) -> list[tuple[dt.date, dict[str, Any]]]: ordered = self._sorted_for(symbol) if not ordered: return [] # Binary scan would be faster but we cap lookback small, so linear-from-end is fine. # Strictly before as_of_date. eligible: list[tuple[dt.date, dict[str, Any]]] = [] for d, b in ordered: if d >= as_of_date: break eligible.append((d, b)) return eligible[-lookback_days:] __all__ = [ "VOL_BREAKOUT_52W_EVENT_TYPE", "BarHistoryProvider", "FrozenT1Features", "PreOpenGapProvider", "VolBreakout52wTriggerInputs", "_SnapshotStoreBarAdapter", "_assert_features_strictly_before_decision_open", "build_candidates", "compute_52w_high_breakout", "compute_atr_normalized", "compute_avg_dollar_volume_20d", "compute_volume_ratio", "evaluate_trigger", ]