AlgoPro UniversityCourse home
Part 5 · Lesson

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.

pythonTotal return and CAGR
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">1

Risk: 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).

pythonAnnualised volatility
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.

pythonSharpe ratio (annualised)
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.

pythonSortino ratio
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() / dd

The 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.

pythonDrawdown series and max drawdown
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.

Underwater plot — drawdown over time
Drawdown = how far below the previous equity peak, in percent. The strategy spends long stretches at 0 (at new highs) punctuated by a punishing -18% hole. This is the pain you must be willing to hold through. Illustrative values.

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.

pythonWin rate, profit factor and exposure from a trade list
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.

pythonA full performance report
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.

pythonMonthly returns table
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%}"))
JanFebMarAprMayJunJulAugSepOctNovDecMonthly returns (%)
One year of monthly returns. Green months outnumber and outweigh red ones, but the -3.1% and -2.2% months are the ones you have to stomach. Illustrative values.

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.