Files
2026-06-26 20:50:07 +08:00

132 lines
4.6 KiB
Python

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