Measuring performance properly
A total-return number is nearly useless on its own. Learn the metrics professionals actually judge a strategy by — Sharpe, Sortino, max drawdown, profit factor — and implement every one.
"It made 66%" is not an evaluation, it is a headline. Over what period? With how much risk? How deep were the holes along the way, and would you have held through them? This lesson turns the equity curve from Lesson 1 into a proper performance report, and implements every metric in code you can drop straight into your backtester.
Return and growth: total return and CAGR
Total return is where you finished versus where you started. But a strategy that returns 100% over ten years is not the same as one that does it in two — so we annualise with the compound annual growth rate (CAGR), the constant yearly rate that would produce the same final equity.
TRADING_DAYS = class="n">252 class="c"># approximate number of trading days in a year
def total_return(equity):
return equity.iloc[-class="n">1] / equity.iloc[class="n">0] - class="n">1
def cagr(equity):
years = len(equity) / TRADING_DAYS
return (equity.iloc[-class="n">1] / equity.iloc[class="n">0]) ** (class="n">1 / years) - class="n">1Risk: volatility
Volatility is the standard deviation of returns — how much the daily P&L bounces around. We annualise a daily figure by multiplying by the square root of the number of trading days (the "square-root-of-time" rule).
def ann_volatility(returns):
return returns.std() * np.sqrt(TRADING_DAYS)The headline number: Sharpe ratio
The Sharpe ratio is return per unit of risk — how much excess return you earned for each unit of volatility you endured. It is the single most-quoted number in the industry because it lets you compare a calm strategy and a wild one on equal terms.
def sharpe(returns, risk_free=class="n">0.0):
class="c"># Convert an annual risk-free rate to a per-day figure
rf_daily = risk_free / TRADING_DAYS
excess = returns - rf_daily
if excess.std() == class="n">0:
return class="n">0.0
return np.sqrt(TRADING_DAYS) * excess.mean() / excess.std()Sortino: only punish the downside
Sharpe penalises *all* volatility, including the upside spikes you actually want. The Sortino ratio fixes this by dividing by the downside deviation — the volatility of negative returns only. A strategy with big upside jumps and small controlled losses will show a much better Sortino than Sharpe.
def sortino(returns, risk_free=class="n">0.0):
rf_daily = risk_free / TRADING_DAYS
excess = returns - rf_daily
downside = excess[excess < class="n">0]
dd = downside.std()
if dd == class="n">0 or np.isnan(dd):
return class="n">0.0
return np.sqrt(TRADING_DAYS) * excess.mean() / ddThe one that gets you: max drawdown
The maximum drawdown is the largest peak-to-trough fall in the equity curve. It is the most emotionally important number you will compute, because it is the pain you actually have to sit through. A strategy with a 60% max drawdown will be abandoned by almost everyone who trades it live, no matter how good the CAGR looks on paper.
def drawdown_series(equity):
running_peak = equity.cummax() class="c"># highest equity seen so far
return equity / running_peak - class="n">1 class="c"># <= class="n">0 everywhere
def max_drawdown(equity):
return drawdown_series(equity).min() class="c"># the deepest hole (a negative number)Plot the drawdown series and you get the "underwater" chart — a picture of how long and how deep the strategy was below its previous high. This is the chart that tells you whether you could actually live with the strategy.
Trade-quality metrics: win rate and profit factor
Return and risk describe the equity curve; these describe the *trades* that built it. Win rate is the fraction of trades that made money. Profit factor is gross profit divided by gross loss — how many dollars you win for every dollar you lose. A profit factor above 1 is profitable; above 1.5 is healthy.
def trade_stats(trade_returns):
class="s">"""trade_returns: a pandas Series of per-trade P&L (one number per closed trade).class="s">"""
wins = trade_returns[trade_returns > class="n">0]
losses = trade_returns[trade_returns < class="n">0]
win_rate = len(wins) / len(trade_returns) if len(trade_returns) else class="n">0.0
gross_profit = wins.sum()
gross_loss = -losses.sum() class="c"># make it a positive number
profit_factor = gross_profit / gross_loss if gross_loss > class="n">0 else np.inf
return {class="s">"win_rate": win_rate, class="s">"profit_factor": profit_factor,
class="s">"n_trades": len(trade_returns)}
def exposure(position):
class="s">""class="s">"Fraction of time capital was actually deployed in the market."class="s">""
return (position != class="n">0).mean()Putting it together: one report function
Every metric above collapses into a single reusable function. Feed it the returns and equity from any backtest in this course and you get a complete, honest scorecard.
def performance_report(strategy_ret, position):
equity = (class="n">1 + strategy_ret).cumprod()
return {
class="s">"total_return": total_return(equity),
class="s">"cagr": cagr(equity),
class="s">"volatility": ann_volatility(strategy_ret),
class="s">"sharpe": sharpe(strategy_ret),
class="s">"sortino": sortino(strategy_ret),
class="s">"max_drawdown": max_drawdown(equity),
class="s">"exposure": exposure(position),
}
report = performance_report(strategy_ret, position)
for k, v in report.items():
print(f"{k:>class="n">14}: {v:>class="n">8.2%}" if abs(v) < class="n">5 else f"{k:>class="n">14}: {v:>class="n">8.2f}")Monthly returns: where the character shows
Resampling daily returns to monthly buckets reveals a strategy's *personality* — is it a steady grinder, or does it live off a few explosive months? The bar chart of monthly returns tells you at a glance, and it is one of the first things a professional allocator looks at.
class="c"># Compound daily returns within each calendar month
monthly = (class="n">1 + strategy_ret).resample(class="s">"ME").prod() - class="n">1
print(monthly.tail(class="n">12).apply(lambda x: fclass="s">"{x:.class="n">2%}"))You can now judge a strategy the way a professional does. Next, the uncomfortable truth: almost every one of these numbers is too flattering, because we assumed trading is free.