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.
275 lines
8.8 KiB
Python
275 lines
8.8 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) -> DailyPortfolioState:
|
|
return DailyPortfolioState(
|
|
date=date,
|
|
equity=equity,
|
|
cash_available=equity,
|
|
gross_exposure=0.0,
|
|
net_exposure=0.0,
|
|
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 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)
|
|
|
|
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
|