Learning Hub Strategy Development Lesson 22
Phase 3 — Lesson 5 of 8
Hub
Phase 3 · Strategy Development · Lesson 22

Performance Metrics
Sharpe, Drawdown & Calmar

Build a complete PerformanceReport class covering Sharpe ratio, Sortino, max drawdown, CAGR, and Calmar — the metrics every serious trader tracks.

~45 min
8 Metrics
Phase 3 · Lesson 5 of 8
Phase 3 Progress63%

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

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(),
        }

What Good Numbers Look Like

MetricPoorAcceptableGoodExcellent
Sharpe< 0.50.5 – 1.01.0 – 2.0> 2.0
Max Drawdown> 30%20 – 30%10 – 20%< 10%
Win Rate< 40%40 – 50%50 – 60%> 60%
Profit Factor< 1.01.0 – 1.51.5 – 2.0> 2.0
Calmar< 0.50.5 – 1.01.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.