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
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}"
|