diff --git a/apps/backtester/run.py b/apps/backtester/run.py index 1713f4e..1ceee71 100644 --- a/apps/backtester/run.py +++ b/apps/backtester/run.py @@ -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. diff --git a/configs/experiments/earnings_runup_poc_v1.json b/configs/experiments/earnings_runup_poc_v1.json new file mode 100644 index 0000000..3712798 --- /dev/null +++ b/configs/experiments/earnings_runup_poc_v1.json @@ -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 +} diff --git a/configs/experiments/peer_sympathy_poc_v1.json b/configs/experiments/peer_sympathy_poc_v1.json new file mode 100644 index 0000000..73ec930 --- /dev/null +++ b/configs/experiments/peer_sympathy_poc_v1.json @@ -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 +} diff --git a/configs/experiments/vol_breakout_52w_poc_v1.json b/configs/experiments/vol_breakout_52w_poc_v1.json new file mode 100644 index 0000000..fc39d50 --- /dev/null +++ b/configs/experiments/vol_breakout_52w_poc_v1.json @@ -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."] +} diff --git a/libs/backtest/domain.py b/libs/backtest/domain.py index 765b5cb..5e6aeaa 100644 --- a/libs/backtest/domain.py +++ b/libs/backtest/domain.py @@ -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): diff --git a/libs/backtest/earnings_runup.py b/libs/backtest/earnings_runup.py new file mode 100644 index 0000000..62032c0 --- /dev/null +++ b/libs/backtest/earnings_runup.py @@ -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", +] diff --git a/libs/backtest/peer_sympathy.py b/libs/backtest/peer_sympathy.py new file mode 100644 index 0000000..21ede30 --- /dev/null +++ b/libs/backtest/peer_sympathy.py @@ -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", +] diff --git a/libs/backtest/vol_breakout_52w.py b/libs/backtest/vol_breakout_52w.py new file mode 100644 index 0000000..0e80810 --- /dev/null +++ b/libs/backtest/vol_breakout_52w.py @@ -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", +] diff --git a/tests/unit/backtest/test_earnings_runup.py b/tests/unit/backtest/test_earnings_runup.py new file mode 100644 index 0000000..63f7965 --- /dev/null +++ b/tests/unit/backtest/test_earnings_runup.py @@ -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 diff --git a/tests/unit/backtest/test_peer_sympathy.py b/tests/unit/backtest/test_peer_sympathy.py new file mode 100644 index 0000000..79c6586 --- /dev/null +++ b/tests/unit/backtest/test_peer_sympathy.py @@ -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." + ) diff --git a/tests/unit/backtest/test_vol_breakout_52w.py b/tests/unit/backtest/test_vol_breakout_52w.py new file mode 100644 index 0000000..731f61a --- /dev/null +++ b/tests/unit/backtest/test_vol_breakout_52w.py @@ -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