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