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.

246 lines
10 KiB
Python

"""Unit tests for event_gen.py."""
import datetime as dt
import numpy as np
import pytest
from libs.backtest.scenarios.event_gen import (
EventDistribution,
_compute_synthetic_score,
_sample_categorical,
generate_events,
)
from libs.backtest.scenarios.price_gen import PriceRegime, generate_price_paths
_DATES_RAW = [dt.date(2024, 1, 2) + dt.timedelta(days=i) for i in range(400)]
_TRADING_DATES = [d for d in _DATES_RAW if d.weekday() < 5][:252]
_SYMBOLS = [f"SYM{i:03d}" for i in range(30)]
@pytest.mark.unit
class TestSampleCategorical:
def test_returns_valid_key(self):
rng = np.random.default_rng(1)
cats = {"a": 0.5, "b": 0.3, "c": 0.2}
result = _sample_categorical(cats, rng)
assert result in cats
def test_distribution_roughly_correct(self):
rng = np.random.default_rng(42)
cats = {"x": 0.9, "y": 0.1}
counts = {"x": 0, "y": 0}
for _ in range(1000):
k = _sample_categorical(cats, rng)
counts[k] += 1
# x should appear roughly 90% of the time
assert counts["x"] > 800
@pytest.mark.unit
class TestComputeSyntheticScore:
def test_high_quality_event_has_high_score(self):
row = {
"signal_strength_score": 0.95,
"document_quality_score": 0.95,
"parse_confidence_overall": 0.95,
"guidance_direction_score": 0.90,
"oneoff_penalty": 0.0,
"reaction_day_return": 0.08,
"volume_ratio_20d": 3.0,
"close_location": 0.85,
"gap_size": 0.02,
"pre_event_entropy_60d": 1.0,
}
score = _compute_synthetic_score(row)
assert score > 0.75
def test_low_quality_event_has_low_score(self):
row = {
"signal_strength_score": 0.20,
"document_quality_score": 0.20,
"parse_confidence_overall": 0.25,
"guidance_direction_score": 0.10,
"oneoff_penalty": 1.0,
"reaction_day_return": -0.05,
"volume_ratio_20d": 0.5,
"close_location": 0.2,
"gap_size": -0.01,
"pre_event_entropy_60d": 1.8,
}
score = _compute_synthetic_score(row)
assert score < 0.35
def test_score_bounded_0_to_1(self):
"""Score must always be in [0, 1]."""
rng = np.random.default_rng(7)
for _ in range(50):
row = {
"signal_strength_score": float(rng.uniform(0, 1)),
"document_quality_score": float(rng.uniform(0, 1)),
"parse_confidence_overall": float(rng.uniform(0, 1)),
"guidance_direction_score": float(rng.uniform(0, 1)),
"oneoff_penalty": float(rng.choice([0.0, 1.0])),
"reaction_day_return": float(rng.normal(0, 0.05)),
"volume_ratio_20d": float(rng.uniform(0.3, 4.0)),
"pre_event_entropy_60d": float(rng.uniform(0.5, 2.0)),
}
score = _compute_synthetic_score(row)
assert 0.0 <= score <= 1.0, f"score {score} out of [0, 1]"
def test_oneoff_penalty_reduces_score(self):
base_row = {
"signal_strength_score": 0.7,
"document_quality_score": 0.7,
"parse_confidence_overall": 0.8,
"guidance_direction_score": 0.6,
"oneoff_penalty": 0.0,
"reaction_day_return": 0.03,
"volume_ratio_20d": 2.0,
"pre_event_entropy_60d": 1.3,
}
penalized_row = {**base_row, "oneoff_penalty": 1.0}
assert _compute_synthetic_score(base_row) > _compute_synthetic_score(penalized_row)
@pytest.mark.unit
class TestGenerateEvents:
def test_returns_dict_keyed_by_exec_date(self):
rng = np.random.default_rng(42)
dist = EventDistribution()
candidates = generate_events(_TRADING_DATES, _SYMBOLS, dist, rng)
assert isinstance(candidates, dict)
for d in candidates:
assert isinstance(d, dt.date)
assert d in _TRADING_DATES
def test_all_exec_dates_within_eligible_range(self):
rng = np.random.default_rng(42)
dist = EventDistribution()
buffer = 25
candidates = generate_events(_TRADING_DATES, _SYMBOLS, dist, rng, max_holding_days_buffer=buffer)
# First 5 days are warm-up; last buffer days are excluded
eligible_end = _TRADING_DATES[len(_TRADING_DATES) - buffer - 1]
for d in candidates:
assert d <= eligible_end, f"exec_date {d} beyond eligible range"
def test_required_fields_present(self):
rng = np.random.default_rng(42)
dist = EventDistribution()
candidates = generate_events(_TRADING_DATES, _SYMBOLS, dist, rng)
required_fields = [
"event_id", "symbol", "event_type", "event_direction",
"filing_time_bucket", "sector",
"event_timestamp", "reaction_date", "entry_date", "execution_date",
"entry_price", "entry_price_est", "event_close", "atr_14", "avg_dollar_volume",
"score",
"signal_strength_score", "document_quality_score", "parse_confidence_overall",
"guidance_direction_score", "oneoff_penalty",
"reaction_day_return", "volume_ratio_20d", "close_location", "gap_size",
"pre_event_rsi_14", "pre_event_bb_position", "pre_event_volatility_20d",
"pre_event_hurst_60d", "pre_event_entropy_60d",
"pre_event_ou_theta_60d", "pre_event_market_temperature",
]
for exec_date, rows in candidates.items():
for row in rows:
for field in required_fields:
assert field in row, f"Missing field '{field}' in candidate on {exec_date}"
def test_execution_date_matches_dict_key(self):
rng = np.random.default_rng(42)
dist = EventDistribution()
candidates = generate_events(_TRADING_DATES, _SYMBOLS, dist, rng)
for exec_date, rows in candidates.items():
for row in rows:
assert row["execution_date"] == exec_date
def test_reaction_date_is_previous_trading_day(self):
rng = np.random.default_rng(42)
dist = EventDistribution()
candidates = generate_events(_TRADING_DATES, _SYMBOLS, dist, rng)
date_set = set(_TRADING_DATES)
for exec_date, rows in candidates.items():
exec_idx = _TRADING_DATES.index(exec_date)
expected_reaction = _TRADING_DATES[exec_idx - 1]
for row in rows:
rxn = row["reaction_date"]
# reaction_date stored as isoformat string
rxn_date = dt.date.fromisoformat(rxn) if isinstance(rxn, str) else rxn
assert rxn_date == expected_reaction, f"reaction_date mismatch on {exec_date}"
def test_event_id_unique(self):
rng = np.random.default_rng(42)
dist = EventDistribution()
candidates = generate_events(_TRADING_DATES, _SYMBOLS, dist, rng)
all_ids = [row["event_id"] for rows in candidates.values() for row in rows]
assert len(all_ids) == len(set(all_ids)), "Duplicate event_ids found"
def test_no_duplicate_symbol_per_day(self):
rng = np.random.default_rng(42)
dist = EventDistribution()
candidates = generate_events(_TRADING_DATES, _SYMBOLS, dist, rng)
for exec_date, rows in candidates.items():
syms = [r["symbol"] for r in rows]
assert len(syms) == len(set(syms)), f"Duplicate symbols on {exec_date}"
def test_prices_positive(self):
rng = np.random.default_rng(42)
dist = EventDistribution()
candidates = generate_events(_TRADING_DATES, _SYMBOLS, dist, rng)
for rows in candidates.values():
for row in rows:
assert row["entry_price"] > 0, f"Non-positive entry_price: {row['entry_price']}"
assert row["atr_14"] > 0, f"Non-positive atr_14: {row['atr_14']}"
def test_score_between_0_and_1(self):
rng = np.random.default_rng(42)
dist = EventDistribution()
candidates = generate_events(_TRADING_DATES, _SYMBOLS, dist, rng)
for rows in candidates.values():
for row in rows:
assert 0.0 <= row["score"] <= 1.0, f"score {row['score']} out of [0, 1]"
def test_uses_bar_close_when_bars_provided(self):
rng = np.random.default_rng(42)
dist = EventDistribution(events_per_day_mean=2.0, events_per_day_std=0.0)
regimes = [PriceRegime(0.10, 0.15, 252)]
bars_by_symbol = generate_price_paths(
n_symbols=len(_SYMBOLS),
initial_prices=None,
regimes=regimes,
trading_dates=_TRADING_DATES,
rng=np.random.default_rng(1),
tickers=_SYMBOLS,
)
candidates = generate_events(
_TRADING_DATES, _SYMBOLS, dist,
np.random.default_rng(42),
bars_by_symbol=bars_by_symbol,
)
# At least some events should be present
assert len(candidates) > 0
# entry_prices should be positive and reasonable
for rows in candidates.values():
for row in rows:
assert row["entry_price"] > 0
def test_event_timestamp_format(self):
"""event_timestamp should be ISO with timezone."""
rng = np.random.default_rng(42)
dist = EventDistribution()
candidates = generate_events(_TRADING_DATES, _SYMBOLS, dist, rng)
for rows in candidates.values():
for row in rows:
ts = row["event_timestamp"]
assert "T" in ts, f"event_timestamp not ISO: {ts}"
assert "+00:00" in ts or "Z" in ts, f"event_timestamp missing TZ: {ts}"
def test_produces_events_with_default_distribution(self):
"""Default distribution should produce at least some events."""
rng = np.random.default_rng(99)
dist = EventDistribution()
candidates = generate_events(_TRADING_DATES, _SYMBOLS, dist, rng)
total = sum(len(v) for v in candidates.values())
assert total > 50, f"Too few events generated: {total}"