You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

550 lines
20 KiB
Python

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