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.

294 lines
8.5 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,
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 == {}