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.

157 lines
6.0 KiB
Python

"""Event-price coupling for synthetic scenario backtesting.
Controls the signal-to-noise ratio between event quality features and
subsequent price movements. This is the core mechanism for overfitting detection:
signal_strength=0.0 → pure noise → strategy should return ~0 (false positive check)
signal_strength=0.35 → realistic SNR → strategy captures genuine alpha
signal_strength=0.60 → strong signal → verify strategy responds to alpha
The coupling injects a drift into bars AFTER the execution date based on the
event's score and reaction features, while preserving OHLCV consistency.
"""
from __future__ import annotations
import datetime as dt
import math
from typing import Any
import numpy as np
def couple_events_to_prices(
candidates: dict[dt.date, list[dict[str, Any]]],
bars_by_symbol: dict[str, dict[dt.date, dict[str, Any]]],
trading_dates: list[dt.date],
signal_strength: float,
signal_decay_days: int = 10,
false_positive_rate: float = 0.15,
rng: np.random.Generator | None = None,
) -> None:
"""Inject signal-driven drift into bars after each event's execution date.
Modifies bars_by_symbol in-place. Also updates each candidate's
entry_price / entry_price_est / event_close to match the actual
bar close on its reaction_date (so stop prices are consistent).
Args:
candidates: candidates_by_exec_date dict from generate_events().
bars_by_symbol: OHLCV bars to modify in-place.
trading_dates: Ordered list of NYSE trading dates.
signal_strength: 0.0 = pure noise, 0.35 = realistic, 0.6 = strong alpha.
signal_decay_days: Days over which signal drift decays to zero.
false_positive_rate: Fraction of qualifying events that produce negative
returns (traps / false positives).
rng: NumPy random generator.
"""
if rng is None:
rng = np.random.default_rng()
date_to_idx = {d: i for i, d in enumerate(trading_dates)}
for exec_date, rows in candidates.items():
exec_idx = date_to_idx.get(exec_date)
if exec_idx is None:
continue
# reaction_date is the day before execution
react_idx = exec_idx - 1
if react_idx < 0:
continue
reaction_date = trading_dates[react_idx]
for row in rows:
symbol = str(row.get("symbol", ""))
sym_bars = bars_by_symbol.get(symbol)
if sym_bars is None:
continue
# Sync entry_price with actual bar close on reaction_date
react_bar = sym_bars.get(reaction_date)
if react_bar and react_bar.get("close", 0) > 0:
actual_close = float(react_bar["close"])
row["entry_price"] = round(actual_close, 4)
row["entry_price_est"] = round(actual_close, 4)
row["event_close"] = round(actual_close, 4)
# Recompute ATR based on actual price
atr_pct = float(row.get("atr_14", actual_close * 0.022)) / max(float(row.get("entry_price", actual_close)), 1e-4)
row["atr_14"] = round(actual_close * atr_pct, 4)
# Skip coupling if signal_strength == 0 (pure noise scenario)
if signal_strength <= 1e-9:
continue
# Compute expected drift from event features
score = float(row.get("score", 0.5))
reaction_return = float(row.get("reaction_day_return", 0.0))
volume_ratio = float(row.get("volume_ratio_20d", 1.5))
expected_drift_5d = signal_strength * (
0.030 * (score - 0.5)
+ 0.020 * reaction_return
+ 0.008 * max(0.0, volume_ratio - 1.5)
)
# False positive: flip signal direction
if rng.random() < false_positive_rate:
expected_drift_5d = -expected_drift_5d * 0.7
if abs(expected_drift_5d) < 1e-6:
continue
# Distribute drift over signal_decay_days using a decay schedule
total_drift = expected_drift_5d
decay = _compute_decay_schedule(total_drift, signal_decay_days)
# Inject drift into bars starting at exec_date + 1 (first day we hold)
apply_start_idx = exec_idx + 1
for k, daily_adj in enumerate(decay):
bar_idx = apply_start_idx + k
if bar_idx >= len(trading_dates):
break
bar_date = trading_dates[bar_idx]
bar = sym_bars.get(bar_date)
if bar is None:
continue
_apply_drift_to_bar(bar, daily_adj)
def _compute_decay_schedule(total_drift: float, decay_days: int) -> list[float]:
"""Distribute total_drift over decay_days using exponential decay.
Returns a list of per-day drift adjustments that sum to total_drift.
"""
if decay_days <= 0:
return [total_drift]
# Exponential decay weights
weights = [math.exp(-0.5 * k / max(decay_days, 1)) for k in range(decay_days)]
total_weight = sum(weights)
return [total_drift * w / total_weight for w in weights]
def _apply_drift_to_bar(bar: dict[str, Any], daily_drift: float) -> None:
"""Multiply all OHLCV price fields by (1 + daily_drift), preserving consistency.
Applies uniform multiplicative adjustment so that OHLC relationships are
maintained exactly. Volume is unchanged.
"""
factor = 1.0 + daily_drift
factor = max(0.5, min(2.0, factor)) # guard against extreme values
for field in ("open", "high", "low", "close"):
val = bar.get(field)
if val is not None and float(val) > 0:
bar[field] = round(float(val) * factor, 4)
# Ensure OHLCV consistency after adjustment
o = bar.get("open", 0)
h = bar.get("high", 0)
lo = bar.get("low", 0)
c = bar.get("close", 0)
if o and h and lo and c:
bar["high"] = round(max(float(h), float(o), float(c)), 4)
bar["low"] = round(max(0.01, min(float(lo), float(o), float(c))), 4)