Skip to content
GetProfitable
Search

CAGR, Sharpe and max drawdown

Lesson 16 · about 12 min

A final equity number tells you almost nothing on its own. Over how long? With how much variation along the way? How deep was the worst stretch? Three metrics answer those questions and, between them, describe most of what matters about an equity curve. This lesson implements them in a metrics.py module, with the conventions written down so that your numbers can be compared with anyone else's.

CAGR: return per year, compounded

The compound annual growth rate is the constant yearly return that would have turned the starting equity into the ending equity over the same span.

CAGR = (end / start)^(1 / years) − 1

import numpy as np
import pandas as pd

TRADING_DAYS = 252


def cagr(equity: pd.Series, periods_per_year: int = TRADING_DAYS) -> float:
    """Compound annual growth rate from an equity curve."""
    years = (len(equity) - 1) / periods_per_year
    if years <= 0 or equity.iloc[0] <= 0:
        return float("nan")
    return float((equity.iloc[-1] / equity.iloc[0]) ** (1.0 / years) - 1.0)

periods_per_year is 252 for daily stock and futures bars, 365 for crypto (it trades every day), about 260 for forex, and 252 × 6.5 × 60 for one-minute US equity bars during regular hours. Getting this wrong scales every annualised number by the wrong factor, so it belongs in config.py.

Using bar counts rather than calendar dates keeps the function independent of the index, which is convenient for synthetic data. For real data you can use (equity.index[-1] − equity.index[0]).days / 365.25 instead; the two agree to within a few percent.

Sharpe: return per unit of volatility

The Sharpe ratio is the mean excess return divided by the standard deviation of returns, annualised.

Sharpe = mean(r − rf) / std(r) × √(periods per year)

def sharpe(returns: pd.Series, periods_per_year: int = TRADING_DAYS, rf_annual: float = 0.0) -> float:
    """Annualised Sharpe ratio from per-period simple returns."""
    r = returns.dropna()
    if len(r) < 2:
        return float("nan")
    rf_period = (1 + rf_annual) ** (1 / periods_per_year) - 1
    excess = r - rf_period
    sd = excess.std(ddof=1)
    if not sd > 1e-12:                   # constant returns: std is zero or float noise; Sharpe is undefined
        return float("nan")
    return float(excess.mean() / sd * np.sqrt(periods_per_year))

Conventions to know:

  • Risk-free rate. Many backtests use zero. Include it if you hold a cash-like position and want to compare with a money-market alternative; write down which you did.
  • Annualisation by √N assumes returns are independent across periods, which is not quite true, and it grows less accurate for higher-frequency data. It is the universal convention, so use it, but do not read the third decimal.
  • Simple vs log returns. Sharpe is conventionally computed on simple returns. The difference is small for daily data.
  • Zero-return bars. A long-only strategy that is flat half the time has many zero-return days. They lower the standard deviation and the mean; whether to include them depends on whether you want the Sharpe of the strategy (include) or of the trades (exclude). Include, and say so.

A daily-bar Sharpe above 1 over several years is good for a single simple strategy. Above 2 on daily bars, with costs, from a rule you found in an afternoon, should be treated as a bug until proven otherwise.

Max drawdown and how long it lasted

def drawdown(equity: pd.Series) -> pd.Series:
    return equity / equity.cummax() - 1.0


def max_drawdown(equity: pd.Series) -> float:
    return float(drawdown(equity).min())


def max_drawdown_duration(equity: pd.Series) -> int:
    """Longest run of bars spent below a previous peak."""
    under = drawdown(equity) < 0
    run_id = (~under).cumsum()               # increments at every new high
    runs = under.groupby(run_id).sum()       # bars under water in each run
    return int(runs.max()) if len(runs) else 0

Duration is the number the return metrics hide. A strategy that lost 15% and recovered in three months and one that lost 15% and took three years are not the same strategy, and only the second would have made you quit.

(~under).cumsum() increments each time equity is at a new high, so every drawdown episode (the bars between one high and the next) gets its own id, and the longest one is the answer.

Calmar and a summary function

Calmar ratio = CAGR ÷ |max drawdown|. It rewards return earned without deep losses and is the metric most useful for comparing strategies that will be sized by drawdown tolerance.

def summary(equity: pd.Series, returns: pd.Series, periods_per_year: int = TRADING_DAYS) -> dict:
    mdd = max_drawdown(equity)
    c = cagr(equity, periods_per_year)
    return {
        "final_equity": round(float(equity.iloc[-1]), 4),
        "cagr": round(c, 4),
        "ann_vol": round(float(returns.dropna().std(ddof=1) * np.sqrt(periods_per_year)), 4),
        "sharpe": round(sharpe(returns, periods_per_year), 3),
        "max_drawdown": round(mdd, 4),
        "dd_duration_bars": max_drawdown_duration(equity),
        "calmar": round(c / abs(mdd), 3) if mdd < 0 else float("nan"),
        "pct_time_in_market": round(float((returns != 0).mean()), 3),
    }

Run it on the module 5 backtest:

from src.data import synthetic_ohlcv
from src.indicators import sma


def ma_crossover_signal(close, fast=20, slow=50):
    f, s = sma(close, fast), sma(close, slow)
    signal = (f > s).astype(float)
    signal[s.isna()] = np.nan
    return signal


bars = synthetic_ohlcv(1500, seed=42)
asset_ret = bars["close"].pct_change()
position = ma_crossover_signal(bars["close"]).shift(1).fillna(0.0)
net = (position * asset_ret).fillna(0.0) - position.diff().abs().fillna(0.0) * 5 / 10_000
equity = (1 + net).cumprod()

for k, v in summary(equity, net).items():
    print(f"{k:>20}: {v}")
bench = (1 + asset_ret.fillna(0)).cumprod()
print("benchmark sharpe:", round(sharpe(asset_ret), 3))

Key idea: Report CAGR, annualised volatility, Sharpe, max drawdown and its duration together, with the periods-per-year and risk-free conventions stated. Any one of them alone can be made to look good.

Sanity checks on the metrics themselves

Metrics code is code and can be wrong. Three tests:

flat = pd.Series(np.ones(253))
assert abs(cagr(flat)) < 1e-12 and max_drawdown(flat) == 0.0

doubling = pd.Series(np.linspace(1, 2, 253))                # 1.0 -> 2.0 over exactly one year
assert abs(cagr(doubling) - 1.0) < 1e-9

steady = pd.Series(np.full(252, 0.001))                      # constant +0.1%/day has zero std
assert np.isnan(sharpe(steady))

Try it: Compute the Sharpe of the same strategy three ways: on all bars, on in-market bars only, and on log returns. Record the three numbers. Then change periods_per_year to 365 and watch every annualised figure move. Decide which conventions you will use and put them in config.py before you compare anything to anything.

Recap

  • CAGR = (end / start)^(1/years) − 1; the periods-per-year constant belongs in config.
  • Sharpe = mean excess return ÷ std × √periods; state the risk-free rate and whether flat bars are included.
  • Max drawdown is the minimum of equity / cummax − 1; report its duration as well.
  • Calmar = CAGR ÷ |max drawdown| is the best single number for drawdown-tolerant comparison.
  • Test the metrics on flat, doubling and constant-return series.

See it drawn

Original diagrams for the ideas on this page. Illustrative, not real market data.

An equity curve and its drawdownAn account balance rising over a year, falling from a peak to a trough, then climbing back to the old peak.ACCOUNT EQUITY$20k$12k$8k024681012TIME (MONTHS)PEAK $16,000TROUGH $12,000DRAWDOWN−25%RECOVERY
Equity curve and drawdown. An account balance plotted month by month. The fall from the $16,000 peak to the $12,000 trough is a 25% drawdown, and the shaded area lasts until the balance climbs back to the old peak.
Compounding against a flat returnTwo account balances over fifteen years at the same yearly rate: one curve bends upwards as gains are left in, the other rises in a straight line.ACCOUNT VALUE$10k$20k$30k$40k051015YEARSCOMPOUNDED 10% a yearSIMPLE: 10% of the original sumboth start at $10,000 and run 15 years$41,772DIFFERENCE$16,772$25,000
Compounding against a flat return. Two accounts start at $10,000 and earn 10% a year for fifteen years. Leaving the gains in means each year earns on a larger balance, so the curve bends away from the straight line and ends $16,772 higher.
A fast and a slow moving average crossingA jagged price line with two smoother average lines through it; the fast average dips below the slow one on the left and cuts back above it in the middle, where a circle marks the crossing.pricefast averageslow averagefast crosses belowfast crosses abovethe slow averageAverages of recent closes; the fast one reacts sooner than the slow one.
Fast and slow moving averages crossing. A moving average is the average of the last few closing prices, redrawn each period. An average over fewer periods turns sooner than one over many, so the two lines cross whenever the recent pace of the market changes.