Key Metrics
The Metrics Dashboard
A good strategy report contains these six numbers. If any one of them is missing, you're flying blind.
Total Return
+34.2%
Net return on initial capital
Sharpe Ratio
1.87
Return per unit of risk
Max Drawdown
−8.4%
Worst peak-to-trough loss
Win Rate
57%
% of trades profitable
Profit Factor
1.82
Gross profit / gross loss
Calmar Ratio
4.07
CAGR / max drawdown
Implementation
PerformanceReport Class
PYTHONperformance.py
import pandas as pd import numpy as np class PerformanceReport: def __init__( self, equity : pd.Series, # equity curve indexed by datetime trades : pd.DataFrame, # trade log with net_pnl_rs column periods_per_year: int = 252 * 375 # 1-min candles (252 days × 375 min) ): self.eq = equity.dropna() self.tr = trades self.ppy = periods_per_year # ── Returns ─────────────────────────────────────────────── def total_return(self) -> float: return (self.eq.iloc[-1] / self.eq.iloc[0] - 1) * 100 def cagr(self) -> float: n_years = len(self.eq) / self.ppy return ((self.eq.iloc[-1] / self.eq.iloc[0]) ** (1/n_years) - 1) * 100 # ── Volatility / Sharpe ─────────────────────────────────── def sharpe(self, risk_free_annual: float = 0.065) -> float: """Annualised Sharpe ratio (risk-free = 6.5% typical INR rate).""" rets = self.eq.pct_change().dropna() rf = risk_free_annual / self.ppy excess = rets - rf if excess.std() == 0: return 0.0 return float(excess.mean() / excess.std() * np.sqrt(self.ppy)) def sortino(self, risk_free_annual: float = 0.065) -> float: """Like Sharpe but only penalises downside volatility.""" rets = self.eq.pct_change().dropna() rf = risk_free_annual / self.ppy excess = rets - rf down = excess[excess < 0] if len(down) == 0 or down.std() == 0: return float("inf") return float(excess.mean() / down.std() * np.sqrt(self.ppy)) # ── Drawdown ────────────────────────────────────────────── def drawdown_series(self) -> pd.Series: peak = self.eq.cummax() return (self.eq - peak) / peak * 100 # as % of peak def max_drawdown(self) -> float: return float(self.drawdown_series().min()) def max_drawdown_duration(self) -> int: """Max consecutive candles in drawdown.""" dd = self.drawdown_series() in_dd = (dd < 0).astype(int) groups = in_dd * (in_dd.groupby((in_dd != in_dd.shift()).cumsum()).cumcount() + 1) return int(groups.max()) # ── Calmar ──────────────────────────────────────────────── def calmar(self) -> float: mdd = abs(self.max_drawdown()) return self.cagr() / mdd if mdd > 0 else float("inf") # ── Trade stats ─────────────────────────────────────────── def trade_stats(self) -> dict: tr = self.tr if len(tr) == 0: return {} wins = tr[tr["net_pnl_rs"] > 0] losses = tr[tr["net_pnl_rs"] <= 0] return { "total_trades" : len(tr), "win_rate" : round(len(wins)/len(tr)*100, 1), "avg_win_rs" : round(wins["net_pnl_rs"].mean(), 2) if len(wins) else 0, "avg_loss_rs" : round(losses["net_pnl_rs"].mean(), 2) if len(losses) else 0, "profit_factor": round( wins["net_pnl_rs"].sum() / abs(losses["net_pnl_rs"].sum()), 2 ) if len(losses) > 0 else float("inf"), } # ── Master summary ──────────────────────────────────────── def summary(self) -> dict: return { "total_return_pct" : round(self.total_return(), 2), "cagr_pct" : round(self.cagr(), 2), "sharpe" : round(self.sharpe(), 2), "sortino" : round(self.sortino(), 2), "max_drawdown_pct" : round(self.max_drawdown(), 2), "calmar" : round(self.calmar(), 2), **self.trade_stats(), }
Benchmarks
What Good Numbers Look Like
| Metric | Poor | Acceptable | Good | Excellent |
|---|---|---|---|---|
| Sharpe | < 0.5 | 0.5 – 1.0 | 1.0 – 2.0 | > 2.0 |
| Max Drawdown | > 30% | 20 – 30% | 10 – 20% | < 10% |
| Win Rate | < 40% | 40 – 50% | 50 – 60% | > 60% |
| Profit Factor | < 1.0 | 1.0 – 1.5 | 1.5 – 2.0 | > 2.0 |
| Calmar | < 0.5 | 0.5 – 1.0 | 1.0 – 3.0 | > 3.0 |
Don't chase Sharpe alone
A strategy can have a high Sharpe but still be undiversified or overfit. Always look at max drawdown, drawdown duration, and trade count together. A strategy with only 20 trades and a Sharpe of 3.0 is statistically meaningless.
