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