"""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, FilledTrade, MetricsBundle, OpenPosition, PlannedOrder, PositionStatus, ReportingConfig, RiskConfig, SignalConfig, 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 == [] 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 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 == {}