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