A backtest in twenty lines
Worked examples
Read the claim, then run it and check the machine agrees. One at a time — nothing here is taken on trust.
prices = [100.0, 102.0, 101.0, 103.0, 106.0]
rets = [prices[i] / prices[i - 1] - 1 for i in range(1, len(prices))]
signals = [1 if r > 0 else 0 for r in rets] # known at each close
strat = [signals[i - 1] * rets[i] for i in range(1, len(rets))]
equity = 1.0
for r in strat:
equity *= 1 + r
print("returns:", [f"{r:+.4%}" for r in rets])
print("signals:", signals)
print("strategy earns:", [f"{r:+.4%}" for r in strat])
print(f"equity: {equity:.6f} ({equity - 1:+.4%})")
print(f"buy & hold: {prices[-1] / prices[0] - 1:+.4%}")
The whole machine, small enough to check every digit against the
slide: the signal list is shifted by construction (signals[i-1]
earns rets[i]), day 4 earns nothing because day 3 closed down, and
the equity lands on 1.019037 — exactly the board's number. Whenever
a vectorized backtest surprises you, shrink it to five days and do
this again.
import numpy as np
import qadata
prices = qadata.trending_prices() # 756 seeded daily closes
def make_positions(prices):
ma = prices.rolling(20).mean()
return (prices > ma).astype(float) # NaN comparisons are False -> flat
rets = prices.pct_change().fillna(0.0)
pos = make_positions(prices)
strat_rets = pos.shift(1).fillna(0.0) * rets
equity = (1.0 + strat_rets).cumprod()
sharpe = strat_rets.mean() / strat_rets.std() * np.sqrt(252)
bh = (1.0 + rets).cumprod()
bh_sharpe = rets.mean() / rets.std() * np.sqrt(252)
print(f"days long : {pos.sum():.0f} of {len(pos)}")
print(f"strategy : {equity.iloc[-1] - 1:+.2%} Sharpe {sharpe:.2f}")
print(f"buy & hold: {bh.iloc[-1] - 1:+.2%} Sharpe {bh_sharpe:.2f}")
The five-day logic, vectorized: shift(1) is the signal list offset,
cumprod is the compounding loop. Long 418 of 756 days, +27.26% at
Sharpe 0.74 versus +30.36% at 0.63 for buy-and-hold — the filter
kept pace while flat 45% of the time. Note what we did NOT conclude:
that trend-following "works". One rule, one seeded path, zero costs
proves nothing yet — m02-2 charges costs and m05 demands statistics.
import numpy as np
import qadata
prices = qadata.trending_prices()
rets = prices.pct_change().fillna(0.0)
def make_positions(prices):
return (prices > prices.rolling(20).mean()).astype(float)
pos = make_positions(prices)
honest = (1.0 + pos.shift(1).fillna(0.0) * rets).cumprod()
cheat = (1.0 + pos * rets).cumprod() # same bar: position knows its own close
for name, eq in [("honest", honest), ("no shift", cheat)]:
r = eq.pct_change().fillna(0.0)
print(f"{name:9}: {eq.iloc[-1] - 1:+8.2%} Sharpe {r.mean() / r.std() * np.sqrt(252):.2f}")
qadata.assert_causal(make_positions, prices)
print("assert_causal: the rule itself is causal")
try: # a rule built on the FULL-SAMPLE mean peeks at the future
qadata.assert_causal(lambda p: (p > p.mean()).astype(float), prices)
except AssertionError as e:
print(f"caught: {e}")
Same rule, same market: +27% honest, +255% with the shift deleted —
look-ahead manufactured a 9x difference out of one removed method
call. The second half shows the grader's two jobs are different:
the shift protects the backtest arithmetic, while assert_causal
attacks the signal itself — the full-sample-mean rule is perfectly
shifted and still fails, because truncating history changes
positions it had already decided. You'll meet both checks in the
challenge.
Check the concept
One question at a time. Unsure? Revisit the lecture, then answer.
The challenge
Pass the quiz to unlock the challenge — your code will still be waiting here.