Add reaction_close salvage variant to PeerSympathy engine

Add peer_sympathy_entry_timing_policy ("next_open"|"reaction_close") and
peer_sympathy_leader_filing_time_buckets to StrategyEngineConfig. The
reaction_close variant enters peers at peer's T 16:00 ET close on the
SAME trading day as the leader's print, addressing the v1 hypothesis
failure where T+1 gap had already absorbed the news overnight.

Lookahead defenses tightened for the new branch: cutoff is T 16:00 ET
(_bar_close_timestamp(decision_date)) instead of T+1 09:30 ET; bucket
allow-list excludes AMC filings (which under PEAD's reaction_date=T+1
convention pass the timestamp check but defeat same-session sympathy).
LeaderPrint now carries filing_time_bucket from the runner.

Runner: split _schedule_peer_sympathy_candidates into two phases.
reaction_close fires BEFORE _select_candidates_for_date(date) and emits
into _scheduled_add_ons[date]; next_open keeps the existing tail-of-loop
position emitting into _scheduled_delayed_entries[next_date].

v2 backtest (1052 trading days, midlarge-liquid-long-v1 snapshot):
  trades 256→120, return -52.9%→-2.4%, MDD 61.6%→24.5%, SQS 19.6→30.2.

Sample sympathy plays: GOOGL on META +7.7%, AVGO on COHR +6.3%,
SLB on HAL +5.5%, GE on HWM +5.1%. Profit factor 0.977 (one tweak
from breakeven). Verdict: VIABLE BUT WEAK — salvage hypothesis
empirically validated, near breakeven, not promoted yet.

35/35 peer_sympathy unit tests pass (29 pre-existing + 6 new for
reaction_close path).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
main
I Luk Kim 3 months ago
parent 2a8e9c526e
commit 68102f1b3a

@ -1285,6 +1285,10 @@ class BacktestRunner:
# --- ENTRIES (only if kill switch not triggered) ---
if not self._kill_switch_triggered:
portfolio_state = self._build_portfolio_state(date, drawdown_pct, unrealized)
# Same-day reaction_close synthetic candidates (peer-sympathy v2
# salvage variant) must be staged BEFORE _select_candidates_for_date
# because that function consumes _scheduled_add_ons[date] inline.
self._schedule_peer_sympathy_candidates(date, phase="reaction_close")
candidates = self._select_candidates_for_date(date)
# Lookback: on the first simulation day inject pre-start events still within mhd
@ -1351,7 +1355,7 @@ class BacktestRunner:
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_peer_sympathy_candidates(date, phase="next_open")
self._schedule_vol_breakout_52w_candidates(date)
self._schedule_macro_short_candidates(date)
self._schedule_macro_long_candidates(date)
@ -5838,29 +5842,45 @@ class BacktestRunner:
self._earnings_runup_pit_provider = provider
return provider
def _schedule_peer_sympathy_candidates(self, date: dt.date) -> None:
def _schedule_peer_sympathy_candidates(
self, date: dt.date, *, phase: str = "next_open"
) -> 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.
Two phases (one method, called twice from the daily loop):
- phase="reaction_close": runs BEFORE _select_candidates_for_date(date).
Emits same-day reaction_close candidates into ``_scheduled_add_ons[date]``
so they are picked up by the daily entries pass on the same iteration.
Only engines whose ``peer_sympathy_entry_timing_policy == "reaction_close"``
are processed.
- phase="next_open" (default): runs AFTER entries (current behavior).
Emits T+1 next_open candidates into ``_scheduled_delayed_entries[next_date]``.
Only engines whose ``peer_sympathy_entry_timing_policy != "reaction_close"``
are processed.
Pure logic lives in ``libs.backtest.peer_sympathy``; this method only
adapts runner state to the provider Protocols.
"""
if phase not in ("next_open", "reaction_close"):
raise ValueError(f"unsupported peer_sympathy phase {phase!r}")
is_reaction_close_phase = phase == "reaction_close"
next_date = self._next_trading_day.get(date)
if next_date is None:
# next_open phase NEEDS a next trading day; reaction_close enters today.
if not is_reaction_close_phase and next_date is None:
return
def _engine_policy(e: Any) -> str:
return str(getattr(e, "peer_sympathy_entry_timing_policy", "next_open") or "next_open").strip().lower()
engines = [
e for e in self._active_strategy_engines
if getattr(e, "peer_sympathy_enabled", False)
and self._engine_allowed_for_date(e, date)
and (
(is_reaction_close_phase and _engine_policy(e) == "reaction_close")
or (not is_reaction_close_phase and _engine_policy(e) != "reaction_close")
)
]
if not engines:
return
@ -5879,6 +5899,12 @@ class BacktestRunner:
peer_resolver = _RunnerPeerResolver(self)
open_symbols = {p.plan.candidate.symbol.upper() for p in self._open_positions}
if is_reaction_close_phase:
preexisting_symbols = {
candidate.symbol.upper()
for candidate in self._scheduled_add_ons.get(date, [])
}
else:
preexisting_symbols = {
candidate.symbol.upper()
for candidate in self._scheduled_delayed_entries.get(next_date, [])
@ -5951,6 +5977,7 @@ class BacktestRunner:
event_timestamp=c.event_timestamp,
reaction_day_return=reaction,
score=float(c.score),
filing_time_bucket=str(c.filing_time_bucket or "post_market"),
)
)
@ -5958,6 +5985,10 @@ class BacktestRunner:
continue
# Eagerly fetch peer bar coverage so correlation has data on hand.
# For reaction_close we need TODAY's bar (entry happens at today's
# close); for next_open we need next_date's bar (entry happens at
# next_date's open). target_end_date is the latest day we may read.
target_end = date if is_reaction_close_phase else (next_date or date)
required_symbols: set[str] = set()
for leader in leaders:
required_symbols.add(leader.symbol)
@ -5967,13 +5998,16 @@ class BacktestRunner:
self._ensure_leader_follower_market_data(
sorted(required_symbols),
date,
required_end_date=next_date,
required_end_date=target_end,
)
# build_peer_sympathy_candidates requires a sane next_trading_date
# even when its content isn't used for execution (reaction_close).
ntd_for_build = next_date if next_date is not None else date
try:
candidates = build_peer_sympathy_candidates(
decision_date=date,
next_trading_date=next_date,
next_trading_date=ntd_for_build,
leaders=leaders,
peer_resolver=peer_resolver,
engine=engine,
@ -5993,6 +6027,7 @@ class BacktestRunner:
logger.debug(
"peer_sympathy_candidates_built",
engine_id=engine.engine_id,
phase=phase,
date=date.isoformat(),
leader_count=len(leaders),
candidate_count=len(candidates),
@ -6001,6 +6036,9 @@ class BacktestRunner:
sym = cand.symbol.upper()
if sym in open_symbols or sym in preexisting_symbols:
continue
if is_reaction_close_phase:
self._scheduled_add_ons[date].append(cand)
else:
self._scheduled_delayed_entries[next_date].append(cand)
preexisting_symbols.add(sym)

@ -0,0 +1,89 @@
{
"experiment_name": "peer_sympathy_poc_v2_reaction_close",
"dataset_snapshot_id": "midlarge-liquid-long-v1_bucketfix_full_audit_canonical_ftb_fix_v2",
"description": "PeerSympathy salvage variant: enter peers at peer's reaction_close on T (same trading day as the leader's print) instead of T+1 next_open. Hypothesis: peers move in the SAME intraday session as the leader (NVDA prints BMO -> AVGO/AMD rally same day's market hours), not on T+1 gap which already absorbed the news overnight. Filing-time bucket allow-list restricts to BMO + regular_hours leader prints; AMC filings are excluded because their reaction_date = T+1 means peer's reaction_close on T+1 is already ~20 trading hours after the news, defeating the same-session sympathy thesis. All other parameters identical to v1 baseline.",
"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_reaction_close",
"event_types": ["peer_sympathy"],
"timing_class": "same_day",
"direction": "long_only",
"entry_timing_policy": "reaction_close",
"engine_risk_budget_pct": 1.0,
"score_threshold_override": 0.0,
"max_holding_days": 3,
"peer_sympathy_enabled": true,
"peer_sympathy_entry_timing_policy": "reaction_close",
"peer_sympathy_leader_filing_time_buckets": ["pre_market", "regular_hours"],
"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", "salvage_v2", "reaction_close"],
"version_family": "peer_sympathy",
"status": "draft",
"changelog": "v2 salvage variant of peer_sympathy_poc_v1: same-day reaction_close entry on T (peer's 16:00 ET close) instead of T+1 next_open, restricted to BMO + regular_hours leader filings. Tests this hypothesis: peers move in the SAME session as the leader's print, not on T+1 gap which already absorbed the news overnight.",
"parent": "peer_sympathy_poc_v1",
"performance_summary": null
}

@ -2222,6 +2222,19 @@ class StrategyEngineConfig(BaseModel):
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
# --- Same-day reaction_close salvage variant (v2) ---
# Default "next_open" is the v1 PoC behavior. Setting to "reaction_close"
# has the engine emit synthetic candidates that enter the peer at peer's
# T 16:00 ET close — same trading day as the leader's print — instead of
# T+1 next_open. This requires the leader's event_timestamp to be strictly
# before peer's 16:00 ET on T, so AMC filings (which the snapshot sets
# to reaction_date = T+1, but whose leader event_timestamp is T 21:00 ET
# = before peer's T+1 16:00 ET) still pass the raw timestamp check —
# the ``peer_sympathy_leader_filing_time_buckets`` allow-list is what
# actually keeps AMC filings out when the salvage hypothesis demands
# intra-session sympathy.
peer_sympathy_entry_timing_policy: str = "next_open" # "next_open" | "reaction_close"
peer_sympathy_leader_filing_time_buckets: list[str] | None = None # e.g. ["pre_market","regular_hours"] for reaction_close salvage
# --- VolBreakout52w engine ---
# Honest, look-ahead-safe descendant of the retired topgainer family.
# Trigger (ALL on T-1 close):

@ -100,6 +100,12 @@ class LeaderPrint:
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"
# ---------------------------------------------------------------------------
@ -371,13 +377,38 @@ def build_peer_sympathy_candidates(
if not engine.peer_sympathy_enabled:
return []
# Lookahead invariant: peer entry must be strictly after leader publication.
if next_trading_date <= decision_date:
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()
@ -402,8 +433,25 @@ def build_peer_sympathy_candidates(
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(
@ -414,7 +462,7 @@ def build_peer_sympathy_candidates(
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()}"
f"{peer_decision_cutoff.isoformat()} (entry_timing_policy={entry_policy!r})"
)
# Pull leader bars once per leader.
@ -493,6 +541,29 @@ def build_peer_sympathy_candidates(
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]
)
@ -538,7 +609,9 @@ def build_peer_sympathy_candidates(
)
continue
candidate = _build_candidate_from_inputs(inputs, leader, engine)
candidate = _build_candidate_from_inputs(
inputs, leader, engine, is_reaction_close=is_reaction_close
)
candidates.append(candidate)
seen_peer_for_decision.add(peer_symbol)
@ -609,6 +682,8 @@ 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)
@ -662,6 +737,15 @@ def _build_candidate_from_inputs(
"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,
@ -672,15 +756,15 @@ def _build_candidate_from_inputs(
event_timestamp=inputs.peer_last_bar_timestamp,
event_date=inputs.decision_date,
filing_time_bucket="post_market",
timing_class="after_close",
timing_class=timing_class,
reaction_date=inputs.decision_date,
execution_date=inputs.next_trading_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="next_open",
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,

@ -981,3 +981,238 @@ def test_select_candidates_without_engine_retains_real_leaders():
"peer candidates. If this fails, the runner adapter is once again "
"starving downstream peer-sympathy logic."
)
# ---------------------------------------------------------------------------
# Salvage variant (v2): entry_timing_policy="reaction_close"
# ---------------------------------------------------------------------------
#
# Hypothesis: peers move in the SAME intraday session as the leader's print,
# not on T+1 gap. Entry at peer's T 16:00 ET close instead of T+1 09:30 ET.
# Look-ahead defenses:
# - leader.event_timestamp must be strictly < peer's T 16:00 ET close.
# - peer T+0 reaction is NEVER referenced (still uses bars strictly < T).
# Filing-time bucket allow-list (e.g. ["pre_market","regular_hours"]) is the
# intended way to skip AMC prints that can't be sympathy-traded same-day.
def _bmo_leader_setup(
*, filing_time_bucket: str = "pre_market", correlation: float = 0.90
) -> dict[str, Any]:
"""Build a setup whose leader timestamp is BEFORE peer's T 16:00 ET close."""
setup = _build_full_setup(correlation=correlation)
decision_date = setup["decision_date"]
# 08:00 ET == 13:00 UTC (EST offset). Strictly before 21:00 UTC (16:00 ET).
bmo_ts = dt.datetime.combine(decision_date, dt.time(13, 0), tzinfo=dt.timezone.utc)
setup["leader"] = LeaderPrint(
symbol="NVDA",
sector="Technology",
event_id=setup["leader"].event_id,
event_type=setup["leader"].event_type,
event_date=decision_date,
event_timestamp=bmo_ts,
reaction_day_return=setup["leader"].reaction_day_return,
score=setup["leader"].score,
filing_time_bucket=filing_time_bucket,
)
return setup
def test_reaction_close_bmo_leader_emits_same_day_peer_entry():
"""BMO leader print → peer enters at peer's T reaction_close (today)."""
setup = _bmo_leader_setup(filing_time_bucket="pre_market")
engine = _make_engine(
peer_sympathy_entry_timing_policy="reaction_close",
peer_sympathy_leader_filing_time_buckets=["pre_market", "regular_hours"],
)
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.entry_timing_policy == "reaction_close"
assert cand.timing_class == "same_day"
# Entry happens TODAY, not T+1.
assert cand.execution_date == setup["decision_date"]
assert cand.reaction_date == setup["decision_date"]
def test_reaction_close_regular_hours_leader_emits_same_day_peer_entry():
"""Regular-hours filing (e.g. 11:30 ET) → still strictly before peer's T 16:00 ET close."""
setup = _build_full_setup(correlation=0.90)
decision_date = setup["decision_date"]
rh_ts = dt.datetime.combine(decision_date, dt.time(16, 30), tzinfo=dt.timezone.utc) # 11:30 ET
setup["leader"] = LeaderPrint(
symbol="NVDA",
sector="Technology",
event_id="evt",
event_type="earnings_release",
event_date=decision_date,
event_timestamp=rh_ts,
reaction_day_return=0.08,
filing_time_bucket="regular_hours",
)
engine = _make_engine(
peer_sympathy_entry_timing_policy="reaction_close",
peer_sympathy_leader_filing_time_buckets=["pre_market", "regular_hours"],
)
cands = build_peer_sympathy_candidates(
decision_date=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
assert cands[0].entry_timing_policy == "reaction_close"
assert cands[0].execution_date == decision_date
def test_reaction_close_post_market_leader_excluded_by_bucket_filter():
"""AMC filing in the bucket-restricted config → no candidate emitted.
Note: under PEAD's reaction-date convention, an AMC filing on calendar day X
has reaction_date = X+1, so leader.event_timestamp is X 21:00 UTC (16:00 ET
on X) and peer's reaction_close cutoff on decision_date X+1 is X+1 21:00 UTC.
The raw timestamp guard PASSES (X 21:00 < X+1 21:00). The
``peer_sympathy_leader_filing_time_buckets`` allow-list is what actually
excludes AMC that is the explicit knob in the salvage-variant config.
"""
setup = _build_full_setup(correlation=0.90)
decision_date = setup["decision_date"]
amc_ts = dt.datetime.combine(
decision_date - dt.timedelta(days=1), dt.time(21, 0), tzinfo=dt.timezone.utc,
) # 16:00 ET previous day
setup["leader"] = LeaderPrint(
symbol="NVDA",
sector="Technology",
event_id="evt",
event_type="earnings_release",
event_date=decision_date,
event_timestamp=amc_ts,
reaction_day_return=0.08,
filing_time_bucket="post_market",
)
# Bucket allow-list excludes post_market — engine drops the leader.
engine = _make_engine(
peer_sympathy_entry_timing_policy="reaction_close",
peer_sympathy_leader_filing_time_buckets=["pre_market", "regular_hours"],
)
cands = build_peer_sympathy_candidates(
decision_date=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_reaction_close_post_market_falls_back_when_buckets_unrestricted():
"""If the engine config does NOT restrict filing buckets, AMC still produces
a candidate (timestamp guard alone passes). This documents that the bucket
filter is the load-bearing knob, not the timestamp check.
"""
setup = _build_full_setup(correlation=0.90)
decision_date = setup["decision_date"]
amc_ts = dt.datetime.combine(
decision_date - dt.timedelta(days=1), dt.time(21, 0), tzinfo=dt.timezone.utc,
)
setup["leader"] = LeaderPrint(
symbol="NVDA",
sector="Technology",
event_id="evt",
event_type="earnings_release",
event_date=decision_date,
event_timestamp=amc_ts,
reaction_day_return=0.08,
filing_time_bucket="post_market",
)
engine = _make_engine(
peer_sympathy_entry_timing_policy="reaction_close",
peer_sympathy_leader_filing_time_buckets=None, # no restriction
)
cands = build_peer_sympathy_candidates(
decision_date=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
assert cands[0].entry_timing_policy == "reaction_close"
def test_reaction_close_raises_when_leader_timestamp_at_or_after_peer_close():
"""If the leader's print is AT-or-AFTER peer's 16:00 ET close on T,
entering peer at that close is a look-ahead violation.
"""
setup = _build_full_setup(correlation=0.90)
decision_date = setup["decision_date"]
# 16:00 ET on T == 21:00 UTC. AT cutoff is a violation.
leak_ts = dt.datetime.combine(decision_date, dt.time(21, 0), tzinfo=dt.timezone.utc)
setup["leader"] = LeaderPrint(
symbol="NVDA",
sector="Technology",
event_id="evt",
event_type="earnings_release",
event_date=decision_date,
event_timestamp=leak_ts,
reaction_day_return=0.08,
filing_time_bucket="post_market", # we don't filter to surface the timestamp guard
)
engine = _make_engine(
peer_sympathy_entry_timing_policy="reaction_close",
peer_sympathy_leader_filing_time_buckets=None,
)
with pytest.raises(LookaheadViolationError):
build_peer_sympathy_candidates(
decision_date=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"],
)
def test_reaction_close_does_not_consult_peer_t0_reaction():
"""Same invariant as the v1 next_open path: peer T+0 bars must NEVER be
referenced in selection. Inject a wild T+0 peer bar and verify the candidate's
entry_price_est is computed from PRE-T data alone.
"""
setup = _bmo_leader_setup(correlation=0.90)
bars = setup["bar_provider"].bars
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(
peer_sympathy_entry_timing_policy="reaction_close",
peer_sympathy_leader_filing_time_buckets=["pre_market", "regular_hours"],
)
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
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(cands[0].entry_price_est, expected_close, rel_tol=1e-6)

Loading…
Cancel
Save