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
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)
|