From exercises to a library
You have written a hundred small functions. A library is those functions arranged so that someone else (including you in six months) can use them with confidence: a clear public interface, each piece tested alone, the pieces composed into a pipeline, and the whole thing installable, documented and checked automatically. This capstone is the pattern in miniature. A strategy library is the easiest place to practise it: the signal turns prices into positions, the backtest turns positions into returns after costs, the metrics summarise the returns, and a runner wires them together. Each stage takes plain arrays and returns plain arrays, so each can be tested alone with numbers you can check by hand.
# The shape of a small library: a package, its tests, its metadata and its automated checks
tree = """\
quantlib/
├── pyproject.toml name, version, dependencies (numpy)
├── src/quantlib/
│ ├── __init__.py the public API: sma_positions, strategy_returns, performance_summary, run_strategy
│ ├── signals.py prices -> positions
│ ├── backtest.py positions -> returns after costs
│ └── metrics.py returns -> total return, volatility, Sharpe, drawdown
├── tests/ one test file per module, fixed seeds, hand-checked cases
└── .github/workflows/ci.yml lint, types, tests on every push
"""
print(tree.splitlines()[0]) # quantlib/
print(sum(1 for line in tree.splitlines() if ".py" in line)) # 4 the four module filesSignal and backtest: no look-ahead, costs included
The signal here is a moving-average crossover: be long when the fast average of prices is above the slow one, short when below, flat when they are equal or the slow average does not exist yet. The two rules that decide whether a backtest is honest are visible in the code. First, no look-ahead: the position decided at the close of day t may use prices up to day t only, and it earns the return of day t + 1, which is why the return of step t uses positions[t - 1]. You can prove it with a test: removing the last prices must not change earlier positions. Second, costs: every change of position pays cost * |change|, so a strategy that flips constantly can win before costs and lose after.
# The signal and the honest backtest: positions decided at the close are earned on the NEXT step, and trading costs money
import numpy as np
def sma_positions(prices, fast, slow):
prices = np.asarray(prices, dtype=float)
positions = np.zeros(len(prices), dtype=int)
for t in range(slow - 1, len(prices)):
f = prices[t - fast + 1 : t + 1].mean()
s = prices[t - slow + 1 : t + 1].mean()
positions[t] = 1 if f > s else -1 if f < s else 0
return positions
def strategy_returns(prices, positions, cost):
returns = []
for t in range(1, len(prices)):
before = positions[t - 2] if t >= 2 else 0
returns.append(positions[t - 1] * (prices[t] / prices[t - 1] - 1) - cost * abs(positions[t - 1] - before))
return np.array(returns)
prices = np.array([100, 101, 103, 102, 105, 108, 107, 110, 112, 111, 115, 118], dtype=float)
positions = sma_positions(prices, 2, 4)
print(positions) # [0 0 0 1 1 1 1 1 1 1 1 1] flat until the 4-day average exists, then long
print(np.allclose(sma_positions(prices[:8], 2, 4), positions[:8])) # True later prices never change earlier positions
print(np.round(strategy_returns(prices, positions, 0.0).sum() - strategy_returns(prices, positions, 0.001).sum(), 3)) # 0.001 one purchase at 0.1%Performance numbers, and the runner
A return series is summarised by a few numbers. The total return compounds the periods, prod(1 + r) - 1; the annual return rescales it to a year, (1 + total)^(periods_per_year / n) - 1; the annual volatility is the sample standard deviation times sqrt(periods_per_year); the Sharpe ratio divides the mean return by that deviation, annualised, and is the headline measure of return per unit of risk (0 for a flat series rather than a division error); the maximum drawdown is the worst fall from a peak of the equity curve, the number that tells you how much pain the strategy asks you to bear. A runner function ties it together: positions, then returns, then the summary. It should only compose the other functions, so a bug can always be traced to one stage.
# Performance statistics of a return series, and the runner that wires the three stages together
import math
import numpy as np
def performance_summary(returns, periods_per_year):
returns = np.asarray(returns, dtype=float)
n = len(returns)
equity = np.concatenate(([1.0], np.cumprod(1 + returns)))
total = float(equity[-1] - 1)
deviation = float(np.std(returns, ddof=1))
peak = np.maximum.accumulate(equity)
return {
"total_return": total,
"annual_return": float((1 + total) ** (periods_per_year / n) - 1),
"annual_vol": deviation * math.sqrt(periods_per_year),
"sharpe": float(returns.mean() / deviation * math.sqrt(periods_per_year)) if deviation > 0 else 0.0,
"max_drawdown": float(np.max((peak - equity) / peak)),
}
summary = performance_summary([0.10, -0.20, 0.05], 252)
print(round(summary["total_return"], 4), round(summary["max_drawdown"], 4)) # -0.076 0.2 equity 1 -> 1.1 -> 0.88 -> 0.924: a 20% fall from the peak
print(performance_summary([0.0, 0.0, 0.0], 252)["sharpe"]) # 0.0 no variation: a Sharpe of 0, not an error# The runner only composes: if a number looks wrong you can test each stage on its own
def run_strategy(prices, fast, slow, cost, periods_per_year=252):
positions = sma_positions(prices, fast, slow)
returns = strategy_returns(prices, positions, cost)
return performance_summary(returns, periods_per_year)
# (sma_positions, strategy_returns and performance_summary are the functions you wrote in this lab)
steps = ["sma_positions", "strategy_returns", "performance_summary"]
print(" -> ".join(steps)) # sma_positions -> strategy_returns -> performance_summary
print(len(steps), "stages, one result: a dict of five numbers") # 3 stages, one result: a dict of five numbersYour capstone project
Now build your own, in your own repository. Pick one of two directions. A pricing library: Black-Scholes and Greeks, a Monte Carlo pricer with antithetic variates, an implied-volatility solver, and a calibration of one model to a quote set. Or a strategy library: several signals, a backtest with costs and position limits, walk-forward evaluation and a report. Either way, deliver the same things: a package with a clear public API and type hints; tests with fixed seeds and hand-checked cases; a pyproject.toml and a README that shows how to install and run an example; a CI workflow running lint, types and tests; a performance note with one profiled bottleneck; and a short write-up of the assumptions and what would break the model. Budget your time: design the interface first, build the smallest working pipeline, then widen it.
- A library is small tested functions composed into a pipeline behind a clear interface: signal -> positions -> returns after costs -> metrics, wired by a runner.
- An honest backtest has no look-ahead (the position at t uses prices up to t and earns the next return) and charges costs for every change of position.
- Summarise with total and annual return, volatility, Sharpe and maximum drawdown; then ship a package with tests, CI, a README and an honest write-up.
That completes the skill. You now have the language, the numerical tools, the market-data and execution pieces, the models and the engineering habits of a working quant developer. Keep your capstone repository: it is the best proof of the skills you built here.
Lab · what's graded
- Turn a trend into positions with
sma_positions25 pts - Backtest with costs in
strategy_returns25 pts - Judge the result with
performance_summary25 pts - Wire it all together in
run_strategy25 pts