""" Performance metrics computed from a completed backtest. """ from __future__ import annotations from dataclasses import dataclass from typing import Sequence import numpy as np from .backtest import Trade @dataclass class Metrics: n_trades: int n_wins: int n_losses: int win_rate: float # 0-1 avg_win_r: float # average win in R avg_loss_r: float # average loss in R (negative) profit_factor: float expectancy_r: float # expected R per trade total_pips: float total_return: float # fractional (e.g. 0.12 = 12%) max_drawdown: float # fractional (negative) sharpe: float # annualised daily Sharpe avg_duration: float # hours n_longs: int n_shorts: int final_balance: float def __str__(self) -> str: lines = [ f" Trades : {self.n_trades} ({self.n_longs}L / {self.n_shorts}S)", f" Win rate : {self.win_rate*100:.1f}%", f" Avg win (R) : {self.avg_win_r:+.2f}R", f" Avg loss (R) : {self.avg_loss_r:+.2f}R", f" Expectancy : {self.expectancy_r:+.3f}R / trade", f" Profit factor : {self.profit_factor:.2f}", f" Total pips : {self.total_pips:+.1f}", f" Total return : {self.total_return*100:+.2f}%", f" Final balance : ${self.final_balance:,.2f}", f" Max drawdown : {self.max_drawdown*100:.2f}%", f" Sharpe : {self.sharpe:.2f}", f" Avg duration : {self.avg_duration:.1f} h", ] return "\n".join(lines) def score(self) -> float: """Composite optimisation score (higher = better).""" if self.n_trades < 20: return -999.0 return self.sharpe * (1 + self.expectancy_r) * (1 + self.total_return) def compute( trades: list[Trade], equity: np.ndarray, initial_balance: float = 10_000.0, ) -> Metrics: closed = [t for t in trades if t.closed] if not closed: return Metrics(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, initial_balance) pips = np.array([t.pnl_pips for t in closed]) r_vals = np.array([t.pnl_r for t in closed]) wins = r_vals[r_vals > 0] losses = r_vals[r_vals <= 0] gross_profit = wins.sum() if len(wins) else 0.0 gross_loss = abs(losses.sum()) if len(losses) else 0.0 pf = gross_profit / gross_loss if gross_loss else float("inf") # equity metrics peak = np.maximum.accumulate(equity) dd = (equity - peak) / peak max_dd = dd.min() # annualised Sharpe via daily returns by_day: dict[str, float] = {} for t in closed: day = t.exit_time.date().isoformat() # type: ignore[union-attr] by_day[day] = by_day.get(day, 0) + t.pnl_r * t.risk_pct daily = np.array(list(by_day.values())) sharpe = (daily.mean() / daily.std() * np.sqrt(252)) if (len(daily) > 1 and daily.std() > 0) else 0.0 durations = [t.duration_hours for t in closed] return Metrics( n_trades = len(closed), n_wins = int((r_vals > 0).sum()), n_losses = int((r_vals <= 0).sum()), win_rate = float((r_vals > 0).mean()), avg_win_r = float(wins.mean()) if len(wins) else 0.0, avg_loss_r = float(losses.mean()) if len(losses) else 0.0, profit_factor = pf, expectancy_r = float(r_vals.mean()), total_pips = float(pips.sum()), total_return = float(equity[-1] / initial_balance - 1), max_drawdown = float(max_dd), sharpe = float(sharpe), avg_duration = float(np.mean(durations)), n_longs = sum(1 for t in closed if t.direction == "long"), n_shorts = sum(1 for t in closed if t.direction == "short"), final_balance = float(equity[-1]), )