132 lines
4.6 KiB
Python
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)
|