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.

741 lines
29 KiB
Python

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