Level 3 — ProductionBacktesting Frameworks

Backtesting Frameworks

45 minutes60 marksprintable — key stays hidden on paper

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 16,00016,000, then falls to 12,80012,800, recovers to 18,00018,000. Derive the Maximum Drawdown formula and compute the max DD (as a percentage). (4 marks)

(c) Given daily returns with mean μd=0.0006\mu_d = 0.0006 and standard deviation σd=0.012\sigma_d = 0.012, 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: Vfinal=Vinitial(1+g)nV_{final} = V_{initial}(1+g)^n. Solve for gg: CAGR=(VfinalVinitial)1/n1\text{CAGR} = \left(\frac{V_{final}}{V_{initial}}\right)^{1/n} - 1 (2 marks derivation)

Compute: (1800010000)1/31=1.81/31\left(\frac{18000}{10000}\right)^{1/3} - 1 = 1.8^{1/3} - 1. 1.81/31.216441.8^{1/3} \approx 1.21644, so CAGR 0.2164=21.64%\approx 0.2164 = \mathbf{21.64\%}. (2 marks)

(b) Max Drawdown (4 marks)

Drawdown at time tt: DDt=VtmaxstVsmaxstVsDD_t = \frac{V_t - \max_{s\le t} V_s}{\max_{s\le t} V_s}. Max DD is the most negative. (2 marks derivation)

Running peak before trough is 16,00016{,}000; trough 12,80012{,}800: DD=128001600016000=0.20=20%DD = \frac{12800 - 16000}{16000} = -0.20 = \mathbf{-20\%} (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) =μd/σd= \mu_d / \sigma_d. Annualize: multiply return by 252, std by 252\sqrt{252}, so Sharpeann=μdσd252\text{Sharpe}_{ann} = \frac{\mu_d}{\sigma_d}\sqrt{252} (2 marks derivation)

=0.00060.012252=0.05×15.87450.794= \frac{0.0006}{0.012}\sqrt{252} = 0.05 \times 15.8745 \approx \mathbf{0.794}. (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 = 2×0.08%=0.16%2 \times 0.08\% = 0.16\% of $5,000 = 0.0016 \times 5000 = \8.(2)Annual:. *(2)* Annual: 120 \times $8 = \mathbf{$960}.(2)As. *(2)* As % of \50,000 account: 960/50000=1.92%960/50000 = \mathbf{1.92\%} 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)

  1. Take the 200 historical per-trade returns. (1)
  2. Resample with replacement (bootstrap) 200 trades to form one simulated sequence. (1)
  3. Compound them into an equity curve; compute its max drawdown. (1)
  4. Repeat for e.g. 10,000 simulations, storing each max DD. (1)
  5. 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"}
]