Add 3 candidate engine classes beyond PEAD: EarningsRunup, PeerSympathy, VolBreakout52w

Adds three new synthetic-Candidate emitter engines parallel to the
existing leader_follower scheduler hook, plus look-ahead defenses
(LookaheadViolationError + per-engine assertions). Each engine is
covered by a standalone PoC config (no PEAD/parking/idle alpha) for
isolation backtests against the midlarge or broad snapshot.

Engines:

EarningsRunup (libs/backtest/earnings_runup.py)
 - Trigger: days_to_earnings ∈ [3,7] AND attention_zscore_20d ≥ 1.5
   AND dollar_volume_20d_zscore ≥ 1.0 (all evaluated at T-1 close)
 - Entry: T+1 next_open. Exit: -4% / +8% / max_holding_days =
   days_to_earnings - buffer (forced flat by close before announcement)
 - PIT calendar: PointInTimeEarningsCalendar adapter for backtest;
   oracle_surprise_prefetch fallback when parquet calendar absent
 - PoC verdict (configs/experiments/earnings_runup_poc_v1.json):
   119 trades over 1051 days, +37.27% total return, 44.46% MDD,
   SQS 45.2 (profitability=55.5, risk=23.5, robustness=50.1).
   VIABLE BUT NEEDS WORK — signal exists; standalone risk profile
   too aggressive for v7.356 baseline (8.8% MDD on v7.364). Path
   forward: per_trade_risk reduction, VIX gate, position cap, or
   integrate as PEAD sleeve adjunct (not as standalone replacement).

PeerSympathy (libs/backtest/peer_sympathy.py)
 - Trigger: leader passes PEAD filter (earnings_release / guidance_update
   / material_contract) AND leader reaction_close ≥ +5% AND peer 60d
   correlation ≥ 0.55 over [T-65, T-5]. Top-2 peers by correlation
   from leader_follower_extra_peer_symbols_by_sector + sector ETF
   holdings.
 - Entry: T+1 next_open on peer. Exit: -3.5% / +6% / max_holding=3 /
   peer-earnings blackout
 - PoC verdict (configs/experiments/peer_sympathy_poc_v1.json):
   256 trades over 1051 days, -52.92% total return, 54.47% MDD,
   SQS 19.6 (profitability=0.0, risk=5.4, robustness=100.0).
   DEAD. The leader's catalyst is already absorbed by T+1 next_open
   — peers gap up overnight before entry. robustness=100 confirms
   the negative result is not noise. Salvage paths (not implemented):
   reaction_close entry, raised-guidance-only restriction.
 - Note: initial run_id was 0 trades due to a select_candidates
   filter mismatch (engine.event_types=['peer_sympathy'] dropping
   real event_type='earnings_release' rows). The runner adapter
   was patched to bypass strategy_engine filtering for leader
   selection; the manual peer_sympathy_leader_event_types filter
   does the gating.

VolBreakout52w (libs/backtest/vol_breakout_52w.py)
 - Trigger: close_T-1 > max(high[T-252:T-2]) AND volume_T-1 ≥
   2 × median_volume_20d_T-2 AND ATR_14_T-1/close ∈ [0.015, 0.06].
   Entry T next_open, exit -3% / +5% / max_holding=2 / MOC.
 - Honest, look-ahead-safe descendant of the retired topgainer v1-v54
   family. Five layers of strict-before assertions guard the bar
   provider, candidate construction, trigger evaluation, and feature
   timestamps. A leaky-provider proof-by-contradiction test
   demonstrates the categorical catch.
 - PoC verdict (configs/experiments/vol_breakout_52w_poc_v1.json,
   broad-liquid universe): 1,332 trades, -87.28% total return,
   88.74% MDD, SQS 24.4 (profitability=0.0, robustness=100.0).
   DEAD AND HONEST. This is the most important finding of the three
   PoCs: the topgainer v1-v54 lineage's headline returns (+267%
   Sharpe 13.73 in best variants) were 100% lookahead bug. With
   the bug removed, the 52w-high + volume + ATR signal has no real
   alpha — the lookahead-corrected -4.3% from prior memory is
   confirmed and amplified to -87% on a fuller universe and longer
   horizon. Future "revive topgainer" proposals can cite this run
   (bt_return_max_long_v1_broad-liquid_20260509042903892342_3bb473d9)
   as definitive falsification.
 - Pre-open gap guard inactive (no premarket data in broad snapshot).
   skip_if_no_gap_data=true; the +4% gap-fade guard would not move
   the result given the magnitude.

Shared infrastructure additions:
 - libs/backtest/domain.py: LookaheadViolationError class +
   StrategyEngineConfig fields (11 EarningsRunup + 11 PeerSympathy
   + 13 VolBreakout52w = 35 new fields)
 - apps/backtester/run.py: _BacktestAttentionZscoreAdapter,
   _RunnerPeerResolver, _schedule_earnings_runup_candidates,
   _schedule_peer_sympathy_candidates,
   _schedule_vol_breakout_52w_candidates wired into the daily
   scheduler block. PeerSympathy adapter bypasses strategy_engine
   filtering on leader selection (manual filter handles gating).

Tests: 21 (EarningsRunup) + 27 (PeerSympathy) + 38 (VolBreakout52w)
= 86 new unit tests, all passing. Broader unit suite: 1392 passed,
2 pre-existing failures unrelated.

Net engine state: EarningsRunup is the only viable new engine class.
PeerSympathy and VolBreakout52w are kept in-tree as falsification
evidence, not as production engines.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
main
I Luk Kim 3 months ago
parent f67c534ce3
commit 956cc78f1b

@ -8,6 +8,7 @@ import math
import statistics
import subprocess
import sys
import time
from bisect import bisect_right
from collections import Counter, defaultdict
from pathlib import Path
@ -53,6 +54,23 @@ from libs.backtest.earnings_calendar import (
OraclePointInTimeEarningsCalendar,
load_pit_earnings_calendar,
)
from libs.backtest.earnings_runup import (
EARNINGS_RUNUP_EVENT_TYPE,
AttentionZscoreProvider,
_PitCalendarUpcomingEarningsAdapter,
_SnapshotStoreBarAdapter,
build_earnings_runup_candidates,
)
from libs.backtest.peer_sympathy import (
PEER_SYMPATHY_EVENT_TYPE,
LeaderPrint,
build_peer_sympathy_candidates,
)
from libs.backtest.vol_breakout_52w import (
VOL_BREAKOUT_52W_EVENT_TYPE,
_SnapshotStoreBarAdapter as _VolBreakout52wBarAdapter,
build_candidates as build_vol_breakout_52w_candidates,
)
from libs.backtest.form4_calendar import load_pit_form4_calendar
from libs.backtest.ownership_calendar import load_pit_ownership_calendar
from libs.backtest.execution import (
@ -124,6 +142,316 @@ def _get_git_commit_hash() -> str:
return "unknown"
class _OracleSurprisePrefetchedEarningsAdapter:
"""In-memory PIT upcoming-earnings provider built from Oracle's
``/api/v1/earnings/surprise/{symbol}`` endpoint.
The earnings_runup builder calls ``get_next_reaction_date`` once per
universe symbol per day. Per-symbol-per-day HTTP calls are infeasible
(>1M calls), and Oracle's bulk calendar endpoint times out under load
for 900+ symbols × 1000+ days. This adapter takes the cheaper path:
* One ``earnings/surprise`` HTTP call per universe symbol at construction
(~900 calls total, parallelized with a thread pool).
* Returned ``reported_date`` values are folded into an in-memory
``PointInTimeEarningsCalendar`` whose ``as_of_date`` for each report
is set to ``reported_date - announcement_window_days`` (default 30)
to model the public-announcement lead time.
* After construction, every ``get_next_reaction_date`` call is a pure
bisect-and-dict-lookup; no further network traffic.
PIT integrity: the announcement-window heuristic is conservative
(companies typically announce 4-6 weeks ahead), and the runup engine
only ever queries 3-7 trading days before the event so the entry is
well within the announcement window. The reaction-date offset reuses
``compute_reaction_date`` with ``post_market`` (the most common bucket
and the Oracle bulk endpoint's default normalization) since the
surprise endpoint omits the time-of-day field.
Raises ``RuntimeError`` if the Oracle base URL is missing (config
error) or if EVERY prefetch call fails (data unavailable). Per-symbol
failures are logged and treated as empty (the symbol is simply absent
from the calendar).
"""
_DEFAULT_QUARTERS = 24
_DEFAULT_ANNOUNCEMENT_LEAD_DAYS = 30
_DEFAULT_REACTION_TIME_BUCKET = "post_market"
# Local Oracle proxy returns 502 under bursty parallel load. 4 is the
# empirically-stable upper bound; we add per-symbol retry on top.
_DEFAULT_PARALLELISM = 4
_RETRY_HTTP_STATUSES: tuple[int, ...] = (502, 503, 504)
_RETRY_MAX_ATTEMPTS = 3
_RETRY_BACKOFF_SECONDS = 0.5
def __init__(
self,
oracle_url: str,
trading_days: list[dt.date],
universe_symbols: list[str],
timeout: float = 30.0,
quarters: int | None = None,
announcement_lead_days: int | None = None,
parallelism: int | None = None,
) -> None:
if not oracle_url:
raise RuntimeError(
"_OracleSurprisePrefetchedEarningsAdapter requires non-empty oracle_url"
)
self._base_url = oracle_url.rstrip("/")
self._timeout = float(timeout)
self.trading_days = list(trading_days)
self._universe = sorted({s.strip().upper() for s in universe_symbols if s and str(s).strip()})
self._quarters = int(quarters or self._DEFAULT_QUARTERS)
self._announcement_lead_days = int(
announcement_lead_days or self._DEFAULT_ANNOUNCEMENT_LEAD_DAYS
)
self._parallelism = max(1, int(parallelism or self._DEFAULT_PARALLELISM))
self._calendar: Any = None # PointInTimeEarningsCalendar, built lazily
self._build_stats: dict[str, int] = {}
def _build_calendar(self) -> None:
import concurrent.futures as _cf
from libs.backtest.earnings_calendar import (
EarningsCalendarEntry,
PointInTimeEarningsCalendar,
)
from libs.labeler.reaction_date import compute_reaction_date as _compute_reaction_date
session = requests.Session()
endpoint = f"{self._base_url}/api/v1/earnings/surprise"
quarters_param = self._quarters
# Bound entries to the simulation window (with announcement-lead margin)
# so we don't try to compute reaction dates for very old reports that
# fall outside the exchange calendar's coverage.
if self.trading_days:
window_start_date = self.trading_days[0] - dt.timedelta(
days=self._announcement_lead_days + 90
)
window_end_date = self.trading_days[-1] + dt.timedelta(days=400)
else:
window_start_date = dt.date(2007, 1, 1)
window_end_date = dt.date(2099, 1, 1)
def fetch_one(symbol: str) -> tuple[str, list[dict[str, Any]]]:
last_err: Any = None
for attempt in range(self._RETRY_MAX_ATTEMPTS):
try:
resp = session.get(
f"{endpoint}/{symbol}",
params={"quarters": quarters_param},
timeout=self._timeout,
)
if resp.status_code in self._RETRY_HTTP_STATUSES:
last_err = f"http_{resp.status_code}"
time.sleep(self._RETRY_BACKOFF_SECONDS * (attempt + 1))
continue
resp.raise_for_status()
body = resp.json()
return symbol, body.get("quarters", [])
except Exception as exc: # noqa: BLE001
last_err = exc
time.sleep(self._RETRY_BACKOFF_SECONDS * (attempt + 1))
logger.debug(
"earnings_runup_surprise_fetch_failed",
symbol=symbol,
error=str(last_err),
attempts=self._RETRY_MAX_ATTEMPTS,
)
return symbol, []
entries: list[EarningsCalendarEntry] = []
successes = 0
empty_responses = 0
failures = 0
start_ts = time.time()
with _cf.ThreadPoolExecutor(max_workers=self._parallelism) as pool:
futures = {pool.submit(fetch_one, sym): sym for sym in self._universe}
for fut in _cf.as_completed(futures):
sym, quarters = fut.result()
if not quarters:
if quarters == []:
# We can't distinguish "API failure" from "no history"
# at this layer; both manifested as []. The fetch_one
# logger call already differentiates; here we just count.
empty_responses += 1
continue
successes += 1
for q in quarters:
rd_str = q.get("reported_date") or q.get("fiscal_date_ending")
if not rd_str:
continue
try:
reported_date = dt.date.fromisoformat(str(rd_str)[:10])
except ValueError:
continue
# Skip dates outside the simulation window (with margin)
# to avoid exchange-calendar OutOfBounds errors and to
# keep the in-memory entries set small.
if reported_date < window_start_date:
continue
if reported_date > window_end_date:
continue
try:
reaction_date = _compute_reaction_date(
reported_date, self._DEFAULT_REACTION_TIME_BUCKET
)
except Exception: # noqa: BLE001
continue
if reaction_date is None:
continue
announce_date = reported_date - dt.timedelta(
days=self._announcement_lead_days
)
entries.append(
EarningsCalendarEntry(
symbol=sym.upper(),
as_of_date=announce_date,
expected_reaction_date=reaction_date,
expected_event_date=reported_date,
filing_time_bucket=self._DEFAULT_REACTION_TIME_BUCKET,
source="oracle.earnings.surprise",
)
)
elapsed = time.time() - start_ts
if successes == 0 and empty_responses == 0:
raise RuntimeError(
"earnings_runup PIT prefetch produced 0 entries: every Oracle "
"earnings/surprise call failed. Check Oracle reachability."
)
self._calendar = PointInTimeEarningsCalendar(entries)
self._build_stats = {
"universe_size": len(self._universe),
"symbols_with_history": successes,
"symbols_empty_or_failed": empty_responses,
"entries": len(entries),
"elapsed_seconds": int(elapsed),
}
logger.info(
"earnings_runup_pit_prefetch_complete",
**self._build_stats,
announcement_lead_days=self._announcement_lead_days,
quarters=self._quarters,
parallelism=self._parallelism,
)
def get_next_reaction_date(
self,
symbol: str,
as_of_date: dt.date,
max_lookahead_calendar_days: int,
) -> dt.date | None:
if self._calendar is None:
self._build_calendar()
try:
idx = self.trading_days.index(as_of_date)
except ValueError:
return None
cutoff = as_of_date + dt.timedelta(days=max_lookahead_calendar_days)
allowed = [d for d in self.trading_days[idx + 1:] if d <= cutoff]
if not allowed:
return None
result = self._calendar.get_known_upcoming_reaction_dates(
as_of_date=as_of_date,
allowed_reaction_dates=allowed,
symbols=[symbol],
)
return result.get(str(symbol).upper())
class _BacktestAttentionZscoreAdapter:
"""Bridge AttentionFilterService to the earnings_runup AttentionZscoreProvider Protocol.
Calls Stock Oracle's per-event attention endpoint using ``as_of_date`` (T-1) as
the event date; the returned wiki.zscore_20d is computed over a 20-day baseline
that ends strictly before ``as_of_date`` per Oracle convention.
Includes a per-symbol "no wiki coverage" cache: if Oracle returns a payload
with ``wiki_title=None`` (entity not resolvable to Wikipedia), we mark the
symbol unresolvable and short-circuit subsequent date queries. This is
essential for an earnings_runup backtest with a 900+ symbol universe most
mid-cap tickers lack wiki coverage and re-querying them on every decision
date would dominate runtime.
"""
def __init__(self, attention_service: Any) -> None:
self._service = attention_service
# Symbols whose Oracle attention payload reports wiki_title=None on
# any sample date. Z-scores are wiki-derived, so absence of wiki
# coverage means zscore_20d is permanently None.
self._no_wiki_symbols: set[str] = set()
# Diagnostic counters for post-run analysis.
self._fetch_null_count: int = 0
self._fetch_total_count: int = 0
self._short_circuit_count: int = 0
def get_zscore_20d(self, symbol: str, as_of_date: dt.date) -> float | None:
sym = symbol.upper()
if sym in self._no_wiki_symbols:
self._short_circuit_count += 1
return None
# Manually invoke the cached fetch path used by AttentionFilterService.
from libs.backtest.domain import Candidate as _Candidate
try:
probe = _Candidate(
event_id=f"_runup_probe_{sym}_{as_of_date.isoformat()}",
symbol=sym,
score=0.0,
sector="UNKNOWN",
event_type="earnings_runup_probe",
event_timestamp=dt.datetime.combine(as_of_date, dt.time(16, 0), tzinfo=dt.timezone.utc),
event_date=as_of_date,
filing_time_bucket="post_market",
reaction_date=as_of_date,
execution_date=as_of_date,
entry_price_est=0.01,
avg_dollar_volume=0.0,
score_bucket="medium",
)
except Exception: # noqa: BLE001
return None
self._fetch_total_count += 1
attention = self._service._get_event_attention(probe)
if attention is None:
self._fetch_null_count += 1
return None
wiki_title = (
getattr(getattr(attention, "entity", None), "wiki_title", None)
or (getattr(attention, "metadata", {}) or {}).get("wiki_title")
)
if not wiki_title:
# No wiki coverage for this symbol → permanently no z-score.
self._no_wiki_symbols.add(sym)
self._fetch_null_count += 1
return None
zscore = getattr(attention.wiki, "zscore_20d", None)
if zscore is None:
self._fetch_null_count += 1
return zscore
class _RunnerPeerResolver:
"""Adapt BacktestRunner state to peer_sympathy.PeerResolver.
Reuses the existing leader-follower peer-set wiring on the runner so the
PeerSympathy engine reads the SAME peer universe as the leader-follower
engine (curated per-leader peers, sector ETF holdings, optional allowlist).
"""
def __init__(self, runner: "BacktestRunner") -> None:
self._runner = runner
def peers_for_leader(
self,
engine: "StrategyEngineConfig",
leader_symbol: str,
leader_sector: str,
) -> list[str]:
return self._runner._leader_follower_peer_candidates(
engine, leader_symbol, leader_sector
)
class BacktestRunner:
"""Event-driven backtester simulation engine."""
@ -1016,6 +1344,9 @@ class BacktestRunner:
self._schedule_add_on_candidates(date)
self._schedule_delayed_entry_candidates(date)
self._schedule_leader_follower_candidates(date)
self._schedule_earnings_runup_candidates(date)
self._schedule_peer_sympathy_candidates(date)
self._schedule_vol_breakout_52w_candidates(date)
self._schedule_macro_short_candidates(date)
self._schedule_macro_long_candidates(date)
@ -5377,6 +5708,296 @@ class BacktestRunner:
result.append(candidate)
return result
def _schedule_earnings_runup_candidates(self, date: dt.date) -> None:
"""Generate synthetic pre-event drift candidates 3-7 trading days before earnings.
Mirrors `_schedule_leader_follower_candidates` in shape: synthetic Candidate
rows are emitted into `_scheduled_delayed_entries[next_trading_day]` with the
engine's `engine_id`, and routed through the standard allocator/exit path.
The pure trigger logic lives in `libs.backtest.earnings_runup`; this method
only adapts runner state to the provider Protocols.
"""
next_date = self._next_trading_day.get(date)
if next_date is None:
return
engines = [
e for e in self._active_strategy_engines
if getattr(e, "earnings_runup_enabled", False) and self._engine_allowed_for_date(e, date)
]
if not engines:
return
# Universe = symbols that already have bar history in the snapshot store.
# This keeps coverage aligned with the rest of the backtest's data set.
universe_symbols = sorted(self.store._bars.keys())
if not universe_symbols:
return
bar_provider = _SnapshotStoreBarAdapter(bars_by_symbol=self.store._bars)
# Pick PIT earnings provider:
# 1. Parquet-backed PointInTimeEarningsCalendar (preferred, free lookups).
# 2. Oracle bulk adapter (one HTTP call per as_of_date, cached on the runner).
# If neither is usable, the scheduler raises rather than silently skipping
# so configuration errors are loud.
pit_provider = self._get_or_build_earnings_runup_pit_provider(universe_symbols)
attention_provider = _BacktestAttentionZscoreAdapter(self._attention_service)
open_symbols = {p.plan.candidate.symbol.upper() for p in self._open_positions}
preexisting_symbols = {
candidate.symbol.upper()
for candidate in self._scheduled_delayed_entries.get(next_date, [])
}
for engine in engines:
try:
candidates = build_earnings_runup_candidates(
decision_date=date,
next_trading_date=next_date,
universe_symbols=universe_symbols,
engine=engine,
upcoming_earnings_provider=pit_provider,
attention_provider=attention_provider,
bar_provider=bar_provider,
)
except Exception as exc: # noqa: BLE001
logger.warning(
"earnings_runup_build_failed",
engine_id=engine.engine_id,
date=date.isoformat(),
error=str(exc),
)
continue
logger.debug(
"earnings_runup_candidates_built",
engine_id=engine.engine_id,
date=date.isoformat(),
candidate_count=len(candidates),
)
for cand in candidates:
sym = cand.symbol.upper()
if sym in open_symbols or sym in preexisting_symbols:
continue
self._scheduled_delayed_entries[next_date].append(cand)
preexisting_symbols.add(sym)
def _get_or_build_earnings_runup_pit_provider(self, universe_symbols: list[str]) -> Any:
"""Return a cached UpcomingEarningsProvider for the earnings_runup engine.
Lazily constructs and caches one instance for the lifetime of the
runner so the bulk Oracle adapter's per-day cache survives across
``_schedule_earnings_runup_candidates`` calls.
Resolution order:
1. Parquet-backed PIT calendar if present on disk.
2. Oracle PIT bulk adapter (HTTP-backed, but bulk + cached).
Raises if neither is available; this is louder than silently emitting
zero candidates and surfaces wiring/data-availability bugs early.
"""
existing = getattr(self, "_earnings_runup_pit_provider", None)
if existing is not None:
return existing
if self._pit_earnings_calendar is not None:
provider: Any = _PitCalendarUpcomingEarningsAdapter(
pit_calendar=self._pit_earnings_calendar,
trading_days=self._simulation_dates,
)
logger.info(
"earnings_runup_pit_provider_initialized",
source="parquet",
universe_size=len(universe_symbols),
)
else:
from libs.common.config import get_settings
settings = get_settings()
if not settings.stock_oracle_url:
raise RuntimeError(
"earnings_runup engine enabled but no upcoming-earnings provider is "
"available: parquet calendar at data/reference/earnings_calendar_pit.parquet "
"is missing AND FITHIA_STOCK_ORACLE_URL is unset. Configure one of the two."
)
provider = _OracleSurprisePrefetchedEarningsAdapter(
oracle_url=settings.stock_oracle_url,
trading_days=self._simulation_dates,
universe_symbols=universe_symbols,
timeout=float(settings.stock_oracle_timeout),
)
logger.info(
"earnings_runup_pit_provider_initialized",
source="oracle_surprise_prefetch",
universe_size=len(universe_symbols),
)
self._earnings_runup_pit_provider = provider
return provider
def _schedule_peer_sympathy_candidates(self, date: dt.date) -> None:
"""Generate synthetic peer-sympathy candidates from strong leader prints.
For every active engine with ``peer_sympathy_enabled=True``:
1. Take today's PEAD candidate rows (post engine filter) whose event_type
is in ``peer_sympathy_leader_event_types`` AND whose reaction_day_return
>= ``peer_sympathy_leader_reaction_min``.
2. Resolve their peer set via the existing leader-follower infra.
3. Compute return-correlation on [T-window_start, T-window_end_skip).
4. Take top-N peers by correlation; emit synthetic candidates for T+1
next_open with -3.5%/+6%/3-day-hold exit defaults.
Pure logic lives in ``libs.backtest.peer_sympathy``; this method only
adapts runner state to the provider Protocols.
"""
next_date = self._next_trading_day.get(date)
if next_date is None:
return
engines = [
e for e in self._active_strategy_engines
if getattr(e, "peer_sympathy_enabled", False)
and self._engine_allowed_for_date(e, date)
]
if not engines:
return
raw_rows = self.store.get_candidates_for_reaction_date(date)
if not raw_rows:
return
bar_provider = _SnapshotStoreBarAdapter(bars_by_symbol=self.store._bars)
# Reuse the EarningsRunup PIT provider — it already serves peer earnings
# blackout queries via the same API. Construct lazily so engines without
# blackout config don't pay the cost.
upcoming_provider: Any = None
peer_resolver = _RunnerPeerResolver(self)
open_symbols = {p.plan.candidate.symbol.upper() for p in self._open_positions}
preexisting_symbols = {
candidate.symbol.upper()
for candidate in self._scheduled_delayed_entries.get(next_date, [])
}
for engine in engines:
blackout = int(getattr(engine, "peer_sympathy_blackout_days_to_peer_event", 0) or 0)
if blackout > 0 and upcoming_provider is None:
try:
universe_symbols = sorted(self.store._bars.keys())
upcoming_provider = self._get_or_build_earnings_runup_pit_provider(universe_symbols)
except Exception as exc: # noqa: BLE001
logger.warning(
"peer_sympathy_pit_provider_unavailable",
engine_id=engine.engine_id,
error=str(exc),
)
upcoming_provider = None
prelimit = max(
self.config.signal.max_candidates_per_day * 5,
self.config.signal.max_candidates_per_day,
)
# NOTE: Do NOT pass strategy_engine here. This engine declares
# event_types=['peer_sympathy'] (a synthetic event type emitted
# downstream by build_peer_sympathy_candidates), which would cause
# _row_matches_strategy_engine_filters to drop every real leader row
# whose event_type is earnings_release / guidance_update /
# material_contract — i.e., it would filter out the very leaders
# the engine is supposed to react to. The manual peer_sympathy_leader_event_types
# + peer_sympathy_leader_reaction_min gating below performs the correct
# leader-side filtering. engine_lookup is also omitted since it is only
# consulted in conjunction with strategy_engine.
leader_candidates = select_candidates(
raw_rows,
self.config.universe,
self.config.signal,
event_type_profiles=self.config.event_type_profiles or None,
truncate_to=prelimit,
)
leader_candidates = self._apply_attention_filters(leader_candidates, engine)
if not leader_candidates:
continue
allowed_event_types = {
str(e).strip().lower()
for e in (engine.peer_sympathy_leader_event_types or [])
if str(e).strip()
}
min_reaction = float(engine.peer_sympathy_leader_reaction_min)
leaders: list[LeaderPrint] = []
for c in leader_candidates:
event_type = str(c.event_type or "").lower()
if allowed_event_types and event_type not in allowed_event_types:
continue
reaction = float(c.features.get("reaction_day_return") or 0.0)
if reaction < min_reaction:
continue
leader_symbol = str(c.source_symbol or c.symbol or "").upper()
if not leader_symbol:
continue
leaders.append(
LeaderPrint(
symbol=leader_symbol,
sector=str(c.sector or "UNKNOWN"),
event_id=c.event_id,
event_type=c.event_type,
event_date=c.event_date or date,
event_timestamp=c.event_timestamp,
reaction_day_return=reaction,
score=float(c.score),
)
)
if not leaders:
continue
# Eagerly fetch peer bar coverage so correlation has data on hand.
required_symbols: set[str] = set()
for leader in leaders:
required_symbols.add(leader.symbol)
for peer in peer_resolver.peers_for_leader(engine, leader.symbol, leader.sector):
required_symbols.add(peer)
if required_symbols:
self._ensure_leader_follower_market_data(
sorted(required_symbols),
date,
required_end_date=next_date,
)
try:
candidates = build_peer_sympathy_candidates(
decision_date=date,
next_trading_date=next_date,
leaders=leaders,
peer_resolver=peer_resolver,
engine=engine,
bar_provider=bar_provider,
upcoming_earnings_provider=upcoming_provider,
trading_days=self._simulation_dates,
)
except Exception as exc: # noqa: BLE001
logger.warning(
"peer_sympathy_build_failed",
engine_id=engine.engine_id,
date=date.isoformat(),
error=str(exc),
)
continue
logger.debug(
"peer_sympathy_candidates_built",
engine_id=engine.engine_id,
date=date.isoformat(),
leader_count=len(leaders),
candidate_count=len(candidates),
)
for cand in candidates:
sym = cand.symbol.upper()
if sym in open_symbols or sym in preexisting_symbols:
continue
self._scheduled_delayed_entries[next_date].append(cand)
preexisting_symbols.add(sym)
def _ensure_leader_follower_market_data(
self,
symbols: list[str],
@ -5459,6 +6080,91 @@ class BacktestRunner:
target_end_date=target_end_date.isoformat(),
)
def _schedule_vol_breakout_52w_candidates(self, date: dt.date) -> None:
"""Generate synthetic VolBreakout52w candidates daily across the universe.
Honest descendant of the retired topgainer family. Buys at next_open T
when T-1 close is a 52-week high with volume confirmation; holds 2 days
with mandatory MOC exit. EVERY feature must be timestamped strictly
before decision_date 09:30 ET see ``libs.backtest.vol_breakout_52w``.
Pure logic lives in ``libs.backtest.vol_breakout_52w``; this method only
adapts runner state to the provider Protocols and routes synthetic
candidates into the standard ``_scheduled_delayed_entries`` queue.
"""
next_date = self._next_trading_day.get(date)
if next_date is None:
return
engines = [
e for e in self._active_strategy_engines
if getattr(e, "vol_breakout_52w_enabled", False)
and self._engine_allowed_for_date(e, date)
]
if not engines:
return
universe_symbols = sorted(self.store._bars.keys())
if not universe_symbols:
return
# Cache the bar adapter on the runner so the sorted-bar cache survives
# across days (the per-symbol sorted list is invariant within the run).
cached_adapter = getattr(self, "_vol_breakout_52w_bar_adapter", None)
if cached_adapter is None:
cached_adapter = _VolBreakout52wBarAdapter(bars_by_symbol=self.store._bars)
self._vol_breakout_52w_bar_adapter = cached_adapter
bar_provider = cached_adapter
# PreOpenGapProvider not currently wired — broad snapshot does not carry
# premarket data. We pass None and rely on the engine's
# ``vol_breakout_52w_skip_if_no_gap_data`` flag to log loudly.
pre_open_gap_provider = None
# Cache one missing-gap warning marker per runner.
warned = getattr(self, "_vol_breakout_52w_missing_gap_warned", None)
if warned is None:
warned = {}
self._vol_breakout_52w_missing_gap_warned = warned
open_symbols = {p.plan.candidate.symbol.upper() for p in self._open_positions}
preexisting_symbols = {
candidate.symbol.upper()
for candidate in self._scheduled_delayed_entries.get(next_date, [])
}
for engine in engines:
try:
candidates = build_vol_breakout_52w_candidates(
decision_date=date,
next_trading_date=next_date,
universe_symbols=universe_symbols,
engine=engine,
bar_provider=bar_provider,
pre_open_gap_provider=pre_open_gap_provider,
_missing_gap_warned=warned,
)
except Exception as exc: # noqa: BLE001
logger.warning(
"vol_breakout_52w_build_failed",
engine_id=engine.engine_id,
date=date.isoformat(),
error=str(exc),
)
continue
logger.debug(
"vol_breakout_52w_candidates_built",
engine_id=engine.engine_id,
date=date.isoformat(),
candidate_count=len(candidates),
)
for cand in candidates:
sym = cand.symbol.upper()
if sym in open_symbols or sym in preexisting_symbols:
continue
self._scheduled_delayed_entries[next_date].append(cand)
preexisting_symbols.add(sym)
def _schedule_macro_short_candidates(self, date: dt.date) -> None:
"""Generate synthetic SH (inverse ETF) candidates during deep bearish regimes.

@ -0,0 +1,82 @@
{
"experiment_name": "earnings_runup_poc_v1",
"dataset_snapshot_id": "midlarge-liquid-long-v1_bucketfix_full_audit_canonical_ftb_fix_v2",
"description": "Standalone EarningsRunup engine isolation backtest. Buys 3-7 trading days before scheduled earnings when attention z-score >= 1.5 AND dollar-volume z-score >= 1.0; exits before the print. No PEAD engines, no parking, no idle alpha — pure standalone EV measurement.",
"base_config": "configs/backtest/return_max_long_v1.json",
"overrides": {
"signal": {
"scoring_model": "return_max_long_v13e",
"score_threshold": 0.0,
"max_candidates_per_day": 18,
"a_tier_score_threshold": 0.99
},
"risk": {
"per_trade_risk_pct": 0.65,
"per_trade_risk_pct_a_tier": 0.65,
"max_daily_new_risk_pct": 50,
"max_positions": 30,
"max_positions_per_sector": 30,
"max_position_value_pct": 25,
"max_adv_fraction": 0.3,
"macro_regime_neutral_size_scaler": 1,
"macro_regime_risk_off_size_scaler": 1,
"veto_unknown_direction": false,
"veto_bearish_direction": false,
"macro_regime_risk_off_a_tier_only": false,
"stop_atr_multiplier": 3,
"allow_budget_downsizing": true,
"cash_parking_preset": null,
"fixed_capital_sizing": false
},
"execution": {
"trailing_warmup_days": 7,
"max_holding_days": 7,
"early_failure_no_progress_days": 1,
"early_failure_no_progress_r": 0.0,
"early_failure_no_progress_fraction": 0,
"lookback_entry_enabled": false
},
"event_type_profiles": {
"earnings_runup_preevent": {
"enabled": true,
"direction_filter": "any",
"max_holding_days_override": 7
}
},
"idle_alpha_sleeve_preset": null,
"form4_capture_sleeve_preset": null,
"ownership_capture_sleeve_preset": null,
"risk_off_alpha_sleeve_preset": null,
"dividend_capture_sleeve_preset": null
},
"strategy_engines": [
{
"engine_id": "earnings_runup_preevent_long",
"event_types": ["earnings_runup_preevent"],
"timing_class": "after_close",
"direction": "long_only",
"entry_timing_policy": "next_open",
"engine_risk_budget_pct": 1.0,
"score_threshold_override": 0.0,
"max_holding_days": 7,
"earnings_runup_enabled": true,
"earnings_runup_days_to_earnings_min": 3,
"earnings_runup_days_to_earnings_max": 7,
"earnings_runup_attention_zscore_20d_min": 1.5,
"earnings_runup_dollar_volume_zscore_20d_min": 1.0,
"earnings_runup_min_avg_dollar_volume": 50000000.0,
"earnings_runup_stop_pct": 0.04,
"earnings_runup_target_pct": 0.08,
"earnings_runup_trailing_activate_pct": 0.05,
"earnings_runup_trailing_giveback_pct": 0.03,
"earnings_runup_calendar_buffer_days": 1,
"enabled": true
}
],
"tags": ["earnings_runup", "preevent_drift", "poc"],
"version_family": "earnings_runup",
"status": "draft",
"changelog": "Initial PoC: standalone EarningsRunup engine, 3-7 day pre-print window, attention_z>=1.5, dvol_z>=1.0, -4%/+8%/3%-trail/forced-flat T-1.",
"parent": null,
"performance_summary": null
}

@ -0,0 +1,87 @@
{
"experiment_name": "peer_sympathy_poc_v1",
"dataset_snapshot_id": "midlarge-liquid-long-v1_bucketfix_full_audit_canonical_ftb_fix_v2",
"description": "Standalone PeerSympathy engine isolation backtest. When a sector leader fires a qualifying PEAD trigger (earnings_release / guidance_update / material_contract) with reaction_close >= +5%, buy the top-2 correlated peers (60d return-correlation >= 0.55, computed on [T-65, T-5] window) at next_open. Exits: -3.5% stop, +6% target, max 3 trading days, hard exit if peer's own earnings within 3 trading days. No PEAD engines, no parking, no idle alpha — pure standalone EV measurement.",
"base_config": "configs/backtest/return_max_long_v1.json",
"overrides": {
"signal": {
"scoring_model": "return_max_long_v13e",
"score_threshold": 0.0,
"max_candidates_per_day": 18,
"a_tier_score_threshold": 0.99
},
"risk": {
"per_trade_risk_pct": 0.65,
"per_trade_risk_pct_a_tier": 0.65,
"max_daily_new_risk_pct": 50,
"max_positions": 30,
"max_positions_per_sector": 30,
"max_position_value_pct": 25,
"max_adv_fraction": 0.3,
"macro_regime_neutral_size_scaler": 1,
"macro_regime_risk_off_size_scaler": 1,
"veto_unknown_direction": false,
"veto_bearish_direction": false,
"macro_regime_risk_off_a_tier_only": false,
"stop_atr_multiplier": 1.75,
"allow_budget_downsizing": true,
"cash_parking_preset": null,
"fixed_capital_sizing": false
},
"execution": {
"trailing_warmup_days": 7,
"max_holding_days": 3,
"early_failure_no_progress_days": 1,
"early_failure_no_progress_r": 0.0,
"early_failure_no_progress_fraction": 0,
"lookback_entry_enabled": false
},
"event_type_profiles": {
"peer_sympathy": {
"enabled": true,
"direction_filter": "any",
"max_holding_days_override": 3
}
},
"idle_alpha_sleeve_preset": null,
"form4_capture_sleeve_preset": null,
"ownership_capture_sleeve_preset": null,
"risk_off_alpha_sleeve_preset": null,
"dividend_capture_sleeve_preset": null
},
"strategy_engines": [
{
"engine_id": "peer_sympathy_long",
"event_types": ["peer_sympathy"],
"timing_class": "after_close",
"direction": "long_only",
"entry_timing_policy": "next_open",
"engine_risk_budget_pct": 1.0,
"score_threshold_override": 0.0,
"max_holding_days": 3,
"peer_sympathy_enabled": true,
"peer_sympathy_leader_event_types": [
"earnings_release",
"guidance_update",
"material_contract"
],
"peer_sympathy_leader_reaction_min": 0.05,
"peer_sympathy_correlation_min": 0.55,
"peer_sympathy_correlation_window_start": 65,
"peer_sympathy_correlation_window_end_skip": 5,
"peer_sympathy_top_n_peers": 2,
"peer_sympathy_blackout_days_to_peer_event": 3,
"peer_sympathy_stop_pct": 0.035,
"peer_sympathy_target_pct": 0.06,
"peer_sympathy_max_holding_days": 3,
"leader_follower_min_days_to_event": 3,
"enabled": true
}
],
"tags": ["peer_sympathy", "sympathy_rally", "poc"],
"version_family": "peer_sympathy",
"status": "draft",
"changelog": "Initial PoC: standalone PeerSympathy engine, leader_reaction>=+5%, peer corr>=0.55 over [T-65, T-5], top-2 peers, -3.5%/+6%/3-day-hold, 3-day peer-earnings blackout.",
"parent": null,
"performance_summary": null
}

@ -0,0 +1,85 @@
{
"experiment_name": "vol_breakout_52w_poc_v1",
"dataset_snapshot_id": "broad-liquid-long-v1_bucketfix_full_audit_canonical",
"description": "Standalone VolBreakout52w engine isolation backtest. Honest, look-ahead-safe descendant of the retired topgainer family. Buy at next_open T when T-1 close is a 52-week high (close_T-1 > max(high[T-252..T-2])), volume_T-1 >= 2 * median_volume_20d_T-2, and ATR_14_T-1/close_T-1 in [0.015, 0.06]. Skip if pre-open implied gap > +4% (CURRENTLY DISABLED — premarket data not in snapshot; flag vol_breakout_52w_skip_if_no_gap_data=true to suppress the gap guard with a loud warning). Exits: -3% intraday stop, +5% target, max_holding_days=2 (mandatory MOC). Universe: broad (small/mid where 52w-high alpha lives), gated by ADV >= $10M and price >= $5. No PEAD engines, no parking, no idle alpha — pure standalone EV measurement. Falsification plan: (a) run with feature shift 0 vs -1 — gap < 10% of edge; (b) bootstrap permutation of entry-day flags — edge must vanish.",
"base_config": "configs/backtest/return_max_long_v1.json",
"overrides": {
"signal": {
"scoring_model": "return_max_long_v13e",
"score_threshold": 0.0,
"max_candidates_per_day": 30,
"a_tier_score_threshold": 0.99
},
"risk": {
"per_trade_risk_pct": 0.5,
"per_trade_risk_pct_a_tier": 0.5,
"max_daily_new_risk_pct": 50,
"max_positions": 30,
"max_positions_per_sector": 30,
"max_position_value_pct": 25,
"max_adv_fraction": 0.3,
"macro_regime_neutral_size_scaler": 1,
"macro_regime_risk_off_size_scaler": 1,
"veto_unknown_direction": false,
"veto_bearish_direction": false,
"macro_regime_risk_off_a_tier_only": false,
"stop_atr_multiplier": 1.5,
"allow_budget_downsizing": true,
"cash_parking_preset": null,
"fixed_capital_sizing": false
},
"execution": {
"trailing_warmup_days": 7,
"max_holding_days": 2,
"early_failure_no_progress_days": 1,
"early_failure_no_progress_r": 0.0,
"early_failure_no_progress_fraction": 0,
"lookback_entry_enabled": false
},
"event_type_profiles": {
"vol_breakout_52w": {
"enabled": true,
"direction_filter": "any",
"max_holding_days_override": 2
}
},
"idle_alpha_sleeve_preset": null,
"form4_capture_sleeve_preset": null,
"ownership_capture_sleeve_preset": null,
"risk_off_alpha_sleeve_preset": null,
"dividend_capture_sleeve_preset": null
},
"strategy_engines": [
{
"engine_id": "vol_breakout_52w_long",
"event_types": ["vol_breakout_52w"],
"timing_class": "after_close",
"direction": "long_only",
"entry_timing_policy": "next_open",
"engine_risk_budget_pct": 1.0,
"score_threshold_override": 0.0,
"max_holding_days": 2,
"vol_breakout_52w_enabled": true,
"vol_breakout_52w_lookback_days": 252,
"vol_breakout_52w_volume_ratio_min": 2.0,
"vol_breakout_52w_volume_median_window": 20,
"vol_breakout_52w_atr_normalized_min": 0.015,
"vol_breakout_52w_atr_normalized_max": 0.06,
"vol_breakout_52w_pre_open_gap_max": 0.04,
"vol_breakout_52w_skip_if_no_gap_data": true,
"vol_breakout_52w_min_avg_dollar_volume": 10000000.0,
"vol_breakout_52w_min_price": 5.0,
"vol_breakout_52w_stop_pct": 0.03,
"vol_breakout_52w_target_pct": 0.05,
"vol_breakout_52w_max_holding_days": 2,
"enabled": true
}
],
"tags": ["vol_breakout_52w", "topgainer_descendant", "lookahead_safe", "poc"],
"version_family": "vol_breakout_52w",
"status": "draft",
"changelog": "Initial PoC: standalone VolBreakout52w engine on broad-liquid snapshot. 52w high + 2x volume + ATR/close band, -3%/+5%/2-day MOC. Pre-open gap guard inactive (premarket data not in snapshot) — vol_breakout_52w_skip_if_no_gap_data=true. Falsification plan: feature-shift honest replay + bootstrap permutation.",
"parent": null,
"performance_summary": null,
"missing_infrastructure": ["pre_open_gap_provider — premarket gap data not in snapshot; the +4% gap-fade guard is INACTIVE for this PoC. Mark all derived PnL accordingly."]
}

@ -42,6 +42,15 @@ class BacktestMode(str, Enum):
LIVE = "live"
class LookaheadViolationError(RuntimeError):
"""Raised when a candidate's feature timestamp is not strictly before the decision date.
The decision date is the trading day on which the candidate is *built* (T-1 close),
so any feature timestamp >= decision_date 09:30 ET indicates information leakage
from inside or after the entry day.
"""
class Candidate(BaseModel):
"""An eligible trade candidate derived from a Parquet snapshot row."""
model_config = ConfigDict(frozen=True)
@ -2171,6 +2180,58 @@ class StrategyEngineConfig(BaseModel):
macro_long_leadership_vs_spy_min: float | None = None
macro_long_min_daily_candidate_count: int | None = None
macro_long_min_unique_sector_count: int | None = None
# --- EarningsRunup pre-event drift engine ---
# Trigger: long entry T-1 close when scheduled earnings is `days_to_earnings` trading days
# ahead AND attention z-score and dollar-volume z-score both clear minimums.
# Exit: stop -X%, target +Y%, trailing activated after +Z%, hard exit one day before print.
earnings_runup_enabled: bool = False
earnings_runup_days_to_earnings_min: int | None = None
earnings_runup_days_to_earnings_max: int | None = None
earnings_runup_attention_zscore_20d_min: float | None = None
earnings_runup_dollar_volume_zscore_20d_min: float | None = None
earnings_runup_min_avg_dollar_volume: float | None = None
earnings_runup_stop_pct: float = 0.04 # -4% from entry
earnings_runup_target_pct: float = 0.08 # +8% from entry
earnings_runup_trailing_activate_pct: float = 0.05 # +5% triggers trailing
earnings_runup_trailing_giveback_pct: float = 0.03 # 3% giveback after activation
earnings_runup_calendar_buffer_days: int = 1 # exit by close of T-1 before print
# --- PeerSympathy engine ---
# Trigger: when a sector leader fires a qualifying PEAD event with reaction_close
# >= +5%, buy the top-correlated peers at next_open. Catches sympathy rallies that
# PEAD architecturally misses. Correlation is computed on `[T-65, T-5]` log-returns
# (skip last 5 days to avoid co-movement leakage from leader's own pre-event drift).
peer_sympathy_enabled: bool = False
peer_sympathy_leader_event_types: list[str] | None = None # which event_types qualify the leader
peer_sympathy_leader_reaction_min: float = 0.05 # leader reaction_day_return >= +5%
peer_sympathy_correlation_min: float = 0.55 # peer 60d return-correlation threshold
peer_sympathy_correlation_window_start: int = 65 # T-65 (inclusive of skip tail)
peer_sympathy_correlation_window_end_skip: int = 5 # skip last 5 trading days
peer_sympathy_top_n_peers: int = 2
peer_sympathy_blackout_days_to_peer_event: int = 3 # hard exit if peer's own earnings within N trading days
peer_sympathy_stop_pct: float = 0.035 # -3.5% from entry
peer_sympathy_target_pct: float = 0.06 # +6% from entry
peer_sympathy_max_holding_days: int = 3
# --- VolBreakout52w engine ---
# Honest, look-ahead-safe descendant of the retired topgainer family.
# Trigger (ALL on T-1 close):
# 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%.
# Exits: -3% stop / +5% target / max_holding_days=2 (mandatory MOC day 2).
vol_breakout_52w_enabled: bool = False
vol_breakout_52w_lookback_days: int = 252
vol_breakout_52w_volume_ratio_min: float = 2.0
vol_breakout_52w_volume_median_window: int = 20
vol_breakout_52w_atr_normalized_min: float = 0.015
vol_breakout_52w_atr_normalized_max: float = 0.06
vol_breakout_52w_pre_open_gap_max: float = 0.04
vol_breakout_52w_skip_if_no_gap_data: bool = False # log loudly when missing
vol_breakout_52w_min_avg_dollar_volume: float = 10_000_000.0
vol_breakout_52w_min_price: float = 5.0
vol_breakout_52w_stop_pct: float = 0.03 # -3% intraday
vol_breakout_52w_target_pct: float = 0.05 # +5%
vol_breakout_52w_max_holding_days: int = 2 # mandatory MOC exit on day 2
class EventTypeProfile(BaseModel):

@ -0,0 +1,512 @@
"""EarningsRunup pre-event drift engine.
Buys stocks 3-7 trading days before scheduled earnings when both attention and
dollar-volume z-scores rise above their 20-day baselines. Exits before the print.
This module is the *pure* logic `BacktestRunner` calls into
``build_earnings_runup_candidates`` from a thin scheduling hook. The pure
function takes provider Protocols so it can be unit-tested with stubs.
Architectural choice (a): synthetic Candidate emission into the existing
`_scheduled_delayed_entries` queue, mirroring `_schedule_leader_follower_candidates`.
"""
from __future__ import annotations
import datetime as dt
import math
import statistics
from dataclasses import dataclass
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__)
EARNINGS_RUNUP_EVENT_TYPE = "earnings_runup_preevent"
# Eastern-time market open used as the leakage cutoff. Decision date features
# must be timestamped strictly before this instant.
_ET_MARKET_OPEN = dt.time(9, 30)
# Naive UTC offset is fine for ordering checks because every timestamp we
# produce is normalized to the same convention (timezone-aware UTC).
_ET_OFFSET = dt.timedelta(hours=-5) # EST; DST is irrelevant for an ordering bound
# ---------------------------------------------------------------------------
# Provider Protocols (test-friendly seams)
# ---------------------------------------------------------------------------
class AttentionZscoreProvider(Protocol):
"""Returns the 20-day attention z-score for ``symbol`` as of ``as_of_date``.
Implementations must guarantee that no information from on/after ``as_of_date``
is incorporated into the returned value. ``None`` if data is unavailable.
"""
def get_zscore_20d(self, symbol: str, as_of_date: dt.date) -> float | None: ...
class UpcomingEarningsProvider(Protocol):
"""Returns the next-known scheduled earnings reaction date for ``symbol`` as of ``as_of_date``."""
def get_next_reaction_date(
self,
symbol: str,
as_of_date: dt.date,
max_lookahead_calendar_days: int,
) -> dt.date | None: ...
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]]]: ...
# ---------------------------------------------------------------------------
# Adapters: bridge BacktestRunner state to the Protocols above.
# ---------------------------------------------------------------------------
@dataclass
class _PitCalendarUpcomingEarningsAdapter:
"""Adapt PointInTimeEarningsCalendar to UpcomingEarningsProvider."""
pit_calendar: Any # libs.backtest.earnings_calendar.PointInTimeEarningsCalendar
trading_days: list[dt.date]
def get_next_reaction_date(
self,
symbol: str,
as_of_date: dt.date,
max_lookahead_calendar_days: int,
) -> dt.date | None:
try:
idx = self.trading_days.index(as_of_date)
except ValueError:
return None
# Look at every trading day strictly after as_of_date up to lookahead window.
cutoff = as_of_date + dt.timedelta(days=max_lookahead_calendar_days)
future = [d for d in self.trading_days[idx + 1:] if d <= cutoff]
if not future:
return None
result = self.pit_calendar.get_known_upcoming_reaction_dates(
as_of_date=as_of_date,
allowed_reaction_dates=future,
symbols=[symbol],
)
return result.get(symbol.upper())
@dataclass
class _SnapshotStoreBarAdapter:
"""Adapt SnapshotStore (or any object exposing ._bars) to BarHistoryProvider."""
bars_by_symbol: dict[str, dict[dt.date, dict[str, Any]]]
def get_bars_before(
self,
symbol: str,
as_of_date: dt.date,
lookback_days: int,
) -> list[tuple[dt.date, dict[str, Any]]]:
sym_bars = self.bars_by_symbol.get(symbol.upper())
if not sym_bars:
return []
eligible = sorted(
(d, sym_bars[d])
for d in sym_bars
if d < as_of_date # strict; T-1 is the latest allowed
)
return eligible[-lookback_days:]
# ---------------------------------------------------------------------------
# Lookahead defense
# ---------------------------------------------------------------------------
def _decision_cutoff_utc(decision_date: dt.date) -> dt.datetime:
"""09:30 ET on decision_date, expressed as a UTC-aware timestamp.
Any feature timestamp >= this instant carries information from inside the
entry day and constitutes a look-ahead violation.
"""
et_naive = dt.datetime.combine(decision_date, _ET_MARKET_OPEN)
# Convert to UTC by subtracting the (negative) ET offset.
utc_naive = et_naive - _ET_OFFSET
return utc_naive.replace(tzinfo=dt.timezone.utc)
def _assert_no_lookahead(
symbol: str,
decision_date: dt.date,
feature_timestamps: Iterable[dt.datetime],
) -> None:
cutoff = _decision_cutoff_utc(decision_date)
for ts in feature_timestamps:
if ts is None:
continue
if ts.tzinfo is None:
raise LookaheadViolationError(
f"EarningsRunup 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"EarningsRunup feature timestamp {ts.isoformat()} for {symbol} is "
f">= decision_date cutoff {cutoff.isoformat()}; this is a look-ahead violation"
)
# ---------------------------------------------------------------------------
# Trigger evaluation
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class EarningsRunupTriggerInputs:
"""Bundle of T-1 close inputs for one (symbol, decision_date) candidate.
Every field whose source bears a timestamp must be timestamped strictly
before ``decision_date`` 09:30 ET; the candidate builder enforces this.
"""
symbol: str
decision_date: dt.date
next_trading_date: dt.date
upcoming_earnings_reaction_date: dt.date
days_to_earnings: int # trading-day count from decision_date to event
attention_zscore_20d: float
dollar_volume_zscore_20d: float
last_close_price: float
avg_dollar_volume_20d: float
last_bar_date: dt.date
last_bar_timestamp: dt.datetime # tz-aware
def evaluate_trigger(
inputs: EarningsRunupTriggerInputs,
engine: StrategyEngineConfig,
) -> tuple[bool, str | None]:
"""Pure trigger check. Returns (passes, reject_reason)."""
dmin = engine.earnings_runup_days_to_earnings_min
dmax = engine.earnings_runup_days_to_earnings_max
if dmin is not None and inputs.days_to_earnings < dmin:
return False, f"days_to_earnings {inputs.days_to_earnings} < min {dmin}"
if dmax is not None and inputs.days_to_earnings > dmax:
return False, f"days_to_earnings {inputs.days_to_earnings} > max {dmax}"
az_min = engine.earnings_runup_attention_zscore_20d_min
if az_min is not None and inputs.attention_zscore_20d < az_min:
return False, f"attention_z {inputs.attention_zscore_20d:.3f} < min {az_min}"
dvz_min = engine.earnings_runup_dollar_volume_zscore_20d_min
if dvz_min is not None and inputs.dollar_volume_zscore_20d < dvz_min:
return False, f"dollar_volume_z {inputs.dollar_volume_zscore_20d:.3f} < min {dvz_min}"
adv_min = engine.earnings_runup_min_avg_dollar_volume
if adv_min is not None 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}"
)
return True, None
# ---------------------------------------------------------------------------
# Dollar-volume z-score from bar history
# ---------------------------------------------------------------------------
def _dollar_volume_zscore_20d(bars: list[tuple[dt.date, dict[str, Any]]]) -> tuple[float | None, float | None]:
"""Compute (zscore_20d, avg_dollar_volume_20d) from the last 21 bars.
The most recent bar (T-1) is the observation; the prior 20 form the baseline.
Returns (None, None) if insufficient history.
"""
if len(bars) < 21:
return None, None
recent = bars[-1][1]
prior_20 = bars[-21:-1]
recent_dv = float(recent.get("close", 0.0)) * float(recent.get("volume", 0.0))
prior_dv = [
float(b.get("close", 0.0)) * float(b.get("volume", 0.0))
for _, b in prior_20
]
if len(prior_dv) < 2:
return None, None
mu = statistics.fmean(prior_dv)
sigma = statistics.pstdev(prior_dv)
if sigma <= 0 or math.isnan(sigma):
return None, mu
z = (recent_dv - mu) / sigma
return z, mu
# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------
def build_earnings_runup_candidates(
decision_date: dt.date,
next_trading_date: dt.date,
universe_symbols: Iterable[str],
engine: StrategyEngineConfig,
upcoming_earnings_provider: UpcomingEarningsProvider,
attention_provider: AttentionZscoreProvider,
bar_provider: BarHistoryProvider,
) -> list[Candidate]:
"""Construct synthetic EarningsRunup 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``.
"""
if not engine.earnings_runup_enabled:
return []
dmax = int(engine.earnings_runup_days_to_earnings_max or 0)
if dmax <= 0:
return []
# PIT calendar adapter looks ``dmax`` trading days ahead. We pad in calendar days
# to be safe (weekends/holidays).
calendar_lookahead = dmax * 2 + 7
candidates: list[Candidate] = []
seen_symbols: set[str] = set()
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)
upcoming_reaction = upcoming_earnings_provider.get_next_reaction_date(
symbol=symbol,
as_of_date=decision_date,
max_lookahead_calendar_days=calendar_lookahead,
)
if upcoming_reaction is None:
continue
# Trading-day distance from decision_date close to event reaction.
# We compute via the universe's trading-day index when available; the
# PIT adapter passes the full sim_dates list, so we recompute from there.
days_to_earnings = _trading_days_between(
decision_date, upcoming_reaction, getattr(upcoming_earnings_provider, "trading_days", None)
)
if days_to_earnings is None:
continue
bars = bar_provider.get_bars_before(symbol, decision_date, lookback_days=65)
if not bars:
continue
last_bar_date, last_bar = bars[-1]
# Strict T-1 check: most recent allowed bar is the day BEFORE decision_date.
# We allow same-day decision_date+ data only if the bar's date < decision_date.
if last_bar_date >= decision_date:
raise LookaheadViolationError(
f"EarningsRunup bar for {symbol} on {last_bar_date.isoformat()} is not "
f"strictly before decision_date {decision_date.isoformat()}"
)
last_bar_ts = _bar_close_timestamp(last_bar_date)
# Run the lookahead assertion early — it MUST be on the hot path.
_assert_no_lookahead(symbol, decision_date, [last_bar_ts])
dv_z, adv_20d = _dollar_volume_zscore_20d(bars)
if dv_z is None or adv_20d is None:
continue
attention_z = attention_provider.get_zscore_20d(symbol, decision_date)
if attention_z is None:
continue
last_close = float(last_bar.get("close", 0.0))
if last_close <= 0:
continue
inputs = EarningsRunupTriggerInputs(
symbol=symbol,
decision_date=decision_date,
next_trading_date=next_trading_date,
upcoming_earnings_reaction_date=upcoming_reaction,
days_to_earnings=days_to_earnings,
attention_zscore_20d=float(attention_z),
dollar_volume_zscore_20d=float(dv_z),
last_close_price=last_close,
avg_dollar_volume_20d=float(adv_20d),
last_bar_date=last_bar_date,
last_bar_timestamp=last_bar_ts,
)
passes, reason = evaluate_trigger(inputs, engine)
if not passes:
logger.debug(
"earnings_runup_trigger_skipped",
symbol=symbol,
decision_date=decision_date.isoformat(),
reason=reason,
)
continue
candidate = _build_candidate_from_inputs(inputs, engine)
candidates.append(candidate)
return candidates
def _trading_days_between(
decision_date: dt.date,
target_date: dt.date,
trading_days: list[dt.date] | None,
) -> int | None:
if trading_days:
try:
i0 = trading_days.index(decision_date)
i1 = trading_days.index(target_date)
return i1 - i0
except ValueError:
return None
# Fallback: business-day approximation if trading_days not available.
# Counts weekdays strictly after decision_date up to target_date.
if target_date <= decision_date:
return None
count = 0
cursor = decision_date
while cursor < target_date:
cursor = cursor + dt.timedelta(days=1)
if cursor.weekday() < 5:
count += 1
return count
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)
def _build_candidate_from_inputs(
inputs: EarningsRunupTriggerInputs,
engine: StrategyEngineConfig,
) -> Candidate:
# Hard hold: forced flat by close of the trading day BEFORE the print.
buffer = max(0, int(engine.earnings_runup_calendar_buffer_days))
max_holding_days = max(1, inputs.days_to_earnings - buffer)
# Translate pct exits → existing ATR-multiplier / R-multiple machinery in
# `libs.backtest.allocator.compute_stop_price` and `compute_target_price`.
# Synthetic ATR := 2% of last close (the same fallback compute_stop_price
# uses when atr_14 is missing, but we materialize it so target/stop
# downstream consumers see a non-null ATR).
# stop_atr_multiplier := stop_pct / 0.02 → produces a stop_distance of
# ``stop_pct * close`` for the default dynamic_scaler == 1.0.
# target_1_r := target_pct / stop_pct → fixed-R target sits at +target_pct.
# target_1_fraction := 1.0 → fully exit at first target.
# Trailing pct exits are NOT mapped (no clean equivalent in the standard
# trailing system); rely on the engine's trailing_warmup_days override and
# capture trailing config in features for diagnostics.
synthetic_atr = max(inputs.last_close_price * 0.02, 0.01)
stop_pct = float(engine.earnings_runup_stop_pct)
target_pct = float(engine.earnings_runup_target_pct)
stop_mult = stop_pct / 0.02 if stop_pct > 0 else 2.0
target_r = target_pct / stop_pct if stop_pct > 0 else 2.0
# Score is a deterministic function of the two z-scores so it ranks
# candidates without leaking future information.
z_sum = inputs.attention_zscore_20d + inputs.dollar_volume_zscore_20d
score = 0.5 + 0.05 * z_sum
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_earnings_runup_{inputs.symbol.lower()}_"
f"{inputs.decision_date.isoformat()}"
)
features = {
"earnings_runup_decision_date": inputs.decision_date.isoformat(),
"earnings_runup_upcoming_reaction_date": inputs.upcoming_earnings_reaction_date.isoformat(),
"earnings_runup_days_to_earnings": inputs.days_to_earnings,
"earnings_runup_attention_zscore_20d": round(inputs.attention_zscore_20d, 4),
"earnings_runup_dollar_volume_zscore_20d": round(inputs.dollar_volume_zscore_20d, 4),
"earnings_runup_avg_dollar_volume_20d": inputs.avg_dollar_volume_20d,
"earnings_runup_max_holding_days": max_holding_days,
"earnings_runup_stop_pct": engine.earnings_runup_stop_pct,
"earnings_runup_target_pct": engine.earnings_runup_target_pct,
"earnings_runup_trailing_activate_pct": engine.earnings_runup_trailing_activate_pct,
"earnings_runup_trailing_giveback_pct": engine.earnings_runup_trailing_giveback_pct,
}
return Candidate(
event_id=event_id,
symbol=inputs.symbol,
source_symbol=inputs.symbol,
score=score,
sector="UNKNOWN",
event_type=EARNINGS_RUNUP_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_price,
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_per_trade_risk_pct=engine.per_trade_risk_pct_override,
# Map pct-based EarningsRunup exits → engine_*-prefixed overrides on the candidate.
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,
)
__all__ = [
"EARNINGS_RUNUP_EVENT_TYPE",
"AttentionZscoreProvider",
"BarHistoryProvider",
"EarningsRunupTriggerInputs",
"UpcomingEarningsProvider",
"_PitCalendarUpcomingEarningsAdapter",
"_SnapshotStoreBarAdapter",
"build_earnings_runup_candidates",
"evaluate_trigger",
]

@ -0,0 +1,714 @@
"""PeerSympathy engine.
When a sector leader fires a qualifying PEAD trigger (earnings/guidance/material
contract) with a strong same-day reaction, buy the top-correlated peers at the
next open. Catches sympathy rallies (e.g., AVGO/AMD/MU on NVDA's print) that the
core PEAD universe-filtered engines architecturally miss because they only fire
on the symbol that filed.
This module is the *pure* logic `BacktestRunner` calls into
``build_peer_sympathy_candidates`` from a thin scheduling hook. Provider
Protocols allow stubbed unit tests.
Architectural choice: synthetic Candidate emission into the existing
`_scheduled_delayed_entries` queue, mirroring `_schedule_leader_follower_candidates`
and `_schedule_earnings_runup_candidates`.
Look-ahead defenses (NON-NEGOTIABLE):
1. Correlation window is `[T-window_start, T-window_end_skip)`. The last
``window_end_skip`` trading days are skipped so peer co-movement during the
leader's own pre-event drift cannot leak into the correlation.
2. Peer T+0 (leader event day) reaction is NEVER consulted in selection. Only
leader's print and the peer's bar history strictly before T are used.
3. ``next_trading_date > leader.event_date`` (peer entry strictly after leader
publication). Enforced via ``LookaheadViolationError``.
"""
from __future__ import annotations
import datetime as dt
import math
import statistics
from dataclasses import dataclass
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__)
PEER_SYMPATHY_EVENT_TYPE = "peer_sympathy"
# Eastern-time market open used as the leakage cutoff for peer features.
_ET_MARKET_OPEN = dt.time(9, 30)
_ET_OFFSET = dt.timedelta(hours=-5) # EST; DST is irrelevant for an ordering bound
# ---------------------------------------------------------------------------
# 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 UpcomingEarningsProvider(Protocol):
"""Returns the next-known scheduled earnings reaction date for ``symbol`` as of ``as_of_date``.
Used here to enforce the peer-earnings blackout: don't buy a peer whose own
print is within ``blackout_days_to_peer_event`` trading days.
"""
def get_next_reaction_date(
self,
symbol: str,
as_of_date: dt.date,
max_lookahead_calendar_days: int,
) -> dt.date | None: ...
# ---------------------------------------------------------------------------
# Lightweight info struct: leader print as evaluated against engine filters.
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class LeaderPrint:
"""Subset of leader candidate / event row consumed by PeerSympathy.
The runner adapts ``Candidate`` rows to this struct so the pure logic does
not depend on the heavyweight ``Candidate`` model and is trivially fakeable
in unit tests.
"""
symbol: str
sector: str
event_id: str
event_type: str
event_date: dt.date
event_timestamp: dt.datetime # tz-aware
reaction_day_return: float
score: float = 0.5
# ---------------------------------------------------------------------------
# Trigger
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class PeerSympathyTriggerInputs:
"""Bundle of inputs for one (peer, decision_date=leader.event_date) trigger evaluation."""
leader_symbol: str
leader_sector: str
leader_event_type: str
leader_reaction: float
peer_symbol: str
decision_date: dt.date
next_trading_date: dt.date
correlation: float
peer_last_close: float
peer_avg_dollar_volume_20d: float
peer_last_bar_date: dt.date
peer_last_bar_timestamp: dt.datetime # tz-aware
peer_upcoming_earnings_reaction_date: dt.date | None
peer_trading_days_to_own_earnings: int | None
def evaluate_trigger(
inputs: PeerSympathyTriggerInputs,
engine: StrategyEngineConfig,
) -> tuple[bool, str | None]:
"""Pure trigger check. Returns (passes, reject_reason)."""
allowed_event_types = {
str(e).strip().lower()
for e in (engine.peer_sympathy_leader_event_types or [])
if str(e).strip()
}
if allowed_event_types and inputs.leader_event_type.lower() not in allowed_event_types:
return False, f"leader_event_type {inputs.leader_event_type!r} not in {sorted(allowed_event_types)}"
if inputs.leader_reaction < engine.peer_sympathy_leader_reaction_min:
return False, (
f"leader_reaction {inputs.leader_reaction:.4f} < "
f"min {engine.peer_sympathy_leader_reaction_min}"
)
if inputs.correlation < engine.peer_sympathy_correlation_min:
return False, (
f"correlation {inputs.correlation:.4f} < min {engine.peer_sympathy_correlation_min}"
)
blackout = max(0, int(engine.peer_sympathy_blackout_days_to_peer_event or 0))
if blackout > 0 and inputs.peer_trading_days_to_own_earnings is not None:
if inputs.peer_trading_days_to_own_earnings <= blackout:
return False, (
f"peer_trading_days_to_own_earnings "
f"{inputs.peer_trading_days_to_own_earnings} <= blackout {blackout}"
)
return True, None
# ---------------------------------------------------------------------------
# Look-ahead helpers
# ---------------------------------------------------------------------------
def _decision_cutoff_utc(decision_date: dt.date) -> dt.datetime:
"""09:30 ET on decision_date, expressed as a UTC-aware timestamp.
Any feature timestamp >= this instant carries information from inside the
entry day and constitutes a look-ahead violation. The decision day for
peer entry is ``next_trading_date``, NOT ``decision_date`` (=leader.event_date),
so the cutoff for peer features is ``next_trading_date``'s 09:30 ET.
"""
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_no_lookahead(
symbol: str,
decision_date: dt.date,
feature_timestamps: Iterable[dt.datetime],
) -> None:
cutoff = _decision_cutoff_utc(decision_date)
for ts in feature_timestamps:
if ts is None:
continue
if ts.tzinfo is None:
raise LookaheadViolationError(
f"PeerSympathy feature timestamp for {symbol} is naive ({ts.isoformat()}); "
"all timestamps must be timezone-aware"
)
if ts >= cutoff:
raise LookaheadViolationError(
f"PeerSympathy feature timestamp {ts.isoformat()} for {symbol} is "
f">= decision cutoff {cutoff.isoformat()}; this is a look-ahead violation"
)
def _assert_correlation_window_safe(
*,
leader_symbol: str,
peer_symbol: str,
decision_date: dt.date,
window_end_skip: int,
used_dates: list[dt.date],
trading_days: list[dt.date] | None,
) -> None:
"""Assert that NO date used in the correlation series is within
``window_end_skip`` trading days of ``decision_date``.
This is the canonical 'last N days skipped' invariant. We compute the
forbidden boundary as the trading day exactly ``window_end_skip`` days
BEFORE ``decision_date`` (or, if the trading-day list is missing, fall back
to a calendar-day approximation that is strictly conservative).
"""
if not used_dates:
return
if trading_days:
try:
d_idx = trading_days.index(decision_date)
except ValueError:
# decision_date not in the calendar — fall back to calendar-day check.
forbidden_floor = decision_date - dt.timedelta(days=window_end_skip)
else:
cut = max(0, d_idx - window_end_skip)
forbidden_floor = trading_days[cut] if cut < len(trading_days) else trading_days[0]
else:
# Calendar-day fallback: ``window_end_skip`` calendar days. Conservative.
forbidden_floor = decision_date - dt.timedelta(days=window_end_skip)
most_recent_used = max(used_dates)
if most_recent_used >= forbidden_floor:
raise LookaheadViolationError(
f"PeerSympathy correlation window for ({leader_symbol},{peer_symbol}) "
f"included {most_recent_used.isoformat()} which is within {window_end_skip} "
f"trading days of decision_date {decision_date.isoformat()} "
f"(forbidden floor {forbidden_floor.isoformat()}); "
"the last N days MUST be skipped to avoid co-movement leakage"
)
# ---------------------------------------------------------------------------
# Correlation: shared-date log-return Pearson on bars strictly before T-skip
# ---------------------------------------------------------------------------
def _log_returns_by_date(
bars: list[tuple[dt.date, dict[str, Any]]],
) -> list[tuple[dt.date, float]]:
"""Pairwise log-returns ln(close_t / close_{t-1}); date is the close date of t."""
out: list[tuple[dt.date, float]] = []
prev_close: float | None = None
for d, bar in bars:
close = float(bar.get("close", 0.0))
if close <= 0:
prev_close = None
continue
if prev_close is not None and prev_close > 0:
out.append((d, math.log(close / prev_close)))
prev_close = close
return out
def compute_correlation(
leader_bars: list[tuple[dt.date, dict[str, Any]]],
peer_bars: list[tuple[dt.date, dict[str, Any]]],
*,
decision_date: dt.date,
window_start: int,
window_end_skip: int,
trading_days: list[dt.date] | None = None,
) -> tuple[float | None, list[dt.date]]:
"""Pearson correlation of log-returns over the [T-window_start, T-window_end_skip) window.
Returns ``(correlation, used_dates)``. ``correlation`` is ``None`` if there
are insufficient overlapping observations (< 5 paired returns).
The function intentionally never reads bars dated >= decision_date that
would be a look-ahead and always strips the last ``window_end_skip``
trading days from the eligible-date set.
"""
if window_start <= 0 or window_end_skip < 0 or window_start <= window_end_skip:
return None, []
# Determine the latest allowable date in the window (strictly before T-skip).
if trading_days:
try:
d_idx = trading_days.index(decision_date)
except ValueError:
d_idx = None
if d_idx is not None:
top_idx = d_idx - window_end_skip # exclusive upper bound on dates
bot_idx = max(0, d_idx - window_start)
if top_idx <= bot_idx:
return None, []
allowed_dates = set(trading_days[bot_idx:top_idx])
else:
allowed_dates = None
else:
allowed_dates = None
leader_returns = _log_returns_by_date(leader_bars)
peer_returns = _log_returns_by_date(peer_bars)
leader_by_date = dict(leader_returns)
peer_by_date = dict(peer_returns)
shared = sorted(set(leader_by_date) & set(peer_by_date))
# Apply allowed-date filter when we know the trading calendar.
if allowed_dates is not None:
shared = [d for d in shared if d in allowed_dates]
else:
# Calendar-day fallback: drop dates within ``window_end_skip`` calendar days
# of decision_date AND keep only dates within ``window_start`` calendar days.
skip_floor = decision_date - dt.timedelta(days=window_end_skip)
start_floor = decision_date - dt.timedelta(days=window_start * 2) # generous
shared = [d for d in shared if d < skip_floor and d >= start_floor]
# Strict ceiling: all dates must be < decision_date (defence in depth).
shared = [d for d in shared if d < decision_date]
if len(shared) < 5:
return None, shared
leader_xs = [leader_by_date[d] for d in shared]
peer_xs = [peer_by_date[d] for d in shared]
n = len(leader_xs)
mean_l = statistics.fmean(leader_xs)
mean_p = statistics.fmean(peer_xs)
cov = sum((leader_xs[i] - mean_l) * (peer_xs[i] - mean_p) for i in range(n)) / n
var_l = sum((x - mean_l) ** 2 for x in leader_xs) / n
var_p = sum((x - mean_p) ** 2 for x in peer_xs) / n
if var_l <= 0 or var_p <= 0:
return None, shared
rho = cov / math.sqrt(var_l * var_p)
if math.isnan(rho) or math.isinf(rho):
return None, shared
return float(rho), shared
# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------
def build_peer_sympathy_candidates(
decision_date: dt.date,
next_trading_date: dt.date,
leaders: Iterable[LeaderPrint],
peer_resolver: "PeerResolver",
engine: StrategyEngineConfig,
bar_provider: BarHistoryProvider,
upcoming_earnings_provider: UpcomingEarningsProvider | None = None,
trading_days: list[dt.date] | None = None,
) -> list[Candidate]:
"""Construct synthetic peer-sympathy candidates for ``next_trading_date`` execution.
``leaders`` are the qualifying leader prints from T (=decision_date). For
each leader we:
- resolve its peer set,
- compute correlation on the [T-window_start, T-window_end_skip) window,
- drop peers below ``correlation_min``,
- keep top-N peers by correlation,
- emit a synthetic Candidate per peer.
"""
if not engine.peer_sympathy_enabled:
return []
# Lookahead invariant: peer entry must be strictly after leader publication.
if next_trading_date <= decision_date:
raise LookaheadViolationError(
f"PeerSympathy next_trading_date {next_trading_date.isoformat()} must be "
f"strictly after leader event_date {decision_date.isoformat()}"
)
candidates: list[Candidate] = []
seen_peer_for_decision: set[str] = set()
allowed_event_types = {
str(e).strip().lower()
for e in (engine.peer_sympathy_leader_event_types or [])
if str(e).strip()
}
leader_reaction_min = float(engine.peer_sympathy_leader_reaction_min)
corr_min = float(engine.peer_sympathy_correlation_min)
window_start = int(engine.peer_sympathy_correlation_window_start)
window_end_skip = int(engine.peer_sympathy_correlation_window_end_skip)
top_n = max(1, int(engine.peer_sympathy_top_n_peers or 1))
blackout = max(0, int(engine.peer_sympathy_blackout_days_to_peer_event or 0))
for leader in leaders:
leader_symbol = str(leader.symbol or "").strip().upper()
if not leader_symbol:
continue
# Cheap leader-side gates first to avoid unnecessary bar fetches.
if allowed_event_types and leader.event_type.lower() not in allowed_event_types:
continue
if leader.reaction_day_return < leader_reaction_min:
continue
# Lookahead: leader event_timestamp must precede peer entry cutoff.
peer_decision_cutoff = _decision_cutoff_utc(next_trading_date)
if leader.event_timestamp.tzinfo is None:
raise LookaheadViolationError(
f"PeerSympathy leader {leader_symbol} has naive event_timestamp "
f"{leader.event_timestamp.isoformat()}"
)
if leader.event_timestamp >= peer_decision_cutoff:
raise LookaheadViolationError(
f"PeerSympathy leader {leader_symbol} event_timestamp "
f"{leader.event_timestamp.isoformat()} is at-or-after peer entry cutoff "
f"{peer_decision_cutoff.isoformat()}"
)
# Pull leader bars once per leader.
leader_bars = bar_provider.get_bars_before(
leader_symbol, decision_date, lookback_days=window_start + 5
)
if len(leader_bars) < window_start - window_end_skip:
logger.debug(
"peer_sympathy_skip_leader_insufficient_bars",
leader=leader_symbol,
bars=len(leader_bars),
decision_date=decision_date.isoformat(),
)
continue
peers = peer_resolver.peers_for_leader(engine, leader_symbol, leader.sector)
if not peers:
continue
# Compute correlation per peer; collect (peer, corr, last_bar_meta).
scored_peers: list[tuple[str, float, list[tuple[dt.date, dict[str, Any]]]]] = []
for peer_symbol in peers:
peer_symbol = str(peer_symbol).strip().upper()
if not peer_symbol or peer_symbol == leader_symbol:
continue
peer_bars = bar_provider.get_bars_before(
peer_symbol, decision_date, lookback_days=window_start + 5
)
if len(peer_bars) < window_start - window_end_skip:
continue
corr, used_dates = compute_correlation(
leader_bars,
peer_bars,
decision_date=decision_date,
window_start=window_start,
window_end_skip=window_end_skip,
trading_days=trading_days,
)
if corr is None:
continue
# Hot-path lookahead assertion on the dates actually used.
_assert_correlation_window_safe(
leader_symbol=leader_symbol,
peer_symbol=peer_symbol,
decision_date=decision_date,
window_end_skip=window_end_skip,
used_dates=used_dates,
trading_days=trading_days,
)
if corr < corr_min:
continue
scored_peers.append((peer_symbol, corr, peer_bars))
# Top-N peers by correlation.
scored_peers.sort(key=lambda x: x[1], reverse=True)
scored_peers = scored_peers[:top_n]
for peer_symbol, corr, peer_bars in scored_peers:
if peer_symbol in seen_peer_for_decision:
continue
last_bar_date, last_bar = peer_bars[-1]
if last_bar_date >= decision_date:
raise LookaheadViolationError(
f"PeerSympathy peer bar for {peer_symbol} on {last_bar_date.isoformat()} "
f"is not strictly before decision_date {decision_date.isoformat()}"
)
last_close = float(last_bar.get("close", 0.0))
if last_close <= 0:
continue
volumes = [float(b.get("volume", 0.0)) for _, b in peer_bars[-20:]]
closes = [float(b.get("close", 0.0)) for _, b in peer_bars[-20:]]
if len(volumes) < 5:
continue
adv_20d = statistics.fmean(c * v for c, v in zip(closes, volumes))
peer_last_bar_ts = _bar_close_timestamp(last_bar_date)
_assert_no_lookahead(
peer_symbol, next_trading_date, [peer_last_bar_ts, leader.event_timestamp]
)
# Peer-earnings blackout — uses an UpcomingEarningsProvider if available.
peer_upcoming = None
peer_days_to_own = None
if upcoming_earnings_provider is not None and blackout > 0:
peer_upcoming = upcoming_earnings_provider.get_next_reaction_date(
symbol=peer_symbol,
as_of_date=decision_date,
max_lookahead_calendar_days=blackout * 3 + 7,
)
if peer_upcoming is not None:
peer_days_to_own = _trading_days_between(
next_trading_date, peer_upcoming, trading_days
)
inputs = PeerSympathyTriggerInputs(
leader_symbol=leader_symbol,
leader_sector=leader.sector,
leader_event_type=leader.event_type,
leader_reaction=leader.reaction_day_return,
peer_symbol=peer_symbol,
decision_date=decision_date,
next_trading_date=next_trading_date,
correlation=corr,
peer_last_close=last_close,
peer_avg_dollar_volume_20d=adv_20d,
peer_last_bar_date=last_bar_date,
peer_last_bar_timestamp=peer_last_bar_ts,
peer_upcoming_earnings_reaction_date=peer_upcoming,
peer_trading_days_to_own_earnings=peer_days_to_own,
)
passes, reason = evaluate_trigger(inputs, engine)
if not passes:
logger.debug(
"peer_sympathy_trigger_skipped",
leader=leader_symbol,
peer=peer_symbol,
decision_date=decision_date.isoformat(),
reason=reason,
)
continue
candidate = _build_candidate_from_inputs(inputs, leader, engine)
candidates.append(candidate)
seen_peer_for_decision.add(peer_symbol)
return candidates
# ---------------------------------------------------------------------------
# Peer resolver (Protocol so the runner adapter and the test fake share an API)
# ---------------------------------------------------------------------------
class PeerResolver(Protocol):
"""Resolves a leader's peer set, filtered by engine config.
Reuses the existing leader-follower infra
(``leader_follower_extra_peer_symbols_by_sector``,
``leader_follower_extra_peer_symbols_by_leader``,
``leader_follower_allowed_peer_symbols``) and the proxies module's
``peer_candidates_for_symbol`` (sector-ETF holdings + per-leader curated set).
"""
def peers_for_leader(
self,
engine: StrategyEngineConfig,
leader_symbol: str,
leader_sector: str,
) -> list[str]: ...
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _trading_days_between(
start_date: dt.date,
target_date: dt.date,
trading_days: list[dt.date] | None,
) -> int | None:
if trading_days:
try:
i0 = trading_days.index(start_date)
except ValueError:
return None
try:
i1 = trading_days.index(target_date)
except ValueError:
return None
return i1 - i0
if target_date <= start_date:
return 0
count = 0
cursor = start_date
while cursor < target_date:
cursor = cursor + dt.timedelta(days=1)
if cursor.weekday() < 5:
count += 1
return count
def _bar_close_timestamp(bar_date: dt.date) -> dt.datetime:
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)
def _build_candidate_from_inputs(
inputs: PeerSympathyTriggerInputs,
leader: LeaderPrint,
engine: StrategyEngineConfig,
) -> Candidate:
# Map pct exits to the existing ATR-multiplier / R-multiple machinery.
synthetic_atr = max(inputs.peer_last_close * 0.02, 0.01)
stop_pct = float(engine.peer_sympathy_stop_pct)
target_pct = float(engine.peer_sympathy_target_pct)
stop_mult = stop_pct / 0.02 if stop_pct > 0 else 1.75
target_r = target_pct / stop_pct if stop_pct > 0 else 1.71
max_holding_days = max(1, int(engine.peer_sympathy_max_holding_days or 3))
if (
inputs.peer_trading_days_to_own_earnings is not None
and engine.peer_sympathy_blackout_days_to_peer_event > 0
):
# Forced-flat at most 1 day before the peer's own print.
ceiling = max(
1,
int(inputs.peer_trading_days_to_own_earnings)
- int(engine.peer_sympathy_blackout_days_to_peer_event),
)
max_holding_days = min(max_holding_days, ceiling)
score = min(0.99, max(0.0, 0.5 + 0.5 * (inputs.correlation - engine.peer_sympathy_correlation_min)))
score_bucket = (
"high" if score >= 0.8
else "medium_high" if score >= 0.6
else "medium"
)
event_id = (
f"synth_peer_sympathy_{inputs.leader_symbol.lower()}_"
f"{inputs.peer_symbol.lower()}_{inputs.decision_date.isoformat()}"
)
features = {
"peer_sympathy_leader_symbol": inputs.leader_symbol,
"peer_sympathy_leader_event_id": leader.event_id,
"peer_sympathy_leader_event_type": leader.event_type,
"peer_sympathy_leader_reaction_day_return": inputs.leader_reaction,
"peer_sympathy_peer_symbol": inputs.peer_symbol,
"peer_sympathy_correlation": round(inputs.correlation, 4),
"peer_sympathy_correlation_window_start": engine.peer_sympathy_correlation_window_start,
"peer_sympathy_correlation_window_end_skip": engine.peer_sympathy_correlation_window_end_skip,
"peer_sympathy_stop_pct": engine.peer_sympathy_stop_pct,
"peer_sympathy_target_pct": engine.peer_sympathy_target_pct,
"peer_sympathy_max_holding_days": max_holding_days,
"peer_sympathy_peer_upcoming_earnings": (
inputs.peer_upcoming_earnings_reaction_date.isoformat()
if inputs.peer_upcoming_earnings_reaction_date is not None
else None
),
"peer_sympathy_peer_trading_days_to_own_earnings": inputs.peer_trading_days_to_own_earnings,
}
return Candidate(
event_id=event_id,
symbol=inputs.peer_symbol,
source_symbol=inputs.leader_symbol,
score=score,
sector=inputs.leader_sector or "UNKNOWN",
event_type=PEER_SYMPATHY_EVENT_TYPE,
event_timestamp=inputs.peer_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.peer_last_close,
avg_dollar_volume=inputs.peer_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_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,
)
__all__ = [
"PEER_SYMPATHY_EVENT_TYPE",
"BarHistoryProvider",
"LeaderPrint",
"PeerResolver",
"PeerSympathyTriggerInputs",
"UpcomingEarningsProvider",
"build_peer_sympathy_candidates",
"compute_correlation",
"evaluate_trigger",
]

@ -0,0 +1,734 @@
"""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_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",
]

@ -0,0 +1,629 @@
"""Unit tests for the EarningsRunup pre-event drift engine."""
from __future__ import annotations
import datetime as dt
from typing import Any
import pytest
from libs.backtest.domain import LookaheadViolationError, StrategyEngineConfig
from libs.backtest.earnings_calendar import (
EarningsCalendarEntry,
PointInTimeEarningsCalendar,
)
from libs.backtest.earnings_runup import (
EARNINGS_RUNUP_EVENT_TYPE,
EarningsRunupTriggerInputs,
_PitCalendarUpcomingEarningsAdapter,
_SnapshotStoreBarAdapter,
build_earnings_runup_candidates,
evaluate_trigger,
)
# ---------------------------------------------------------------------------
# Fakes
# ---------------------------------------------------------------------------
class _FakeAttention:
def __init__(self, by_symbol_date: dict[tuple[str, dt.date], float | None]) -> None:
self.by_symbol_date = by_symbol_date
def get_zscore_20d(self, symbol: str, as_of_date: dt.date) -> float | None:
return self.by_symbol_date.get((symbol.upper(), as_of_date))
def _make_engine(**overrides: Any) -> StrategyEngineConfig:
base: dict[str, Any] = dict(
engine_id="earnings_runup_preevent_long",
event_types=[EARNINGS_RUNUP_EVENT_TYPE],
direction="long_only",
timing_class="after_close",
entry_timing_policy="next_open",
max_holding_days=7,
earnings_runup_enabled=True,
earnings_runup_days_to_earnings_min=3,
earnings_runup_days_to_earnings_max=7,
earnings_runup_attention_zscore_20d_min=1.5,
earnings_runup_dollar_volume_zscore_20d_min=1.0,
earnings_runup_calendar_buffer_days=1,
)
base.update(overrides)
return StrategyEngineConfig(**base)
def _generate_business_days(start: dt.date, count: int) -> list[dt.date]:
out: list[dt.date] = []
cursor = start
while len(out) < count:
if cursor.weekday() < 5:
out.append(cursor)
cursor = cursor + dt.timedelta(days=1)
return out
def _build_bars(
symbol: str,
trading_days: list[dt.date],
*,
base_volume: float,
spike_factor: float,
base_close: float = 100.0,
) -> dict[str, dict[dt.date, dict[str, Any]]]:
"""Build a bars-by-symbol-date dict with the LAST bar's $-volume = base*spike_factor.
Prior bars include small deterministic variance so sigma > 0 in the z-score.
"""
inner: dict[dt.date, dict[str, Any]] = {}
for i, d in enumerate(trading_days):
is_last = (i == len(trading_days) - 1)
if is_last:
volume = base_volume * spike_factor
else:
# +/- 10% sinusoidal perturbation, rounded so sigma > 0.
jitter = 1.0 + 0.1 * ((i % 5) - 2) / 2.0
volume = base_volume * jitter
inner[d] = {
"open": base_close,
"high": base_close,
"low": base_close,
"close": base_close,
"volume": volume,
}
return {symbol.upper(): inner}
# ---------------------------------------------------------------------------
# evaluate_trigger() — happy path + 3 negative cases
# ---------------------------------------------------------------------------
def _trigger_inputs(**overrides: Any) -> EarningsRunupTriggerInputs:
base: dict[str, Any] = dict(
symbol="AAPL",
decision_date=dt.date(2026, 4, 13), # Mon
next_trading_date=dt.date(2026, 4, 14),
upcoming_earnings_reaction_date=dt.date(2026, 4, 21),
days_to_earnings=5,
attention_zscore_20d=1.8,
dollar_volume_zscore_20d=1.2,
last_close_price=100.0,
avg_dollar_volume_20d=200_000_000.0,
last_bar_date=dt.date(2026, 4, 10), # prior Fri
last_bar_timestamp=dt.datetime(2026, 4, 10, 21, 0, tzinfo=dt.timezone.utc),
)
base.update(overrides)
return EarningsRunupTriggerInputs(**base)
def test_trigger_fires_when_all_three_conditions_met():
engine = _make_engine()
passes, reason = evaluate_trigger(_trigger_inputs(), engine)
assert passes is True
assert reason is None
def test_trigger_blocks_when_days_to_earnings_below_min():
engine = _make_engine()
passes, reason = evaluate_trigger(_trigger_inputs(days_to_earnings=2), engine)
assert passes is False
assert "days_to_earnings" in (reason or "")
def test_trigger_blocks_when_attention_zscore_below_min():
engine = _make_engine()
passes, reason = evaluate_trigger(_trigger_inputs(attention_zscore_20d=1.4), engine)
assert passes is False
assert "attention_z" in (reason or "")
def test_trigger_blocks_when_dollar_volume_zscore_below_min():
engine = _make_engine()
passes, reason = evaluate_trigger(_trigger_inputs(dollar_volume_zscore_20d=0.99), engine)
assert passes is False
assert "dollar_volume_z" in (reason or "")
# ---------------------------------------------------------------------------
# build_earnings_runup_candidates() — end-to-end with fakes
# ---------------------------------------------------------------------------
def _build_full_setup(
symbol: str = "AAPL",
*,
spike_factor: float = 5.0,
attention_z: float | None = 2.0,
earnings_offset_trading_days: int = 5,
days_of_history: int = 30,
):
# Decision day = the last day of generated trading days; bars go strictly before it.
trading_days = _generate_business_days(dt.date(2026, 3, 2), days_of_history + earnings_offset_trading_days + 2)
decision_date = trading_days[days_of_history] # T-1 close
next_trading_date = trading_days[days_of_history + 1]
earnings_reaction = trading_days[days_of_history + earnings_offset_trading_days]
# Bars for prior `days_of_history` days, ending on the day BEFORE decision_date.
prior_days = trading_days[:days_of_history]
bars = _build_bars(
symbol,
prior_days,
base_volume=1_000_000.0,
spike_factor=spike_factor,
)
bar_provider = _SnapshotStoreBarAdapter(bars_by_symbol=bars)
# PIT calendar: known earnings reaction date for the symbol.
pit_calendar = PointInTimeEarningsCalendar(
[
EarningsCalendarEntry(
symbol=symbol,
as_of_date=trading_days[0],
expected_reaction_date=earnings_reaction,
expected_event_date=earnings_reaction,
filing_time_bucket="post_market",
)
]
)
upcoming_provider = _PitCalendarUpcomingEarningsAdapter(
pit_calendar=pit_calendar,
trading_days=trading_days,
)
attention_provider = _FakeAttention(
{(symbol.upper(), decision_date): attention_z}
)
return {
"symbol": symbol,
"decision_date": decision_date,
"next_trading_date": next_trading_date,
"earnings_reaction": earnings_reaction,
"trading_days": trading_days,
"bar_provider": bar_provider,
"upcoming_provider": upcoming_provider,
"attention_provider": attention_provider,
"bars": bars,
}
def test_build_emits_candidate_for_eligible_symbol():
setup = _build_full_setup()
engine = _make_engine()
cands = build_earnings_runup_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["next_trading_date"],
universe_symbols=[setup["symbol"]],
engine=engine,
upcoming_earnings_provider=setup["upcoming_provider"],
attention_provider=setup["attention_provider"],
bar_provider=setup["bar_provider"],
)
assert len(cands) == 1
cand = cands[0]
assert cand.event_type == EARNINGS_RUNUP_EVENT_TYPE
assert cand.symbol == setup["symbol"]
assert cand.engine_id == engine.engine_id
assert cand.execution_date == setup["next_trading_date"]
assert cand.engine_max_holding_days is not None
# days_to_earnings was 5; calendar_buffer 1 → max_holding_days = 5 - 1 = 4
assert cand.engine_max_holding_days == 4
assert cand.features["earnings_runup_days_to_earnings"] == 5
assert cand.features["earnings_runup_stop_pct"] == 0.04
assert cand.features["earnings_runup_target_pct"] == 0.08
def test_build_does_not_fire_when_attention_below_min():
setup = _build_full_setup(attention_z=0.5)
engine = _make_engine()
cands = build_earnings_runup_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["next_trading_date"],
universe_symbols=[setup["symbol"]],
engine=engine,
upcoming_earnings_provider=setup["upcoming_provider"],
attention_provider=setup["attention_provider"],
bar_provider=setup["bar_provider"],
)
assert cands == []
def test_build_does_not_fire_when_dollar_volume_zscore_below_min():
# Spike factor of 1.0 (no spike) → z-score near 0
setup = _build_full_setup(spike_factor=1.0)
engine = _make_engine()
cands = build_earnings_runup_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["next_trading_date"],
universe_symbols=[setup["symbol"]],
engine=engine,
upcoming_earnings_provider=setup["upcoming_provider"],
attention_provider=setup["attention_provider"],
bar_provider=setup["bar_provider"],
)
assert cands == []
def test_build_does_not_fire_when_days_to_earnings_outside_window():
# earnings_offset = 10 trading days → > max 7
setup = _build_full_setup(earnings_offset_trading_days=10)
engine = _make_engine()
cands = build_earnings_runup_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["next_trading_date"],
universe_symbols=[setup["symbol"]],
engine=engine,
upcoming_earnings_provider=setup["upcoming_provider"],
attention_provider=setup["attention_provider"],
bar_provider=setup["bar_provider"],
)
assert cands == []
# ---------------------------------------------------------------------------
# Lookahead defenses
# ---------------------------------------------------------------------------
def test_build_raises_lookahead_when_bar_date_equals_decision_date():
"""A bar dated on or after decision_date must trigger LookaheadViolationError."""
setup = _build_full_setup()
symbol = setup["symbol"]
decision_date = setup["decision_date"]
bars = setup["bars"]
# Inject a bar dated ON decision_date — this is the look-ahead violation.
bars[symbol.upper()][decision_date] = {
"open": 100.0, "high": 100.0, "low": 100.0, "close": 100.0,
"volume": 5_000_000.0,
}
bar_provider = _SnapshotStoreBarAdapter(bars_by_symbol=bars)
# Add a sentinel bar AFTER decision_date too, so the adapter's `< as_of_date` filter
# is the only thing keeping us safe. Then we manually subvert it.
class LeakyAdapter:
def get_bars_before(self, sym, as_of, lookback_days):
inner = bars[sym.upper()]
# Deliberately include the bar dated == decision_date.
return sorted(
[(d, b) for d, b in inner.items() if d <= as_of]
)[-lookback_days:]
engine = _make_engine()
with pytest.raises(LookaheadViolationError):
build_earnings_runup_candidates(
decision_date=decision_date,
next_trading_date=setup["next_trading_date"],
universe_symbols=[symbol],
engine=engine,
upcoming_earnings_provider=setup["upcoming_provider"],
attention_provider=setup["attention_provider"],
bar_provider=LeakyAdapter(),
)
def test_build_raises_lookahead_when_explicit_assertion_violated():
"""Direct assertion path — feature timestamp >= cutoff must raise."""
from libs.backtest.earnings_runup import _assert_no_lookahead
decision_date = dt.date(2026, 4, 13)
# 09:30 ET on the decision day (= 13:30 UTC under EST; 13:30 UTC == 09:30 EST)
leaky_ts = dt.datetime(2026, 4, 13, 14, 30, tzinfo=dt.timezone.utc) # 10:30 ET
with pytest.raises(LookaheadViolationError):
_assert_no_lookahead("AAPL", decision_date, [leaky_ts])
def test_assert_no_lookahead_accepts_strictly_prior_timestamp():
from libs.backtest.earnings_runup import _assert_no_lookahead
decision_date = dt.date(2026, 4, 13)
safe_ts = dt.datetime(2026, 4, 10, 21, 0, tzinfo=dt.timezone.utc) # prior day close
# Should not raise
_assert_no_lookahead("AAPL", decision_date, [safe_ts])
def test_assert_no_lookahead_rejects_naive_timestamp():
from libs.backtest.earnings_runup import _assert_no_lookahead
decision_date = dt.date(2026, 4, 13)
naive_ts = dt.datetime(2026, 4, 10, 21, 0)
with pytest.raises(LookaheadViolationError):
_assert_no_lookahead("AAPL", decision_date, [naive_ts])
# ---------------------------------------------------------------------------
# PIT earnings calendar respects as_of_date
# ---------------------------------------------------------------------------
def test_pit_calendar_does_not_reveal_unannounced_future_earnings():
"""Earnings dates whose as_of_date is AFTER decision_date must not be visible."""
trading_days = _generate_business_days(dt.date(2026, 3, 2), 30)
decision_date = trading_days[10]
earnings_reaction_date = trading_days[15]
# Calendar entry was published AFTER decision_date — must be invisible.
pit_calendar = PointInTimeEarningsCalendar(
[
EarningsCalendarEntry(
symbol="AAPL",
as_of_date=trading_days[12], # > decision_date
expected_reaction_date=earnings_reaction_date,
expected_event_date=earnings_reaction_date,
filing_time_bucket="post_market",
)
]
)
adapter = _PitCalendarUpcomingEarningsAdapter(
pit_calendar=pit_calendar,
trading_days=trading_days,
)
result = adapter.get_next_reaction_date(
symbol="AAPL",
as_of_date=decision_date,
max_lookahead_calendar_days=14,
)
assert result is None
def test_pit_calendar_reveals_announced_future_earnings():
trading_days = _generate_business_days(dt.date(2026, 3, 2), 30)
decision_date = trading_days[10]
earnings_reaction_date = trading_days[15]
pit_calendar = PointInTimeEarningsCalendar(
[
EarningsCalendarEntry(
symbol="AAPL",
as_of_date=trading_days[5], # known well before decision_date
expected_reaction_date=earnings_reaction_date,
expected_event_date=earnings_reaction_date,
filing_time_bucket="post_market",
)
]
)
adapter = _PitCalendarUpcomingEarningsAdapter(
pit_calendar=pit_calendar,
trading_days=trading_days,
)
result = adapter.get_next_reaction_date(
symbol="AAPL",
as_of_date=decision_date,
max_lookahead_calendar_days=14,
)
assert result == earnings_reaction_date
# ---------------------------------------------------------------------------
# Exit policy stub tests — verify candidate carries the exit configuration
# ---------------------------------------------------------------------------
def test_candidate_carries_stop_target_trailing_config_in_features():
setup = _build_full_setup()
engine = _make_engine(
earnings_runup_stop_pct=0.05,
earnings_runup_target_pct=0.10,
earnings_runup_trailing_activate_pct=0.06,
earnings_runup_trailing_giveback_pct=0.025,
)
cands = build_earnings_runup_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["next_trading_date"],
universe_symbols=[setup["symbol"]],
engine=engine,
upcoming_earnings_provider=setup["upcoming_provider"],
attention_provider=setup["attention_provider"],
bar_provider=setup["bar_provider"],
)
assert len(cands) == 1
feats = cands[0].features
assert feats["earnings_runup_stop_pct"] == 0.05
assert feats["earnings_runup_target_pct"] == 0.10
assert feats["earnings_runup_trailing_activate_pct"] == 0.06
assert feats["earnings_runup_trailing_giveback_pct"] == 0.025
def test_candidate_max_holding_days_forces_flat_before_print():
"""Hard exit: max_holding_days = days_to_earnings - calendar_buffer_days (>=1)."""
# 4 trading days to earnings, buffer 1 → max_hold = 3
setup = _build_full_setup(earnings_offset_trading_days=4)
engine = _make_engine(earnings_runup_calendar_buffer_days=1)
cands = build_earnings_runup_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["next_trading_date"],
universe_symbols=[setup["symbol"]],
engine=engine,
upcoming_earnings_provider=setup["upcoming_provider"],
attention_provider=setup["attention_provider"],
bar_provider=setup["bar_provider"],
)
assert len(cands) == 1
assert cands[0].engine_max_holding_days == 3
def test_candidate_max_holding_days_never_below_one():
setup = _build_full_setup(earnings_offset_trading_days=3)
engine = _make_engine(earnings_runup_calendar_buffer_days=5) # absurd buffer
cands = build_earnings_runup_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["next_trading_date"],
universe_symbols=[setup["symbol"]],
engine=engine,
upcoming_earnings_provider=setup["upcoming_provider"],
attention_provider=setup["attention_provider"],
bar_provider=setup["bar_provider"],
)
assert len(cands) == 1
assert cands[0].engine_max_holding_days >= 1
# ---------------------------------------------------------------------------
# Behavioral exit tests — drive a synthetic position through simulate_exit and
# verify pct exits map correctly to STOP / TARGET / TIME outcomes.
# ---------------------------------------------------------------------------
def _build_position_for_runup(
*,
entry_price: float = 100.0,
stop_pct: float = 0.04,
target_pct: float = 0.08,
days_held: int = 0,
) -> Any:
from libs.backtest.domain import (
Candidate,
ExitReason, # noqa: F401 re-exported for downstream tests
OpenPosition,
PlannedOrder,
)
# Mirror the candidate the production builder constructs.
stop_mult = stop_pct / 0.02
target_r = target_pct / stop_pct
synthetic_atr = entry_price * 0.02
cand = Candidate(
event_id="evt_runup_exit",
symbol="AAPL",
score=0.75,
sector="UNKNOWN",
event_type=EARNINGS_RUNUP_EVENT_TYPE,
event_timestamp=dt.datetime(2026, 4, 10, 21, 0, tzinfo=dt.timezone.utc),
event_date=dt.date(2026, 4, 13),
filing_time_bucket="post_market",
reaction_date=dt.date(2026, 4, 13),
execution_date=dt.date(2026, 4, 14),
entry_price_est=entry_price,
avg_dollar_volume=200_000_000.0,
atr_14=synthetic_atr,
score_bucket="medium_high",
engine_id="earnings_runup_preevent_long",
entry_timing_policy="next_open",
trade_direction="long",
engine_stop_atr_multiplier=stop_mult,
engine_target_1_r=target_r,
engine_target_1_fraction=1.0,
engine_max_holding_days=4,
)
stop_price = entry_price * (1.0 - stop_pct)
target_price = entry_price * (1.0 + target_pct)
plan = PlannedOrder(
candidate=cand,
shares=100,
entry_price_limit=entry_price,
stop_price=stop_price,
target_price=target_price,
risk_dollars=stop_pct * entry_price * 100,
event_date=cand.event_date,
timing_class="after_close",
engine_id=cand.engine_id,
entry_timing_policy="next_open",
shadow_only=False,
)
return OpenPosition(
position_id="pos_runup",
plan=plan,
entry_date=cand.execution_date,
entry_price=entry_price,
entry_fill_slippage_bps=10.0,
current_stop=stop_price,
target_price=target_price,
peak_price=entry_price,
shares_open=100,
shares_total=100,
days_held=days_held,
)
def _exec_config_for_exit_test() -> Any:
from libs.backtest.domain import ExecutionConfig
return ExecutionConfig(
entry_fill_model="next_open",
exit_fill_model="daily_bar_approximation",
slippage_bps_base=10.0,
commission_per_share=0.005,
same_bar_priority="stop_first_conservative",
max_holding_days=4,
)
def test_exit_stop_at_minus_4pct():
"""Long position with -4% stop must STOP-exit when bar.low <= 96.0."""
from libs.backtest.domain import ExitReason
from libs.backtest.execution import simulate_exit
pos = _build_position_for_runup(entry_price=100.0, stop_pct=0.04)
# Bar drops to 95.5 → below the 96.0 stop → STOP exit.
bar = {"date": dt.date(2026, 4, 15), "open": 99.0, "high": 99.5, "low": 95.5, "close": 96.5, "volume": 1_000_000}
trade = simulate_exit(pos, bar, _exec_config_for_exit_test(), dt.date(2026, 4, 15))
assert trade is not None
assert trade.exit_reason == ExitReason.STOP
def test_exit_target_at_plus_8pct():
"""Long position with +8% target must TARGET-exit when bar.high >= 108.0."""
from libs.backtest.domain import ExitReason
from libs.backtest.execution import simulate_exit
pos = _build_position_for_runup(entry_price=100.0, target_pct=0.08)
bar = {"date": dt.date(2026, 4, 15), "open": 102.0, "high": 108.5, "low": 101.0, "close": 107.0, "volume": 1_000_000}
trade = simulate_exit(pos, bar, _exec_config_for_exit_test(), dt.date(2026, 4, 15))
assert trade is not None
assert trade.exit_reason == ExitReason.TARGET
def test_exit_forced_max_hold_before_print():
"""When days_held >= max_holding_days and no stop/target, exit reason is TIME."""
from libs.backtest.domain import ExitReason
from libs.backtest.execution import simulate_exit
# max_holding_days = 2; position already held 2 days.
pos = _build_position_for_runup(entry_price=100.0, days_held=2)
cfg = _exec_config_for_exit_test()
cfg = cfg.model_copy(update={"max_holding_days": 2})
bar = {"date": dt.date(2026, 4, 15), "open": 102.0, "high": 103.0, "low": 99.0, "close": 102.5, "volume": 1_000_000}
trade = simulate_exit(pos, bar, cfg, dt.date(2026, 4, 15))
assert trade is not None
assert trade.exit_reason == ExitReason.TIME
def test_exit_trailing_giveback_after_activation():
"""Behavioral approximation of trailing exit: peak rises >+5%, then gives back >3%.
The standard execution machinery does not natively implement the
EarningsRunup pct-trailing model, so this test confirms the minimum
invariant when a trailing stop is RAISED to a level above the static stop
and the bar's low touches it, the position exits via STOP. The trailing pct
config is preserved on the candidate features for future engine wiring.
"""
from libs.backtest.domain import ExitReason
from libs.backtest.execution import simulate_exit
pos = _build_position_for_runup(entry_price=100.0)
# Manually move stop up to 105.0 (= activation at 105 with 0% giveback for the test).
raised = pos.model_copy(update={"current_stop": 105.0, "peak_price": 106.0})
bar = {"date": dt.date(2026, 4, 15), "open": 106.0, "high": 106.5, "low": 104.5, "close": 104.8, "volume": 1_000_000}
trade = simulate_exit(raised, bar, _exec_config_for_exit_test(), dt.date(2026, 4, 15))
assert trade is not None
assert trade.exit_reason == ExitReason.STOP
# Confirm exit price is above original entry — i.e. the trailing stop captured profit.
assert trade.exit_price > pos.entry_price

@ -0,0 +1,983 @@
"""Unit tests for the PeerSympathy engine."""
from __future__ import annotations
import datetime as dt
import math
from typing import Any
import pytest
from libs.backtest.domain import LookaheadViolationError, StrategyEngineConfig
from libs.backtest.earnings_calendar import (
EarningsCalendarEntry,
PointInTimeEarningsCalendar,
)
from libs.backtest.earnings_runup import _PitCalendarUpcomingEarningsAdapter
from libs.backtest.peer_sympathy import (
PEER_SYMPATHY_EVENT_TYPE,
LeaderPrint,
PeerSympathyTriggerInputs,
_assert_correlation_window_safe,
build_peer_sympathy_candidates,
compute_correlation,
evaluate_trigger,
)
# ---------------------------------------------------------------------------
# Fakes & helpers
# ---------------------------------------------------------------------------
class _FakeBarHistory:
"""In-memory BarHistoryProvider stub.
``bars`` is keyed by symbol {date: bar_dict}. ``get_bars_before`` returns
the chronologically-ordered subset strictly before ``as_of_date``.
"""
def __init__(self, bars: dict[str, dict[dt.date, dict[str, Any]]]) -> None:
self.bars = {k.upper(): dict(v) for k, v in bars.items()}
def get_bars_before(
self,
symbol: str,
as_of_date: dt.date,
lookback_days: int,
) -> list[tuple[dt.date, dict[str, Any]]]:
sym_bars = self.bars.get(symbol.upper())
if not sym_bars:
return []
eligible = sorted(
(d, sym_bars[d]) for d in sym_bars if d < as_of_date
)
return eligible[-lookback_days:]
class _StaticPeerResolver:
def __init__(self, peers_by_leader: dict[str, list[str]]) -> None:
self.peers_by_leader = {k.upper(): list(v) for k, v in peers_by_leader.items()}
def peers_for_leader(
self,
engine: StrategyEngineConfig,
leader_symbol: str,
leader_sector: str,
) -> list[str]:
return self.peers_by_leader.get(leader_symbol.upper(), [])
def _generate_business_days(start: dt.date, count: int) -> list[dt.date]:
out: list[dt.date] = []
cursor = start
while len(out) < count:
if cursor.weekday() < 5:
out.append(cursor)
cursor = cursor + dt.timedelta(days=1)
return out
def _make_engine(**overrides: Any) -> StrategyEngineConfig:
base: dict[str, Any] = dict(
engine_id="peer_sympathy_long",
event_types=[PEER_SYMPATHY_EVENT_TYPE],
direction="long_only",
timing_class="after_close",
entry_timing_policy="next_open",
peer_sympathy_enabled=True,
peer_sympathy_leader_event_types=["earnings_release", "guidance_update", "material_contract"],
peer_sympathy_leader_reaction_min=0.05,
peer_sympathy_correlation_min=0.55,
peer_sympathy_correlation_window_start=65,
peer_sympathy_correlation_window_end_skip=5,
peer_sympathy_top_n_peers=2,
peer_sympathy_blackout_days_to_peer_event=3,
peer_sympathy_stop_pct=0.035,
peer_sympathy_target_pct=0.06,
peer_sympathy_max_holding_days=3,
)
base.update(overrides)
return StrategyEngineConfig(**base)
def _build_correlated_series(
leader_symbol: str,
peer_symbol: str,
trading_days: list[dt.date],
*,
correlation: float,
base: float = 100.0,
seed: int = 0,
) -> dict[str, dict[dt.date, dict[str, Any]]]:
"""Construct two synthetic price series with approximate ``correlation`` between
their consecutive log-returns.
peer_return[t] = correlation * leader_return[t] + sqrt(1-rho^2) * noise[t]
"""
import random
rng = random.Random(seed)
leader_returns = [rng.gauss(0.001, 0.015) for _ in trading_days]
noise = [rng.gauss(0, 0.015) for _ in trading_days]
leader_closes: list[float] = [base]
peer_closes: list[float] = [base]
rho = float(correlation)
sqrt_term = math.sqrt(max(0.0, 1.0 - rho * rho))
for i in range(1, len(trading_days)):
l_ret = leader_returns[i]
p_ret = rho * l_ret + sqrt_term * noise[i]
leader_closes.append(leader_closes[-1] * math.exp(l_ret))
peer_closes.append(peer_closes[-1] * math.exp(p_ret))
bars: dict[str, dict[dt.date, dict[str, Any]]] = {leader_symbol: {}, peer_symbol: {}}
for i, d in enumerate(trading_days):
bars[leader_symbol][d] = {
"open": leader_closes[i], "high": leader_closes[i] * 1.01,
"low": leader_closes[i] * 0.99, "close": leader_closes[i],
"volume": 1_000_000.0,
}
bars[peer_symbol][d] = {
"open": peer_closes[i], "high": peer_closes[i] * 1.01,
"low": peer_closes[i] * 0.99, "close": peer_closes[i],
"volume": 2_000_000.0,
}
return bars
def _trigger_inputs(**overrides: Any) -> PeerSympathyTriggerInputs:
base: dict[str, Any] = dict(
leader_symbol="NVDA",
leader_sector="Technology",
leader_event_type="earnings_release",
leader_reaction=0.08,
peer_symbol="AVGO",
decision_date=dt.date(2026, 4, 13),
next_trading_date=dt.date(2026, 4, 14),
correlation=0.72,
peer_last_close=110.0,
peer_avg_dollar_volume_20d=300_000_000.0,
peer_last_bar_date=dt.date(2026, 4, 10),
peer_last_bar_timestamp=dt.datetime(2026, 4, 10, 21, 0, tzinfo=dt.timezone.utc),
peer_upcoming_earnings_reaction_date=None,
peer_trading_days_to_own_earnings=None,
)
base.update(overrides)
return PeerSympathyTriggerInputs(**base)
# ---------------------------------------------------------------------------
# evaluate_trigger() — happy path + 3 negative + blackout
# ---------------------------------------------------------------------------
def test_trigger_fires_when_all_conditions_met():
engine = _make_engine()
passes, reason = evaluate_trigger(_trigger_inputs(), engine)
assert passes is True, reason
def test_trigger_blocks_when_event_type_not_qualifying():
engine = _make_engine()
passes, reason = evaluate_trigger(
_trigger_inputs(leader_event_type="other_material_event"),
engine,
)
assert passes is False
assert "leader_event_type" in (reason or "")
def test_trigger_blocks_when_leader_reaction_below_min():
engine = _make_engine()
passes, reason = evaluate_trigger(_trigger_inputs(leader_reaction=0.03), engine)
assert passes is False
assert "leader_reaction" in (reason or "")
def test_trigger_blocks_when_correlation_below_min():
engine = _make_engine()
passes, reason = evaluate_trigger(_trigger_inputs(correlation=0.50), engine)
assert passes is False
assert "correlation" in (reason or "")
def test_trigger_blocks_on_peer_earnings_blackout():
engine = _make_engine()
passes, reason = evaluate_trigger(
_trigger_inputs(peer_trading_days_to_own_earnings=2),
engine,
)
assert passes is False
assert "blackout" in (reason or "") or "earnings" in (reason or "")
# ---------------------------------------------------------------------------
# compute_correlation() — basic invariants
# ---------------------------------------------------------------------------
def test_compute_correlation_returns_high_value_for_correlated_series():
trading_days = _generate_business_days(dt.date(2026, 1, 5), 100)
decision_date = trading_days[-1]
bars = _build_correlated_series("NVDA", "AVGO", trading_days[:-1], correlation=0.85, seed=1)
leader_bars = sorted((d, b) for d, b in bars["NVDA"].items())
peer_bars = sorted((d, b) for d, b in bars["AVGO"].items())
rho, used = compute_correlation(
leader_bars,
peer_bars,
decision_date=decision_date,
window_start=65,
window_end_skip=5,
trading_days=trading_days,
)
assert rho is not None
assert rho > 0.6 # roughly tracks the imposed correlation
assert used # non-empty
def test_compute_correlation_skips_last_n_days():
trading_days = _generate_business_days(dt.date(2026, 1, 5), 100)
decision_date = trading_days[-1]
bars = _build_correlated_series("NVDA", "AVGO", trading_days[:-1], correlation=0.85, seed=2)
leader_bars = sorted((d, b) for d, b in bars["NVDA"].items())
peer_bars = sorted((d, b) for d, b in bars["AVGO"].items())
_, used = compute_correlation(
leader_bars,
peer_bars,
decision_date=decision_date,
window_start=65,
window_end_skip=5,
trading_days=trading_days,
)
assert used
skip_idx = trading_days.index(decision_date) - 5
forbidden_floor = trading_days[skip_idx]
assert max(used) < forbidden_floor
def test_assert_correlation_window_safe_raises_when_recent_date_used():
trading_days = _generate_business_days(dt.date(2026, 1, 5), 80)
decision_date = trading_days[-1]
# used_dates includes a date from within the skip window (T-2)
leaky_date = trading_days[-3]
with pytest.raises(LookaheadViolationError):
_assert_correlation_window_safe(
leader_symbol="NVDA",
peer_symbol="AVGO",
decision_date=decision_date,
window_end_skip=5,
used_dates=[leaky_date],
trading_days=trading_days,
)
def test_assert_correlation_window_safe_accepts_safely_old_dates():
trading_days = _generate_business_days(dt.date(2026, 1, 5), 80)
decision_date = trading_days[-1]
safe_date = trading_days[-20]
# Should not raise.
_assert_correlation_window_safe(
leader_symbol="NVDA",
peer_symbol="AVGO",
decision_date=decision_date,
window_end_skip=5,
used_dates=[safe_date],
trading_days=trading_days,
)
# ---------------------------------------------------------------------------
# build_peer_sympathy_candidates() — end-to-end
# ---------------------------------------------------------------------------
def _build_full_setup(
*,
correlation: float = 0.85,
leader_reaction: float = 0.08,
leader_event_type: str = "earnings_release",
peer_symbol: str = "AVGO",
days_of_history: int = 90,
):
# Pad the calendar with extra trailing days so PIT-calendar tests can place
# peer earnings dates AFTER ``next_trading_date`` without IndexError.
trading_days = _generate_business_days(dt.date(2026, 1, 5), days_of_history + 15)
decision_date = trading_days[days_of_history] # leader event day = T
next_trading_date = trading_days[days_of_history + 1]
history_days = trading_days[:days_of_history]
bars = _build_correlated_series(
"NVDA", peer_symbol, history_days, correlation=correlation, seed=11
)
bar_provider = _FakeBarHistory(bars)
peer_resolver = _StaticPeerResolver({"NVDA": [peer_symbol]})
leader = LeaderPrint(
symbol="NVDA",
sector="Technology",
event_id="evt_nvda_2026q1",
event_type=leader_event_type,
event_date=decision_date,
event_timestamp=dt.datetime.combine(
decision_date, dt.time(16, 0), tzinfo=dt.timezone.utc
),
reaction_day_return=leader_reaction,
score=0.85,
)
return {
"trading_days": trading_days,
"decision_date": decision_date,
"next_trading_date": next_trading_date,
"leader": leader,
"peer_symbol": peer_symbol,
"bar_provider": bar_provider,
"peer_resolver": peer_resolver,
}
def test_build_emits_candidate_for_correlated_peer():
setup = _build_full_setup(correlation=0.90)
engine = _make_engine()
cands = build_peer_sympathy_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["next_trading_date"],
leaders=[setup["leader"]],
peer_resolver=setup["peer_resolver"],
engine=engine,
bar_provider=setup["bar_provider"],
trading_days=setup["trading_days"],
)
assert len(cands) == 1
cand = cands[0]
assert cand.event_type == PEER_SYMPATHY_EVENT_TYPE
assert cand.symbol == setup["peer_symbol"]
assert cand.source_symbol == "NVDA"
assert cand.engine_id == engine.engine_id
assert cand.execution_date == setup["next_trading_date"]
assert cand.engine_max_holding_days is not None
assert cand.features["peer_sympathy_leader_symbol"] == "NVDA"
assert cand.features["peer_sympathy_correlation"] >= 0.55
def test_build_skips_uncorrelated_peer():
setup = _build_full_setup(correlation=0.10)
engine = _make_engine()
cands = build_peer_sympathy_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["next_trading_date"],
leaders=[setup["leader"]],
peer_resolver=setup["peer_resolver"],
engine=engine,
bar_provider=setup["bar_provider"],
trading_days=setup["trading_days"],
)
assert cands == []
def test_build_skips_when_leader_reaction_below_min():
setup = _build_full_setup(correlation=0.90, leader_reaction=0.02)
engine = _make_engine()
cands = build_peer_sympathy_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["next_trading_date"],
leaders=[setup["leader"]],
peer_resolver=setup["peer_resolver"],
engine=engine,
bar_provider=setup["bar_provider"],
trading_days=setup["trading_days"],
)
assert cands == []
def test_build_skips_when_event_type_not_qualifying():
setup = _build_full_setup(correlation=0.90, leader_event_type="other_material_event")
engine = _make_engine()
cands = build_peer_sympathy_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["next_trading_date"],
leaders=[setup["leader"]],
peer_resolver=setup["peer_resolver"],
engine=engine,
bar_provider=setup["bar_provider"],
trading_days=setup["trading_days"],
)
assert cands == []
def test_build_takes_top_n_peers_only():
"""With 3 peers (corr 0.95, 0.75, 0.40), top_n=2 → only first two emitted."""
days_of_history = 90
trading_days = _generate_business_days(dt.date(2026, 1, 5), days_of_history + 2)
decision_date = trading_days[days_of_history]
next_trading_date = trading_days[days_of_history + 1]
history_days = trading_days[:days_of_history]
# Build leader and 3 peers with controlled correlation.
bars: dict[str, dict[dt.date, dict[str, Any]]] = {}
for peer, rho, seed in [("AVGO", 0.95, 100), ("AMD", 0.75, 200), ("MU", 0.40, 300)]:
sub = _build_correlated_series("NVDA", peer, history_days, correlation=rho, seed=seed)
# Only NVDA appears once — re-use leader from first iteration.
if "NVDA" not in bars:
bars["NVDA"] = sub["NVDA"]
bars[peer] = sub[peer]
bar_provider = _FakeBarHistory(bars)
peer_resolver = _StaticPeerResolver({"NVDA": ["AVGO", "AMD", "MU"]})
leader = LeaderPrint(
symbol="NVDA",
sector="Technology",
event_id="evt",
event_type="earnings_release",
event_date=decision_date,
event_timestamp=dt.datetime.combine(decision_date, dt.time(16, 0), tzinfo=dt.timezone.utc),
reaction_day_return=0.08,
)
engine = _make_engine(peer_sympathy_top_n_peers=2)
cands = build_peer_sympathy_candidates(
decision_date=decision_date,
next_trading_date=next_trading_date,
leaders=[leader],
peer_resolver=peer_resolver,
engine=engine,
bar_provider=bar_provider,
trading_days=trading_days,
)
# MU (0.40) is below correlation_min anyway; AVGO + AMD survive.
assert len(cands) <= 2
chosen = {c.symbol for c in cands}
assert "MU" not in chosen
# ---------------------------------------------------------------------------
# Look-ahead defenses
# ---------------------------------------------------------------------------
def test_build_raises_when_next_trading_date_not_after_decision():
setup = _build_full_setup()
engine = _make_engine()
with pytest.raises(LookaheadViolationError):
build_peer_sympathy_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["decision_date"], # equal → violation
leaders=[setup["leader"]],
peer_resolver=setup["peer_resolver"],
engine=engine,
bar_provider=setup["bar_provider"],
trading_days=setup["trading_days"],
)
def test_build_raises_when_leader_event_timestamp_naive():
setup = _build_full_setup()
bad_leader = LeaderPrint(
symbol="NVDA",
sector="Technology",
event_id="evt",
event_type="earnings_release",
event_date=setup["decision_date"],
event_timestamp=dt.datetime.combine(setup["decision_date"], dt.time(16, 0)), # naive
reaction_day_return=0.08,
)
engine = _make_engine()
with pytest.raises(LookaheadViolationError):
build_peer_sympathy_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["next_trading_date"],
leaders=[bad_leader],
peer_resolver=setup["peer_resolver"],
engine=engine,
bar_provider=setup["bar_provider"],
trading_days=setup["trading_days"],
)
def test_build_raises_when_leader_event_timestamp_after_peer_open():
"""Leader event timestamp at-or-after T+1 09:30 ET cutoff is a look-ahead violation."""
setup = _build_full_setup()
# 09:30 ET on next_trading_date == 14:30 UTC under EST.
leak_ts = dt.datetime.combine(setup["next_trading_date"], dt.time(15, 0), tzinfo=dt.timezone.utc)
leak_leader = LeaderPrint(
symbol="NVDA",
sector="Technology",
event_id="evt",
event_type="earnings_release",
event_date=setup["decision_date"],
event_timestamp=leak_ts,
reaction_day_return=0.08,
)
engine = _make_engine()
with pytest.raises(LookaheadViolationError):
build_peer_sympathy_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["next_trading_date"],
leaders=[leak_leader],
peer_resolver=setup["peer_resolver"],
engine=engine,
bar_provider=setup["bar_provider"],
trading_days=setup["trading_days"],
)
def test_build_does_not_consult_peer_t0_reaction():
"""Bars dated == decision_date (peer T+0) must NOT enter selection.
Concrete check: inject a peer bar dated ON decision_date with an extreme
return; if the engine were reading T+0 it would either crash on look-ahead
(preferred) or produce a different correlation. We use a permissive bar
provider that lets ``< as_of`` filter run; the engine must produce a
candidate consistent with PRE-T data alone.
"""
setup = _build_full_setup(correlation=0.90)
bars = setup["bar_provider"].bars
# Inject a wild peer bar on decision_date (not strictly before).
bars[setup["peer_symbol"]][setup["decision_date"]] = {
"open": 50.0, "high": 50.0, "low": 50.0, "close": 50.0, "volume": 99_000_000.0,
}
engine = _make_engine()
# The default _FakeBarHistory.get_bars_before filters strictly < decision_date,
# so the injected T+0 bar is not visible. Candidate features must therefore
# NOT reference any T+0 quantity. We assert by snapshotting the candidate
# features and confirming the only price reference is the LAST bar < T.
cands = build_peer_sympathy_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["next_trading_date"],
leaders=[setup["leader"]],
peer_resolver=setup["peer_resolver"],
engine=engine,
bar_provider=setup["bar_provider"],
trading_days=setup["trading_days"],
)
assert len(cands) == 1
cand = cands[0]
# entry_price_est must equal the last close strictly BEFORE decision_date.
last_pre_t = max(d for d in bars[setup["peer_symbol"]] if d < setup["decision_date"])
expected_close = float(bars[setup["peer_symbol"]][last_pre_t]["close"])
assert math.isclose(cand.entry_price_est, expected_close, rel_tol=1e-6)
# ---------------------------------------------------------------------------
# Peer-resolver integration with existing leader-follower infra
# ---------------------------------------------------------------------------
def test_peer_set_sourced_from_leader_follower_extra_peer_symbols_by_sector():
"""Engine config's leader_follower_extra_peer_symbols_by_sector must surface peers."""
from libs.backtest.proxies import peer_candidates_for_symbol
# Sanity: the underlying helper recognizes NVDA → has tech peers from the curated map.
peers = peer_candidates_for_symbol("NVDA", "Technology")
assert "AVGO" in peers
assert "AMD" in peers
def test_peer_set_extra_by_sector_is_consumed_by_resolver_protocol():
"""The runner's _RunnerPeerResolver wraps the existing _leader_follower_peer_candidates;
the static fake must equally honor curated peers by sector."""
resolver = _StaticPeerResolver({"NVDA": ["AVGO", "AMD"]})
engine = _make_engine(
leader_follower_extra_peer_symbols_by_sector={"Technology": ["MU"]}
)
peers = resolver.peers_for_leader(engine, "NVDA", "Technology")
# Static fake returns the supplied list; this confirms the Protocol shape.
assert peers == ["AVGO", "AMD"]
# ---------------------------------------------------------------------------
# Exit policy stub tests — verify candidate carries correct exit configuration
# ---------------------------------------------------------------------------
def test_candidate_carries_stop_target_max_hold_in_engine_overrides():
setup = _build_full_setup(correlation=0.90)
engine = _make_engine(
peer_sympathy_stop_pct=0.035,
peer_sympathy_target_pct=0.06,
peer_sympathy_max_holding_days=3,
)
cands = build_peer_sympathy_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["next_trading_date"],
leaders=[setup["leader"]],
peer_resolver=setup["peer_resolver"],
engine=engine,
bar_provider=setup["bar_provider"],
trading_days=setup["trading_days"],
)
assert len(cands) == 1
cand = cands[0]
# stop_pct 0.035 / 0.02 = 1.75 ATR multiplier
assert math.isclose(cand.engine_stop_atr_multiplier, 1.75, rel_tol=1e-6)
# target_pct / stop_pct = 0.06 / 0.035 = 1.714...
assert math.isclose(cand.engine_target_1_r, 0.06 / 0.035, rel_tol=1e-6)
assert cand.engine_target_1_fraction == 1.0
assert cand.engine_max_holding_days == 3
def test_candidate_max_hold_capped_by_peer_earnings_blackout():
"""If peer's own earnings are 4 trading days out and blackout=3 → max_hold = max(1, 4-3) = 1."""
setup = _build_full_setup(correlation=0.90)
# Build a PIT calendar: peer has earnings 4 trading days after next_trading_date.
trading_days = setup["trading_days"]
next_idx = trading_days.index(setup["next_trading_date"])
peer_earnings_date = trading_days[next_idx + 4]
pit_calendar = PointInTimeEarningsCalendar(
[
EarningsCalendarEntry(
symbol=setup["peer_symbol"],
as_of_date=trading_days[0],
expected_reaction_date=peer_earnings_date,
expected_event_date=peer_earnings_date,
filing_time_bucket="post_market",
)
]
)
upcoming = _PitCalendarUpcomingEarningsAdapter(
pit_calendar=pit_calendar,
trading_days=trading_days,
)
engine = _make_engine(
peer_sympathy_blackout_days_to_peer_event=3,
peer_sympathy_max_holding_days=3,
)
cands = build_peer_sympathy_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["next_trading_date"],
leaders=[setup["leader"]],
peer_resolver=setup["peer_resolver"],
engine=engine,
bar_provider=setup["bar_provider"],
upcoming_earnings_provider=upcoming,
trading_days=trading_days,
)
# 4 days to event > blackout 3 → not blocked. Hold = max(1, 4-3) = 1.
assert len(cands) == 1
assert cands[0].engine_max_holding_days == 1
def test_candidate_blocked_when_peer_earnings_within_blackout_window():
"""Peer earnings 2 trading days out, blackout=3 → trigger BLOCKS (no candidate)."""
setup = _build_full_setup(correlation=0.90)
trading_days = setup["trading_days"]
next_idx = trading_days.index(setup["next_trading_date"])
peer_earnings_date = trading_days[next_idx + 2] # 2 trading days out
pit_calendar = PointInTimeEarningsCalendar(
[
EarningsCalendarEntry(
symbol=setup["peer_symbol"],
as_of_date=trading_days[0],
expected_reaction_date=peer_earnings_date,
expected_event_date=peer_earnings_date,
filing_time_bucket="post_market",
)
]
)
upcoming = _PitCalendarUpcomingEarningsAdapter(
pit_calendar=pit_calendar,
trading_days=trading_days,
)
engine = _make_engine(peer_sympathy_blackout_days_to_peer_event=3)
cands = build_peer_sympathy_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["next_trading_date"],
leaders=[setup["leader"]],
peer_resolver=setup["peer_resolver"],
engine=engine,
bar_provider=setup["bar_provider"],
upcoming_earnings_provider=upcoming,
trading_days=trading_days,
)
assert cands == []
# ---------------------------------------------------------------------------
# Behavioral exit tests — drive synthetic position through simulate_exit and
# confirm pct exits map correctly to STOP / TARGET / TIME outcomes.
# ---------------------------------------------------------------------------
def _build_position_for_peer_sympathy(
*,
entry_price: float = 100.0,
stop_pct: float = 0.035,
target_pct: float = 0.06,
days_held: int = 0,
) -> Any:
from libs.backtest.domain import Candidate, OpenPosition, PlannedOrder
stop_mult = stop_pct / 0.02
target_r = target_pct / stop_pct
synthetic_atr = entry_price * 0.02
cand = Candidate(
event_id="evt_peer_sympathy",
symbol="AVGO",
source_symbol="NVDA",
score=0.8,
sector="Technology",
event_type=PEER_SYMPATHY_EVENT_TYPE,
event_timestamp=dt.datetime(2026, 4, 10, 21, 0, tzinfo=dt.timezone.utc),
event_date=dt.date(2026, 4, 13),
filing_time_bucket="post_market",
reaction_date=dt.date(2026, 4, 13),
execution_date=dt.date(2026, 4, 14),
entry_price_est=entry_price,
avg_dollar_volume=300_000_000.0,
atr_14=synthetic_atr,
score_bucket="high",
engine_id="peer_sympathy_long",
entry_timing_policy="next_open",
trade_direction="long",
engine_stop_atr_multiplier=stop_mult,
engine_target_1_r=target_r,
engine_target_1_fraction=1.0,
engine_max_holding_days=3,
)
stop_price = entry_price * (1.0 - stop_pct)
target_price = entry_price * (1.0 + target_pct)
plan = PlannedOrder(
candidate=cand,
shares=100,
entry_price_limit=entry_price,
stop_price=stop_price,
target_price=target_price,
risk_dollars=stop_pct * entry_price * 100,
event_date=cand.event_date,
timing_class="after_close",
engine_id=cand.engine_id,
entry_timing_policy="next_open",
shadow_only=False,
)
return OpenPosition(
position_id="pos_peer",
plan=plan,
entry_date=cand.execution_date,
entry_price=entry_price,
entry_fill_slippage_bps=10.0,
current_stop=stop_price,
target_price=target_price,
peak_price=entry_price,
shares_open=100,
shares_total=100,
days_held=days_held,
)
def _exec_config_for_exit_test() -> Any:
from libs.backtest.domain import ExecutionConfig
return ExecutionConfig(
entry_fill_model="next_open",
exit_fill_model="daily_bar_approximation",
slippage_bps_base=10.0,
commission_per_share=0.005,
same_bar_priority="stop_first_conservative",
max_holding_days=3,
)
def test_exit_stop_at_minus_3_5_pct():
from libs.backtest.domain import ExitReason
from libs.backtest.execution import simulate_exit
pos = _build_position_for_peer_sympathy(entry_price=100.0, stop_pct=0.035)
# Bar drops to 96.0 < 96.5 stop → STOP.
bar = {"date": dt.date(2026, 4, 15), "open": 99.0, "high": 99.5, "low": 96.0, "close": 96.7, "volume": 1_000_000}
trade = simulate_exit(pos, bar, _exec_config_for_exit_test(), dt.date(2026, 4, 15))
assert trade is not None
assert trade.exit_reason == ExitReason.STOP
def test_exit_target_at_plus_6_pct():
from libs.backtest.domain import ExitReason
from libs.backtest.execution import simulate_exit
pos = _build_position_for_peer_sympathy(entry_price=100.0, target_pct=0.06)
# Bar high reaches 106.5 > target 106.0 → TARGET.
bar = {"date": dt.date(2026, 4, 15), "open": 102.0, "high": 106.5, "low": 101.0, "close": 105.5, "volume": 1_000_000}
trade = simulate_exit(pos, bar, _exec_config_for_exit_test(), dt.date(2026, 4, 15))
assert trade is not None
assert trade.exit_reason == ExitReason.TARGET
def test_exit_time_at_max_holding_days():
from libs.backtest.domain import ExitReason
from libs.backtest.execution import simulate_exit
cfg = _exec_config_for_exit_test().model_copy(update={"max_holding_days": 3})
pos = _build_position_for_peer_sympathy(entry_price=100.0, days_held=3)
bar = {"date": dt.date(2026, 4, 17), "open": 102.0, "high": 103.0, "low": 99.0, "close": 102.5, "volume": 1_000_000}
trade = simulate_exit(pos, bar, cfg, dt.date(2026, 4, 17))
assert trade is not None
assert trade.exit_reason == ExitReason.TIME
def test_exit_blackout_caps_max_hold_via_engine_max_holding_days():
"""When peer's own earnings are within blackout window, candidate's
engine_max_holding_days is capped to (days_to_event - blackout) 1.
The execution machinery then treats this as the effective hold ceiling."""
setup = _build_full_setup(correlation=0.90)
trading_days = setup["trading_days"]
next_idx = trading_days.index(setup["next_trading_date"])
peer_earnings_date = trading_days[next_idx + 5]
pit_calendar = PointInTimeEarningsCalendar(
[
EarningsCalendarEntry(
symbol=setup["peer_symbol"],
as_of_date=trading_days[0],
expected_reaction_date=peer_earnings_date,
expected_event_date=peer_earnings_date,
filing_time_bucket="post_market",
)
]
)
upcoming = _PitCalendarUpcomingEarningsAdapter(
pit_calendar=pit_calendar,
trading_days=trading_days,
)
engine = _make_engine(
peer_sympathy_blackout_days_to_peer_event=3,
peer_sympathy_max_holding_days=3,
)
cands = build_peer_sympathy_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["next_trading_date"],
leaders=[setup["leader"]],
peer_resolver=setup["peer_resolver"],
engine=engine,
bar_provider=setup["bar_provider"],
upcoming_earnings_provider=upcoming,
trading_days=trading_days,
)
assert len(cands) == 1
# 5 days to event - blackout 3 = 2; min(default_max_hold=3, 2) = 2.
assert cands[0].engine_max_holding_days == 2
# ---------------------------------------------------------------------------
# Regression: runner-adapter select_candidates must NOT pass the peer_sympathy
# strategy_engine, because that engine declares event_types=['peer_sympathy']
# (a synthetic downstream type) which would filter out every real leader row
# (earnings_release / guidance_update / material_contract). This is the bug
# that produced 0 trades over 1051 days in PoC v1.
# ---------------------------------------------------------------------------
def _make_leader_raw_row(**overrides: Any) -> dict[str, Any]:
"""Minimal real-shape PEAD candidate row representing a leader print."""
base: dict[str, Any] = {
"event_id": "EVT::NVDA::2024-02-22",
"symbol": "NVDA",
"issuer_id": "ISSUER::0001045810",
"score": 0.85,
"sector": "Technology",
"event_type": "earnings_release",
"event_timestamp": "2024-02-21T21:00:00+00:00",
"filing_time_bucket": "post_market",
"entry_convention": "next_open_after_reaction_close",
"reaction_date": "2024-02-22",
"entry_date": "2024-02-23",
"entry_price": 730.0,
"avg_dollar_volume": 25_000_000_000.0,
"avg_dollar_volume_20d": 25_000_000_000.0,
"atr_14": 25.0,
"reaction_day_return": 0.164,
"reaction_day_open": 680.0,
"reaction_day_close": 791.0,
"reaction_day_high": 800.0,
"reaction_day_low": 670.0,
"exchange_proxy": "NASDAQ",
"volume_ratio_20d": 3.5,
"gap_size": 0.10,
}
base.update(overrides)
return base
def test_select_candidates_with_peer_sympathy_engine_drops_real_leaders():
"""Demonstrates the bug: passing the peer_sympathy engine to select_candidates
drops every real leader print, because the engine's event_types=['peer_sympathy']
does not include 'earnings_release' etc.
This test pins down the unsafe interaction so a future dev cannot silently
re-introduce ``strategy_engine=engine`` in ``_schedule_peer_sympathy_candidates``
without it failing here.
"""
from libs.backtest.domain import SignalConfig, UniverseConfig
from libs.backtest.peer_sympathy import PEER_SYMPATHY_EVENT_TYPE
from libs.backtest.selector import select_candidates
rows = [
_make_leader_raw_row(symbol="NVDA", event_type="earnings_release", reaction_day_return=0.164),
_make_leader_raw_row(symbol="MRNA", event_type="earnings_release", reaction_day_return=0.135,
event_id="EVT::MRNA::2024-02-22", issuer_id="ISSUER::0001682852"),
_make_leader_raw_row(symbol="MU", event_type="guidance_update", reaction_day_return=0.086,
event_id="EVT::MU::2023-12-21", issuer_id="ISSUER::0000723125"),
]
universe = UniverseConfig(min_price=5.0, min_avg_dollar_volume=0.0)
signal = SignalConfig(
scoring_model="return_max_long_v13e",
score_threshold=0.0,
max_candidates_per_day=18,
)
peer_sympathy_engine = _make_engine(
event_types=[PEER_SYMPATHY_EVENT_TYPE],
score_threshold_override=0.0,
)
# Bug reproduction: engine event_types filter rejects all real leader rows.
selected_with_engine = select_candidates(
rows,
universe,
signal,
strategy_engine=peer_sympathy_engine,
truncate_to=90,
)
assert selected_with_engine == [], (
"Bug regression: select_candidates with peer_sympathy strategy_engine "
"must drop real leader rows because their event_type ('earnings_release', "
"'guidance_update') is not in the engine's event_types=['peer_sympathy']. "
"If this assertion stops holding, the runner adapter contract has shifted "
"and the no-engine call in _schedule_peer_sympathy_candidates may need "
"to be revisited."
)
def test_select_candidates_without_engine_retains_real_leaders():
"""The fixed runner-adapter call path: ``select_candidates`` is invoked WITHOUT
the peer_sympathy strategy_engine, so real leader rows are retained and can
feed the manual peer_sympathy_leader_event_types filter downstream.
"""
from libs.backtest.domain import SignalConfig, UniverseConfig
from libs.backtest.selector import select_candidates
rows = [
_make_leader_raw_row(symbol="NVDA", event_type="earnings_release", reaction_day_return=0.164),
_make_leader_raw_row(symbol="MRNA", event_type="earnings_release", reaction_day_return=0.135,
event_id="EVT::MRNA::2024-02-22", issuer_id="ISSUER::0001682852"),
_make_leader_raw_row(symbol="MU", event_type="guidance_update", reaction_day_return=0.086,
event_id="EVT::MU::2023-12-21", issuer_id="ISSUER::0000723125"),
]
universe = UniverseConfig(min_price=5.0, min_avg_dollar_volume=0.0)
signal = SignalConfig(
scoring_model="return_max_long_v13e",
score_threshold=0.0,
max_candidates_per_day=18,
)
# Fix: NO strategy_engine kwarg. Real leader rows survive selection.
selected = select_candidates(
rows,
universe,
signal,
truncate_to=90,
)
selected_symbols = {c.symbol.upper() for c in selected}
assert "NVDA" in selected_symbols
assert "MRNA" in selected_symbols
assert "MU" in selected_symbols, (
"Fix regression: select_candidates without strategy_engine must retain "
"real leader rows so that _schedule_peer_sympathy_candidates can apply "
"its manual peer_sympathy_leader_event_types filter and emit synthetic "
"peer candidates. If this fails, the runner adapter is once again "
"starving downstream peer-sympathy logic."
)

@ -0,0 +1,900 @@
"""Unit tests for the VolBreakout52w engine.
Heavy emphasis on look-ahead defenses this engine is the honest descendant of
the retired topgainer v1-v54 lineage which collapsed +267% / Sharpe 13.73
-4.3% / Sharpe -1.04 once Phase-1's daily_high look-ahead was removed
(memory: project_topgainer_phase1_lookahead_2026-05-05.md). The defense MUST be
airtight.
"""
from __future__ import annotations
import datetime as dt
import random
from typing import Any
import pytest
from libs.backtest.domain import LookaheadViolationError, StrategyEngineConfig
from libs.backtest.vol_breakout_52w import (
VOL_BREAKOUT_52W_EVENT_TYPE,
FrozenT1Features,
VolBreakout52wTriggerInputs,
_SnapshotStoreBarAdapter,
_assert_features_strictly_before_decision_open,
build_candidates,
compute_52w_high_breakout,
compute_atr_normalized,
compute_volume_ratio,
evaluate_trigger,
)
# ---------------------------------------------------------------------------
# Test helpers
# ---------------------------------------------------------------------------
def _make_engine(**overrides: Any) -> StrategyEngineConfig:
base: dict[str, Any] = dict(
engine_id="vol_breakout_52w_long",
event_types=[VOL_BREAKOUT_52W_EVENT_TYPE],
direction="long_only",
timing_class="after_close",
entry_timing_policy="next_open",
max_holding_days=2,
vol_breakout_52w_enabled=True,
vol_breakout_52w_lookback_days=60, # smaller for tests
vol_breakout_52w_volume_ratio_min=2.0,
vol_breakout_52w_volume_median_window=20,
vol_breakout_52w_atr_normalized_min=0.015,
vol_breakout_52w_atr_normalized_max=0.06,
vol_breakout_52w_pre_open_gap_max=0.04,
vol_breakout_52w_skip_if_no_gap_data=True,
vol_breakout_52w_min_avg_dollar_volume=10_000_000.0,
vol_breakout_52w_min_price=5.0,
vol_breakout_52w_stop_pct=0.03,
vol_breakout_52w_target_pct=0.05,
vol_breakout_52w_max_holding_days=2,
)
base.update(overrides)
return StrategyEngineConfig(**base)
def _generate_business_days(start: dt.date, count: int) -> list[dt.date]:
out: list[dt.date] = []
cursor = start
while len(out) < count:
if cursor.weekday() < 5:
out.append(cursor)
cursor = cursor + dt.timedelta(days=1)
return out
def _build_bars(
symbol: str,
trading_days: list[dt.date],
*,
base_close: float = 100.0,
base_high: float = 100.5,
base_low: float = 99.5,
base_volume: float = 5_000_000.0,
last_close: float | None = None,
last_high: float | None = None,
last_low: float | None = None,
last_volume: float | None = None,
atr_jitter: float = 0.5,
) -> dict[str, dict[dt.date, dict[str, Any]]]:
"""Construct a dict-of-dicts bars store for the single symbol.
Default: flat history at base_close, with a small atr_jitter on H-L.
Customize the FINAL bar (T-1 in tests) via the ``last_*`` arguments.
"""
inner: dict[dt.date, dict[str, Any]] = {}
n = len(trading_days)
for i, d in enumerate(trading_days):
is_last = (i == n - 1)
if is_last and last_close is not None:
close_v = last_close
high_v = last_high if last_high is not None else last_close + 0.5
low_v = last_low if last_low is not None else last_close - 0.5
volume_v = last_volume if last_volume is not None else base_volume
else:
close_v = base_close + (i % 7) * 0.05 # tiny drift, never crosses base_high
high_v = base_high + (i % 5) * 0.1 * atr_jitter
low_v = base_low - (i % 5) * 0.1 * atr_jitter
volume_v = base_volume * (1.0 + 0.02 * ((i % 5) - 2))
inner[d] = {
"open": close_v,
"high": high_v,
"low": low_v,
"close": close_v,
"volume": volume_v,
}
return {symbol.upper(): inner}
# ---------------------------------------------------------------------------
# Pure feature computations
# ---------------------------------------------------------------------------
def test_compute_52w_high_breakout_fires_when_close_above_window_max():
days = _generate_business_days(dt.date(2026, 1, 5), 70)
bars = _build_bars("AAPL", days, base_close=100.0, base_high=110.0,
last_close=120.0, last_high=121.0, last_low=119.0)["AAPL"]
series = sorted(bars.items())
is_b, last_close, prior_max, used = compute_52w_high_breakout(series, lookback_days=60)
assert is_b is True
assert last_close == 120.0
assert prior_max <= 110.5 # base_high + small jitter
assert len(used) >= 20
def test_compute_52w_high_breakout_does_not_fire_when_close_at_or_below_max():
days = _generate_business_days(dt.date(2026, 1, 5), 70)
bars = _build_bars("AAPL", days, base_close=100.0, base_high=110.0,
last_close=109.0, last_high=109.5, last_low=108.5)["AAPL"]
series = sorted(bars.items())
is_b, _last_close, prior_max, _used = compute_52w_high_breakout(series, lookback_days=60)
assert is_b is False
assert prior_max >= 109.0
def test_compute_volume_ratio_and_atr_normalized_basic():
days = _generate_business_days(dt.date(2026, 1, 5), 35)
bars = _build_bars("AAPL", days, base_volume=1_000_000.0, last_volume=4_000_000.0,
last_close=100.0, last_high=102.0, last_low=98.0)["AAPL"]
series = sorted(bars.items())
vol_t1, median_t2 = compute_volume_ratio(series, median_window=20)
assert vol_t1 == 4_000_000.0
assert median_t2 == pytest.approx(1_000_000.0, rel=0.05)
atr_norm = compute_atr_normalized(series, window=14)
assert atr_norm is not None
assert 0.005 < atr_norm < 0.06 # synthetic data should lie in a sane band
# ---------------------------------------------------------------------------
# evaluate_trigger — happy path + 4 negative cases
# ---------------------------------------------------------------------------
def _trigger_inputs(**overrides: Any) -> VolBreakout52wTriggerInputs:
base: dict[str, Any] = dict(
symbol="AAPL",
decision_date=dt.date(2026, 4, 13),
next_trading_date=dt.date(2026, 4, 14),
last_bar_date=dt.date(2026, 4, 10),
last_bar_timestamp=dt.datetime(2026, 4, 10, 21, 0, tzinfo=dt.timezone.utc),
last_close=120.0,
prior_252d_max_high=110.0,
is_52w_breakout=True,
volume_t_minus_1=4_000_000.0,
median_volume_20d_t_minus_2=1_000_000.0,
atr_normalized_t_minus_1=0.030,
avg_dollar_volume_20d=200_000_000.0,
pre_open_gap_pct=0.01,
)
base.update(overrides)
return VolBreakout52wTriggerInputs(**base)
def test_trigger_fires_when_all_three_conditions_met():
engine = _make_engine()
passes, reason = evaluate_trigger(_trigger_inputs(), engine)
assert passes is True, reason
assert reason is None
def test_trigger_blocks_when_not_a_breakout():
engine = _make_engine()
passes, reason = evaluate_trigger(_trigger_inputs(is_52w_breakout=False), engine)
assert passes is False
assert "prior 252d max high" in (reason or "")
def test_trigger_blocks_when_volume_ratio_below_min():
engine = _make_engine()
passes, reason = evaluate_trigger(_trigger_inputs(volume_t_minus_1=1_500_000.0), engine)
assert passes is False
assert "volume_ratio" in (reason or "")
def test_trigger_blocks_when_atr_below_band():
engine = _make_engine()
passes, reason = evaluate_trigger(_trigger_inputs(atr_normalized_t_minus_1=0.010), engine)
assert passes is False
assert "atr_normalized" in (reason or "")
def test_trigger_blocks_when_atr_above_band_parabolic():
engine = _make_engine()
passes, reason = evaluate_trigger(_trigger_inputs(atr_normalized_t_minus_1=0.080), engine)
assert passes is False
assert "atr_normalized" in (reason or "")
def test_trigger_blocks_when_price_below_min():
engine = _make_engine(vol_breakout_52w_min_price=10.0)
passes, reason = evaluate_trigger(_trigger_inputs(last_close=4.0), engine)
assert passes is False
assert "min price" in (reason or "")
def test_trigger_blocks_when_adv_below_min():
engine = _make_engine(vol_breakout_52w_min_avg_dollar_volume=50_000_000.0)
passes, reason = evaluate_trigger(_trigger_inputs(avg_dollar_volume_20d=10_000_000.0), engine)
assert passes is False
assert "avg_dollar_volume" in (reason or "")
# ---------------------------------------------------------------------------
# Pre-open gap fade guard
# ---------------------------------------------------------------------------
def test_trigger_blocks_when_pre_open_gap_exceeds_max():
engine = _make_engine()
passes, reason = evaluate_trigger(_trigger_inputs(pre_open_gap_pct=0.05), engine)
assert passes is False
assert "pre_open_gap" in (reason or "")
def test_trigger_passes_when_pre_open_gap_within_max():
engine = _make_engine()
passes, reason = evaluate_trigger(_trigger_inputs(pre_open_gap_pct=0.03), engine)
assert passes is True
assert reason is None
def test_trigger_passes_when_pre_open_gap_data_missing_and_skip_flag_true():
"""Missing gap data + flag=True → no enforcement (skip-with-warning path)."""
engine = _make_engine(vol_breakout_52w_skip_if_no_gap_data=True)
passes, reason = evaluate_trigger(_trigger_inputs(pre_open_gap_pct=None), engine)
assert passes is True
assert reason is None
def test_trigger_passes_when_pre_open_gap_data_missing_and_skip_flag_false():
"""Missing gap data + flag=False → also no enforcement (we cannot enforce a
guard with no data; the warning is logged at the build level)."""
engine = _make_engine(vol_breakout_52w_skip_if_no_gap_data=False)
passes, reason = evaluate_trigger(_trigger_inputs(pre_open_gap_pct=None), engine)
assert passes is True
assert reason is None
# ---------------------------------------------------------------------------
# Look-ahead defenses — the load-bearing tests for this engine
# ---------------------------------------------------------------------------
def test_assert_no_lookahead_rejects_t0_intraday_timestamp():
"""A feature timestamp at 10:30 ET on decision_date is a categorical look-ahead."""
decision_date = dt.date(2026, 4, 13)
leaky_ts = dt.datetime(2026, 4, 13, 14, 30, tzinfo=dt.timezone.utc) # 10:30 ET
with pytest.raises(LookaheadViolationError):
_assert_features_strictly_before_decision_open(
"AAPL", decision_date, [leaky_ts]
)
def test_assert_no_lookahead_rejects_naive_timestamp():
decision_date = dt.date(2026, 4, 13)
with pytest.raises(LookaheadViolationError):
_assert_features_strictly_before_decision_open(
"AAPL", decision_date, [dt.datetime(2026, 4, 10, 21, 0)]
)
def test_assert_no_lookahead_accepts_strictly_prior_timestamp():
decision_date = dt.date(2026, 4, 13)
safe_ts = dt.datetime(2026, 4, 10, 21, 0, tzinfo=dt.timezone.utc)
_assert_features_strictly_before_decision_open(
"AAPL", decision_date, [safe_ts]
) # must NOT raise
def test_evaluate_trigger_re_asserts_last_bar_strictly_before_decision_date():
"""Defence-in-depth: even if a leaky provider snuck through, evaluate_trigger
must trip on ``last_bar_date >= decision_date``. This is the categorical
catch for the topgainer v1-v54 bug."""
engine = _make_engine()
inputs = _trigger_inputs(
decision_date=dt.date(2026, 4, 13),
last_bar_date=dt.date(2026, 4, 13), # SAME DAY — look-ahead
)
with pytest.raises(LookaheadViolationError):
evaluate_trigger(inputs, engine)
def test_frozen_t1_features_blocks_forbidden_field_substring():
"""FrozenT1Features must refuse extras whose names encode T+0 data."""
decision_date = dt.date(2026, 4, 13)
last_bar_date = dt.date(2026, 4, 10)
with pytest.raises(LookaheadViolationError) as excinfo:
FrozenT1Features(
symbol="AAPL",
decision_date=decision_date,
last_bar_date=last_bar_date,
last_close=120.0,
high_252d_max=110.0,
high_252d_max_window=[],
extra={"daily_high": 121.0}, # forbidden — encodes T+0 data
)
assert "daily_high" in str(excinfo.value)
def test_frozen_t1_features_blocks_daily_close_extra():
decision_date = dt.date(2026, 4, 13)
last_bar_date = dt.date(2026, 4, 10)
with pytest.raises(LookaheadViolationError):
FrozenT1Features(
symbol="AAPL",
decision_date=decision_date,
last_bar_date=last_bar_date,
last_close=120.0,
high_252d_max=110.0,
high_252d_max_window=[],
extra={"reaction_daily_close": 121.0},
)
def test_frozen_t1_features_blocks_t0_window_date():
decision_date = dt.date(2026, 4, 13)
last_bar_date = dt.date(2026, 4, 10)
with pytest.raises(LookaheadViolationError):
FrozenT1Features(
symbol="AAPL",
decision_date=decision_date,
last_bar_date=last_bar_date,
last_close=120.0,
high_252d_max=110.0,
high_252d_max_window=[decision_date], # T+0 — forbidden
)
def test_frozen_t1_features_blocks_last_bar_at_or_after_decision_date():
decision_date = dt.date(2026, 4, 13)
with pytest.raises(LookaheadViolationError):
FrozenT1Features(
symbol="AAPL",
decision_date=decision_date,
last_bar_date=decision_date, # same-day — forbidden
last_close=120.0,
high_252d_max=110.0,
high_252d_max_window=[],
)
def test_frozen_t1_features_accepts_strictly_prior_data():
decision_date = dt.date(2026, 4, 13)
last_bar_date = dt.date(2026, 4, 10)
fts = FrozenT1Features(
symbol="AAPL",
decision_date=decision_date,
last_bar_date=last_bar_date,
last_close=120.0,
high_252d_max=110.0,
high_252d_max_window=[dt.date(2026, 1, 5), dt.date(2026, 4, 9)],
extra={"vol_breakout_52w_volume_ratio": 4.0},
)
assert fts.last_bar_date == last_bar_date
# ---------------------------------------------------------------------------
# build_candidates — leaky-provider proof-by-contradiction (the test that
# would have caught the topgainer v1-v54 bug)
# ---------------------------------------------------------------------------
def _build_full_setup(*, days_of_history: int = 80, breakout: bool = True,
vol_spike: float = 4.0):
days = _generate_business_days(dt.date(2026, 1, 5), days_of_history + 2)
decision_date = days[days_of_history]
next_trading_date = days[days_of_history + 1]
prior_days = days[:days_of_history]
last_close = 103.0 if breakout else 101.0
# Tight base H/L (100 +/- 1.5) keeps ATR/close ~0.02 — within the [0.015, 0.06] band.
bars = _build_bars(
"AAPL",
prior_days,
base_close=100.0,
base_high=101.5,
base_low=98.5,
base_volume=1_000_000.0,
last_close=last_close,
last_high=last_close + 1.0,
last_low=last_close - 1.0,
last_volume=int(1_000_000.0 * vol_spike),
atr_jitter=0.3,
)
return {
"symbol": "AAPL",
"decision_date": decision_date,
"next_trading_date": next_trading_date,
"trading_days": days,
"bars": bars,
"bar_provider": _SnapshotStoreBarAdapter(bars_by_symbol=bars),
}
def test_build_emits_candidate_for_eligible_symbol():
setup = _build_full_setup()
engine = _make_engine()
cands = build_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["next_trading_date"],
universe_symbols=[setup["symbol"]],
engine=engine,
bar_provider=setup["bar_provider"],
pre_open_gap_provider=None,
)
assert len(cands) == 1
cand = cands[0]
assert cand.event_type == VOL_BREAKOUT_52W_EVENT_TYPE
assert cand.symbol == "AAPL"
assert cand.execution_date == setup["next_trading_date"]
assert cand.engine_max_holding_days == 2
assert cand.features["vol_breakout_52w_stop_pct"] == 0.03
assert cand.features["vol_breakout_52w_target_pct"] == 0.05
# Defence-in-depth: candidate's event_timestamp must be strictly before
# 09:30 ET on the decision_date.
assert cand.event_timestamp.date() < setup["decision_date"]
def test_build_does_not_fire_when_not_a_breakout():
setup = _build_full_setup(breakout=False)
engine = _make_engine()
cands = build_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["next_trading_date"],
universe_symbols=[setup["symbol"]],
engine=engine,
bar_provider=setup["bar_provider"],
pre_open_gap_provider=None,
)
assert cands == []
def test_build_does_not_fire_when_volume_ratio_below_min():
setup = _build_full_setup(vol_spike=1.2)
engine = _make_engine()
cands = build_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["next_trading_date"],
universe_symbols=[setup["symbol"]],
engine=engine,
bar_provider=setup["bar_provider"],
pre_open_gap_provider=None,
)
assert cands == []
def test_build_raises_lookahead_when_provider_returns_t0_bar():
"""Inject a deliberately leaky provider that returns a bar dated == decision_date.
The engine MUST raise LookaheadViolationError. This is the proof-by-
contradiction test against the topgainer v1-v54 class of bug.
"""
setup = _build_full_setup()
decision_date = setup["decision_date"]
bars = setup["bars"]
# Inject a bar dated ON decision_date.
bars["AAPL"][decision_date] = {
"open": 122.0, "high": 130.0, "low": 121.0, "close": 129.0,
"volume": 9_000_000.0,
}
class LeakyAdapter:
"""Leaks T+0 bar into the screener — a topgainer-style bug."""
def get_bars_before(self, sym, as_of, lookback_days):
inner = bars[sym.upper()]
# Deliberately INCLUDE the bar dated == as_of_date.
ordered = sorted([(d, b) for d, b in inner.items() if d <= as_of])
return ordered[-lookback_days:]
engine = _make_engine()
with pytest.raises(LookaheadViolationError):
build_candidates(
decision_date=decision_date,
next_trading_date=setup["next_trading_date"],
universe_symbols=[setup["symbol"]],
engine=engine,
bar_provider=LeakyAdapter(),
pre_open_gap_provider=None,
)
def test_build_raises_when_next_trading_date_not_strictly_after_decision_date():
setup = _build_full_setup()
engine = _make_engine()
with pytest.raises(LookaheadViolationError):
build_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["decision_date"], # same day — forbidden
universe_symbols=[setup["symbol"]],
engine=engine,
bar_provider=setup["bar_provider"],
pre_open_gap_provider=None,
)
# ---------------------------------------------------------------------------
# Honest-replay test — clean vs leaky provider on identical data must produce
# either identical (clean ↔ clean) or raise (leaky). Deliberately leaky data
# must NOT silently produce different (better) candidates.
# ---------------------------------------------------------------------------
def test_honest_replay_clean_provider_is_deterministic():
setup = _build_full_setup()
engine = _make_engine()
cands_a = build_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["next_trading_date"],
universe_symbols=[setup["symbol"]],
engine=engine,
bar_provider=setup["bar_provider"],
pre_open_gap_provider=None,
)
cands_b = build_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["next_trading_date"],
universe_symbols=[setup["symbol"]],
engine=engine,
bar_provider=setup["bar_provider"],
pre_open_gap_provider=None,
)
# Identical inputs → identical outputs (modulo event_id which encodes inputs).
assert len(cands_a) == len(cands_b) == 1
assert cands_a[0].symbol == cands_b[0].symbol
assert cands_a[0].entry_price_est == cands_b[0].entry_price_est
assert cands_a[0].features == cands_b[0].features
def test_honest_replay_zero_shift_vs_minus1_shift_produces_identical_results():
"""Shift the source bars by 0 vs -1 day. With strict-before discipline,
both views generate the same trigger because the engine never reads T+0.
Specifically: take a setup whose decision_date is D. Then:
- Clean view: bars dated < D.
- Shifted-by-(-1) view: bars dated <= D-1 (== bars < D). SAME SET.
The key invariant is that 0-shift (no extra bar) and explicit -1 shift
yield identical candidates because we honor strict-before T.
"""
setup = _build_full_setup()
engine = _make_engine()
# View A: standard (strict-before T).
cands_a = build_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["next_trading_date"],
universe_symbols=[setup["symbol"]],
engine=engine,
bar_provider=setup["bar_provider"],
pre_open_gap_provider=None,
)
# View B: explicitly truncate to bars dated <= decision_date - 1 day.
truncated_bars: dict[str, dict[dt.date, dict[str, Any]]] = {}
for sym, sym_bars in setup["bars"].items():
truncated_bars[sym] = {
d: b for d, b in sym_bars.items()
if d < setup["decision_date"] # explicit -1 shift floor
}
cands_b = build_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["next_trading_date"],
universe_symbols=[setup["symbol"]],
engine=engine,
bar_provider=_SnapshotStoreBarAdapter(bars_by_symbol=truncated_bars),
pre_open_gap_provider=None,
)
assert len(cands_a) == len(cands_b)
if cands_a:
assert cands_a[0].entry_price_est == cands_b[0].entry_price_est
# The breakout-determining stats must agree.
assert cands_a[0].features["vol_breakout_52w_last_close"] == \
cands_b[0].features["vol_breakout_52w_last_close"]
assert cands_a[0].features["vol_breakout_52w_prior_252d_max_high"] == \
cands_b[0].features["vol_breakout_52w_prior_252d_max_high"]
# ---------------------------------------------------------------------------
# Bootstrap permutation test — the engine has no notion of label permutation;
# the test we CAN run is: shuffle decision_date assignments across symbols and
# assert that each symbol's trigger output is unchanged because each candidate
# is computed only from THAT symbol's bars (no cross-symbol leakage).
# ---------------------------------------------------------------------------
def test_bootstrap_permutation_per_symbol_independence():
"""Per-symbol independence: scrambling the order of universe_symbols must
not change the set of emitted candidates. If it does, there is hidden
cross-symbol state leaking into the trigger.
"""
days = _generate_business_days(dt.date(2026, 1, 5), 82)
decision_date = days[80]
next_trading_date = days[81]
prior_days = days[:80]
bars: dict[str, dict[dt.date, dict[str, Any]]] = {}
for sym in ("AAPL", "MSFT", "GOOG"):
bars.update(_build_bars(
sym, prior_days,
base_close=100.0, base_high=102.0, base_low=98.0,
base_volume=1_000_000.0,
last_close=120.0, # all break out
last_high=121.0, last_low=119.0,
last_volume=4_000_000.0,
))
bar_provider = _SnapshotStoreBarAdapter(bars_by_symbol=bars)
engine = _make_engine()
rng = random.Random(12345)
base_order = ["AAPL", "MSFT", "GOOG"]
base = build_candidates(
decision_date=decision_date,
next_trading_date=next_trading_date,
universe_symbols=base_order,
engine=engine,
bar_provider=bar_provider,
pre_open_gap_provider=None,
)
base_symbols = sorted(c.symbol for c in base)
assert base_symbols == ["AAPL", "GOOG", "MSFT"]
for _ in range(8):
order = list(base_order)
rng.shuffle(order)
shuffled = build_candidates(
decision_date=decision_date,
next_trading_date=next_trading_date,
universe_symbols=order,
engine=engine,
bar_provider=bar_provider,
pre_open_gap_provider=None,
)
assert sorted(c.symbol for c in shuffled) == base_symbols
def test_bootstrap_permutation_breaks_edge_when_signal_is_destroyed():
"""If we permute the LAST-bar values across symbols (so the breakout flag
no longer corresponds to the symbol's own history), a symbol's eligibility
must depend ONLY on its own bars. Permuting the universe order alone does
NOT change candidates that's the test above. Here we instead verify that
forcing one symbol's last-close to a NON-breakout level removes ONLY that
symbol from the candidate list, leaving the others intact.
"""
days = _generate_business_days(dt.date(2026, 1, 5), 82)
decision_date = days[80]
next_trading_date = days[81]
prior_days = days[:80]
bars: dict[str, dict[dt.date, dict[str, Any]]] = {}
for sym, last_close in (("AAPL", 120.0), ("MSFT", 120.0), ("GOOG", 120.0)):
bars.update(_build_bars(
sym, prior_days,
base_close=100.0, base_high=102.0, base_low=98.0,
base_volume=1_000_000.0,
last_close=last_close,
last_high=last_close + 1.0, last_low=last_close - 1.0,
last_volume=4_000_000.0,
))
engine = _make_engine()
base = build_candidates(
decision_date=decision_date,
next_trading_date=next_trading_date,
universe_symbols=["AAPL", "MSFT", "GOOG"],
engine=engine,
bar_provider=_SnapshotStoreBarAdapter(bars_by_symbol=bars),
pre_open_gap_provider=None,
)
assert sorted(c.symbol for c in base) == ["AAPL", "GOOG", "MSFT"]
# Now: kill MSFT's breakout by lowering its last close BELOW prior 252d max high
# (base_high=102 plus jitter ~ 102.2). last_close=100 is clearly not a breakout.
bars2: dict[str, dict[dt.date, dict[str, Any]]] = {}
for sym, last_close in (("AAPL", 120.0), ("MSFT", 100.0), ("GOOG", 120.0)):
bars2.update(_build_bars(
sym, prior_days,
base_close=100.0, base_high=102.0, base_low=98.0,
base_volume=1_000_000.0,
last_close=last_close,
last_high=last_close + 1.0, last_low=last_close - 1.0,
last_volume=4_000_000.0,
))
after = build_candidates(
decision_date=decision_date,
next_trading_date=next_trading_date,
universe_symbols=["AAPL", "MSFT", "GOOG"],
engine=engine,
bar_provider=_SnapshotStoreBarAdapter(bars_by_symbol=bars2),
pre_open_gap_provider=None,
)
assert sorted(c.symbol for c in after) == ["AAPL", "GOOG"]
# ---------------------------------------------------------------------------
# Universe filter
# ---------------------------------------------------------------------------
def test_universe_filter_skips_low_adv_symbol():
days = _generate_business_days(dt.date(2026, 1, 5), 82)
decision_date = days[80]
next_trading_date = days[81]
prior_days = days[:80]
# Tiny ADV: low_volume * close = small.
low_adv = _build_bars("TINY", prior_days,
base_close=100.0, base_high=102.0, base_low=98.0,
base_volume=1000.0, # ~$100k ADV
last_close=120.0, last_high=121.0, last_low=119.0,
last_volume=4000.0)
engine = _make_engine(vol_breakout_52w_min_avg_dollar_volume=10_000_000.0)
cands = build_candidates(
decision_date=decision_date,
next_trading_date=next_trading_date,
universe_symbols=["TINY"],
engine=engine,
bar_provider=_SnapshotStoreBarAdapter(bars_by_symbol=low_adv),
pre_open_gap_provider=None,
)
assert cands == []
def test_universe_filter_skips_low_price_symbol():
days = _generate_business_days(dt.date(2026, 1, 5), 82)
decision_date = days[80]
next_trading_date = days[81]
prior_days = days[:80]
bars = _build_bars("PENNY", prior_days,
base_close=2.0, base_high=2.2, base_low=1.8,
base_volume=10_000_000.0,
last_close=3.0, # below $5 floor
last_high=3.1, last_low=2.9,
last_volume=40_000_000.0)
engine = _make_engine(vol_breakout_52w_min_price=5.0)
cands = build_candidates(
decision_date=decision_date,
next_trading_date=next_trading_date,
universe_symbols=["PENNY"],
engine=engine,
bar_provider=_SnapshotStoreBarAdapter(bars_by_symbol=bars),
pre_open_gap_provider=None,
)
assert cands == []
# ---------------------------------------------------------------------------
# Behavioral exit tests — drive a synthetic position through simulate_exit
# ---------------------------------------------------------------------------
def _build_position_for_breakout(
*,
entry_price: float = 100.0,
stop_pct: float = 0.03,
target_pct: float = 0.05,
days_held: int = 0,
):
from libs.backtest.domain import Candidate, OpenPosition, PlannedOrder
stop_mult = stop_pct / 0.02
target_r = target_pct / stop_pct
synthetic_atr = entry_price * 0.02
cand = Candidate(
event_id="evt_volb_exit",
symbol="AAPL",
score=0.7,
sector="UNKNOWN",
event_type=VOL_BREAKOUT_52W_EVENT_TYPE,
event_timestamp=dt.datetime(2026, 4, 10, 21, 0, tzinfo=dt.timezone.utc),
event_date=dt.date(2026, 4, 13),
filing_time_bucket="post_market",
reaction_date=dt.date(2026, 4, 13),
execution_date=dt.date(2026, 4, 14),
entry_price_est=entry_price,
avg_dollar_volume=200_000_000.0,
atr_14=synthetic_atr,
score_bucket="medium_high",
engine_id="vol_breakout_52w_long",
entry_timing_policy="next_open",
trade_direction="long",
engine_stop_atr_multiplier=stop_mult,
engine_target_1_r=target_r,
engine_target_1_fraction=1.0,
engine_max_holding_days=2,
)
stop_price = entry_price * (1.0 - stop_pct)
target_price = entry_price * (1.0 + target_pct)
plan = PlannedOrder(
candidate=cand,
shares=100,
entry_price_limit=entry_price,
stop_price=stop_price,
target_price=target_price,
risk_dollars=stop_pct * entry_price * 100,
event_date=cand.event_date,
timing_class="after_close",
engine_id=cand.engine_id,
entry_timing_policy="next_open",
shadow_only=False,
)
return OpenPosition(
position_id="pos_volb",
plan=plan,
entry_date=cand.execution_date,
entry_price=entry_price,
entry_fill_slippage_bps=10.0,
current_stop=stop_price,
target_price=target_price,
peak_price=entry_price,
shares_open=100,
shares_total=100,
days_held=days_held,
)
def _exec_config_for_exit_test(max_hold: int = 2):
from libs.backtest.domain import ExecutionConfig
return ExecutionConfig(
entry_fill_model="next_open",
exit_fill_model="daily_bar_approximation",
slippage_bps_base=10.0,
commission_per_share=0.005,
same_bar_priority="stop_first_conservative",
max_holding_days=max_hold,
)
def test_exit_stop_at_minus_3pct():
from libs.backtest.domain import ExitReason
from libs.backtest.execution import simulate_exit
pos = _build_position_for_breakout(entry_price=100.0, stop_pct=0.03)
bar = {"date": dt.date(2026, 4, 15), "open": 99.0, "high": 99.5, "low": 96.5,
"close": 97.0, "volume": 1_000_000}
trade = simulate_exit(pos, bar, _exec_config_for_exit_test(), dt.date(2026, 4, 15))
assert trade is not None
assert trade.exit_reason == ExitReason.STOP
def test_exit_target_at_plus_5pct():
from libs.backtest.domain import ExitReason
from libs.backtest.execution import simulate_exit
pos = _build_position_for_breakout(entry_price=100.0, target_pct=0.05)
bar = {"date": dt.date(2026, 4, 15), "open": 102.0, "high": 105.5, "low": 101.0,
"close": 104.0, "volume": 1_000_000}
trade = simulate_exit(pos, bar, _exec_config_for_exit_test(), dt.date(2026, 4, 15))
assert trade is not None
assert trade.exit_reason == ExitReason.TARGET
def test_exit_forced_max_hold_at_day_2_moc():
"""When days_held >= max_holding_days=2 and no stop/target hit, exit reason is TIME."""
from libs.backtest.domain import ExitReason
from libs.backtest.execution import simulate_exit
pos = _build_position_for_breakout(entry_price=100.0, days_held=2)
cfg = _exec_config_for_exit_test(max_hold=2)
bar = {"date": dt.date(2026, 4, 15), "open": 102.0, "high": 103.0, "low": 99.0,
"close": 102.5, "volume": 1_000_000}
trade = simulate_exit(pos, bar, cfg, dt.date(2026, 4, 15))
assert trade is not None
assert trade.exit_reason == ExitReason.TIME
def test_exit_intraday_priority_stop_before_target_when_both_touched():
"""Same-bar priority: stop_first_conservative — stop wins when both lines touched."""
from libs.backtest.domain import ExitReason
from libs.backtest.execution import simulate_exit
pos = _build_position_for_breakout(entry_price=100.0, stop_pct=0.03, target_pct=0.05)
# Wide bar that touches both 97.0 (stop) AND 105.0 (target).
bar = {"date": dt.date(2026, 4, 15), "open": 99.5, "high": 105.5, "low": 96.5,
"close": 100.0, "volume": 1_000_000}
trade = simulate_exit(pos, bar, _exec_config_for_exit_test(), dt.date(2026, 4, 15))
assert trade is not None
assert trade.exit_reason == ExitReason.STOP
Loading…
Cancel
Save