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.

396 lines
18 KiB
Python

"""Synthetic event candidate generation for scenario backtesting.
Generates event candidate rows that are compatible with SnapshotStore's
candidates_by_exec_date structure. Each row contains all fields required
by build_candidate() in selector.py plus feature fields used by scoring
functions and strategy engine filters.
Event timing model (post_market/after_close pattern):
- event_date N: Company announces post-market → event_timestamp = N 21:00 UTC
- reaction_date: N (market's first reaction is on the announcement day close)
- execution_date: next_trading_day(N) — trade entered at next open
"""
from __future__ import annotations
import datetime as dt
import math
from dataclasses import dataclass, field
from typing import Any
import numpy as np
@dataclass
class EventDistribution:
"""Statistical distribution parameters for synthetic event feature generation.
Each feature is described as (mean, std) for truncated-normal sampling,
or as a dict mapping category → probability for categorical features.
All (mean, std) pairs use np.clip to keep values in reasonable ranges.
"""
# ---- Event arrival ----
events_per_day_mean: float = 8.0
"""Average number of events per trading day."""
events_per_day_std: float = 3.0
"""Standard deviation of events per trading day."""
# ---- Categorical distributions ----
event_types: dict[str, float] = field(default_factory=lambda: {
"earnings_release": 0.65,
"guidance_update": 0.15,
"material_contract": 0.08,
"other_material_event": 0.07,
"unknown": 0.05,
})
event_directions: dict[str, float] = field(default_factory=lambda: {
"bullish": 0.45,
"mixed": 0.25,
"unknown": 0.20,
"bearish": 0.10,
})
guidance_statuses: dict[str, float] = field(default_factory=lambda: {
"raised": 0.35,
"inline_or_maintained": 0.40,
"not_provided": 0.15,
"lowered": 0.10,
})
filing_time_buckets: dict[str, float] = field(default_factory=lambda: {
"post_market": 0.60,
"pre_market": 0.35,
"intraday": 0.05,
})
sectors: dict[str, float] = field(default_factory=lambda: {
"Technology": 0.22,
"Health Care": 0.14,
"Consumer Discretionary": 0.12,
"Financials": 0.13,
"Industrials": 0.11,
"Communication Services": 0.09,
"Consumer Staples": 0.07,
"Energy": 0.05,
"Materials": 0.04,
"Utilities": 0.03,
})
# ---- Quality features (scoring inputs) ----
signal_strength_mean: float = 0.65
signal_strength_std: float = 0.15
document_quality_mean: float = 0.70
document_quality_std: float = 0.12
parse_confidence_mean: float = 0.80
parse_confidence_std: float = 0.10
guidance_direction_score_mean: float = 0.55
guidance_direction_score_std: float = 0.20
oneoff_penalty_prob: float = 0.05
"""Probability of a 1-off event penalty (reduces quality score)."""
# ---- Reaction features ----
reaction_return_mean: float = 0.025
"""Mean reaction-day return (positive = bullish bias)."""
reaction_return_std: float = 0.055
volume_ratio_mean: float = 1.8
volume_ratio_std: float = 0.9
close_location_mean: float = 0.60
close_location_std: float = 0.18
gap_size_mean: float = 0.010
gap_size_std: float = 0.025
# ---- Technical pre-event features ----
rsi_14_mean: float = 52.0
rsi_14_std: float = 12.0
bb_position_mean: float = 0.55
bb_position_std: float = 0.20
volatility_20d_mean: float = 0.28
volatility_20d_std: float = 0.08
hurst_60d_mean: float = 0.50
hurst_60d_std: float = 0.07
entropy_60d_mean: float = 1.45
entropy_60d_std: float = 0.15
ou_theta_60d_mean: float = 5.0
ou_theta_60d_std: float = 2.0
market_temperature_mean: float = 0.80
market_temperature_std: float = 0.25
gravitational_pull_mean: float = 0.0
gravitational_pull_std: float = 0.03
sector_momentum_20d_mean: float = 0.005
sector_momentum_20d_std: float = 0.03
# ---- Universe features ----
avg_dollar_volume_mean: float = 5_000_000.0
avg_dollar_volume_std: float = 3_000_000.0
price_mean: float = 85.0
price_std: float = 40.0
# ---- ATR ----
atr_pct_mean: float = 0.022
"""ATR-14 as a percentage of price."""
atr_pct_std: float = 0.008
def _sample_categorical(categories: dict[str, float], rng: np.random.Generator) -> str:
"""Sample one category weighted by probabilities."""
keys = list(categories.keys())
probs = np.array(list(categories.values()), dtype=float)
probs /= probs.sum()
return str(rng.choice(keys, p=probs))
def _clamp_normal(mean: float, std: float, lo: float, hi: float, rng: np.random.Generator) -> float:
"""Sample from a clipped normal distribution."""
return float(np.clip(rng.normal(mean, std), lo, hi))
def generate_events(
trading_dates: list[dt.date],
symbols: list[str],
dist: EventDistribution,
rng: np.random.Generator,
bars_by_symbol: dict[str, dict[dt.date, dict[str, Any]]] | None = None,
max_holding_days_buffer: int = 25,
) -> dict[dt.date, list[dict[str, Any]]]:
"""Generate synthetic event candidates distributed across trading_dates.
Each candidate row contains all fields required by:
- selector.build_candidate() (event_id, event_timestamp, entry_price, etc.)
- scoring functions (signal_strength_score, reaction_day_return, etc.)
- strategy engine filters (pre_event_rsi_14, pre_event_hurst_60d, etc.)
Events are placed on execution_dates. The corresponding reaction_date is
the previous trading day (post_market filing pattern).
Args:
trading_dates: Full sequence of NYSE trading dates.
symbols: List of ticker symbols to assign events to.
dist: EventDistribution parameters.
rng: NumPy random generator.
bars_by_symbol: If provided, entry_price is taken from bar close on reaction_date.
max_holding_days_buffer: Days at end of date range excluded from event placement
(so all positions can close before scenario end).
Returns:
candidates_by_exec_date dict compatible with SnapshotStore.
"""
n = len(trading_dates)
# Reserve the first ~5 days (warm-up) and last N days (holding buffer)
eligible_range_start = 5
eligible_range_end = max(eligible_range_start + 1, n - max_holding_days_buffer)
candidates: dict[dt.date, list[dict[str, Any]]] = {}
event_counter = 0
used_symbols_today: dict[dt.date, set[str]] = {}
for i in range(eligible_range_start, eligible_range_end):
exec_date = trading_dates[i]
reaction_date = trading_dates[i - 1] # previous trading day
# Number of events today (Poisson-like)
n_events = max(0, int(round(rng.normal(dist.events_per_day_mean, dist.events_per_day_std))))
if n_events == 0:
continue
today_candidates: list[dict[str, Any]] = []
used_syms = used_symbols_today.setdefault(exec_date, set())
# Pick symbols for today's events (without replacement from pool)
available = [s for s in symbols if s not in used_syms]
if not available:
continue
rng.shuffle(available)
n_events = min(n_events, len(available))
for j in range(n_events):
symbol = available[j]
used_syms.add(symbol)
event_counter += 1
event_type = _sample_categorical(dist.event_types, rng)
event_direction = _sample_categorical(dist.event_directions, rng)
guidance_status = _sample_categorical(dist.guidance_statuses, rng)
filing_bucket = _sample_categorical(dist.filing_time_buckets, rng)
sector = _sample_categorical(dist.sectors, rng)
# Event date / timestamp: model after-close and same-day patterns.
# post_market / pre_market → event happened on the trading day BEFORE
# reaction_date (after-close pattern: reaction_date > event_date → "after_close").
# intraday → event happened on reaction_date itself ("same_day").
if filing_bucket in ("post_market", "pre_market"):
event_date_d = trading_dates[i - 2] # i >= eligible_range_start=5, safe
hour = "21:00:00" if filing_bucket == "post_market" else "07:00:00"
else:
event_date_d = reaction_date # intraday → same_day timing
hour = "14:00:00"
event_timestamp = f"{event_date_d.isoformat()}T{hour}+00:00"
# Price (try to get from bars, else sample)
if bars_by_symbol and symbol in bars_by_symbol:
bar = bars_by_symbol[symbol].get(reaction_date)
if bar and bar.get("close", 0) > 0:
entry_price = float(bar["close"])
event_close = entry_price
atr_14 = entry_price * _clamp_normal(dist.atr_pct_mean, dist.atr_pct_std, 0.005, 0.08, rng)
exec_bar = bars_by_symbol[symbol].get(exec_date)
avg_dollar_volume = float(bar.get("volume", 1_000_000)) * entry_price
else:
entry_price = max(5.0, _clamp_normal(dist.price_mean, dist.price_std, 5.0, 500.0, rng))
event_close = entry_price
atr_14 = entry_price * _clamp_normal(dist.atr_pct_mean, dist.atr_pct_std, 0.005, 0.08, rng)
avg_dollar_volume = max(100_000.0, rng.normal(dist.avg_dollar_volume_mean, dist.avg_dollar_volume_std))
else:
entry_price = max(5.0, _clamp_normal(dist.price_mean, dist.price_std, 5.0, 500.0, rng))
event_close = entry_price
atr_14 = entry_price * _clamp_normal(dist.atr_pct_mean, dist.atr_pct_std, 0.005, 0.08, rng)
avg_dollar_volume = max(100_000.0, float(rng.normal(dist.avg_dollar_volume_mean, dist.avg_dollar_volume_std)))
# Quality features
signal_strength = _clamp_normal(dist.signal_strength_mean, dist.signal_strength_std, 0.0, 1.0, rng)
doc_quality = _clamp_normal(dist.document_quality_mean, dist.document_quality_std, 0.0, 1.0, rng)
parse_conf = _clamp_normal(dist.parse_confidence_mean, dist.parse_confidence_std, 0.0, 1.0, rng)
guidance_dir_score = _clamp_normal(dist.guidance_direction_score_mean, dist.guidance_direction_score_std, 0.0, 1.0, rng)
oneoff_penalty = 1.0 if rng.random() < dist.oneoff_penalty_prob else 0.0
# Reaction features (biased by event_direction)
direction_bias = {"bullish": 0.03, "bearish": -0.03, "mixed": 0.005, "unknown": 0.0}.get(event_direction, 0.0)
reaction_return = float(rng.normal(dist.reaction_return_mean + direction_bias, dist.reaction_return_std))
volume_ratio = max(0.5, float(rng.normal(dist.volume_ratio_mean, dist.volume_ratio_std)))
close_location = _clamp_normal(dist.close_location_mean, dist.close_location_std, 0.0, 1.0, rng)
gap_size = float(rng.normal(dist.gap_size_mean, dist.gap_size_std))
# Technical features
rsi_14 = _clamp_normal(dist.rsi_14_mean, dist.rsi_14_std, 5.0, 95.0, rng)
bb_position = _clamp_normal(dist.bb_position_mean, dist.bb_position_std, -0.2, 1.2, rng)
vol_20d = _clamp_normal(dist.volatility_20d_mean, dist.volatility_20d_std, 0.05, 0.8, rng)
hurst_60d = _clamp_normal(dist.hurst_60d_mean, dist.hurst_60d_std, 0.2, 0.8, rng)
entropy_60d = _clamp_normal(dist.entropy_60d_mean, dist.entropy_60d_std, 0.5, 2.0, rng)
ou_theta = _clamp_normal(dist.ou_theta_60d_mean, dist.ou_theta_60d_std, 0.5, 30.0, rng)
mkt_temp = _clamp_normal(dist.market_temperature_mean, dist.market_temperature_std, 0.0, 2.0, rng)
grav_pull = float(rng.normal(dist.gravitational_pull_mean, dist.gravitational_pull_std))
sector_mom = float(rng.normal(dist.sector_momentum_20d_mean, dist.sector_momentum_20d_std))
# Compute score using the same logic as the real scoring system
row_for_scoring: dict[str, Any] = {
"event_type": event_type,
"event_direction": event_direction,
"guidance_status": guidance_status,
"signal_strength_score": signal_strength,
"document_quality_score": doc_quality,
"parse_confidence_overall": parse_conf,
"guidance_direction_score": guidance_dir_score,
"oneoff_penalty": oneoff_penalty,
"reaction_day_return": reaction_return,
"volume_ratio_20d": volume_ratio,
"close_location": close_location,
"gap_size": gap_size,
"pre_event_entropy_60d": entropy_60d,
}
score = _compute_synthetic_score(row_for_scoring)
row: dict[str, Any] = {
# Identity
"event_id": f"SYNTH::{symbol}::{event_counter:06d}",
"symbol": symbol,
"event_type": event_type,
"event_direction": event_direction,
"guidance_status": guidance_status,
"filing_time_bucket": filing_bucket,
"sector": sector,
# Dates and timestamps
"event_date": event_date_d.isoformat(),
"event_timestamp": event_timestamp,
"reaction_date": reaction_date.isoformat(),
"entry_date": exec_date.isoformat(),
"execution_date": exec_date,
# Pricing
"entry_price": round(entry_price, 4),
"entry_price_est": round(entry_price, 4),
"event_close": round(event_close, 4),
"atr_14": round(atr_14, 4),
"avg_dollar_volume": round(avg_dollar_volume, 2),
"avg_dollar_volume_20d": round(avg_dollar_volume, 2),
"market_cap_proxy": round(max(avg_dollar_volume * 400, 3_000_000_000), 0),
# Score
"score": round(score, 4),
# Quality features
"signal_strength_score": round(signal_strength, 4),
"document_quality_score": round(doc_quality, 4),
"parse_confidence_overall": round(parse_conf, 4),
"guidance_direction_score": round(guidance_dir_score, 4),
"oneoff_penalty": oneoff_penalty,
# Reaction features
"reaction_day_return": round(reaction_return, 4),
"volume_ratio_20d": round(volume_ratio, 4),
"close_location": round(close_location, 4),
"gap_size": round(gap_size, 4),
"reaction_day_low": round(entry_price * (1.0 - abs(reaction_return) * 0.5), 4),
"reaction_day_high": round(entry_price * (1.0 + abs(reaction_return) * 0.5), 4),
"reaction_day_range_pct": round(abs(reaction_return) + abs(gap_size), 4),
"upper_wick_pct": round(max(0.0, float(rng.exponential(0.01))), 4),
# Technical pre-event features
"pre_event_rsi_14": round(rsi_14, 2),
"pre_event_bb_position": round(bb_position, 4),
"pre_event_volatility_20d": round(vol_20d, 4),
"pre_event_obv_slope_20d": float(rng.normal(0, 0.1)),
"pre_event_hurst_60d": round(hurst_60d, 4),
"pre_event_entropy_60d": round(entropy_60d, 4),
"pre_event_short_ratio": max(0.0, float(rng.exponential(0.05))),
"pre_event_sector_momentum_20d": round(sector_mom, 4),
"pre_event_ou_theta_60d": round(ou_theta, 4),
"pre_event_gravitational_pull": round(grav_pull, 4),
"pre_event_market_temperature": round(mkt_temp, 4),
# Fundamental (optional, not always present)
"reported_eps": None,
"estimated_eps": None,
"earnings_beat": None,
"earnings_surprise_pct": None,
# Universe
"exchange_proxy": "NASDAQ" if rng.random() > 0.4 else "NYSE",
"asset_type_proxy": "stock",
}
today_candidates.append(row)
if today_candidates:
candidates[exec_date] = today_candidates
return candidates
def _compute_synthetic_score(row: dict[str, Any]) -> float:
"""Compute a realistic score using the same weighting as compute_entry_score (v5 base).
Simplified version that does not require the full scoring module imports,
matching the 3-component structure: event_quality (65%) + reaction (20%) + volume (15%).
"""
# Event quality component (65%)
doc_q = float(row.get("document_quality_score") or 0.65)
sig_s = float(row.get("signal_strength_score") or 0.60)
parse_c = float(row.get("parse_confidence_overall") or 0.75)
guidance = float(row.get("guidance_direction_score") or 0.50)
oneoff = float(row.get("oneoff_penalty") or 0.0)
base_quality = (doc_q * 0.35 + sig_s * 0.30 + parse_c * 0.20 + guidance * 0.15)
if oneoff:
base_quality *= 0.60
# Reaction direction component (20%)
reaction_return = float(row.get("reaction_day_return") or 0.0)
if reaction_return > 0.01:
reaction_score = min(1.0, 0.5 + reaction_return * 5.0)
elif reaction_return < -0.01:
reaction_score = max(0.0, 0.5 + reaction_return * 5.0)
else:
reaction_score = 0.5
# Volume conviction component (15%)
vol_ratio = float(row.get("volume_ratio_20d") or 1.0)
volume_score = min(1.0, vol_ratio / 3.0)
# Low entropy bonus (v13e entropy feature)
entropy = row.get("pre_event_entropy_60d")
entropy_bonus = 0.0
if entropy is not None and float(entropy) < 1.2:
entropy_bonus = 0.03 * (1.2 - float(entropy))
raw = base_quality * 0.65 + reaction_score * 0.20 + volume_score * 0.15 + entropy_bonus
return max(0.0, min(1.0, raw))