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