You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
799 lines
31 KiB
Python
799 lines
31 KiB
Python
"""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
|
|
# ``filing_time_bucket`` lets the salvage variant (entry_timing_policy=
|
|
# "reaction_close") restrict to BMO / regular-hours leader prints so
|
|
# post-market filings — which can't be sympathy-traded same-day — are
|
|
# excluded. Defaults to "post_market" so existing call-sites that haven't
|
|
# been migrated still match the prior behaviour (no filter applied).
|
|
filing_time_bucket: str = "post_market"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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 []
|
|
|
|
entry_policy = (engine.peer_sympathy_entry_timing_policy or "next_open").strip().lower()
|
|
if entry_policy not in ("next_open", "reaction_close"):
|
|
raise ValueError(
|
|
f"PeerSympathy unsupported entry_timing_policy {entry_policy!r}; "
|
|
"expected 'next_open' or 'reaction_close'"
|
|
)
|
|
is_reaction_close = entry_policy == "reaction_close"
|
|
|
|
# Lookahead invariant.
|
|
# next_open: peer entry is strictly after leader publication (T+1).
|
|
# reaction_close: peer entry is the SAME trading day's close (==T). The
|
|
# stronger guard is the per-leader leader.event_timestamp < T 16:00 ET
|
|
# check below.
|
|
if not is_reaction_close and 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()} "
|
|
f"(entry_timing_policy={entry_policy!r})"
|
|
)
|
|
if is_reaction_close and next_trading_date < decision_date:
|
|
raise LookaheadViolationError(
|
|
f"PeerSympathy next_trading_date {next_trading_date.isoformat()} must be "
|
|
f">= decision_date {decision_date.isoformat()} when entry_timing_policy="
|
|
f"'reaction_close'"
|
|
)
|
|
|
|
allowed_buckets = {
|
|
str(b).strip().lower()
|
|
for b in (engine.peer_sympathy_leader_filing_time_buckets or [])
|
|
if str(b).strip()
|
|
}
|
|
|
|
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
|
|
# Filing-time bucket allow-list (used by the salvage variant to skip
|
|
# AMC prints that can't be sympathy-traded intra-session).
|
|
if allowed_buckets:
|
|
bucket = (leader.filing_time_bucket or "").strip().lower()
|
|
if bucket not in allowed_buckets:
|
|
logger.debug(
|
|
"peer_sympathy_skip_leader_filing_time_bucket",
|
|
leader=leader_symbol,
|
|
bucket=bucket,
|
|
allowed=sorted(allowed_buckets),
|
|
)
|
|
continue
|
|
|
|
# Lookahead: leader event_timestamp must precede peer entry cutoff.
|
|
# next_open path: cutoff is T+1 09:30 ET.
|
|
# reaction_close path: cutoff is T 16:00 ET (peer's same-day close).
|
|
if is_reaction_close:
|
|
peer_decision_cutoff = _bar_close_timestamp(decision_date)
|
|
else:
|
|
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()} (entry_timing_policy={entry_policy!r})"
|
|
)
|
|
|
|
# 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)
|
|
# Cutoff is the FIRST instant at which we could read peer
|
|
# quantities for the entry:
|
|
# next_open: T+1 09:30 ET (use _decision_cutoff_utc)
|
|
# reaction_close: T 16:00 ET (use _bar_close_timestamp(T))
|
|
# Both feature timestamps (peer last bar, leader event_timestamp)
|
|
# must be strictly before this cutoff.
|
|
if is_reaction_close:
|
|
cutoff = _bar_close_timestamp(decision_date)
|
|
for ts in (peer_last_bar_ts, leader.event_timestamp):
|
|
if ts is None:
|
|
continue
|
|
if ts.tzinfo is None:
|
|
raise LookaheadViolationError(
|
|
f"PeerSympathy feature timestamp for {peer_symbol} is naive "
|
|
f"({ts.isoformat()}); all timestamps must be timezone-aware"
|
|
)
|
|
if ts >= cutoff:
|
|
raise LookaheadViolationError(
|
|
f"PeerSympathy feature timestamp {ts.isoformat()} for "
|
|
f"{peer_symbol} is >= reaction_close cutoff "
|
|
f"{cutoff.isoformat()} (decision_date={decision_date.isoformat()})"
|
|
)
|
|
else:
|
|
_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, is_reaction_close=is_reaction_close
|
|
)
|
|
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,
|
|
*,
|
|
is_reaction_close: bool = False,
|
|
) -> 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,
|
|
}
|
|
|
|
if is_reaction_close:
|
|
execution_date = inputs.decision_date
|
|
entry_timing_policy = "reaction_close"
|
|
timing_class = "same_day"
|
|
else:
|
|
execution_date = inputs.next_trading_date
|
|
entry_timing_policy = "next_open"
|
|
timing_class = "after_close"
|
|
|
|
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=timing_class,
|
|
reaction_date=inputs.decision_date,
|
|
execution_date=execution_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=entry_timing_policy,
|
|
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",
|
|
]
|