"""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