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

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