diff --git a/apps/backtester/run.py b/apps/backtester/run.py index 1145a3e..dde8139 100644 --- a/apps/backtester/run.py +++ b/apps/backtester/run.py @@ -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,10 +5899,16 @@ class BacktestRunner: peer_resolver = _RunnerPeerResolver(self) open_symbols = {p.plan.candidate.symbol.upper() for p in self._open_positions} - preexisting_symbols = { - candidate.symbol.upper() - for candidate in self._scheduled_delayed_entries.get(next_date, []) - } + 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, []) + } for engine in engines: blackout = int(getattr(engine, "peer_sympathy_blackout_days_to_peer_event", 0) or 0) @@ -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,7 +6036,10 @@ class BacktestRunner: sym = cand.symbol.upper() if sym in open_symbols or sym in preexisting_symbols: continue - self._scheduled_delayed_entries[next_date].append(cand) + 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) def _ensure_leader_follower_market_data( diff --git a/configs/experiments/peer_sympathy_poc_v2_reaction_close.json b/configs/experiments/peer_sympathy_poc_v2_reaction_close.json new file mode 100644 index 0000000..3488c1c --- /dev/null +++ b/configs/experiments/peer_sympathy_poc_v2_reaction_close.json @@ -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 +} diff --git a/libs/backtest/domain.py b/libs/backtest/domain.py index 2e43b39..27c1d82 100644 --- a/libs/backtest/domain.py +++ b/libs/backtest/domain.py @@ -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): diff --git a/libs/backtest/peer_sympathy.py b/libs/backtest/peer_sympathy.py index 21ede30..e89a7d4 100644 --- a/libs/backtest/peer_sympathy.py +++ b/libs/backtest/peer_sympathy.py @@ -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"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,9 +433,26 @@ 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. - peer_decision_cutoff = _decision_cutoff_utc(next_trading_date) + # 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 " @@ -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,9 +541,32 @@ 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) - _assert_no_lookahead( - peer_symbol, next_trading_date, [peer_last_bar_ts, leader.event_timestamp] - ) + # 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 @@ -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, diff --git a/tests/unit/backtest/test_peer_sympathy.py b/tests/unit/backtest/test_peer_sympathy.py index 79c6586..6fb4819 100644 --- a/tests/unit/backtest/test_peer_sympathy.py +++ b/tests/unit/backtest/test_peer_sympathy.py @@ -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)