Level 5 — MasteryBacktesting Frameworks

Backtesting Frameworks

90 minutes60 marksprintable — key stays hidden on paper

Time limit: 90 minutes Total marks: 60 Instructions: Answer all three questions. Show all working, derivations, and reasoning. Code answers may be written in Python (pandas/numpy). Round final numeric answers to 4 significant figures unless stated otherwise.


Question 1 — Performance Metrics, Bias & Cost Modeling (24 marks)

A strategy is backtested on daily data over exactly 3 years (756 trading days, 252 per year). The gross equity curve grows the initial $100,000 to a final value of $168,000 before costs.

The strategy trades on average twice per day (a round-trip of buy + sell), i.e. 1512 fills over the 3 years. Each fill incurs:

  • a commission of 0.5 basis points (bps) of traded notional, and
  • slippage modeled as slip=kσintraday\text{slip} = k\,\sigma_{\text{intraday}} with k=0.10k = 0.10 and σintraday=8\sigma_{\text{intraday}} = 8 bps, applied per fill.

Assume the average traded notional per fill equals the average equity, which you may take as $130,000, and that total cost is deducted from the terminal equity as a lump sum (a simplification you must critique).

(a) Compute the strategy's gross CAGR over the 3-year period. (4)

(b) Compute the total transaction cost in dollars (commission + slippage combined across all 1512 fills), then the net terminal equity and net CAGR. (6)

(c) During the backtest the maximum drawdown (net) was recorded as a peak of $155,000 falling to a trough of $126,000. Compute the maximum drawdown as a percentage. (3)

(d) The universe used was the current S&P 500 constituents pulled today. Explain survivorship bias in this context, give one concrete mechanism by which it inflates the measured CAGR, and describe a data-sourcing fix. (5)

(e) Critique the "lump-sum cost deduction" assumption: explain why deducting costs per-trade (compounding) generally produces a lower net CAGR than the lump-sum method, and state the direction of the error. (6)


Question 2 — Look-Ahead Bias in Code + In/Out-of-Sample Design (18 marks)

The following pandas snippet is intended to generate a long/flat signal from a 20-day moving-average crossover and compute strategy returns:

df['ma']     = df['close'].rolling(20).mean()
df['signal'] = (df['close'] > df['ma']).astype(int)
df['ret']    = df['close'].pct_change()
df['stratret'] = df['signal'] * df['ret']

(a) Identify the look-ahead / timing bias in df['stratret'] and give the corrected line(s) of code. Explain precisely which bar's information is being used to trade which bar's return. (6)

(b) The dataset spans 2015–2024 (10 years). Design an in-sample / out-of-sample split with a walk-forward scheme using 3 folds. State the train/test windows explicitly and explain why walk-forward is preferred over a single random shuffle for time-series strategy validation. (7)

(c) After optimization, in-sample Sharpe = 1.9 but out-of-sample Sharpe = 0.4. State what this gap indicates and name two design changes that reduce it. (5)


Question 3 — Monte Carlo Simulation of Returns (18 marks)

A strategy produces daily net log-returns modeled as i.i.d. rtN(μ,σ2)r_t \sim \mathcal{N}(\mu, \sigma^2) with μ=0.0006\mu = 0.0006 per day and σ=0.012\sigma = 0.012 per day.

(a) Derive the expected value and variance of the 252-day cumulative log-return R=t=1252rtR = \sum_{t=1}^{252} r_t. Give numeric values. (4)

(b) Using the fact that RN(μR,σR2)R \sim \mathcal{N}(\mu_R, \sigma_R^2), compute the probability that the annual log-return is negative, P(R<0)P(R<0). Give the answer using the standard normal CDF and a numeric value. (5)

(c) Write a Python function mc_terminal(mu, sigma, days, n_paths) using numpy that simulates n_paths equity curves of an initial $1 and returns the array of terminal values. Then explain how you would estimate the 5th-percentile terminal wealth (a VaR-style statistic) from its output. (5)

(d) Explain one reason the i.i.d. Normal assumption understates real-world tail risk, and name a resampling alternative that preserves empirical fat tails. (4)

Answer keyMark scheme & solutions

Question 1

(a) Gross CAGR (4 marks)

CAGR =(Vf/V0)1/n1= (V_f/V_0)^{1/n} - 1 where n=3n = 3 years.

CAGR=(168000100000)1/31=1.681/31\text{CAGR} = \left(\frac{168000}{100000}\right)^{1/3} - 1 = 1.68^{1/3} - 1

1.681/3=1.1888761.68^{1/3} = 1.188876, so CAGR ≈ 0.1889 = 18.89%.

  • Formula (1), substitution (1), cube-root (1), answer (1).

(b) Total cost, net terminal, net CAGR (6 marks)

Cost per fill (in bps of notional):

  • commission = 0.5 bps
  • slippage = kσ=0.10×8=0.8k\sigma = 0.10 \times 8 = 0.8 bps
  • total = 1.3 bps = 0.00013 of notional. (2)

Cost per fill in $ = 0.00013 \times 130000 = \16.90.Totalover1512fills=. Total over 1512 fills = 16.90 \times 1512 = $25{,}552.80$. (2)

Net terminal = 168000 - 25552.80 = \142{,}447.20.NetCAGR. Net CAGR = (142447.2/100000)^{1/3} - 1 = 1.424472^{1/3} - 1.. 1.424472^{1/3} = 1.125215$, so net CAGR ≈ 0.1252 = 12.52%. (2)

(c) Max drawdown (3 marks)

MDD=155000126000155000=29000155000=0.18710=18.71%\text{MDD} = \frac{155000 - 126000}{155000} = \frac{29000}{155000} = 0.18710 = \textbf{18.71\%}

  • Formula peak-to-trough over peak (1), value (1), % (1).

(d) Survivorship bias (5 marks)

  • Definition: using only currently surviving constituents excludes firms that were delisted, went bankrupt, or were removed for poor performance during the test window (2).
  • Mechanism inflating CAGR: the excluded delisted names typically had large negative returns before removal; their absence removes losers from the sample, biasing average/compound returns upward. Also index-inclusion effect (winners added late) (1).
  • Fix: source a point-in-time / as-was constituent list with full delisting history and continue holding delisted names through to their final delisting price (a database with delisted securities, e.g. CRSP-style) (2).

(e) Lump-sum vs per-trade compounding critique (6 marks)

  • In the lump-sum method the full $25,552.80 is subtracted once at the end, so all costs are effectively "paid" with terminal-value dollars and do not reduce the capital base that compounds during the period (2).
  • In reality each trade's cost reduces equity at the time it occurs, so subsequent compounding acts on a smaller base — the drag compounds (2).
  • Therefore per-trade deduction yields a lower net terminal value and lower net CAGR than the lump-sum figure; the lump-sum method overstates performance (the error is optimistic/positive-biased) (2).

Question 2

(a) Look-ahead bias (6 marks)

  • Bug: signal computed from close[t] is multiplied by ret[t] where ret[t] = (close[t]/close[t-1]-1). This uses the same bar's closing price both to decide the position and to earn that bar's return — you cannot know today's close in time to have held the position that produced today's return (3).
  • Fix: lag the signal by one bar so today's return is driven by yesterday's (known) signal:
df['stratret'] = df['signal'].shift(1) * df['ret']

(3). (Accept also shifting the signal generation to use close.shift(1).)

(b) Walk-forward in/out-of-sample (7 marks)

Example 3-fold expanding (or rolling) walk-forward on 2015–2024:

Fold Train (in-sample) Test (out-of-sample)
1 2015–2018 2019
2 2015–2020 2021–2022
3 2015–2022 2023–2024

(any monotone, non-overlapping, train-before-test scheme accepted) (4).

Why walk-forward over random shuffle: time-series data is autocorrelated and non-stationary; random shuffling leaks future information into training (look-ahead) and destroys temporal ordering, so it overstates out-of-sample skill. Walk-forward always trains on the past and tests on the strictly later, unseen future, matching live deployment (3).

(c) Sharpe gap (5 marks)

  • The large drop (1.9 → 0.4) indicates overfitting to the in-sample data (curve-fitting to noise / too many optimized parameters) (2).
  • Two fixes (any two, 1.5 each): reduce parameter count / regularize; use fewer optimization trials; enforce economic rationale; use nested cross-validation; add transaction costs during optimization; ensemble/robust parameter regions rather than a single peak.

Question 3

(a) Mean & variance of cumulative log-return (4 marks)

For sum of 252 i.i.d.: μR=252μ=252(0.0006)=0.1512\mu_R = 252\mu = 252(0.0006) = 0.1512 σR2=252σ2=252(0.012)2=252(0.000144)=0.036288,σR=0.190494\sigma_R^2 = 252\sigma^2 = 252(0.012)^2 = 252(0.000144) = 0.036288,\quad \sigma_R = 0.190494

  • Mean (2), variance/sd (2).

(b) P(R<0) (5 marks)

P(R<0)=Φ ⁣(0μRσR)=Φ ⁣(0.15120.190494)=Φ(0.79373)P(R<0) = \Phi\!\left(\frac{0-\mu_R}{\sigma_R}\right) = \Phi\!\left(\frac{-0.1512}{0.190494}\right) = \Phi(-0.79373) Φ(0.7937)0.2137\Phi(-0.7937) \approx 0.2137. P(R<0) ≈ 0.2137 (≈ 21.4%).

  • Standardization (2), z-value (1), CDF numeric (2).

(c) Monte Carlo function (5 marks)

import numpy as np
def mc_terminal(mu, sigma, days, n_paths):
    r = np.random.normal(mu, sigma, size=(n_paths, days))
    terminal = np.exp(r.sum(axis=1))   # start from $1, log-returns
    return terminal

(3 marks: correct shape, summing log-returns, exp to get wealth).

5th-percentile estimate: np.percentile(mc_terminal(...), 5) — sort the terminal-wealth array and take the value below which 5% of simulated outcomes fall; this is a Monte-Carlo VaR estimate of worst-case terminal wealth (2).

(d) Tail-risk critique (4 marks)

  • Normal assumption has thin tails and assumes independence; real returns exhibit fat tails (excess kurtosis) and volatility clustering / autocorrelation, so extreme drawdowns occur far more often than the Normal predicts — VaR is understated (2).
  • Alternative: bootstrap / block-bootstrap resampling of historical returns (or stationary bootstrap), which preserves the empirical distribution's fat tails and (with block resampling) short-range dependence (2).

[
  {"claim":"Gross CAGR of 168000/100000 over 3 yrs ~ 0.1889","code":"cagr=(Rational(168000,100000))**(Rational(1,3))-1; result=abs(float(cagr)-0.1889)<0.001"},
  {"claim":"Total cost = 1.3bps*130000*1512 = 25552.80","code":"cost=Rational(13,100000)*130000*1512; result=abs(float(cost)-25552.80)<0.01"},
  {"claim":"Net CAGR ~ 0.1252","code":"net=(Rational(1424472,1000000))**(Rational(1,3))-1; result=abs(float(net)-0.1252)<0.001"},
  {"claim":"Max drawdown = 29000/155000 ~ 0.1871","code":"mdd=Rational(29000,155000); result=abs(float(mdd)-0.1871)<0.001"},
  {"claim":"mu_R=0.1512 and sigma_R^2=0.036288","code":"muR=252*Rational(6,10000); varR=252*(Rational(12,1000))**2; result=(abs(float(muR)-0.1512)<1e-9) and (abs(float(varR)-0.036288)<1e-9)"},
  {"claim":"P(R<0)=Phi(-0.79373)~0.2137","code":"from sympy import erf,sqrt as ssqrt; z=Rational(-1512,10000)/(ssqrt(Rational(36288,1000000))); p=(1+erf(z/ssqrt(2)))/2; result=abs(float(p)-0.2137)<0.002"}
]