"""Compute standard backtest metrics from a :class:`Result` (doc 02 ยง2). A separate :func:`compute_metrics` turns the engine's trade list + equity curve into the numbers you optimize on: Net Profit, Profit Factor, Win Rate, max Balance/Equity Drawdown, Sharpe, APR, trade count. """ from __future__ import annotations from dataclasses import dataclass, field from typing import Any import numpy as np import pandas as pd from .engine import Result @dataclass class Metrics: """Standard backtest metrics. Use ``equity_dd_max`` (Equity Drawdown Maximal, peak-to-trough) โ€” not Absolute โ€” when judging risk; the peak-to-trough is what matters. """ net_profit: float = 0.0 gross_profit: float = 0.0 gross_loss: float = 0.0 profit_factor: float = 0.0 # gross_profit / |gross_loss| (inf-safe) win_rate: float = 0.0 # wins / total_trades total_trades: int = 0 wins: int = 0 losses: int = 0 avg_win: float = 0.0 avg_loss: float = 0.0 expectancy: float = 0.0 # avg pnl per trade max_balance_dd: float = 0.0 # in account currency max_equity_dd: float = 0.0 # in account currency (the one to watch) max_balance_dd_pct: float = 0.0 max_equity_dd_pct: float = 0.0 sharpe: float = 0.0 # annualized, 0 if undefined apr: float = 0.0 # annualized percent return # Free-form extras for strategy-specific diagnostics. extras: dict[str, Any] = field(default_factory=dict) def compute_metrics(result: Result, *, periods_per_year: int = 252) -> Metrics: """Compute :class:`Metrics` from a :class:`Result`. Parameters ---------- result Engine output (trade list + equity curve). periods_per_year Annualization factor for Sharpe (default 252 trading days). Adjust to match your equity-curve sampling (e.g. 252 for daily, 252*24 for hourly sampling). """ trades = result.trades m = Metrics() m.total_trades = len(trades) if m.total_trades == 0: # No trades โ€” equity curve is just the flat deposit. _compute_drawdowns(result, m) return m pnls = np.array([t.pnl for t in trades], dtype=float) wins = pnls[pnls > 0] losses = pnls[pnls < 0] m.net_profit = float(pnls.sum()) m.gross_profit = float(wins.sum()) if wins.size else 0.0 m.gross_loss = float(losses.sum()) if losses.size else 0.0 m.wins = int(wins.size) m.losses = int(losses.size) m.win_rate = m.wins / m.total_trades m.avg_win = float(wins.mean()) if wins.size else 0.0 m.avg_loss = float(losses.mean()) if losses.size else 0.0 m.expectancy = float(pnls.mean()) m.profit_factor = ( m.gross_profit / abs(m.gross_loss) if m.gross_loss != 0.0 else float("inf") ) _compute_drawdowns(result, m) _compute_risk_ratios(result, m, periods_per_year) return m def _compute_drawdowns(result: Result, m: Metrics) -> None: """Peak-to-trough drawdowns from the equity curve.""" ec = result.equity_curve if ec is None or ec.empty or "equity" not in ec.columns: return equity = ec["equity"].to_numpy(dtype=float) if equity.size == 0: return running_max = np.maximum.accumulate(equity) dd = running_max - equity m.max_equity_dd = float(dd.max()) m.max_equity_dd_pct = ( float(dd.max() / running_max.max()) if running_max.max() > 0 else 0.0 ) if "balance" in ec.columns: balance = ec["balance"].to_numpy(dtype=float) if balance.size: rb = np.maximum.accumulate(balance) ddb = rb - balance m.max_balance_dd = float(ddb.max()) m.max_balance_dd_pct = ( float(ddb.max() / rb.max()) if rb.max() > 0 else 0.0 ) def _compute_risk_ratios(result: Result, m: Metrics, periods_per_year: int) -> None: """Annualized Sharpe and APR from the equity curve.""" ec = result.equity_curve if ec is None or ec.empty or "equity" not in ec.columns: return equity = ec["equity"].to_numpy(dtype=float) if equity.size < 2 or result.initial_deposit <= 0: return returns = np.diff(equity) / equity[:-1] returns = returns[np.isfinite(returns)] if returns.size > 1 and returns.std() > 0: m.sharpe = float(returns.mean() / returns.std() * np.sqrt(periods_per_year)) final = equity[-1] total_return = (final / result.initial_deposit) - 1.0 # APR from total return assuming ``periods_per_year`` samples per year. n_years = max(equity.size / periods_per_year, 1e-9) m.apr = float(((1.0 + total_return) ** (1.0 / n_years) - 1.0) * 100.0)