Backtesting Frameworks
Time Limit: 45 minutes Total Marks: 60 Instructions: Show all derivations from scratch. Code must be written from memory. Explain-out-loud prompts require conceptual reasoning, not just facts.
Question 1 — Performance Metrics from Scratch (12 marks)
A strategy grows an initial equity of $10,000 to $18,000 over 3 years.
(a) Derive the CAGR formula from first principles (compound growth) and compute the CAGR for this strategy. (4 marks)
(b) The equity curve hits these peaks and troughs (in $): peak , then falls to , recovers to . Derive the Maximum Drawdown formula and compute the max DD (as a percentage). (4 marks)
(c) Given daily returns with mean and standard deviation , derive and compute the annualized Sharpe ratio (risk-free rate = 0, 252 trading days). (4 marks)
Question 2 — Look-Ahead & Survivorship Bias (10 marks)
(a) Explain out loud: what is look-ahead bias? Give TWO concrete coding mistakes that introduce it in a pandas backtest, and state the fix for each. (6 marks)
(b) Explain how survivorship bias inflates backtest returns. Describe the data-sourcing property a dataset must have to eliminate it. (4 marks)
Question 3 — Transaction Cost & Slippage Modeling (10 marks)
A strategy executes 120 round-trip trades/year. Each round-trip involves a position of $5,000. Commission is 0.05% per side; assumed slippage is 0.03% per side.
(a) Derive the total annual cost drag (in $ and as % of a $50,000 account). (6 marks)
(b) Explain out loud why optimistic slippage assumptions are the most common cause of a backtest that "works" but fails live. (4 marks)
Question 4 — Code from Memory (14 marks)
(a) Write a pandas function max_drawdown(equity) that takes a Series of equity values and returns the maximum drawdown as a negative fraction. Comment each key line. (7 marks)
(b) Write a from-memory snippet that computes a simple moving-average crossover signal (fast=20, slow=50) on a DataFrame df with a close column, shifting signals correctly to avoid look-ahead bias. (7 marks)
Question 5 — In-Sample vs Out-of-Sample & Monte Carlo (14 marks)
(a) Explain the in-sample / out-of-sample split and why over-optimizing on in-sample data produces overfit strategies. Describe walk-forward analysis in 3–4 sentences. (6 marks)
(b) A strategy has per-trade returns that are i.i.d. You have 200 historical trades. Describe, step by step, how you would run a Monte Carlo simulation (bootstrap) of the equity curve to estimate a 5th-percentile max drawdown. (5 marks)
(c) Why is paper trading still necessary after a passing out-of-sample backtest? Give ONE thing paper trading catches that backtests cannot. (3 marks)
Answer keyMark scheme & solutions
Question 1
(a) CAGR derivation & compute (4 marks)
Compound growth: . Solve for : (2 marks derivation)
Compute: . , so CAGR . (2 marks)
(b) Max Drawdown (4 marks)
Drawdown at time : . Max DD is the most negative. (2 marks derivation)
Running peak before trough is ; trough : (2 marks) The later recovery to 18,000 sets a new peak but does not change the max DD.
(c) Sharpe (4 marks)
Sharpe (daily) . Annualize: multiply return by 252, std by , so (2 marks derivation)
. (2 marks)
Question 2
(a) Look-ahead bias (6 marks)
- Definition: using information not available at decision time to generate signals — inflates results. (2 marks)
- Mistake 1: computing signals on today's close then trading at today's close/open. Fix:
signal = signal.shift(1)so you act on the next bar. (2 marks) - Mistake 2: using full-sample statistics (e.g. mean/std, or
.max()over entire series, or normalizing with future data) in indicators. Fix: use rolling/expanding windows only. (2 marks)
(b) Survivorship bias (4 marks)
- Testing only on companies that still exist today drops the delisted/bankrupt losers, so historical performance looks artificially strong (winners' selection). (2 marks)
- The dataset must be point-in-time / include delisted securities (a "survivorship-bias-free" universe reflecting the actual constituents at each historical date). (2 marks)
Question 3
(a) Cost drag (6 marks) Each round-trip = 2 sides. Cost rate per side = commission + slippage = 0.05% + 0.03% = 0.08%. (1) Per round-trip = of $5,000 = 0.0016 \times 5000 = \8120 \times $8 = \mathbf{$960}50,000 account: annual drag. (1)
(b) Slippage explain-out-loud (4 marks) Backtests fill at ideal prices (close/mid), but live orders move the market, cross spreads, and get partial fills — especially in illiquid names or fast markets. Optimistic slippage understates true entry/exit prices; the edge of many strategies is thinner than the real slippage, so a strategy profitable on paper becomes a loser once realistic slippage/spread is applied. (full 4 for capturing spread crossing + market impact + edge-vs-cost point)
Question 4
(a) max_drawdown (7 marks)
def max_drawdown(equity):
running_max = equity.cummax() # peak-to-date
drawdown = equity / running_max - 1 # fractional DD, <=0
return drawdown.min() # most negative value(2 marks cummax, 2 marks drawdown formula, 2 marks .min(), 1 mark correctness/comments)
(b) MA crossover (7 marks)
df['fast'] = df['close'].rolling(20).mean()
df['slow'] = df['close'].rolling(50).mean()
df['raw'] = (df['fast'] > df['slow']).astype(int) # 1 long, 0 flat
df['signal'] = df['raw'].shift(1) # act next bar: no look-ahead(2 rolling means, 2 comparison signal, 2 shift(1), 1 correctness)
Question 5
(a) IS/OOS & walk-forward (6 marks)
- Split data: optimize/parameter-tune on in-sample; validate untouched on out-of-sample. (2)
- Over-optimizing fits parameters to noise in the in-sample data; the "edge" doesn't generalize, so OOS performance collapses (overfitting). (2)
- Walk-forward: repeatedly optimize on a rolling training window, test on the following window, then roll forward — approximating live re-optimization and giving a chain of OOS results. (2)
(b) Monte Carlo bootstrap (5 marks)
- Take the 200 historical per-trade returns. (1)
- Resample with replacement (bootstrap) 200 trades to form one simulated sequence. (1)
- Compound them into an equity curve; compute its max drawdown. (1)
- Repeat for e.g. 10,000 simulations, storing each max DD. (1)
- Take the 5th percentile of the max-DD distribution as the estimated adverse-case drawdown. (1)
(c) Paper trading (3 marks) Backtests can't capture real-world execution: broker latency, order rejections, data feed differences, actual fills/slippage, and operational bugs. Paper trading in live market conditions surfaces these before real capital is risked. (3 for any valid execution/operational point)
[
{"claim":"CAGR of 10000->18000 over 3yr is ~21.64%","code":"cagr=(18000/10000)**(sympy.Rational(1,3))-1; result=abs(float(cagr)-0.2164)<0.001"},
{"claim":"Max DD from peak 16000 to trough 12800 is -20%","code":"dd=(12800-16000)/16000; result=dd==-0.20"},
{"claim":"Annualized Sharpe = 0.0006/0.012*sqrt(252) ~ 0.794","code":"s=(0.0006/0.012)*sympy.sqrt(252); result=abs(float(s)-0.7937)<0.01"},
{"claim":"Annual cost drag is $960 = 1.92% of 50000","code":"cost=120*2*0.0008*5000; result=cost==960 and abs(cost/50000-0.0192)<1e-9"}
]