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

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