Lesson 2.1.1 · 70 min
Hypothesis tests, p-values, bootstrap and regression with robust standard errors
A backtest is a sample and its mean return is an estimate with error. This lesson measures that error with three tools (the t-test, the bootstrap and Newey-West standard errors) on synthetic data where you know which strategies have an edge and which do not, and shows why overlapping returns manufacture a t of 10.5 where the honest one is 1.8.
- Module
- 2.1 · Statistics and econometrics for trading
- Duration
- 70 min
- Prerequisites
- 0.7.2 · Probability and distributions, correlation versus causation and simple regression, 1.6.2 · Stylized facts: fat tails, clustering, autocorrelation and leverage, 1.6.3 · Seasonality: day of the week, month, turn of the month and intraday sessions
- 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
A backtest is a sample
A four-year backtest with a mean return of 15% a year does not say that the strategy earns 15% a year. It says that, in those four particular years, it earned that. Had the market rolled other dice, the mean would be different. The question of this lesson is how different it could have turned out and, in particular, whether a result like that is compatible with the true edge being zero.
Lesson 1.6.3 already used the t-statistic and the bootstrap for calendar effects. Here the method is formalized, its power is measured and the two problems that produce the most false t's in practice are dealt with: observations that overlap and assets that move together.
Mermaid source
flowchart TB
H[Null hypothesis] --> E[Statistic]
E --> D[Distrib. under H0]
D --> P[p-value]
P --> R{p < α?}
R -- yes --> RE[Reject H0]
R -- no --> NR[Do not reject]- Null hypothesis (H0): the expected mean return is zero. It is what you try to refute.
- Statistic: a number computed from the sample that would be small if H0 were true. For the mean, Student's t:
with the mean of the returns and their standard deviation.
- Distribution under H0: how the statistic would be distributed if H0 were true. With independent observations and large , a standard normal; with the bootstrap, the one that comes out of resampling the data.
- p-value: the probability, if H0 were true, of obtaining a statistic at least as extreme as the one observed. It is not the probability that H0 is true.
When deciding with a threshold α (5% by convention) you can make two errors:
| H0 true (no edge) | H0 false (there is an edge) | |
|---|---|---|
| H0 is rejected | Type I error (false positive), probability α | Correct: the power |
| H0 is not rejected | Correct | Type II error (false negative) |
In trading, the type I error is the expensive one: you put money into a strategy with no edge and pay costs for nothing. The type II error is quieter: you discard a good strategy because the sample was short.
A useful identity connects the t with the Sharpe. If is the annual Sharpe and the sample lasts years,
A Sharpe of 1 over 4 years gives a t of 2; a Sharpe of 0.5 needs 16 years to reach 2.
Four strategies with a known answer
ruido2_estrategias.parquet holds the daily returns of 1,000 fictional strategies over the 1,003 sessions from 2022
to 2025. They are synthetic data: 995 have no edge at all (true Sharpe 0) and five have a planted one, documented in
ruido2_verdad.csv. That one of them wins here says nothing about any real market; it serves to know whether a
method gets it right. Four are chosen:
e0518, true Sharpe 1.5: the clearest edge in the set.e0258, true Sharpe 0.7: a real but modest edge.e0001, no edge: any one of them.- the best of the 995 with no edge, by its measured Sharpe.
import numpy as np
import pandas as pd
from scipy import stats
r = pd.read_parquet("ruido2_estrategias.parquet").pivot(
index="fecha", columns="estrategia", values="rendimiento")
verdad = pd.read_csv("ruido2_verdad.csv").set_index("estrategia")
sr = r.mean() / r.std() * np.sqrt(252)
nulas = verdad.index[verdad["sharpe_real"] == 0]
mejor_nula = sr[nulas].idxmax()
elegidas = ["e0518", "e0258", "e0001", mejor_nula]
filas = {}
for e in elegidas:
t, p = stats.ttest_1samp(r[e], 0)
filas[e] = {"sharpe_real": verdad.loc[e, "sharpe_real"],
"sharpe_medido": sr[e],
"media_anual_%": 100 * 252 * r[e].mean(),
"t": t, "p": p,
"sr_x_raiz_anos": sr[e] * np.sqrt(len(r) / 252)}
print(len(r), "sessions;", len(nulas), "strategies with no edge")
pd.DataFrame(filas).T.round(3)1003 sessions; 995 strategies with no edge
sharpe_real sharpe_medido media_anual_% t p sr_x_raiz_anos
e0518 1.5 1.579 33.165 3.150 0.002 3.150
e0258 0.7 0.622 8.492 1.240 0.215 1.240
e0001 0.0 0.003 0.063 0.007 0.995 0.007
e0598 0.0 1.503 26.607 2.999 0.003 2.999The last column checks the identity: the t from scipy.stats.ttest_1samp matches the measured Sharpe times the square
root of the years (3.98 years). The table already shows the two errors:
e0518(true Sharpe 1.5) comes out with t = 3.15 and p = 0.002: the test gets it right.e0258has a real edge, but its measured Sharpe is 0.62 and its p, 0.215: with four years it cannot be told apart from noise. It is a type II error, and it is not exceptional bad luck: with a true Sharpe of 0.7 and four years, the expected t is 1.4.- The best of the nulls,
e0598, has a measured Sharpe of 1.50 and p = 0.003: more “significant” than one of the good ones. It is the maximum of 995 tests; the p-value of a test is only valid for a hypothesis chosen before looking. Lesson 2.1.8 deals with that selection; here it is enough to remember that no method in this lesson corrects it.
The bootstrap: the distribution without formulas
The t-test assumes that the mean is normally distributed. With fat-tailed returns (lesson 1.6.2) and a thousand observations, the approximation is usually good, but you do not always have a formula: for the Sharpe, the drawdown or the median you have to work harder. The bootstrap avoids the formula: it resamples the data with replacement thousands of times, recomputes the statistic on each resample and uses the dispersion of those values as its error.
Mermaid source
flowchart LR M[Sample of n days] --> R[Resample B times] R --> E[Statistic ×B] E --> I[CI percentiles]
The i.i.d. bootstrap draws individual days, so it destroys any dependence between consecutive days. The block
bootstrap draws stretches of consecutive days and keeps the dependence within each stretch. The arch library has
both: IIDBootstrap and StationaryBootstrap (Politis and Romano, 1994), whose blocks have a random length with a
given mean.
from arch.bootstrap import IIDBootstrap, StationaryBootstrap
def media_anual(x):
return 252 * x.mean()
def intervalos(x, bloque=20, reps=2000, semilla=7):
x = np.asarray(x)
m, se = media_anual(x), 252 * x.std(ddof=1) / np.sqrt(len(x))
q = stats.t.ppf(0.975, len(x) - 1)
out = {"t": (m - q * se, m + q * se)}
for nombre, bs in [("iid", IIDBootstrap(x, seed=semilla)),
("bloques", StationaryBootstrap(
bloque, x, seed=semilla))]:
ic = bs.conf_int(media_anual, reps=reps, method="percentile")
out[nombre] = tuple(ic.ravel())
return out
ic = {(e, k): 100 * np.array(v) for e in elegidas
for k, v in intervalos(r[e]).items()}
pd.DataFrame(ic, index=["inf_%", "sup_%"]).T.round(1) inf_% sup_%
e0518 t 12.5 53.8
iid 13.5 55.0
bloques 9.0 55.5
e0258 t -4.9 21.9
iid -4.7 22.1
bloques -5.0 22.2
e0001 t -18.8 19.0
iid -18.7 20.2
bloques -19.1 19.3
e0598 t 9.2 44.0
iid 9.0 43.7
bloques 6.8 46.0The three methods give almost the same interval for the four strategies; the block one comes out somewhat wider at
the bottom for e0518 and e0598 (9.0 versus 12.5 for the first), because of resampling randomness and because of
volatility clustering, which the blocks preserve. It is no coincidence that they agree: these daily returns have no
autocorrelation (only volatility clustering), so resampling individual days or blocks of 20 gives the same thing, and
with 1,003 days the mean is already distributed almost like a normal. It is the usual situation with daily
returns of a strategy that does not hold positions for many days: the t-test is enough.
The practical lesson is in the width. The interval for e0518 runs from about 12% to 55% a year: the edge is real,
but four years do not say whether it is large or small. The one for e0258, from −6% to +22%, includes zero even
though the edge exists.
The bootstrap also gives a p-value. To test H0, the sample is centered at zero (its mean is subtracted, so the null is true in the resamples) and you count what fraction of the resampled means is as extreme as the observed one:
x = r["e0258"].to_numpy()
bs = StationaryBootstrap(20, x - x.mean(), seed=7)
medias = np.array([media_anual(d[0][0])
for d in bs.bootstrap(4000)])
p_bs = np.mean(np.abs(medias) >= abs(media_anual(x)))
print(f"observed annual mean: {100 * media_anual(x):.2f}%")
print(f"bootstrap p: {p_bs:.3f} (t-test p: "
f"{stats.ttest_1samp(x, 0).pvalue:.3f})")observed annual mean: 8.49%
bootstrap p: 0.232 (t-test p: 0.215)The two p-values are similar (0.232 versus 0.215) and lead to the same conclusion. On daily returns without autocorrelation, the bootstrap confirms the t-test; its value shows up when the formula does not exist or the dependence does, as in the sections that follow.
Power: how much history you need
The figure at the top answers the inverse question: if the edge exists, with what probability does a test detect it? 4,000 strategies of 1,003 days are simulated with Student's t innovations (fat tails) and four true Sharpes. It is a seeded simulation, not the course data: that way the answer is known for each one.
rng = np.random.default_rng(211)
n, sims = 1003, 4000
filas = {}
for sr_real in [0, 0.5, 1.0, 1.5]:
z = rng.standard_t(5, (sims, n)) * np.sqrt(3 / 5) # variance 1
x = sr_real / np.sqrt(252) + z
t = x.mean(1) / (x.std(1, ddof=1) / np.sqrt(n))
p = 2 * stats.t.sf(np.abs(t), n - 1)
filas[sr_real] = {
"rechaza_5%": np.mean(p < 0.05),
"teorica": stats.norm.cdf(sr_real * np.sqrt(n / 252) - 1.96),
"p_mediano": np.median(p)}
pd.DataFrame(filas).T.round(3) rechaza_5% teorica p_mediano
0.0 0.056 0.025 0.498
0.5 0.166 0.168 0.294
1.0 0.528 0.514 0.042
1.5 0.850 0.849 0.003With Sharpe 0 (the null), 5.6% of the simulations reject at 5%: the nominal size, with the noise of 4,000
replications. The p-values are uniform: each 0.05 bin receives 5% of them, which is what you see in the left panel
of the figure. With Sharpe 1, the power is 52.8%: half of the time a Sharpe 1 strategy tested over four years does not
pass the threshold. With Sharpe 0.5, only 16.6%. The teorica column (the normal with mean , ignoring
rejection on the negative side) is almost exactly right.
Solving for in the condition “expected t = 1.96 + 0.84” (the 0.84 gives 80% power) yields the rule of thumb:
for s in [0.25, 0.5, 1.0, 1.5, 2.0]:
anos = ((stats.norm.ppf(0.975) + stats.norm.ppf(0.8)) / s) ** 2
print(f"Sharpe {s:4.2f}: {anos:6.1f} years")Sharpe 0.25: 125.6 years
Sharpe 0.50: 31.4 years
Sharpe 1.00: 7.8 years
Sharpe 1.50: 3.5 years
Sharpe 2.00: 2.0 yearsWith a true Sharpe of 0.5, already a good figure for a real strategy after costs, you would need 31 years of history to detect it with 80% probability. That is why short backtests with a high Sharpe should be read with suspicion (the measured Sharpe of the lucky ones is inflated, lesson 2.5.4) and why low-Sharpe strategies are validated with many assets at once rather than with a long history of only one.
Overlapping returns: the t that multiplies
Many signals predict a long horizon: “the return over the next 21 sessions”. If that target is computed every day, two consecutive observations share 20 of their 21 sessions. The mean is still correct; the standard error of the classic formula is not. With overlap , the autocovariances are positive up to lag and the true variance of the mean is about times what assumes: the naive t comes out inflated by about .
The Newey-West estimator (1987) corrects the variance by adding the autocovariances up to a lag , with decreasing weights that guarantee the result is positive:
In statsmodels it is a regression on a constant with cov_type="HAC". First, by hand and with the library, on a
strategy with no edge:
import statsmodels.api as sm
def nw_var_media(y, L):
y = np.asarray(y) - np.mean(y)
n = len(y)
g = [y[j:] @ y[:n - j] / n for j in range(L + 1)]
lrv = g[0] + 2 * sum((1 - j / (L + 1)) * g[j]
for j in range(1, L + 1))
return lrv / n
h = 21
lr = np.log1p(r["e0001"]).cumsum()
y = (lr.shift(-h) - lr).dropna() # 21 sessions, every day
ols = sm.OLS(y.to_numpy(), np.ones(len(y))).fit()
hac = sm.OLS(y.to_numpy(), np.ones(len(y))).fit(
cov_type="HAC", cov_kwds={"maxlags": h})
print(f"autocorrelation at lag 1: {y.autocorr(1):.3f}")
print(f"naive t: {ols.tvalues[0]:.2f}; "
f"Newey-West t: {hac.tvalues[0]:.2f}")
print(f"standard error by hand: {np.sqrt(nw_var_media(y, h)):.6f}; "
f"statsmodels: {hac.bse[0]:.6f}")autocorrelation at lag 1: 0.959
naive t: -0.97; Newey-West t: -0.25
standard error by hand: 0.007131; statsmodels: 0.007131The autocorrelation at lag 1 is 0.96, close to the theoretical 20/21. The naive t is about four times the Newey-West one, and the standard error computed by hand matches the statsmodels one (which uses the same Bartlett weights by default). In this strategy both t's are far from 2, but the important question is what happens across all of them.
The 995 strategies with no edge make it possible to measure the false positive rate of each method on data whose truth is known:
lr = np.log1p(r[nulas]).cumsum()
Y = (lr.shift(-h) - lr).dropna()
def p_media(y, L=None):
m = sm.OLS(y, np.ones(len(y)))
f = m.fit() if L is None else m.fit(
cov_type="HAC", cov_kwds={"maxlags": L})
return f.pvalues[0]
tasas = {
"daily, t": np.mean([stats.ttest_1samp(r[e], 0).pvalue < 0.05
for e in nulas]),
"overlapping, OLS": np.mean([p_media(Y[e].to_numpy()) < 0.05
for e in nulas])}
for L in [10, 21, 42]:
tasas[f"overlapping, NW L={L}"] = np.mean(
[p_media(Y[e].to_numpy(), L) < 0.05 for e in nulas])
tasas["no overlap, t"] = np.mean(
[stats.ttest_1samp(Y[e].iloc[::h], 0).pvalue < 0.05
for e in nulas])
pd.Series(tasas, name="falsos_positivos").round(3)daily, t 0.044
overlapping, OLS 0.653
overlapping, NW L=10 0.183
overlapping, NW L=21 0.094
overlapping, NW L=42 0.068
no overlap, t 0.039
Name: falsos_positivos, dtype: float64With daily returns, the t-test gives 4.4% false positives: the nominal level. With the same strategies and a target overlapping over 21 sessions, the regression with classic errors declares 65.3% of them significant: two out of three strategies with no edge “predict” their own return. Newey-West corrects it almost completely, and the choice of matters: with 10 lags (fewer than the overlap) 18.3% remains; with 21, 9.4%; with 42, 6.8%. Sampling one observation every 21 sessions (no overlap) brings it back to 3.9%, at the cost of being left with 47 observations.
The bootstrap also fails with overlap if the i.i.d. one is used, and it works with blocks longer than the overlap:
y = Y["e0001"].to_numpy()
ee = {"classic formula": y.std(ddof=1) / np.sqrt(len(y)),
"Newey-West L=42": np.sqrt(nw_var_media(y, 42))}
for nombre, bs in [
("iid bootstrap", IIDBootstrap(y, seed=3)),
("blocks mean 63", StationaryBootstrap(63, y, seed=3))]:
m = [d[0][0].mean() for d in bs.bootstrap(2000)]
ee[nombre] = np.std(m)
pd.Series(ee, name="error_tipico_media").round(5)classic formula 0.00186
Newey-West L=42 0.00810
iid bootstrap 0.00191
blocks mean 63 0.00792
Name: error_tipico_media, dtype: float64The i.i.d. bootstrap reproduces the error of the classic formula (0.0019) because, by drawing individual days, it breaks the overlap; the block one, with blocks of 63 sessions on average, gives an error on the same scale as Newey-West, about four times larger. The rule is the same for both methods: the window (lags or block) has to cover the dependence.
Multiple regression with robust errors: carry and momentum in futures
The most frequent case is a predictive regression: the future return of an asset on one or more of today's signals.
futuros2_continuos.parquet and futuros2_carry.parquet hold 24 synthetic futures markets from 2000 to 2025 with two
planted effects (level 2 README): the expected annual return is
, with
the long-run volatility of each market and the last term a time-series momentum. The observed carry, carry_anual,
is the sum of the two parts: an exogenous one, with a premium, and another that only discounts the return of the price
to its anchor, with almost no premium. The natural regression is
with the annualized return of the next 21 sessions and the observed carry. The known answer is
; is a mix of 0.5 and 0.1 depending on which part of the carry dominates (the README measures
0.15 with month-end data). is estimated with all returns up to each date (expanding window): it is the
long-run volatility without looking ahead. The observations are daily, so there is an overlap of 21; in addition, the
24 markets share days and factors. The Driscoll-Kraay estimator (1998) handles both: for each date it sums the
regressor × residual products of all markets and applies Newey-West to that series. In statsmodels it is
cov_type="hac-groupsum" with the date index.
cont = pd.read_parquet("futuros2_continuos.parquet")
carry = pd.read_parquet("futuros2_carry.parquet")
lp = np.log(cont.pivot(index="fecha", columns="mercado",
values="cierre_ajustado"))
c = carry.pivot(index="fecha", columns="mercado",
values="carry_anual")
fwd = (lp.shift(-h) - lp) * 252 / h # annualized
# long-run σ of each market, without looking ahead
vol = lp.diff().expanding(252).std() * np.sqrt(252)
mom = np.sign(lp - lp.shift(252)) * vol
panel = pd.concat({"y": fwd, "carry": c, "mom": mom}, axis=1)
panel = panel.stack(future_stack=True).dropna()
X = sm.add_constant(panel[["carry", "mom"]])
fecha = panel.index.get_level_values("fecha").factorize()[0]
ols = sm.OLS(panel["y"], X).fit()
dk = sm.OLS(panel["y"], X).fit(
cov_type="hac-groupsum", cov_kwds={"time": fecha, "maxlags": h})
print(len(panel), "rows,", panel.index.get_level_values(
"mercado").nunique(), "markets")
pd.DataFrame({"coef": ols.params, "t_OLS": ols.tvalues,
"t_DK": dk.tvalues}).round(3)150384 rows, 24 markets
coef t_OLS t_DK
const 0.013 5.844 0.808
carry 0.171 8.683 1.596
mom 0.107 10.511 1.834The coefficients recover the answer with error: 0.107 for momentum (truth 0.15) and 0.171 for carry, within the expected mix and close to the README's 0.15. What changes completely is the confidence. With classic errors, the t's are 8.68 and 10.51: apparent certainty. With Driscoll-Kraay, 1.60 and 1.83: neither passes the threshold, even though both effects exist. There are 150,384 rows, but the information is much smaller: about 300 independent monthly returns per market, and markets that move together in crises. That the honest test does not reject does not say there is no effect (here you know there is one): it says that 26 years of 24 correlated markets are not enough to prove it with a t of 2. It is the power of the previous section, now in a panel.
The check without sophisticated formulas is to remove the overlap: one date out of every 21 and errors clustered by
date (cov_type="cluster"), which correct the correlation between markets on the same day.
fechas = np.sort(panel.index.get_level_values("fecha").unique())
mensual = panel[panel.index.get_level_values("fecha").isin(
fechas[::h])]
Xm = sm.add_constant(mensual[["carry", "mom"]])
grupos = mensual.index.get_level_values("fecha").factorize()[0]
om = sm.OLS(mensual["y"], Xm).fit()
cl = sm.OLS(mensual["y"], Xm).fit(
cov_type="cluster", cov_kwds={"groups": grupos})
print(len(mensual), "rows on", len(fechas[::h]), "dates")
pd.DataFrame({"coef": om.params, "t_OLS": om.tvalues,
"t_cluster": cl.tvalues}).round(3)7176 rows on 299 dates
coef t_OLS t_cluster
const 0.011 1.100 0.588
carry 0.209 2.288 1.536
mom 0.053 1.126 0.668Without overlap, the classic t of carry drops from 8.68 to 2.29 and that of momentum, from 10.51 to 1.13; clustering by date, to 1.54 and 0.67. The coefficients move more than with Driscoll-Kraay (0.209 and 0.053: keeping one date out of every 21 throws away information and leaves a noisier estimate), but the conclusion is the same: the honest uncertainty is several times the naive one.
What if the signal were useless? A persistent placebo regressor, with no relation at all to prices, is the acid test. 20 AR(1) placebos are simulated with the same persistence as the generator's carry (0.997 daily), each one independent per market, and you count how many times it comes out “significant”:
rng = np.random.default_rng(2111)
rech = {"OLS": 0, "Driscoll-Kraay": 0, "DK L=63": 0}
for _ in range(20):
e = rng.standard_normal(lp.shape) * np.sqrt(1 - 0.997**2)
pl = np.empty(lp.shape)
pl[0] = rng.standard_normal(lp.shape[1])
for i in range(1, len(pl)):
pl[i] = 0.997 * pl[i - 1] + e[i]
pl = pd.DataFrame(pl, index=lp.index, columns=lp.columns)
d = pd.concat({"y": fwd, "carry": c, "mom": mom, "pl": pl},
axis=1).stack(future_stack=True).dropna()
Xp = sm.add_constant(d[["carry", "mom", "pl"]])
tt = d.index.get_level_values("fecha").factorize()[0]
m = sm.OLS(d["y"], Xp)
rech["OLS"] += m.fit().pvalues["pl"] < 0.05
for k, L in [("Driscoll-Kraay", h), ("DK L=63", 63)]:
f = m.fit(cov_type="hac-groupsum",
cov_kwds={"time": tt, "maxlags": L})
rech[k] += f.pvalues["pl"] < 0.05
print({k: f"{v} of 20" for k, v in rech.items()}){'OLS': '14 of 20', 'Driscoll-Kraay': '1 of 20', 'DK L=63': '1 of 20'}With classic errors, the placebo is “significant” in 14 of 20 simulations. With Driscoll-Kraay, in 1 with 21 lags and in 1 with 63. Twenty replications are few to measure a rate precisely, but the order of magnitude is clear: with persistent signals and overlapping targets, classic errors are not an optimistic approximation, they are a discovery generator.
Which standard error to use
Mermaid source
flowchart TB
Q1{Dependence?} -- no --> Q2{Several assets?}
Q1 -- yes --> Q3{Several assets?}
Q2 -- no --> A[Classic t or iid]
Q2 -- yes --> B[Cluster by date]
Q3 -- no --> C[Newey-West L=2h]
Q3 -- yes --> D[Driscoll-Kraay]| Situation | Standard error | In statsmodels | Check |
|---|---|---|---|
| Daily returns of a strategy | Classic; i.i.d. bootstrap if there is no formula | fit() | Block bootstrap: should agree |
| Overlapping target of sessions, one asset | Newey-West with | cov_type="HAC" | Non-overlapping subsample |
| Several assets, no overlap | Clustered by date | cov_type="cluster" | Fama-MacBeth (lesson 2.3.5) |
| Several assets and overlap | Driscoll-Kraay | cov_type="hac-groupsum" | No overlap + cluster by date |
| Any statistic without a formula (Sharpe, drawdown) | Block bootstrap | arch.bootstrap | Several block lengths |
Three warnings that no row of the table resolves:
- Selection. If a hundred variants were tried and the best one is reported, all these p-values are too low (lessons 2.1.8 and 2.5.4).
- Costs. A significant coefficient on gross returns may not survive the bid-ask spread and commissions (lesson 2.4.7). Statistical significance is not profitability.
- Stability. A t of 3 over 20 years may come from a single crisis. Look at the coefficient by subperiods before trusting it (lesson 2.5.6).
Exercises
- Repeat the false positive table for the 995 null strategies with a target overlapping over 5 sessions and over 63 sessions, with classic errors and with Newey-West with . How does the problem grow with the overlap?
Solution
for hh in [5, 63]:
lr = np.log1p(r[nulas]).cumsum()
Yh = (lr.shift(-hh) - lr).dropna()
ols_fp = np.mean([p_media(Yh[e].to_numpy()) < 0.05
for e in nulas])
nw_fp = np.mean([p_media(Yh[e].to_numpy(), hh) < 0.05
for e in nulas])
print(f"h = {hh:2d}: OLS {ols_fp:.3f}; NW L=h {nw_fp:.3f}")h = 5: OLS 0.364; NW L=h 0.076
h = 63: OLS 0.805; NW L=h 0.138With overlap 5, classic errors already give 36.4% false positives; with 63, 80.5%: the naive t grows with and more and more null strategies exceed the threshold. Newey-West lowers the rate in both cases (7.6% and 13.8%), but with 63 lags on about 940 observations it stays well above the nominal level: with such a long overlap there are few independent observations (about 15) and any variance estimator is imprecise. With very long targets, the prudent thing is to sample without overlap or to use more history.
- Compute the 95% interval of the annual Sharpe of
e0518ande0258with a stationary block bootstrap with a mean block length of 20. Does it include the true Sharpe? Does it include zero?
Solution
def sharpe(x):
return np.sqrt(252) * x.mean() / x.std()
for e in ["e0518", "e0258"]:
bs = StationaryBootstrap(20, r[e].to_numpy(), seed=11)
inf, sup = bs.conf_int(sharpe, reps=2000,
method="percentile").ravel()
print(f"{e}: measured Sharpe {sharpe(r[e].to_numpy()):.2f}, "
f"95% CI [{inf:.2f}, {sup:.2f}], "
f"true {verdad.loc[e, 'sharpe_real']}")e0518: measured Sharpe 1.58, 95% CI [0.43, 2.77], true 1.5
e0258: measured Sharpe 0.62, 95% CI [-0.43, 1.69], true 0.7Both intervals include the true Sharpe and have a width of about two Sharpe points: it is the standard error of
≈ 0.5 given by the rule with four years. The one for e0518 excludes zero; the one for e0258 includes
it. A measured Sharpe of 1.58 is compatible with a true one of 0.5 or 2.7: four years do not distinguish an excellent
strategy from a mediocre one.
- A colleague presents a regression of the 12-month return of 50 stocks on a value signal, with monthly data over 15 years (9,000 rows), and a t of 6 with classic errors. What two problems does the t have and how would you recompute it?
Solution
First, overlap: with 12-month returns observed every month, each observation shares 11 months with the next one, and
the t is inflated by about . Second, correlation between stocks in the same month: the 50
returns on a date move together, so they are not 50 independent data points. I would recompute it with Driscoll-Kraay
(cov_type="hac-groupsum" with the date and at least 12 lags) and check it with a non-overlapping annual subsample
and errors clustered by date. A naive t of 6 can end up at 1 or 2. And then I would ask how many signals were tried
before this one (lesson 2.1.8).
Each answer is folded under its question
Self-assessment
A strategy has a true annual Sharpe of 1.0 and you test it with four years of daily data. What is the probability that the t-test at 5% declares it significant?
- AAlmost 100%: a Sharpe of 1 is very high
- B5%
- CAbout 50%: with four years, the expected t-statistic is 1 × √4 = 2, right at the threshold
- D95%
Show answer
Answer C. About 50%: with four years, the expected t-statistic is 1 × √4 = 2, right at the threshold
The t-statistic of the mean is approximately the annual Sharpe times the square root of the years. With an expected t of 2 and a threshold of 1.96, half of the samples fall below it. In the lesson's simulation, 52.8% of 4,000 samples reject the null.
With 21-session returns computed every day (overlapping), why does the t of the mean come out inflated?
- ABecause 21-day returns are larger
- BBecause the mean of overlapping returns is biased
- CBecause the variance of 21-day returns is smaller
- DBecause two consecutive observations share 20 of their 21 days: they are not independent, and the classic formula counts 21 times more information than there is
Show answer
Answer D. Because two consecutive observations share 20 of their 21 days: they are not independent, and the classic formula counts 21 times more information than there is
The mean is not biased; its standard error is. The formula s/√n assumes independent observations; with overlap h, the true variance of the mean is about h times what the formula gives. In the 995 strategies with no edge, the naive test gives 65.3% false positives.
Newey-West with 21 lags on returns overlapping over 21 sessions gives 9.4% false positives instead of 5%. What explains it best?
- AThat the Bartlett weights (1 − j/(L+1)) underweight the autocovariances close to lag L; with L equal to the overlap, the standard error falls somewhat short. With L = 2h it drops to 6.8%
- BThat Newey-West is badly implemented in statsmodels
- CThat 0 lags should be used
- DThat the strategies did have an edge
Show answer
Answer A. That the Bartlett weights (1 − j/(L+1)) underweight the autocovariances close to lag L; with L equal to the overlap, the standard error falls somewhat short. With L = 2h it drops to 6.8%
With overlap h, the autocovariances are positive up to lag h − 1 and Bartlett weights them less and less. It is a known finite-sample bias; more lags reduce it at the cost of more variance in the estimator. Sampling without overlap is the simple check.
In the panel of 24 futures, the momentum coefficient has t = 10.51 with OLS and t = 1.83 with Driscoll-Kraay. What changes between the two?
- AThe estimated coefficient
- BOnly the standard error: Driscoll-Kraay sums the regressor × residual products of all markets on each date and applies Newey-West to that series, so it accounts for the overlap and for the fact that the 24 markets move together
- CThe number of observations
- DThe dependent variable
Show answer
Answer B. Only the standard error: Driscoll-Kraay sums the regressor × residual products of all markets on each date and applies Newey-West to that series, so it accounts for the overlap and for the fact that the 24 markets move together
The estimator is the same (0.107). OLS treats the 150,384 rows as independent; in reality there are about 300 distinct monthly returns per market and the markets are correlated on the same day. With the honest error, the planted momentum does not reach t = 2.
Among 995 strategies with no edge, the best one has t = 3.0 in its daily test and its block bootstrap interval does not include zero. Which conclusion is correct?
- ANothing: the test and the bootstrap are valid for a strategy chosen in advance, not for the best of hundreds. Selection has to be corrected separately (lesson 2.1.8)
- BIt has an edge: the block bootstrap is robust
- CThat a 1% level should be used
- DThat the bootstrap has a bug
Show answer
Answer A. Nothing: the test and the bootstrap are valid for a strategy chosen in advance, not for the best of hundreds. Selection has to be corrected separately (lesson 2.1.8)
No standard error method corrects for selection. With 995 nulls you expect about 50 with p < 0.05 and some with p < 0.005. The known answer of the data set confirms it: its true Sharpe is 0.
Key points
- The t-statistic of the mean is approximately annual Sharpe × √years: with a true Sharpe of 0.5 you would need 31 years to have 80% power.
- Under the null, p-values are uniform; under the alternative they pile up near zero, but with low power many good strategies stay above 0.05.
- The i.i.d. and block bootstraps agree if there is no serial dependence; with overlapping returns, only the block bootstrap (with blocks longer than the overlap) gives the right error.
- Overlapping returns multiply the t by about √h: 65.3% false positives in 995 null strategies. Newey-West with L ≥ h or sampling without overlap corrects it.
- In a panel, same-day correlation between assets also inflates the t: Driscoll-Kraay lowers the momentum t from 10.51 to 1.83 without touching the coefficient, and an effect that exists stops being significant.
- No standard error corrects for selection among many tests: that is lesson 2.1.8.
Resources
Documentation
Books
- Newey, W. K. and West, K. D. (1987): A Simple, Positive Semi-Definite, Heteroskedasticity and Autocorrelation Consistent Covariance Matrix. Econometrica 55(3), 703-708
- Politis, D. N. and Romano, J. P. (1994): The Stationary Bootstrap. Journal of the American Statistical Association 89(428), 1303-1313
- Driscoll, J. C. and Kraay, A. C. (1998): Consistent Covariance Matrix Estimation with Spatially Dependent Panel Data. Review of Economics and Statistics 80(4), 549-560