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.
508 lines
18 KiB
Python
508 lines
18 KiB
Python
"""Unit tests for libs/backtest/domain.py."""
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from libs.backtest.domain import (
|
|
BacktestConfig,
|
|
Candidate,
|
|
DailyPortfolioState,
|
|
EventTypeProfile,
|
|
ExitReason,
|
|
ExecutionConfig,
|
|
ExperimentManifest,
|
|
FORM4_CAPTURE_SLEEVE_PRESETS,
|
|
FORM4_PIT_EVENTS_V1_PATH,
|
|
FORM4_PIT_EVENTS_V3_PATH,
|
|
Form4CaptureConfig,
|
|
FilledTrade,
|
|
IDLE_ALPHA_SLEEVE_PRESETS,
|
|
MetricsBundle,
|
|
OpenPosition,
|
|
PlannedOrder,
|
|
PositionStatus,
|
|
ReportingConfig,
|
|
RiskConfig,
|
|
SignalConfig,
|
|
StrategyEngineConfig,
|
|
UniverseConfig,
|
|
)
|
|
|
|
_UTC = ZoneInfo("UTC")
|
|
_NOW = dt.datetime(2026, 1, 5, 14, 30, tzinfo=_UTC)
|
|
_TODAY = dt.date(2026, 1, 5)
|
|
_TOMORROW = dt.date(2026, 1, 6)
|
|
|
|
|
|
def _make_candidate(**kwargs) -> Candidate:
|
|
defaults = dict(
|
|
event_id="EVT::DOC::TEST::earnings::0",
|
|
symbol="AAPL",
|
|
issuer_id="ISSUER::0000320193",
|
|
score=0.75,
|
|
sector="Technology",
|
|
event_type="earnings",
|
|
event_timestamp=_NOW,
|
|
filing_time_bucket="post_market",
|
|
reaction_date=_TODAY,
|
|
execution_date=_TOMORROW,
|
|
entry_price_est=150.0,
|
|
avg_dollar_volume=5_000_000.0,
|
|
atr_14=3.5,
|
|
score_bucket="high",
|
|
)
|
|
defaults.update(kwargs)
|
|
return Candidate(**defaults)
|
|
|
|
|
|
def _make_filled_trade(**kwargs) -> FilledTrade:
|
|
defaults = dict(
|
|
trade_id="t1",
|
|
position_id="p1",
|
|
event_id="EVT::TEST",
|
|
symbol="AAPL",
|
|
entry_date=_TODAY,
|
|
exit_date=_TOMORROW,
|
|
entry_price=150.0,
|
|
exit_price=160.0,
|
|
exit_reason=ExitReason.TARGET,
|
|
shares=10,
|
|
commission=0.10,
|
|
slippage_bps=10.0,
|
|
gross_pnl=100.0,
|
|
net_pnl=99.9,
|
|
pnl_pct=0.0667,
|
|
r_multiple=2.0,
|
|
holding_days=1,
|
|
)
|
|
defaults.update(kwargs)
|
|
return FilledTrade(**defaults)
|
|
|
|
|
|
class TestPositionStatus:
|
|
def test_values(self):
|
|
assert PositionStatus.PLANNED == "PLANNED"
|
|
assert PositionStatus.CLOSED == "CLOSED"
|
|
|
|
def test_all_statuses(self):
|
|
expected = {"PLANNED", "ENTERED", "PARTIALLY_EXITED", "OPEN", "EXIT_PENDING", "CLOSED", "ARCHIVED"}
|
|
assert {s.value for s in PositionStatus} == expected
|
|
|
|
|
|
class TestExitReason:
|
|
def test_values(self):
|
|
assert ExitReason.STOP == "STOP"
|
|
assert ExitReason.TARGET == "TARGET"
|
|
assert ExitReason.TIME == "TIME"
|
|
assert ExitReason.TRAILING == "TRAILING"
|
|
assert ExitReason.KILL_SWITCH == "KILL_SWITCH"
|
|
assert ExitReason.MISSING_BAR == "MISSING_BAR"
|
|
|
|
|
|
class TestCandidate:
|
|
def test_basic_creation(self):
|
|
c = _make_candidate()
|
|
assert c.symbol == "AAPL"
|
|
assert c.score == 0.75
|
|
assert c.event_timestamp.tzinfo is not None
|
|
|
|
def test_frozen(self):
|
|
c = _make_candidate()
|
|
with pytest.raises(Exception): # frozen model
|
|
c.score = 0.9
|
|
|
|
def test_timezone_aware_timestamp(self):
|
|
c = _make_candidate(event_timestamp=dt.datetime(2026, 1, 5, 20, 0, tzinfo=_UTC))
|
|
assert c.event_timestamp.tzinfo is not None
|
|
|
|
def test_features_default_empty(self):
|
|
c = _make_candidate()
|
|
assert c.features == {}
|
|
|
|
def test_features_stored(self):
|
|
c = _make_candidate(features={"foo": 1.0, "bar": "baz"})
|
|
assert c.features["foo"] == 1.0
|
|
|
|
|
|
class TestFilledTrade:
|
|
def test_basic(self):
|
|
t = _make_filled_trade()
|
|
assert t.net_pnl == 99.9
|
|
assert t.exit_reason == ExitReason.TARGET
|
|
|
|
def test_frozen(self):
|
|
t = _make_filled_trade()
|
|
with pytest.raises(Exception):
|
|
t.net_pnl = 0.0
|
|
|
|
def test_stop_exit_reason(self):
|
|
t = _make_filled_trade(exit_reason=ExitReason.STOP, net_pnl=-50.0)
|
|
assert t.exit_reason == ExitReason.STOP
|
|
|
|
|
|
class TestOpenPosition:
|
|
def test_mutable(self):
|
|
c = _make_candidate()
|
|
plan = PlannedOrder(
|
|
candidate=c,
|
|
shares=10,
|
|
entry_price_limit=150.0,
|
|
stop_price=144.0,
|
|
target_price=162.0,
|
|
risk_dollars=60.0,
|
|
)
|
|
pos = OpenPosition(
|
|
position_id="p1",
|
|
plan=plan,
|
|
entry_date=_TOMORROW,
|
|
entry_price=150.5,
|
|
entry_fill_slippage_bps=10.0,
|
|
current_stop=144.0,
|
|
target_price=162.0,
|
|
peak_price=150.5,
|
|
shares_open=10,
|
|
shares_total=10,
|
|
)
|
|
# Should be mutable
|
|
pos.days_held = 3
|
|
assert pos.days_held == 3
|
|
pos.current_stop = 146.0
|
|
assert pos.current_stop == 146.0
|
|
|
|
|
|
class TestDailyPortfolioState:
|
|
def test_basic(self):
|
|
s = DailyPortfolioState(
|
|
date=_TODAY,
|
|
equity=100_000.0,
|
|
cash_available=90_000.0,
|
|
gross_exposure=10_000.0,
|
|
net_exposure=10_000.0,
|
|
reserved_risk_budget=1_000.0,
|
|
unrealized_pnl=500.0,
|
|
realized_pnl=200.0,
|
|
open_positions=["p1"],
|
|
daily_new_risk_used=500.0,
|
|
peak_equity=100_500.0,
|
|
current_drawdown_pct=0.5,
|
|
)
|
|
assert s.equity == 100_000.0
|
|
assert len(s.open_positions) == 1
|
|
|
|
|
|
class TestMetricsBundle:
|
|
def test_defaults(self):
|
|
m = MetricsBundle()
|
|
assert m.trade_count == 0
|
|
assert m.win_rate is None
|
|
assert m.score_bucket_hit_rate == {}
|
|
|
|
def test_with_values(self):
|
|
m = MetricsBundle(trade_count=10, win_rate=0.6, total_return_pct=15.0)
|
|
assert m.trade_count == 10
|
|
assert m.win_rate == 0.6
|
|
|
|
|
|
class TestConfigModels:
|
|
def test_universe_config_defaults(self):
|
|
u = UniverseConfig()
|
|
assert u.min_price == 5.0
|
|
assert u.exclude_asset_types == []
|
|
|
|
def test_risk_config(self):
|
|
r = RiskConfig(
|
|
per_trade_risk_pct=0.01,
|
|
max_daily_new_risk_pct=0.03,
|
|
max_positions=10,
|
|
max_positions_per_sector=3,
|
|
)
|
|
assert r.per_trade_risk_pct == 0.01
|
|
|
|
def test_backtest_config(self):
|
|
cfg = BacktestConfig(strategy_name="test", dataset_snapshot_id="snap_001")
|
|
assert cfg.strategy_name == "test"
|
|
assert isinstance(cfg.risk, RiskConfig)
|
|
assert isinstance(cfg.execution, ExecutionConfig)
|
|
|
|
def test_execution_config_target_model(self):
|
|
e = ExecutionConfig(target_model="atr_multiple", target_atr_multiplier=2.0)
|
|
assert e.target_model == "atr_multiple"
|
|
assert e.target_atr_multiplier == 2.0
|
|
|
|
def test_execution_config_defaults(self):
|
|
e = ExecutionConfig()
|
|
assert e.target_model == "fixed_r"
|
|
assert e.target_atr_multiplier == 1.5
|
|
assert e.target_1_fraction is None
|
|
|
|
def test_experiment_manifest(self):
|
|
m = ExperimentManifest(
|
|
experiment_name="test_exp",
|
|
dataset_snapshot_id="snap_001",
|
|
base_config="configs/backtest/defaults.json",
|
|
overrides={},
|
|
)
|
|
assert m.experiment_name == "test_exp"
|
|
assert m.splits == []
|
|
|
|
def test_form4_v1_presets_are_frozen_to_v1_cache(self):
|
|
assert FORM4_CAPTURE_SLEEVE_PRESETS["reserve_form4_cluster"]["pit_events_path"] == FORM4_PIT_EVENTS_V1_PATH
|
|
assert FORM4_CAPTURE_SLEEVE_PRESETS["reserve_form4_cluster_plus"]["pit_events_path"] == FORM4_PIT_EVENTS_V1_PATH
|
|
assert FORM4_CAPTURE_SLEEVE_PRESETS["reserve_form4_cluster_plus_fresh"]["pit_events_path"] == FORM4_PIT_EVENTS_V1_PATH
|
|
assert FORM4_CAPTURE_SLEEVE_PRESETS["reserve_form4_cluster_plus_fresh_same_day"]["pit_events_path"] == FORM4_PIT_EVENTS_V1_PATH
|
|
|
|
def test_form4_v3_presets_use_separate_cache(self):
|
|
assert (
|
|
FORM4_CAPTURE_SLEEVE_PRESETS[
|
|
"reserve_form4_cluster_plus_fresh_same_day_v3_aggressive_plus_cooldown180_high"
|
|
]["pit_events_path"]
|
|
== FORM4_PIT_EVENTS_V3_PATH
|
|
)
|
|
assert (
|
|
FORM4_CAPTURE_SLEEVE_PRESETS[
|
|
"reserve_form4_cluster_plus_fresh_same_day_v3_aggressive_plus_cooldown180_ultra"
|
|
]["pit_events_path"]
|
|
== FORM4_PIT_EVENTS_V3_PATH
|
|
)
|
|
|
|
def test_form4_v3_aggressive_plus_cooldown180_high_preset_shape(self):
|
|
preset = FORM4_CAPTURE_SLEEVE_PRESETS[
|
|
"reserve_form4_cluster_plus_fresh_same_day_v3_aggressive_plus_cooldown180_high"
|
|
]
|
|
assert preset["reserve_pct"] == 0.46
|
|
assert preset["min_purchase_pct"] == 0.005
|
|
assert preset["symbol_cooldown_days_after_loss"] == 180
|
|
assert preset["max_transaction_span_days"] == 0
|
|
|
|
def test_form4_v3_aggressive_plus_cooldown180_ultra_preset_shape(self):
|
|
preset = FORM4_CAPTURE_SLEEVE_PRESETS[
|
|
"reserve_form4_cluster_plus_fresh_same_day_v3_aggressive_plus_cooldown180_ultra"
|
|
]
|
|
assert preset["reserve_pct"] == 0.50
|
|
assert preset["min_purchase_pct"] == 0.005
|
|
assert preset["symbol_cooldown_days_after_loss"] == 180
|
|
assert preset["max_transaction_span_days"] == 0
|
|
|
|
def test_form4_capture_config_supports_symbol_cooldown_fields(self):
|
|
config = Form4CaptureConfig.model_validate(
|
|
{
|
|
"enabled": True,
|
|
"symbol_cooldown_days_after_loss": 45,
|
|
"symbol_max_entries_in_lookback": 3,
|
|
"symbol_entry_lookback_days": 540,
|
|
"max_total_value": 125_000_000.0,
|
|
}
|
|
)
|
|
|
|
assert config.symbol_cooldown_days_after_loss == 45
|
|
assert config.symbol_max_entries_in_lookback == 3
|
|
assert config.symbol_entry_lookback_days == 540
|
|
assert config.max_total_value == 125_000_000.0
|
|
|
|
def test_idle_alpha_cash_convex_preset_shape(self):
|
|
preset = IDLE_ALPHA_SLEEVE_PRESETS["micro_event_alpha_plus_event_plus_cash_convex"]
|
|
assert preset["idle_alpha"]["dynamic_allocator_cash_scale_low"] == 0.78
|
|
assert preset["idle_alpha"]["dynamic_allocator_cash_scale_high"] == 1.07
|
|
assert preset["idle_alpha"]["dynamic_allocator_synthetic_scale_multiplier"] == 1.05
|
|
assert preset["idle_alpha"]["dynamic_allocator_max_scale"] == 1.07
|
|
|
|
def test_idle_alpha_cash_convex_microcap8_guarded_preset_shape(self):
|
|
preset = IDLE_ALPHA_SLEEVE_PRESETS[
|
|
"micro_event_alpha_plus_event_plus_cash_convex_microcap8_guarded"
|
|
]
|
|
guidance_engine = next(
|
|
engine for engine in preset["strategy_engines"]
|
|
if engine["engine_id"] == "next_open_long_guidance_mixed_micro_postmarket"
|
|
)
|
|
assert guidance_engine["max_market_cap_proxy"] == 8_000_000_000.0
|
|
assert preset["idle_alpha"]["dynamic_allocator_cash_scale_low"] == 0.78
|
|
assert preset["idle_alpha"]["dynamic_allocator_cash_scale_high"] == 1.07
|
|
|
|
def test_idle_alpha_strict_breadth_experimental_preset_shape(self):
|
|
preset = IDLE_ALPHA_SLEEVE_PRESETS[
|
|
"micro_event_alpha_plus_event_plus_strict_breadth_cash_experimental"
|
|
]
|
|
breadth = next(
|
|
engine for engine in preset["strategy_engines"]
|
|
if engine["engine_id"] == "idle_macro_breadth_smh_postalloc"
|
|
)
|
|
assert breadth["max_holding_days"] == 1
|
|
assert breadth["engine_risk_budget_pct"] == 0.018
|
|
assert breadth["per_trade_risk_pct_override"] == 0.0028
|
|
assert breadth["macro_long_reaction_day_return_min"] == 0.021
|
|
assert breadth["macro_long_breadth_reaction_day_return_min"] == 0.0115
|
|
assert breadth["macro_long_close_location_min"] == 0.67
|
|
assert breadth["macro_long_breadth_close_location_min"] == 0.615
|
|
|
|
|
|
class TestEventTypeProfile:
|
|
def test_defaults(self):
|
|
p = EventTypeProfile()
|
|
assert p.enabled is True
|
|
assert p.score_threshold_override is None
|
|
assert p.direction_filter == "any"
|
|
|
|
def test_disabled(self):
|
|
p = EventTypeProfile(enabled=False)
|
|
assert p.enabled is False
|
|
|
|
def test_overrides(self):
|
|
p = EventTypeProfile(
|
|
max_holding_days_override=15,
|
|
stop_atr_multiplier_override=2.5,
|
|
target_atr_multiplier_override=1.0,
|
|
)
|
|
assert p.max_holding_days_override == 15
|
|
|
|
def test_backtest_config_with_profiles(self):
|
|
cfg = BacktestConfig(
|
|
strategy_name="test",
|
|
dataset_snapshot_id="snap_001",
|
|
event_type_profiles={
|
|
"earnings_release": EventTypeProfile(max_holding_days_override=15),
|
|
"management_change": EventTypeProfile(enabled=False),
|
|
},
|
|
)
|
|
p = cfg.get_event_profile("earnings_release")
|
|
assert p is not None
|
|
assert p.max_holding_days_override == 15
|
|
assert cfg.get_event_profile("unknown") is None
|
|
|
|
def test_backtest_config_default_empty_profiles(self):
|
|
cfg = BacktestConfig(strategy_name="test", dataset_snapshot_id="snap_001")
|
|
assert cfg.event_type_profiles == {}
|
|
assert cfg.get_event_profile("anything") is None
|
|
|
|
def test_backtest_config_strategy_engines_helpers(self):
|
|
cfg = BacktestConfig(
|
|
strategy_name="test",
|
|
dataset_snapshot_id="snap_001",
|
|
strategy_engines=[
|
|
StrategyEngineConfig(
|
|
engine_id="active_engine",
|
|
event_types=["earnings_release"],
|
|
timing_class="same_day",
|
|
direction="long_only",
|
|
),
|
|
StrategyEngineConfig(
|
|
engine_id="shadow_engine",
|
|
event_types=["earnings_release"],
|
|
timing_class="after_close",
|
|
direction="short_only",
|
|
shadow_only=True,
|
|
),
|
|
],
|
|
)
|
|
assert [engine.engine_id for engine in cfg.get_strategy_engines()] == [
|
|
"active_engine",
|
|
"shadow_engine",
|
|
]
|
|
assert [engine.engine_id for engine in cfg.get_active_strategy_engines()] == [
|
|
"active_engine",
|
|
]
|
|
assert [engine.engine_id for engine in cfg.get_shadow_strategy_engines()] == [
|
|
"shadow_engine",
|
|
]
|
|
|
|
def test_backtest_config_resolves_strategy_engine_inheritance(self):
|
|
cfg = BacktestConfig(
|
|
strategy_name="test",
|
|
dataset_snapshot_id="snap_001",
|
|
strategy_engines=[
|
|
StrategyEngineConfig(
|
|
engine_id="parent_core",
|
|
event_types=["guidance_update"],
|
|
filing_time_buckets=["post_market"],
|
|
close_location_min=0.8,
|
|
entry_timing_policy="next_open",
|
|
),
|
|
StrategyEngineConfig(
|
|
engine_id="child_core",
|
|
inherits_from_engine_id="parent_core",
|
|
close_location_min=0.9,
|
|
),
|
|
],
|
|
)
|
|
|
|
engines = {engine.engine_id: engine for engine in cfg.get_strategy_engines()}
|
|
child = engines["child_core"]
|
|
assert child.event_types == ["guidance_update"]
|
|
assert child.filing_time_buckets == ["post_market"]
|
|
assert child.close_location_min == pytest.approx(0.9)
|
|
assert child.entry_timing_policy == "next_open"
|
|
|
|
def test_backtest_config_strategy_engines_respect_selection_priority(self):
|
|
cfg = BacktestConfig(
|
|
strategy_name="test",
|
|
dataset_snapshot_id="snap_001",
|
|
strategy_engines=[
|
|
StrategyEngineConfig(
|
|
engine_id="base_engine",
|
|
event_types=["earnings_release"],
|
|
timing_class="after_close",
|
|
direction="long_only",
|
|
),
|
|
StrategyEngineConfig(
|
|
engine_id="proxy_engine",
|
|
event_types=["earnings_release"],
|
|
timing_class="after_close",
|
|
direction="long_only",
|
|
selection_priority=100,
|
|
),
|
|
StrategyEngineConfig(
|
|
engine_id="shadow_engine",
|
|
event_types=["earnings_release"],
|
|
timing_class="after_close",
|
|
direction="long_only",
|
|
shadow_only=True,
|
|
selection_priority=50,
|
|
),
|
|
],
|
|
)
|
|
|
|
assert [engine.engine_id for engine in cfg.get_strategy_engines()] == [
|
|
"proxy_engine",
|
|
"shadow_engine",
|
|
"base_engine",
|
|
]
|
|
assert [engine.engine_id for engine in cfg.get_active_strategy_engines()] == [
|
|
"proxy_engine",
|
|
"base_engine",
|
|
]
|
|
assert [engine.engine_id for engine in cfg.get_shadow_strategy_engines()] == [
|
|
"shadow_engine",
|
|
]
|
|
|
|
def test_strategy_engine_config_supports_engine_specific_thresholds(self):
|
|
engine = StrategyEngineConfig(
|
|
engine_id="after_close_long_quality",
|
|
score_threshold_override=0.75,
|
|
pead_reaction_threshold_override=0.12,
|
|
pead_volume_threshold_override=3.0,
|
|
reaction_day_return_min=-0.45,
|
|
reaction_day_return_max=0.35,
|
|
gap_size_min=0.05,
|
|
gap_size_max=0.30,
|
|
)
|
|
assert engine.score_threshold_override == 0.75
|
|
assert engine.pead_reaction_threshold_override == 0.12
|
|
assert engine.pead_volume_threshold_override == 3.0
|
|
assert engine.reaction_day_return_min == -0.45
|
|
assert engine.reaction_day_return_max == 0.35
|
|
assert engine.gap_size_min == 0.05
|
|
assert engine.gap_size_max == 0.30
|
|
|
|
|
|
class TestMetricsBundleBootstrap:
|
|
def test_bootstrap_cis_field(self):
|
|
m = MetricsBundle(bootstrap_cis={"win_rate_ci_95": (0.2, 0.6)})
|
|
assert m.bootstrap_cis["win_rate_ci_95"] == (0.2, 0.6)
|
|
|
|
def test_bootstrap_cis_default_empty(self):
|
|
m = MetricsBundle()
|
|
assert m.bootstrap_cis == {}
|