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.
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
periodsargument (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 pddef cagr(equity, periods=252):years = (len(equity) - 1) / periodsreturn (equity.iloc[-1] / equity.iloc[0]) ** (1 / years) - 1def max_drawdown(equity):dd = equity / equity.cummax() - 1return dd.min() # negative number, e.g. -0.25def sharpe(returns, rf=0.0, periods=252):ex = returns - rf / periodsreturn 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 0return 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, pytestfrom metrics import cagr, max_drawdown, profit_factordef 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.
- 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.)
- 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 %.
- 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.
- 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.
- 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
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
periodsargument 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
ddofand 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.
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.