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.

317 lines
11 KiB
Python

"""Unit tests for libs/backtest/metrics.py."""
from __future__ import annotations
import datetime as dt
import pytest
from libs.backtest.domain import DailyPortfolioState, ExitReason, FilledTrade
def _make_trade(
net_pnl: float,
exit_reason: ExitReason = ExitReason.TARGET,
r_multiple: float = 1.0,
holding_days: int = 5,
exit_date: dt.date = dt.date(2026, 1, 10),
symbol: str = "AAPL",
) -> FilledTrade:
entry_price = 100.0
return FilledTrade(
trade_id=f"t_{symbol}_{exit_date}_{net_pnl}",
position_id="p1",
event_id="EVT::TEST",
symbol=symbol,
entry_date=dt.date(2026, 1, 5),
exit_date=exit_date,
entry_price=entry_price,
exit_price=entry_price + (net_pnl / max(1, 10)),
exit_reason=exit_reason,
shares=10,
commission=0.1,
slippage_bps=10.0,
gross_pnl=net_pnl + 0.1,
net_pnl=net_pnl,
pnl_pct=net_pnl / (entry_price * 10),
r_multiple=r_multiple,
holding_days=holding_days,
)
def _make_equity_state(
date: dt.date,
equity: float,
n_positions: int = 0,
gross_exposure: float = 0.0,
net_exposure: float = 0.0,
) -> DailyPortfolioState:
return DailyPortfolioState(
date=date,
equity=equity,
cash_available=equity,
gross_exposure=gross_exposure,
net_exposure=net_exposure,
reserved_risk_budget=0.0,
unrealized_pnl=0.0,
realized_pnl=0.0,
open_positions=[f"p{i}" for i in range(n_positions)],
daily_new_risk_used=0.0,
peak_equity=equity,
current_drawdown_pct=0.0,
)
class TestWinRate:
def test_all_wins(self):
from libs.backtest.metrics import compute_win_rate
trades = [_make_trade(100), _make_trade(200)]
assert compute_win_rate(trades) == 1.0
def test_mixed(self):
from libs.backtest.metrics import compute_win_rate
trades = [_make_trade(100), _make_trade(-50)]
assert compute_win_rate(trades) == pytest.approx(0.5)
def test_empty_returns_none(self):
from libs.backtest.metrics import compute_win_rate
assert compute_win_rate([]) is None
class TestProfitFactor:
def test_basic(self):
from libs.backtest.metrics import compute_profit_factor
trades = [_make_trade(200), _make_trade(100), _make_trade(-100)]
pf = compute_profit_factor(trades)
assert pf == pytest.approx(3.0)
def test_no_losses_returns_none(self):
from libs.backtest.metrics import compute_profit_factor
trades = [_make_trade(100), _make_trade(200)]
assert compute_profit_factor(trades) is None
def test_empty_returns_none(self):
from libs.backtest.metrics import compute_profit_factor
assert compute_profit_factor([]) is None
class TestExpectancyR:
def test_positive(self):
from libs.backtest.metrics import compute_expectancy_r
trades = [_make_trade(100, r_multiple=2.0), _make_trade(-50, r_multiple=-1.0)]
er = compute_expectancy_r(trades)
assert er == pytest.approx(0.5)
def test_empty_returns_none(self):
from libs.backtest.metrics import compute_expectancy_r
assert compute_expectancy_r([]) is None
class TestTotalReturnPct:
def test_basic(self):
from libs.backtest.metrics import compute_total_return_pct
curve = [
_make_equity_state(dt.date(2026, 1, 5), 100_000),
_make_equity_state(dt.date(2026, 1, 6), 110_000),
]
assert compute_total_return_pct(curve) == pytest.approx(10.0)
def test_single_point_returns_none(self):
from libs.backtest.metrics import compute_total_return_pct
assert compute_total_return_pct([_make_equity_state(dt.date(2026, 1, 5), 100_000)]) is None
class TestMaxDrawdown:
def test_basic_drawdown(self):
from libs.backtest.metrics import compute_max_drawdown_pct
curve = [
_make_equity_state(dt.date(2026, 1, 5), 100_000),
_make_equity_state(dt.date(2026, 1, 6), 120_000), # peak
_make_equity_state(dt.date(2026, 1, 7), 90_000), # drawdown from 120k
_make_equity_state(dt.date(2026, 1, 8), 100_000),
]
dd = compute_max_drawdown_pct(curve)
# Max drawdown = (120k - 90k) / 120k = 25%
assert dd == pytest.approx(25.0)
def test_no_drawdown(self):
from libs.backtest.metrics import compute_max_drawdown_pct
curve = [
_make_equity_state(dt.date(2026, 1, 5), 100_000),
_make_equity_state(dt.date(2026, 1, 6), 110_000),
]
assert compute_max_drawdown_pct(curve) == pytest.approx(0.0)
class TestSharpeRatio:
def test_positive_sharpe(self):
from libs.backtest.metrics import compute_sharpe_ratio
# Steady returns → positive Sharpe
curve = [
_make_equity_state(dt.date(2026, 1, 2) + dt.timedelta(days=i), 100_000 + i * 100)
for i in range(50)
]
sharpe = compute_sharpe_ratio(curve)
assert sharpe is not None
assert sharpe > 0
def test_not_enough_data_returns_none(self):
from libs.backtest.metrics import compute_sharpe_ratio
curve = [_make_equity_state(dt.date(2026, 1, 5), 100_000)]
assert compute_sharpe_ratio(curve) is None
class TestExposureMetrics:
def test_avg_gross_and_net_exposure_pct(self):
from libs.backtest.metrics import (
compute_avg_gross_exposure_pct,
compute_avg_net_exposure_pct,
compute_days_in_market_pct,
)
curve = [
_make_equity_state(dt.date(2026, 1, 5), 100_000, gross_exposure=0.0, net_exposure=0.0),
_make_equity_state(dt.date(2026, 1, 6), 100_000, gross_exposure=20_000.0, net_exposure=-20_000.0),
_make_equity_state(dt.date(2026, 1, 7), 100_000, gross_exposure=10_000.0, net_exposure=5_000.0),
]
assert compute_avg_gross_exposure_pct(curve) == pytest.approx(10.0)
assert compute_avg_net_exposure_pct(curve) == pytest.approx(-5.0)
assert compute_days_in_market_pct(curve) == pytest.approx(66.6666666667)
class TestStopExitRate:
def test_basic(self):
from libs.backtest.metrics import compute_stop_exit_rate
trades = [
_make_trade(100, ExitReason.TARGET),
_make_trade(-50, ExitReason.STOP),
_make_trade(-30, ExitReason.STOP),
]
assert compute_stop_exit_rate(trades) == pytest.approx(2 / 3)
def test_trailing_counts_as_stop(self):
from libs.backtest.metrics import compute_stop_exit_rate
trades = [
_make_trade(50, ExitReason.TRAILING),
]
assert compute_stop_exit_rate(trades) == pytest.approx(1.0)
class TestMonthlyWinRate:
def test_basic(self):
from libs.backtest.metrics import compute_monthly_win_rate
trades = [
_make_trade(100, exit_date=dt.date(2026, 1, 15)),
_make_trade(200, exit_date=dt.date(2026, 1, 20)),
_make_trade(-50, exit_date=dt.date(2026, 2, 10)),
]
# Jan: +300 = win, Feb: -50 = loss → 1/2 = 0.5
mwr = compute_monthly_win_rate(trades)
assert mwr == pytest.approx(0.5)
class TestBuildMetricsBundle:
def test_builds_with_trades_and_curve(self):
from libs.backtest.metrics import build_metrics_bundle
trades = [
_make_trade(100, ExitReason.TARGET, r_multiple=2.0),
_make_trade(-50, ExitReason.STOP, r_multiple=-1.0),
]
curve = [
_make_equity_state(dt.date(2026, 1, 5), 100_000),
_make_equity_state(dt.date(2026, 1, 10), 105_000),
]
m = build_metrics_bundle(trades, curve)
assert m.trade_count == 2
assert m.win_rate == pytest.approx(0.5)
assert m.total_return_pct == pytest.approx(5.0)
assert m.avg_gross_exposure_pct == pytest.approx(0.0)
assert m.avg_net_exposure_pct == pytest.approx(0.0)
assert m.days_in_market_pct == pytest.approx(0.0)
def test_builds_exposure_metrics(self):
from libs.backtest.metrics import build_metrics_bundle
curve = [
_make_equity_state(dt.date(2026, 1, 5), 100_000, gross_exposure=0.0, net_exposure=0.0),
_make_equity_state(dt.date(2026, 1, 6), 100_000, gross_exposure=30_000.0, net_exposure=-10_000.0),
_make_equity_state(dt.date(2026, 1, 7), 100_000, gross_exposure=10_000.0, net_exposure=10_000.0),
]
m = build_metrics_bundle([], curve)
assert m.avg_gross_exposure_pct == pytest.approx(13.3333333333)
assert m.avg_net_exposure_pct == pytest.approx(0.0)
assert m.days_in_market_pct == pytest.approx(66.6666666667)
def test_empty_trades(self):
from libs.backtest.metrics import build_metrics_bundle
m = build_metrics_bundle([], [])
assert m.trade_count == 0
assert m.win_rate is None
def test_bootstrap_cis_included(self):
from libs.backtest.metrics import build_metrics_bundle
# Need ≥5 trades for bootstrap
trades = [
_make_trade(100, ExitReason.TARGET, r_multiple=2.0, exit_date=dt.date(2026, 1, 10 + i))
for i in range(3)
] + [
_make_trade(-50, ExitReason.STOP, r_multiple=-1.0, exit_date=dt.date(2026, 1, 20 + i))
for i in range(3)
]
curve = [
_make_equity_state(dt.date(2026, 1, 5), 100_000),
_make_equity_state(dt.date(2026, 1, 30), 105_000),
]
m = build_metrics_bundle(trades, curve)
assert "win_rate_ci_95" in m.bootstrap_cis
ci = m.bootstrap_cis["win_rate_ci_95"]
assert ci is not None
assert ci[0] <= ci[1] # lower ≤ upper
class TestBootstrapCI:
def test_basic_ci(self):
from libs.backtest.metrics import bootstrap_ci, compute_win_rate
trades = [_make_trade(100)] * 4 + [_make_trade(-50)] * 4
ci = bootstrap_ci(trades, compute_win_rate, n_iterations=500, seed=42)
assert ci is not None
lo, hi = ci
assert 0.0 <= lo <= hi <= 1.0
def test_too_few_trades(self):
from libs.backtest.metrics import bootstrap_ci, compute_win_rate
trades = [_make_trade(100)] * 3
ci = bootstrap_ci(trades, compute_win_rate)
assert ci is None
def test_deterministic_with_seed(self):
from libs.backtest.metrics import bootstrap_ci, compute_win_rate
trades = [_make_trade(100)] * 5 + [_make_trade(-50)] * 5
ci1 = bootstrap_ci(trades, compute_win_rate, seed=42)
ci2 = bootstrap_ci(trades, compute_win_rate, seed=42)
assert ci1 == ci2