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.

228 lines
7.1 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