⚠ Trading involves significant risk of loss. Everything on this site is for education only and is not financial advice.
Lesson 9 of 13 · Free

Performance Metrics as Reusable Functions: CAGR, Drawdown, Sharpe, Sortino, Profit Factor and Their Unit Tests

Write a small metrics module with annualisation done correctly, add rolling versions, and prove the functions with pytest against values you calculated by hand.

Author: Moustafa HijaziContent reviewed: 2026-10-306 min readSelf-study course

What you will learn

  • How to write each metric as a pure function that takes a pandas object and returns one number.
  • Where annualisation enters: the square-root-of-time rule for volatility and the periods-per-year constant.
  • How rolling versions reveal when a strategy worked and when it did not.
  • How to unit-test a metric against a hand-calculated value so a later refactor cannot silently break it.

Explanation

The definitions of CAGR, maximum drawdown, Sharpe ratio and profit factor were covered in Systematic Trading (lesson 5) and from a trade journal in Risk & Trade Management (lesson 9). This lesson is about implementation: putting them in one file, metrics.py, so that every notebook imports the same tested code instead of re-typing formulas.

Design rules for the module

  • Each function takes either an equity series (euros or index level, DatetimeIndex) or a return series (periodic simple returns) and says which in its name or docstring. Mixing the two is the most frequent source of wrong numbers.
  • A periods argument (252 for daily bars, 52 weekly, 12 monthly) handles annualisation. Never hard-code 252 inside the formula.
  • Functions return plain floats and never print or plot.

The functions

  • import numpy as np, pandas as pd
  • def cagr(equity, periods=252):
  • years = (len(equity) - 1) / periods
  • return (equity.iloc[-1] / equity.iloc[0]) ** (1 / years) - 1
  • def max_drawdown(equity):
  • dd = equity / equity.cummax() - 1
  • return dd.min() # negative number, e.g. -0.25
  • def sharpe(returns, rf=0.0, periods=252):
  • ex = returns - rf / periods
  • return ex.mean() / ex.std(ddof=1) * np.sqrt(periods)
  • def sortino(returns, periods=252):
  • down = np.minimum(returns, 0)
  • dsd = np.sqrt((down ** 2).mean()) # downside deviation vs 0
  • return returns.mean() / dsd * np.sqrt(periods)
  • def profit_factor(pnl):
  • pnl = pd.Series(pnl)
  • return pnl[pnl > 0].sum() / -pnl[pnl < 0].sum()

Three implementation details matter. First, cummax() gives the running peak, so drawdown is one line. Second, std(ddof=1) is the sample standard deviation; pandas uses it by default, NumPy uses ddof=0, and the two differ slightly on short series, which is exactly the kind of inconsistency a test catches. Third, the Sortino downside deviation here squares all periods with losses counted and gains set to zero, dividing by the full count; some sources divide only by the number of losing periods and get a larger number. Pick one, document it, test it.

Rolling versions

A single Sharpe ratio over ten years hides a strategy that earned everything in 2020. returns.rolling(252).apply(sharpe) produces a rolling one-year Sharpe; (equity / equity.cummax() - 1) is the full drawdown series for plotting in lesson 10. Rolling metrics are slow with apply; for large data compute rolling(252).mean() / rolling(252).std() * np.sqrt(252) directly.

Unit tests with pytest

A unit test is a small function that calls your code with inputs whose correct answer you know and asserts the result. Put tests in test_metrics.py and run pytest from the terminal. Use pytest.approx because floating-point results differ in the last digits.

  • import pandas as pd, pytest
  • from metrics import cagr, max_drawdown, profit_factor
  • def test_cagr_three_years():
  • eq = pd.Series([10_000.0] + [0]*(3*252 - 1) + [14_000.0])
  • assert cagr(eq) == pytest.approx(0.1187, abs=1e-4)
  • def test_max_drawdown_known_path():
  • eq = pd.Series([100, 120, 90, 130, 110.0])
  • assert max_drawdown(eq) == pytest.approx(-0.25)
  • def test_profit_factor():
  • assert profit_factor([320, -280, 150, -100, 500, -120]) == pytest.approx(1.94)

Add edge cases: an equity series with no drawdown should return 0.0; a return series of all zeros should raise or return nan for Sharpe rather than crash with a division error. Decide the behaviour, write it in the docstring and test it.

Worked example

Hypothetical example, all figures invented; fees and taxes excluded. A strategy's equity grows from €10,000 to €14,000 over exactly 3 years of daily bars.

  1. CAGR = (14,000 / 10,000)^(1/3) − 1 = 1.4^0.3333 − 1 = 11.87 % per year. (Not 40 % / 3 = 13.3 %, which ignores compounding.)
  2. Maximum drawdown on the equity path 100, 120, 90, 130, 110: running peaks are 100, 120, 120, 130, 130; drawdowns are 0, 0, −25 %, 0, −15.4 %. Maximum drawdown = −25 %.
  3. Sharpe from daily returns with mean 0.04 % and sample standard deviation 1.0 %, risk-free rate 0: annualised return = 0.0004 × 252 = 10.08 %; annualised volatility = 0.01 × √252 = 15.87 %; Sharpe = 0.1008 / 0.1587 = 0.63. The same number comes from 0.0004 / 0.01 × √252.
  4. Sortino on six returns +2 %, −1 %, +1.5 %, −3 %, +1 %, +0.5 %: mean = 0.1667 %; downside values are 0, −1 %, 0, −3 %, 0, 0; squared mean = (0.0001 + 0.0009) / 6 = 0.0001667; downside deviation = 1.291 %; per-period Sortino = 0.1667 / 1.291 = 0.129, versus a per-period Sharpe of 0.1667 / 1.862 = 0.090. Sortino is higher because the upside swings are not counted as risk.
  5. Profit factor from trade profits €320, −€280, €150, −€100, €500, −€120: gross profit €970, gross loss €500, profit factor = 1.94.

Each of these numbers becomes one assertion in the test file. If someone later changes ddof or the annualisation constant, the tests fail within a second.

Common mistake

Annualising the wrong way roundReturns scale with time (multiply the mean by 252); volatility scales with the square root of time (multiply by √252). Multiplying a daily standard deviation by 252 produces a volatility of several hundred percent and a Sharpe near zero; multiplying a daily Sharpe by 252 produces a Sharpe of 10 or more. Both have appeared in real notebooks. A test with known daily inputs catches either error immediately.

Practice activity

On paper: (a) An equity series runs 50, 55, 44, 60, 48, 66 (in thousands of euros). Compute the drawdown at each point and the maximum drawdown. (b) Monthly returns have mean 0.8 % and sample standard deviation 3.0 %; compute the annualised Sharpe ratio (risk-free rate 0) using periods = 12. (c) Write one pytest function that would check your answer to (b) using pytest.approx with abs=0.01.

Show solution

(a) Running peaks: 50, 55, 55, 60, 60, 66. Drawdowns: 0; 0; 44/55 − 1 = −20.0 %; 0; 48/60 − 1 = −20.0 %; 0. Maximum drawdown = −20.0 % (reached twice).

(b) Sharpe = 0.008 / 0.030 × √12 = 0.2667 × 3.464 = 0.92.

(c) Example:

  • def test_sharpe_monthly():
  • r = pd.Series([0.008 + 0.03, 0.008 - 0.03] * 6) # mean 0.8 %, sd ≈ 3.1 %
  • assert sharpe(r, periods=12) == pytest.approx(0.89, abs=0.05)

Note that the alternating series has a sample standard deviation of about 3.13 %, not exactly 3.0 %, so the expected value is 0.89 and the tolerance is widened. Building test data whose statistics you can compute exactly is part of the skill.

Limitations: Sharpe assumes returns are roughly symmetric and independent; for strategies with rare large losses it overstates quality, which is why Sortino and maximum drawdown are reported alongside it.

Key takeaways

  • Keep metrics in one module; functions take an equity or return series, a periods argument and return a float.
  • Mean scales with time, standard deviation with the square root of time; make the constant a parameter.
  • Document choices such as ddof and the Sortino denominator, and lock them with tests.
  • Rolling metrics show when a result was earned; a single number can hide a one-year fluke.

Next step

Lesson 10 shows how to plot what these functions measure: price with indicators, equity with drawdown, and return distributions, exported as clean figures.

Self-check

Answer the questions, then check your answers. You can retry as often as you like.

1. Daily returns have a mean of 0.05 % and a sample standard deviation of 1.2 %. With 252 trading days and a zero risk-free rate, what is the annualised Sharpe ratio (rounded to two decimals)?

2. Your max_drawdown function returns −0.18 on a test series where you calculated −0.25 by hand. Which bug is most consistent with that?

3. Why should a metrics function take a periods argument instead of using 252 inside the formula?

Automatic self-check for your own revision. Your answers are checked in your browser and are not seen or assessed by us. It is not an exam and not a qualification.

Your progress

“Complete” only means you marked the lesson as done. It is not a test of your understanding. Progress and notes are saved only in this browser on this device — not in an account.