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_yearto 365 and watch every annualised figure move. Decide which conventions you will use and put them inconfig.pybefore 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.