"""Breakout52w — George-Hwang (2004) 52-week-high price-momentum engine. Monthly rebalance, top-N cross-sectional by 20-day volume ratio descending. Long-only. Buys liquid mid/large caps that close at a NEW 52-week (252-day) high on T-1. Enters at next_open, holds for breakout_holding_days trading days (default 21, monthly cadence). Distinct from libs.backtest.vol_breakout_52w (which is a 2-day MOC tactical engine with -3%/+5% bracket exits). This engine targets the trend-continuation tail catalyzed by George-Hwang's anchoring-bias finding: stocks that print new 52-week highs systematically under-react and continue higher. The retired topgainer family (v1-v54, killed at -87% honest replay in project_topgainer_phase1_lookahead_2026-05-05.md) used today's daily_high in a Phase-1 pre-screen — a fatal look-ahead. This engine structurally cannot repeat that bug: * BarHistoryProvider returns bars STRICTLY before decision_date. * The 52w max-high window excludes T-1 itself: bars[-(lookback+1):-1]. Wait — actually we compute "new high today" so we INCLUDE T-1's close being compared against the prior 252-day max. The prior max is over [T-252..T-2], so T-1's close > max(T-252..T-2).high is the breakout. * _assert_strictly_before re-checks every used bar date. Mirrors libs.backtest.low_vol_anomaly exactly for monthly rebalance, top-N, 21d hold, next_open entry — ranking is descending 20d volume ratio (top conviction breakouts first) rather than ascending volatility. """ from __future__ import annotations import datetime as dt import statistics from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Iterable, Protocol if TYPE_CHECKING: from libs.backtest.breakout_52w_cache import Breakout52wRankCache from libs.backtest.domain import ( Candidate, LookaheadViolationError, StrategyEngineConfig, ) from libs.common.logging import get_logger logger = get_logger(__name__) BREAKOUT_52W_EVENT_TYPE = "breakout_52w" _ET_MARKET_OPEN = dt.time(9, 30) _ET_OFFSET = dt.timedelta(hours=-5) # --------------------------------------------------------------------------- # Provider Protocol # --------------------------------------------------------------------------- class BarHistoryProvider(Protocol): def get_bars_before( self, symbol: str, as_of_date: dt.date, lookback_days: int, ) -> list[tuple[dt.date, dict[str, Any]]]: ... # --------------------------------------------------------------------------- # Lookahead defense # --------------------------------------------------------------------------- def _assert_strictly_before( symbol: str, decision_date: dt.date, used_dates: Iterable[dt.date], ) -> None: for d in used_dates: if d >= decision_date: raise LookaheadViolationError( f"Breakout52w {symbol}: bar date {d.isoformat()} " f"is not strictly before decision_date {decision_date.isoformat()}" ) def _bar_close_timestamp(bar_date: dt.date) -> dt.datetime: """16:00 ET close on bar_date, tz-aware 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) # --------------------------------------------------------------------------- # Pure trigger computations # --------------------------------------------------------------------------- def is_rebalance_day( bars: list[tuple[dt.date, dict[str, Any]]], decision_date: dt.date, ) -> bool: """First trading day of the calendar month iff last_bar's month != decision_date.month.""" if not bars: return False last_bar_date = bars[-1][0] if last_bar_date >= decision_date: raise LookaheadViolationError( f"is_rebalance_day: last_bar_date {last_bar_date.isoformat()} " f"is not strictly before decision_date {decision_date.isoformat()}" ) return last_bar_date.month != decision_date.month 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). The "prior 52-week high" is the max(high) over the window of `lookback_days` bars BEFORE T-1 — i.e. bars[-(lookback+1):-1] — so T-1's own bar never enters the max. Breakout = T-1's close strictly greater than prior max high. Bars must be chronologically ordered AND strictly before decision_date. Returns (False, 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): 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_20d( bars: list[tuple[dt.date, dict[str, Any]]], ) -> float | None: """volume_T-1 / median(volume over 20 bars ending T-2). None on insufficient history.""" if len(bars) < 21: return None last_vol = float(bars[-1][1].get("volume", 0.0)) prior_window = bars[-21:-1] prior_volumes = [float(b.get("volume", 0.0)) for _, b in prior_window] if not prior_volumes: return None median_vol = float(statistics.median(prior_volumes)) if median_vol <= 0: return None return last_vol / median_vol def compute_avg_dollar_volume_20d( bars: list[tuple[dt.date, dict[str, Any]]], ) -> float: if not bars: return 0.0 tail = bars[-20:] return statistics.fmean( float(b.get("close", 0.0)) * float(b.get("volume", 0.0)) for _, b in tail ) # --------------------------------------------------------------------------- # Per-symbol inputs + universe gates # --------------------------------------------------------------------------- @dataclass(frozen=True) class Breakout52wInputs: symbol: str decision_date: dt.date next_trading_date: dt.date last_bar_date: dt.date last_bar_timestamp: dt.datetime last_close: float prior_252d_max_high: float is_52w_breakout: bool volume_ratio_20d: float avg_dollar_volume_20d: float def evaluate_universe_gates( inputs: Breakout52wInputs, engine: StrategyEngineConfig, ) -> tuple[bool, str | None]: """Reject low-quality candidates BEFORE ranking. Pure function.""" if inputs.last_bar_date >= inputs.decision_date: raise LookaheadViolationError( f"Breakout52w {inputs.symbol}: last_bar_date " f"{inputs.last_bar_date.isoformat()} not strictly before decision_date" ) price_min = float(getattr(engine, "breakout_52w_min_price", 10.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_min:.2f}" adv_min = float(getattr(engine, "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} < min {adv_min:,.0f}" ) 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}" ) return True, None # --------------------------------------------------------------------------- # Build candidates (top-N descending volume ratio across universe) # --------------------------------------------------------------------------- def _build_candidates_from_cache( rows: list[dict[str, Any]], decision_date: dt.date, next_trading_date: dt.date, engine: StrategyEngineConfig, top_n: int, ) -> list[Candidate]: """Build top-N candidates from cached ranked rows (already post-quality-gate). Sorts DESCENDING by volume_ratio_20d (highest conviction first). """ rows_sorted = sorted(rows, key=lambda r: float(r["volume_ratio_20d"]), reverse=True) top = rows_sorted[:top_n] total_ranked = len(rows_sorted) candidates: list[Candidate] = [] for rank, row in enumerate(top, start=1): last_bar_date = dt.date.fromisoformat(str(row["last_bar_date"])) _assert_strictly_before(str(row["symbol"]), decision_date, [last_bar_date]) inputs = Breakout52wInputs( symbol=str(row["symbol"]), decision_date=decision_date, next_trading_date=next_trading_date, last_bar_date=last_bar_date, last_bar_timestamp=dt.datetime.fromisoformat(str(row["last_bar_timestamp_iso"])), last_close=float(row["last_close"]), prior_252d_max_high=float(row["prior_252d_max_high"]), is_52w_breakout=True, volume_ratio_20d=float(row["volume_ratio_20d"]), avg_dollar_volume_20d=float(row["avg_dollar_volume_20d"]), ) candidates.append(_build_candidate(inputs, engine, rank=rank, total_ranked=total_ranked)) if candidates: logger.info( "breakout_52w_rebalance", decision_date=decision_date.isoformat(), universe_scanned=total_ranked, top_n_emitted=len(candidates), top_score=float(top[0]["volume_ratio_20d"]) if top else None, bottom_score=float(top[-1]["volume_ratio_20d"]) if top else None, cache_hit=True, ) return candidates def build_candidates( decision_date: dt.date, next_trading_date: dt.date, universe_symbols: Iterable[str], engine: StrategyEngineConfig, bar_provider: BarHistoryProvider, *, cache: "Breakout52wRankCache | None" = None, ) -> list[Candidate]: """Emit top-N synthetic 52w-breakout candidates on rebalance days only. On non-rebalance days, returns []. The universe is scanned once per rebalance day; symbols that closed at a NEW 52-week high on T-1 pass the universe gates and are ranked DESCENDING by 20d volume ratio. Top-N enter. """ if not getattr(engine, "breakout_52w_enabled", False): return [] if next_trading_date <= decision_date: raise LookaheadViolationError( f"Breakout52w next_trading_date {next_trading_date.isoformat()} " f"must be strictly after decision_date {decision_date.isoformat()}" ) lookback = int(getattr(engine, "breakout_52w_lookback_days", 252) or 252) top_n = int(getattr(engine, "breakout_52w_top_n", 20) or 20) # Need enough bars for the 252d window + 20d volume ratio buffer + weekend gap allowance. fetch_lookback = lookback + 25 scored: list[tuple[float, Breakout52wInputs]] = [] seen: set[str] = set() rebalance_checked = False for raw_symbol in universe_symbols: symbol = str(raw_symbol).strip().upper() if not symbol or symbol in seen: continue seen.add(symbol) bars = bar_provider.get_bars_before(symbol, decision_date, lookback_days=fetch_lookback) if not bars: continue last_bar_date, last_bar = bars[-1] if last_bar_date >= decision_date: raise LookaheadViolationError( f"Breakout52w bar for {symbol} on {last_bar_date.isoformat()} " f"is not strictly before decision_date {decision_date.isoformat()}" ) # Rebalance gate — check once per call, derived from bars (no calendar). if not rebalance_checked: is_rebalance = is_rebalance_day(bars, decision_date) rebalance_checked = True if not is_rebalance: return [] # Rebalance confirmed: try cache before scanning remaining universe. if cache is not None: cached_rows = cache.get_date(decision_date) if cached_rows is not None: return _build_candidates_from_cache( cached_rows, decision_date, next_trading_date, engine, top_n ) last_close = float(last_bar.get("close", 0.0)) if last_close <= 0: continue is_brk, _last, prior_max, used_dates = compute_52w_high_breakout( bars, lookback_days=lookback ) if not is_brk: continue _assert_strictly_before(symbol, decision_date, used_dates) vol_ratio = compute_volume_ratio_20d(bars) if vol_ratio is None: continue adv_20d = compute_avg_dollar_volume_20d(bars) inputs = Breakout52wInputs( symbol=symbol, decision_date=decision_date, next_trading_date=next_trading_date, last_bar_date=last_bar_date, last_bar_timestamp=_bar_close_timestamp(last_bar_date), last_close=last_close, prior_252d_max_high=prior_max, is_52w_breakout=is_brk, volume_ratio_20d=vol_ratio, avg_dollar_volume_20d=adv_20d, ) passes, _reason = evaluate_universe_gates(inputs, engine) if not passes: continue scored.append((vol_ratio, inputs)) # Sort by volume_ratio DESCENDING (highest conviction = best), take top N. scored.sort(key=lambda t: t[0], reverse=True) top = scored[:top_n] # Save full ranked universe to cache (pre-top_n) for future runs. if cache is not None and scored: cache.save_date(decision_date, [ { "decision_date": decision_date.isoformat(), "symbol": inp.symbol, "volume_ratio_20d": inp.volume_ratio_20d, "avg_dollar_volume_20d": inp.avg_dollar_volume_20d, "last_close": inp.last_close, "prior_252d_max_high": inp.prior_252d_max_high, "last_bar_date": inp.last_bar_date.isoformat(), "last_bar_timestamp_iso": inp.last_bar_timestamp.isoformat(), } for _, inp in scored ]) candidates: list[Candidate] = [] for rank, (_, inputs) in enumerate(top, start=1): candidates.append(_build_candidate(inputs, engine, rank=rank, total_ranked=len(scored))) if candidates: logger.info( "breakout_52w_rebalance", decision_date=decision_date.isoformat(), universe_scanned=len(scored), top_n_emitted=len(candidates), top_score=top[0][0] if top else None, bottom_score=top[-1][0] if top else None, ) return candidates def _build_candidate( inputs: Breakout52wInputs, engine: StrategyEngineConfig, *, rank: int, total_ranked: int, ) -> Candidate: holding_days = int(getattr(engine, "breakout_52w_holding_days", 21) or 21) stop_pct = float(getattr(engine, "breakout_52w_stop_pct", 0.10) or 0.10) target_pct = float(getattr(engine, "breakout_52w_target_pct", 0.30) or 0.30) synthetic_atr = max(inputs.last_close * 0.02, 0.01) stop_mult = stop_pct / 0.02 if stop_pct > 0 else 5.0 target_r = target_pct / stop_pct if stop_pct > 0 else 3.0 # Score: higher volume ratio → higher score, monotonic, clipped to [0.5, 0.99]. # Map vol_ratio [1.0, 5.0] → score [0.5, 0.99] (linear). vol_ref = 5.0 norm = min(1.0, max(0.0, (inputs.volume_ratio_20d - 1.0) / (vol_ref - 1.0))) score = 0.5 + 0.49 * norm score_bucket = ( "high" if score >= 0.8 else "medium_high" if score >= 0.6 else "medium" ) event_id = ( f"synth_breakout52w_{inputs.symbol.lower()}_" f"{inputs.decision_date.isoformat()}" ) features = { "breakout_52w_decision_date": inputs.decision_date.isoformat(), "breakout_52w_last_close": inputs.last_close, "breakout_52w_prior_252d_max_high": inputs.prior_252d_max_high, "breakout_52w_volume_ratio_20d": round(inputs.volume_ratio_20d, 4), "breakout_52w_avg_dollar_volume_20d": inputs.avg_dollar_volume_20d, "breakout_52w_rank": rank, "breakout_52w_total_ranked": total_ranked, "breakout_52w_stop_pct": stop_pct, "breakout_52w_target_pct": target_pct, "breakout_52w_holding_days": holding_days, } return Candidate( event_id=event_id, symbol=inputs.symbol, source_symbol=inputs.symbol, score=score, sector="UNKNOWN", event_type=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=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_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, # breakout_52w needs full holding period — explicitly disable NO_PROGRESS / trailing # so v9.x base config's tight gates don't fire on synthetic candidates. engine_early_failure_no_progress_days=0, engine_early_failure_no_progress_r=0.0, engine_early_failure_no_progress_fraction=0.0, engine_trailing_model="none", engine_trailing_warmup_days=999, shadow_only=engine.shadow_only, features=features, ) # --------------------------------------------------------------------------- # Adapter (mirrors low_vol_anomaly / xsmom / vol_breakout_52w pattern) # --------------------------------------------------------------------------- @dataclass class _SnapshotStoreBarAdapter: 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 [] 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__ = [ "BREAKOUT_52W_EVENT_TYPE", "BarHistoryProvider", "Breakout52wInputs", "_SnapshotStoreBarAdapter", "build_candidates", "compute_52w_high_breakout", "compute_avg_dollar_volume_20d", "compute_volume_ratio_20d", "evaluate_universe_gates", "is_rebalance_day", ]