Lesson 2.5.7 · 65 min
Synthetic data for stress testing: GBM, GARCH and bootstrapping
A market has only one history, and it does not contain the crises that have not happened yet. A synthetic data generator produces hundreds of alternative histories: it is useful for measuring how a strategy behaves in scenarios the sample does not have and for checking that a method finds what is known to be there, but not for proving that an edge exists.
- Module
- 2.5 · Biases and validation
- Duration
- 65 min
- Prerequisites
- 2.1.4 · Volatility models: ARCH, GARCH, EGARCH and GJR with the arch library, 2.5.5 · Monte Carlo: trade resampling, block bootstrap and signal permutations, 1.6.2 · Stylized facts: fat tails, clustering, autocorrelation and leverage
- Author
- Jesús Cuesta
- Updated
Level 2 lessons
2.1 · Statistics and econometrics for trading
- 2.1.1 Hypothesis tests, p-values, bootstrap and regression with robust standard errors
- 2.1.2 Stationarity: ADF, KPSS, differencing and fractional differencing
- 2.1.3 ARMA and ARIMA models: autocorrelation (ACF) and partial autocorrelation (PACF)
- 2.1.4 Volatility models: ARCH, GARCH, EGARCH and GJR with the arch library
- 2.1.5 Cointegration: Engle-Granger, Johansen and the half-life of mean reversion
- 2.1.6 Kalman filter for dynamic hedge ratios
- 2.1.7 Regime detection: hidden Markov models and structural breaks
- 2.1.8 The multiple testing problem: Bonferroni, FDR and the “factor zoo”
2.2 · Anatomy of a trading system
2.3 · Strategy families
- 2.3.1 Trend following and momentum
- 2.3.2 Mean reversion: RSI and Bollinger, intraday reversal and gaps
- 2.3.3 Pairs trading and statistical arbitrage: distance, cointegration and Kalman
- 2.3.4 Carry in currencies, futures and bonds; seasonality and calendar effects
- 2.3.5 Factors (value, quality, low volatility, size) and event strategies
- 2.3.6 Introduction to market making: inventory, spread and adverse selection (Avellaneda-Stoikov)
- 2.3.7 Crypto-specific strategies: momentum, funding arbitrage and basis
2.4 · Backtesting: engines and paradigms
- 2.4.1 Vectorized backtest from scratch: matrices, lag, costs and limits
- 2.4.2 Event-driven backtest from scratch: loop, portfolio, orders and fills
- 2.4.3 vectorbt: massive parameter sweeps and portfolio simulation
- 2.4.4 backtesting.py and backtrader: two teaching engines and their status
- 2.4.5 NautilusTrader: architecture, data, strategies and the same code in backtest and live
- 2.4.6 LEAN/QuantConnect and Zipline Reloaded; bt for asset allocation
- 2.4.7 Cost modeling: commissions, bid-ask spread, impact, financing and securities borrowing
- 2.4.8 Fill modeling: limit orders, queue, partial fills, gaps and stops
2.5 · Biases and validation
- 2.5.1 Backtest biases: look-ahead, survivorship, selection and narrative
- 2.5.2 In-sample and out-of-sample: anchored and rolling walk-forward
- 2.5.3 Combinatorial purged cross-validation for strategies
- 2.5.4 Deflated Sharpe Ratio and Probability of Backtest Overfitting (PBO)
- 2.5.5 Monte Carlo: trade resampling, block bootstrap and signal permutations
- 2.5.6 Parameter sensitivity: plateaus versus peaks, robustness across assets and periods
- 2.5.7 Synthetic data for stress testing: GBM, GARCH and bootstrapping
- 2.5.8 “Honest backtest” checklist before moving to paper trading
2.6 · Performance metrics
Why manufacture histories
A backtest on history answers one question: what would have happened with these data. Lesson 2.5.5 resampled that history to see how much the result could vary. But resampling only recombines what already happened: if the sample has no 35% drop in a month, no bootstrap is going to invent it. A synthetic data generator is a model that produces new series with whatever statistical properties are asked of it: it can be asked for a crisis that is not in the sample, a thousand years of data or a market with no edge at all.
Three legitimate uses and one that is not:
Mermaid source
flowchart LR G[Generator] --> C[Test the code] G --> E[Stress tests] G --> R[Risk and sizing] G --> V[Validate an edge] C --> C2[Known answer] E --> E2[New crises] R --> R2[Tails and drawdown] V --> V2[No: circular]
- Test the code: if the generator plants an effect of known size, a correct method must recover it, and on a series with no effect it must find nothing. That is what all the data of this course do.
- Stress tests: insert scenarios that history does not have, or has only once, and see how the strategy responds.
- Risk and sizing: estimate the distribution of drawdowns or losses with more paths than history provides.
- Validate an edge: no. On synthetic data, a strategy wins if the generator contains what the strategy exploits. The reasoning is circular: we come back to this at the end.
Three generators
The reference series is the future on the level 2 fictitious index IDA, 26 years of sessions. It is itself synthetic, and that allows something that cannot be done with real data: comparing what each generator estimates with the model that actually produced the data (further below).
import numpy as np
import pandas as pd
from scipy import stats
fut = pd.read_parquet("futuros2_continuos.parquet")
ida = (fut[fut["mercado"] == "IDA"].set_index("fecha")
["cierre_ajustado"])
r = np.log(ida).diff().dropna()
print(f"{len(r)} sessions, {r.index[0]:%Y-%m-%d} "
f"to {r.index[-1]:%Y-%m-%d}")
print(f"Annual mean {252 * r.mean():.1%}, "
f"volatility {np.sqrt(252) * r.std():.1%}")6538 sessions, 2000-01-04 to 2025-12-31
Annual mean 6.3%, volatility 20.1%Geometric Brownian motion
The simplest model, and the one behind Black-Scholes: the log of the price is a random walk with independent normal returns of constant mean and volatility :
N, T = 200, len(r)
rng = np.random.default_rng(2025)
mu, sigma = r.mean(), r.std()
gbm = mu + sigma * rng.standard_normal((N, T))
print(gbm.shape)(200, 6538)GJR-GARCH with t innovations
Bollerslev's GARCH (1986) makes today's variance depend on yesterday's shock and variance; the extension by Glosten, Jagannathan and Runkle (1993) adds a term that only acts after negative days (the leverage effect), and Student's innovations give fatter tails than the normal (lesson 2.1.4):
arch fits the model by maximum likelihood. To simulate hundreds of paths it is faster to write the recursion with
NumPy, one time step for all paths at once, than to call the model's simulate 200 times:
from arch import arch_model
ajuste = arch_model(100 * r, mean="Constant", vol="GARCH",
p=1, o=1, q=1, dist="t").fit(disp="off")
print(ajuste.params.round(3).to_dict())
def simular_gjr(p, T, N, rng, burn=500):
"""N paths of T sessions from a GJR-GARCH-t (as fractions)."""
m, w, a, g, b, nu = (p["mu"], p["omega"], p["alpha[1]"],
p["gamma[1]"], p["beta[1]"], p["nu"])
z = rng.standard_t(nu, (T + burn, N)) * np.sqrt((nu - 2) / nu)
s2 = np.full(N, w / (1 - a - b - g / 2))
e = np.zeros(N)
out = np.empty((T + burn, N))
for t in range(T + burn):
s2 = w + (a + g * (e < 0)) * e ** 2 + b * s2
e = np.sqrt(s2) * z[t]
out[t] = m + e
return out[burn:].T / 100
p_sim = ajuste.params.copy()
p_sim["mu"] = 100 * r.mean() # the sample mean (see the text)
garch = simular_gjr(p_sim, T, N, rng)
persistencia = (ajuste.params["alpha[1]"] + ajuste.params["beta[1]"]
+ ajuste.params["gamma[1]"] / 2)
print(f"Persistence: {persistencia:.3f}; "
f"mean of the fit: {252 * ajuste.params['mu'] / 100:.1%} annual"){'mu': 0.054, 'omega': 0.032, 'alpha[1]': 0.044, 'gamma[1]': 0.104, 'beta[1]': 0.882, 'nu': 5.422}
Persistence: 0.978; mean of the fit: 13.5% annualTwo details. The persistence, , is 0.978: a volatility shock takes about 31 sessions to halve (). And the estimated mean, 0.054% daily, is 13.5% annual, more than double the sample mean (6.3%). It is not a mistake: with innovations, the likelihood gives little weight to extreme days, which in IDA are mostly drops, and the estimated mean goes up. Simulating with it would draw a considerably more bullish market than the real one, so the simulation uses the sample mean.
Stationary bootstrap of the history
The third generator assumes no model: it reassembles history in blocks of random length, as in lesson 2.5.5, with an average of 75 sessions to preserve volatility streaks.
def indices_estacionario(n, largo, b, rng):
idx = np.empty(largo, dtype=np.int64)
idx[0] = rng.integers(n)
salto = rng.random(largo) < 1 / b
nuevo = rng.integers(n, size=largo)
for t in range(1, largo):
idx[t] = nuevo[t] if salto[t] else (idx[t - 1] + 1) % n
return idx
x = r.to_numpy()
boot = np.array([x[indices_estacionario(T, T, 75, rng)]
for _ in range(N)])
print(gbm.shape, garch.shape, boot.shape)(200, 6538) (200, 6538) (200, 6538)What does each one reproduce?
The stylized facts of lesson 1.6.2, measured on IDA and on the median of each generator's 200 paths:
def acf(x, k):
x = x - x.mean()
return (x[k:] * x[:-k]).sum() / (x * x).sum()
def hechos(x):
z = (x - x.mean()) / x.std()
eq = np.exp(np.cumsum(x))
return {"vol": np.sqrt(252) * x.std(),
"curtosis": stats.kurtosis(x),
"asimetría": stats.skew(x),
"días >4σ": (np.abs(z) > 4).sum(),
"acf r 1": acf(x, 1),
"acf |r| 1": acf(np.abs(x), 1),
"acf |r| 20": acf(np.abs(x), 20),
"apalanc.": np.corrcoef(x[:-1], np.abs(x[1:]))[0, 1],
"dd máx": (eq / np.maximum.accumulate(eq) - 1).min()}
tabla = {"IDA": hechos(x)}
for nombre, sims in (("GBM", gbm), ("GJR-t", garch),
("bootstrap", boot)):
tabla[nombre] = pd.DataFrame([hechos(s) for s in sims]).median()
pd.DataFrame(tabla).round(3) IDA GBM GJR-t bootstrap
vol 0.201 0.201 0.182 0.201
curtosis 12.584 -0.004 9.009 12.141
asimetría -0.373 0.001 0.105 -0.369
días >4σ 51.000 0.000 39.000 49.000
acf r 1 0.020 0.000 -0.003 0.019
acf |r| 1 0.247 -0.001 0.215 0.240
acf |r| 20 0.180 0.002 0.122 0.134
apalanc. -0.056 -0.000 -0.038 -0.054
dd máx -0.706 -0.540 -0.505 -0.661| Fact | IDA | GBM | GJR-GARCH-t | Bootstrap (b = 75) |
|---|---|---|---|---|
| Tails (excess kurtosis) | 12.6 | 0: none | 9.0: a good part | 12.1: almost all |
| Days beyond 4σ | 51 | 0 | 39 | 49 |
| Skewness | −0.37 | 0 | 0.105 | −0.37 |
| Clustering (ACF of |r| at 1 session) | 0.25 | 0 | 0.21 | 0.24 |
| Leverage effect (corr. of r with next |r|) | −0.056 | 0 | −0.038 | −0.054 |
| Maximum drawdown (median) | −70.6% | −54.0% | −50.5% | −66.1% |
GBM reproduces nothing but the mean and the volatility: no tails, no streaks, no skewness. GJR-GARCH-t reproduces short-term clustering (somewhat less: 0.21 against 0.25) and a good part of the tails and of the leverage effect (the term provides it), but not the skewness of daily returns, because its innovations are symmetric. The bootstrap reproduces almost everything, as expected: they are the same days. Its median drawdown (−66.1%) comes close to the historical one (−70.6%); the other two stay at −54% and −51% because they spread the drops without clustering them.
The header figure shows the fact that none of them reproduces well: the long memory of volatility. IDA's autocorrelation of |r| is still 0.130 at 50 sessions and 0.077 at 100, and dies out around 150. That of GJR-t falls to 0.052 and 0.010, because its memory decays exponentially; that of the bootstrap, to 0.062 and 0.015, because the 75-session blocks cut any longer dependence.
RETARDOS = [1, 2, 5, 10, 20, 50, 100, 150, 200, 250]
memoria = pd.DataFrame(
{"IDA": [acf(np.abs(x), k) for k in RETARDOS]}, index=RETARDOS)
for nombre, sims in (("GBM", gbm), ("GJR-t", garch),
("bootstrap", boot)):
memoria[nombre] = [np.median([acf(np.abs(s), k) for s in sims])
for k in RETARDOS]
memoria.rename_axis("retardo").round(3) IDA GBM GJR-t bootstrap
retardo
1 0.247 -0.001 0.215 0.240
2 0.277 0.000 0.210 0.272
5 0.253 0.000 0.192 0.234
10 0.211 0.001 0.166 0.184
20 0.180 0.002 0.122 0.134
50 0.130 0.001 0.052 0.062
100 0.077 -0.001 0.010 0.015
150 0.007 0.002 0.002 -0.003
200 0.012 -0.001 -0.000 -0.002
250 -0.011 0.001 -0.005 -0.003For a risk test, the consequence is direct: GBM and GJR-t place IDA's real drawdown in the tail of their distribution (below their 25th percentile: −60.6% in GBM and −60.2% in GJR-t), that is, they treat it as bad luck. The bootstrap leaves it inside its central box (25th percentile: −76.7%): it treats it as normal. Choosing the generator is choosing how much risk you are going to see.
What the fit does not give back
Since IDA is synthetic, it can be compared with the model that generated it. The level 2 README documents it: the equity market factor is a GJR-GARCH(1,1) with innovations (, , , in the generator's code) whose long-run volatility and drift change according to a script of segments with fixed dates. From 2006, the IDA future follows exactly that factor (before, one of its own with the same script and the same parameters), plus the planted carry and momentum. The fit above returned , , and : close in and , but with a persistence of 0.978 against the generator's 0.945 (). The script can be read in the data, but not as is:
guion = { # segments from the level 2 README: (from, to, vol, drift)
"calma 2004-07": ("2004-01-02", "2007-07-13", 0.12, 0.07),
"crisis 2008-09": ("2008-09-02", "2009-03-09", 0.52, -0.95),
"calma 2012-15": ("2012-06-01", "2015-08-14", 0.12, 0.13),
"covid 2020": ("2020-02-20", "2020-03-23", 0.80, -4.00),
"2022": ("2022-01-04", "2022-10-12", 0.25, -0.32),
}
filas = {}
for k, (a, b, v, d) in guion.items():
t = r.loc[a:b]
filas[k] = {"sesiones": len(t), "vol_lp_guion": v,
"vol_IDA": np.sqrt(252) * t.std(),
"deriva_guion": d, "deriva_IDA": 252 * t.mean()}
pd.DataFrame(filas).T.round(2) sesiones vol_lp_guion vol_IDA deriva_guion deriva_IDA
calma 2004-07 888.0 0.12 0.13 0.07 0.09
crisis 2008-09 130.0 0.52 0.50 -0.95 -1.01
calma 2012-15 806.0 0.12 0.13 0.13 0.13
covid 2020 23.0 0.80 0.53 -4.00 -5.60
2022 195.0 0.25 0.19 -0.32 -0.05In the long, quiet segments, realized volatility (0.13) comes close to the script's (0.12), and in the 130 sessions of 2008-2009 too (0.50 against 0.52). In the short 2020 segment, it falls short: the script sets the long-run volatility, and a GARCH takes dozens of sessions to approach it. In its 23 sessions, the script asked for 0.80 and realized volatility was 0.53. The drifts deviate more, because the GARCH-t noise sits on top of the script's: in 2020, −5.60 against −4.00; in 2022, −0.05 against −0.32 (the level 2 README documents, year by year, how far the realized factor deviates from the script). A GARCH fitted to the whole series does not see segments: it sees a volatility that rises and falls with a lot of inertia, and summarizes it in a higher persistence than the generator's (0.978 against 0.945).
It is a general lesson. The estimated parameters are those of the model being fitted, not those of the process that produced the data, and when the model is not the true one (with real data, it never is) the generator that comes out of the fit inherits its simplifications. To check that estimation does work when the model is the right one, it is enough to fit the GJR-t to its own paths:
import warnings
from arch.utility.exceptions import ConvergenceWarning
warnings.filterwarnings("ignore", category=ConvergenceWarning)
rng_ajuste = np.random.default_rng(7)
propios = simular_gjr(ajuste.params, T, 10, rng_ajuste)
reajustes = pd.DataFrame([
arch_model(100 * s, mean="Constant", vol="GARCH", p=1, o=1, q=1,
dist="t").fit(disp="off").params for s in propios])
pd.DataFrame({"verdad": ajuste.params,
"media_10_ajustes": reajustes.mean(),
"desv_10_ajustes": reajustes.std()}).round(3) verdad media_10_ajustes desv_10_ajustes
mu 0.054 0.059 0.007
omega 0.032 0.031 0.003
alpha[1] 0.044 0.047 0.009
gamma[1] 0.104 0.102 0.010
beta[1] 0.882 0.881 0.007
nu 5.422 5.351 0.351On paths generated by the GJR-t itself, the fit recovers its parameters with a small error. On IDA it came close in
, and , but not in persistence, because IDA is not a GJR-t with fixed parameters: its long-run
volatility changes by segment. The ConvergenceWarning from arch (which the optimizer may emit on some series) is
silenced by its category so that the output is stable; in real work it is worth reviewing.
Stress tests with inserted crises
The main use of a model-based generator is being able to insert scenarios. On the 200 GJR-t paths, starting at session 3,000, three crises of different shapes are inserted: a fast 35% drop in 23 sessions (like the 2020 one) with volatility tripled, a slow 45% drop over 500 sessions (like the 2000-2002 one) with volatility ×1.5, and a fast drop followed by a 45% rebound in 60 sessions (a V). In each segment the mean of the returns is replaced by the scenario's drift and their deviations are amplified:
T0 = 3000 # session at which the scenario starts
def escenario(sims, caida, sesiones, mult_vol, rebote=0.0,
sesiones_rebote=0):
"""Insert a drop (and a rebound) into each simulated path."""
out = sims.copy()
tramo = slice(T0, T0 + sesiones)
base = out[:, tramo] - out[:, tramo].mean(axis=1, keepdims=True)
out[:, tramo] = np.log(1 + caida) / sesiones + mult_vol * base
if sesiones_rebote:
tramo2 = slice(T0 + sesiones, T0 + sesiones + sesiones_rebote)
base2 = out[:, tramo2] - out[:, tramo2].mean(axis=1,
keepdims=True)
out[:, tramo2] = (np.log(1 + rebote) / sesiones_rebote
+ mult_vol * base2)
return out
ESCENARIOS = {
"base": garch,
"caída rápida": escenario(garch, -0.35, 23, 3.0),
"caída lenta": escenario(garch, -0.45, 500, 1.5),
"en V": escenario(garch, -0.35, 23, 3.0, rebote=0.45,
sesiones_rebote=60),
}
for nombre, sims in ESCENARIOS.items():
caida = np.exp(sims[:, T0:T0 + 560].cumsum(axis=1)).min(axis=1)
print(f"{nombre:>13}: median worst point "
f"{np.median(caida) - 1:.1%}") base: median worst point -10.1%
caída rápida: median worst point -42.5%
caída lenta: median worst point -50.0%
en V: median worst point -37.2%In the base scenario, the worst point of the 560 sessions after session 3,000 is, in median, 10.1% below the start. With the inserted crises, between −37.2% and −50.0%. Three simple strategies are put through each scenario: holding the index, holding it with a volatility target of 15% (position = 15% / 20-session EWMA volatility, capped at 2) and the 12-month trend of lesson 2.5.5 with the same scaling. All of them use only data up to the previous session.
def ewm_vol(sims, span=20):
a = 2 / (span + 1)
v = np.empty_like(sims)
v[:, 0] = sims[:, :250].var(axis=1)
for t in range(1, sims.shape[1]):
v[:, t] = (1 - a) * v[:, t - 1] + a * sims[:, t - 1] ** 2
return np.sqrt(252 * v) # with data up to t - 1
def estrategias(sims):
simple = np.expm1(sims)
vol = ewm_vol(sims)
tend = np.sign(pd.DataFrame(sims.T).rolling(252).sum()
.shift(1).fillna(0).to_numpy().T)
peso_vol = np.minimum(0.15 / vol, 2.0)
return {"comprar y mantener": simple,
"objetivo de vol.": peso_vol * simple,
"tendencia 12 m": tend * peso_vol * simple}
def peor_caida(rend):
eq = np.cumprod(1 + rend[:, T0:T0 + 560], axis=1)
return eq.min(axis=1) - 1
estres = {}
for esc, sims in ESCENARIOS.items():
for est, rend in estrategias(sims).items():
c = peor_caida(rend)
estres[(esc, est)] = {"mediana": np.median(c),
"p5": np.quantile(c, 0.05)}
estres = pd.DataFrame(estres).T
for col in ("mediana", "p5"):
print(f"Worst drawdown in 560 sessions ({col}):")
print(estres[col].unstack().reindex(list(ESCENARIOS))
.round(3).to_string(), end="\n\n")Worst drawdown in 560 sessions (mediana):
comprar y mantener objetivo de vol. tendencia 12 m
base -0.101 -0.085 -0.149
caída rápida -0.425 -0.253 -0.185
caída lenta -0.500 -0.401 -0.137
en V -0.372 -0.204 -0.228
Worst drawdown in 560 sessions (p5):
comprar y mantener objetivo de vol. tendencia 12 m
base -0.442 -0.299 -0.398
caída rápida -0.607 -0.433 -0.430
caída lenta -0.651 -0.494 -0.265
en V -0.513 -0.336 -0.436Each strategy has its bad scenario:
- Holding absorbs each crisis in full: −42.5% in the fast drop and −50.0% in the slow one (medians).
- The volatility target reduces the fast drop from −42.5% to −25.3%, because volatility spikes and the position shrinks within a few days; in the slow one it helps less (−40.1%), because volatility rises little and the position barely changes.
- Trend is the best in the slow drop (−13.7%): it has time to change sign and ends up short. In the V it loses more than in the fast drop with no rebound (−22.8% against −18.5%), because it goes short near the bottom and the rebound catches it the wrong way round. And in the base scenario, a GJR-t with no planted trend, it is the worst of the three (−14.9%): with no effect to capture, it only pays for the sign changes.
The 5th percentiles tell the same story more starkly: in the fast drop, one path in twenty leaves the holder with a loss of more than 60%. This is the information a stress test should give before trading: not whether the strategy wins, but in what kind of market it loses and how much.
The data of this course are synthetic
Everything above also describes how the data used in this course were made. The README of the level 2 data (linked in the resources) documents its generator, and it is a good example of what can and cannot be asked of a generator:
Mermaid source
flowchart LR G[Segment script] --> M[GJR-t market] M --> A[Returns] S[GARCH-t sectors] --> A I[Idiosyncratic] --> A P[Planted effects] --> A E[Events] --> A A --> D[Files and README]
- A script of segments with fixed dates (calm, the 2000-2002 crisis, 2008, 2020, 2022…) sets the market's drift and long-run volatility; in the stress segments betas compress toward 1 and correlations rise.
- A GJR-GARCH market factor with innovations, GARCH-t sector factors and an idiosyncratic component per asset provide the tails, the clustering and the correlation structure.
- The planted effects have published formulas and sizes: the time-series momentum of the futures, the momentum,
value and low-volatility premia of the stocks, the cointegration of the pairs, the carry of the currencies… Alongside
them, controls with no effect: the “quality” and the ESG placebo of the stocks, the correlated but not
cointegrated pairs, the
rupturaseries after its break, 995 of the 1,000 strategies inruido2. - The events (guided bankruptcies, splits, dividends, index rebalances, earnings) and the fundamentals are generated consistently with the prices.
- A fixed seed per dataset makes everything reproducible byte for byte with the command given in the README.
That construction is what makes it possible to use the data as a test bench: if a method applied to the futures momentum does not find it, the method fails; if it finds an effect in the ESG placebo more often than the significance level, it fails too. But it has limits that must be kept in mind every time a figure from this course is read:
| The course generator has | The course generator does not have |
|---|---|
| Fat tails and volatility clustering | Liquidity jumps, trading gaps from suspensions, technical failures |
| Crises on fixed dates, common to all series | New crises or crises shaped differently from the script's |
| Constant effects of the published size | Effects that weaken once they become known or are arbitraged away |
| Costs and spreads by formula | Market impact of one's own orders |
| A known “truth” | No guarantee that this truth resembles that of a real market |
The most important limit is the third one on the right. In real markets, published anomalies lose part of their return after publication1Note 1 · McLean, R. D. and Pontiff, J. (2016), “Does Academic Research Destroy Stock Return Predictability?”, The Journal of Finance 71(1), 5-32, doi:10.1111/jofi.12365: across 97 variables that predicted stock returns, portfolio returns are 26% lower out of the original sample and 58% lower after publication., and profitable strategies attract capital that erodes them. In the course data, the planted momentum is worth the same in 2001 as in 2025 because that is how it is written. No return in these data is evidence that a strategy works: it is evidence that the code finds what the generator contains.
Exercises
- Add a fourth scenario: a 50% drop in 10 sessions with volatility multiplied by 4 (an extreme crash) and compute the median worst drawdown and the 5th percentile for the three strategies.
Solution
crash = escenario(garch, -0.50, 10, 4.0)
for est, rend in estrategias(crash).items():
c = peor_caida(rend)
print(f"{est:>18}: median {np.median(c):.1%}, "
f"p5 {np.quantile(c, 0.05):.1%}")comprar y mantener: median -55.5%, p5 -70.0%
objetivo de vol.: median -27.0%, p5 -44.6%
tendencia 12 m: median -18.2%, p5 -42.5%With a 50% crash in 10 sessions, holding leaves a median worst drawdown of −55.5% (−70.0% at the 5th percentile). The volatility target cuts it in half (−27.0%), but at the 5th percentile it still loses 44.6%: in 10 sessions, the 20-session EWMA does not have time to cut the whole position before the worst has passed. Trend ends at a median of −18.2%, with a 5th percentile of −42.5%: when the path was bearish it was already short, and when it was bullish, volatility scaling is the only thing protecting it. None of the three avoids a large loss in the worst paths.
- Repeat the bootstrap with blocks of 250 sessions on average. Do the volatility memory at 100 sessions and the median drawdown change? What is gained and what is lost with such long blocks?
Solution
rng250 = np.random.default_rng(250)
boot250 = np.array([x[indices_estacionario(T, T, 250, rng250)]
for _ in range(N)])
h = pd.DataFrame([hechos(s) for s in boot250]).median()
print(f"ACF |r| at 100: "
f"{np.median([acf(np.abs(s), 100) for s in boot250]):.3f}")
print(f"Median drawdown: {h['dd máx']:.1%}")ACF |r| at 100: 0.041
Median drawdown: -63.7%With 250-session blocks, the autocorrelation of |r| at 100 sessions rises from 0.015 to 0.041 (IDA: 0.077) and the median drawdown changes little (from −66.1% to −63.7%). Long memory is gained, because each block keeps almost a year of continuous history; variety is lost, because with 6,538 sessions only about 26 distinct blocks fit per path and the paths look very much like each other and like history. In the limit, a block as long as the series returns history itself.
- A colleague proposes validating an intraday reversal strategy by generating 10,000 days with a GJR-GARCH-t fitted to the index and checking that it wins in 90% of them. What would you tell them?
Solution
That the result cannot validate the strategy. A GJR-GARCH-t with a constant mean generates returns with no autocorrelation: a reversal strategy cannot have an edge on those data except through an error (alignment, costs, look-ahead) or an effect that the model produces unintentionally. If it wins in 90% of the paths, the first thing to look for is that error, and synthetic data are excellent for that, because the correct answer is known to be “it does not win”. What can be done with the generator is a stress test: how much the strategy loses when volatility triples or there are large gaps. Evidence of the edge has to come from real data, with the validations of this module (lessons 2.5.2 to 2.5.6), and from a hypothesis about where it comes from (lesson 2.2.1).
Footnotes
-
McLean, R. D. and Pontiff, J. (2016), “Does Academic Research Destroy Stock Return Predictability?”, The Journal of Finance 71(1), 5-32, doi:10.1111/jofi.12365: across 97 variables that predicted stock returns, portfolio returns are 26% lower out of the original sample and 58% lower after publication. ↩
Each answer is folded under its question
Self-assessment
The GBM paths have the same mean and volatility as IDA. Which stylized fact do they reproduce?
- AFat tails
- BNone of those in the table except the average volatility: kurtosis 0, no day beyond 4σ, no volatility memory and no leverage effect
- CVolatility clustering
- DIDA's drawdown
Show answer
Answer B. None of those in the table except the average volatility: kurtosis 0, no day beyond 4σ, no volatility memory and no leverage effect
A GBM has normal, independent log returns. It reproduces the mean and the volatility, and nothing else: IDA has an excess kurtosis of 12.6 and 51 days beyond 4σ; the GBM paths, 0 and 0. A stress test with GBM underestimates extreme days and losing streaks.
The GJR-GARCH-t fitted to IDA has a daily mean of 0.054% (13.5% annual) and IDA's sample mean is 6.3% annual. Why does the lesson simulate with the sample mean?
- ABecause the mean of the fit is miscalculated
- BBecause that way the drawdown comes out smaller
- CBecause the t likelihood gives little weight to extreme days, which in IDA are mostly drops: its estimated mean is higher than the sample one, and simulating with it would draw a more bullish market than the real one
- DBecause
archdoes not allow simulating with the fitted mean
Show answer
Answer C. Because the t likelihood gives little weight to extreme days, which in IDA are mostly drops: its estimated mean is higher than the sample one, and simulating with it would draw a more bullish market than the real one
With t innovations, the maximum likelihood estimator of the mean resembles a robust mean: it reduces the influence of extreme days. In a series with large drops, that raises the mean: here, to more than double. Simulating with it would give paths with a drift the series never had and milder drawdowns.
IDA's autocorrelation of |r| is still 0.077 at 100 sessions; that of the fitted GJR-GARCH-t is 0.010 and that of the bootstrap with blocks of 75, 0.015. Where does that long memory in IDA come from?
- AFrom the segment script of the course generator: long-run volatility changes by era, and a GARCH with a single long-run volatility turns it into persistence, but its memory decays exponentially
- BFrom an error in the data
- CFrom the carry of the futures
- DFrom the contract rolls
Show answer
Answer A. From the segment script of the course generator: long-run volatility changes by era, and a GARCH with a single long-run volatility turns it into persistence, but its memory decays exponentially
The level 2 data combine a GJR-GARCH with a different long-run volatility in each segment of the script (0.12 in calm, 0.52 in 2008…). No generator in the lesson has those regime changes: the GARCH absorbs them into a higher persistence than the generator's (0.978 against 0.945) and the bootstrap cuts them into 75-session blocks.
In the V scenario (a 35% drop in 23 sessions and a 45% rebound in 60), the trend rule loses more (−22.8%) than in the fast drop with no rebound (−18.5%). Why?
- ABecause volatility is higher
- BBecause the trend does not use volatility targeting
- CBy chance: there are few paths
- DBecause after the drop the 12-month signal turns short near the bottom and the rebound costs it money: trend suffers in fast reversals
Show answer
Answer D. Because after the drop the 12-month signal turns short near the bottom and the rebound costs it money: trend suffers in fast reversals
A trend rule needs time to change sign. In a slow drop it has that time and ends up short (−13.7%, the best of the three); in a V, it changes late, just before the rebound. It is the kind of weakness a stress test should make visible before trading.
A strategy earns a lot on the synthetic data of this course. What can be concluded?
- AThat it will work in real markets
- BThat the generator is realistic
- CThat the code finds the effect the generator planted (or one the generator produces unintentionally); about real markets, nothing
- DThat there are no biases in the backtest
Show answer
Answer C. That the code finds the effect the generator planted (or one the generator produces unintentionally); about real markets, nothing
In synthetic data, the “truth” is the model that generated them. A result on them says whether the method recovers what the model contains. Evidence of a real edge has to come from real data, with the validations of this module, and from an economic hypothesis.
Key points
- Each generator reproduces what it carries inside: GBM, only mean and volatility; GJR-GARCH-t, tails and clustering with short memory; the block bootstrap, almost everything in the history, but only what the history contains and chopped into blocks.
- Against IDA (kurtosis 12.6, drawdown −70.6%), GBM gives kurtosis 0 and a median drawdown of −54%; GJR-t, kurtosis 9.0 and −51%; the bootstrap, 12.1 and −66%.
- The parameters of a fit are not those of the process that generated the data: with long-run volatility changing by segment, the GARCH estimates a persistence of 0.978 (the generator's is 0.945) and a mean of 13.5% annual against the 6.3% sample mean.
- A useful stress test inserts crises with different shapes: in a fast drop, volatility targeting reduces the median worst drawdown from −42.5% to −25.3%; in a V, trend loses more than in the drop with no rebound.
- The data of this course are synthetic, with effects planted and documented in their README: they are for checking that a method recovers a known effect and does not invent others, never for proving that a strategy works in real markets.
Resources
Books
- Bollerslev, T. (1986): Generalized autoregressive conditional heteroskedasticity. Journal of Econometrics 31(3), 307-327
- Glosten, L., Jagannathan, R. and Runkle, D. (1993): On the Relation between the Expected Value and the Volatility of the Nominal Excess Return on Stocks. The Journal of Finance 48(5), 1779-1801
- Cont, R. (2001): Empirical properties of asset returns: stylized facts and statistical issues. Quantitative Finance 1(2), 223-236