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.
345 lines
10 KiB
Python
345 lines
10 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,
|
|
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 == []
|
|
|
|
|
|
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_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 == {}
|