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.
233 lines
6.4 KiB
Python
233 lines
6.4 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,
|
|
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_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 == []
|