Lesson 2.1.8 · 70 min

The multiple testing problem: Bonferroni, FDR and the “factor zoo”

Whoever tests a thousand strategies finds dozens of “significant” ones even if none of them works. This lesson measures it with a thousand synthetic strategies for which it is known which have an edge, applies the classic corrections and shows their price: with four years of data, not even the good ones survive. Then it does the same with the characteristics of a stock universe.

Fig. — The best 60 of 1,000 synthetic strategies by Sharpe measured over four years, with the p < 0.05 and Bonferroni thresholds
Level 2 lessons
  1. 2.1 · Statistics and econometrics for trading

    1. 2.1.1 Hypothesis tests, p-values, bootstrap and regression with robust standard errors
    2. 2.1.2 Stationarity: ADF, KPSS, differencing and fractional differencing
    3. 2.1.3 ARMA and ARIMA models: autocorrelation (ACF) and partial autocorrelation (PACF)
    4. 2.1.4 Volatility models: ARCH, GARCH, EGARCH and GJR with the arch library
    5. 2.1.5 Cointegration: Engle-Granger, Johansen and the half-life of mean reversion
    6. 2.1.6 Kalman filter for dynamic hedge ratios
    7. 2.1.7 Regime detection: hidden Markov models and structural breaks
    8. 2.1.8 The multiple testing problem: Bonferroni, FDR and the “factor zoo”
  2. 2.2 · Anatomy of a trading system

    1. 2.2.1 Edge hypotheses: risk, behavior, structure and information
    2. 2.2.2 Universe, signal, filter, entry, exit, sizing and risk management
    3. 2.2.3 Frequencies: intraday, swing and position trading, and the capacity of a strategy
    4. 2.2.4 Research journal and experiment tracking against data snooping
  3. 2.3 · Strategy families

    1. 2.3.1 Trend following and momentum
    2. 2.3.2 Mean reversion: RSI and Bollinger, intraday reversal and gaps
    3. 2.3.3 Pairs trading and statistical arbitrage: distance, cointegration and Kalman
    4. 2.3.4 Carry in currencies, futures and bonds; seasonality and calendar effects
    5. 2.3.5 Factors (value, quality, low volatility, size) and event strategies
    6. 2.3.6 Introduction to market making: inventory, spread and adverse selection (Avellaneda-Stoikov)
    7. 2.3.7 Crypto-specific strategies: momentum, funding arbitrage and basis
  4. 2.4 · Backtesting: engines and paradigms

    1. 2.4.1 Vectorized backtest from scratch: matrices, lag, costs and limits
    2. 2.4.2 Event-driven backtest from scratch: loop, portfolio, orders and fills
    3. 2.4.3 vectorbt: massive parameter sweeps and portfolio simulation
    4. 2.4.4 backtesting.py and backtrader: two teaching engines and their status
    5. 2.4.5 NautilusTrader: architecture, data, strategies and the same code in backtest and live
    6. 2.4.6 LEAN/QuantConnect and Zipline Reloaded; bt for asset allocation
    7. 2.4.7 Cost modeling: commissions, bid-ask spread, impact, financing and securities borrowing
    8. 2.4.8 Fill modeling: limit orders, queue, partial fills, gaps and stops
  5. 2.5 · Biases and validation

    1. 2.5.1 Backtest biases: look-ahead, survivorship, selection and narrative
    2. 2.5.2 In-sample and out-of-sample: anchored and rolling walk-forward
    3. 2.5.3 Combinatorial purged cross-validation for strategies
    4. 2.5.4 Deflated Sharpe Ratio and Probability of Backtest Overfitting (PBO)
    5. 2.5.5 Monte Carlo: trade resampling, block bootstrap and signal permutations
    6. 2.5.6 Parameter sensitivity: plateaus versus peaks, robustness across assets and periods
    7. 2.5.7 Synthetic data for stress testing: GBM, GARCH and bootstrapping
    8. 2.5.8 “Honest backtest” checklist before moving to paper trading
  6. 2.6 · Performance metrics

    1. 2.6.1 CAGR, Sharpe, Sortino, Calmar, Omega, drawdown and Ulcer Index
    2. 2.6.2 Win rate, payoff, expectancy, alpha, beta, information ratio, turnover and capacity
    3. 2.6.3 Reports with QuantStats or pyfolio-reloaded and custom tearsheets

A thousand strategies, five good ones

A p-value answers a specific question: if this strategy had no edge, what would be the probability of seeing a result as good as the one observed? With a threshold of 0.05, a strategy with no edge passes the test one time in twenty. If you test one, it is a reasonable risk. If you test a thousand and present the best, the p-value no longer answers the question that matters, which is a different one: what is the probability that the best of a thousand looks this good by chance? Almost certain.

This is the heart of honest quantitative research, and the reason for lessons 2.2.4 (logging every experiment), 2.5.4 (Deflated Sharpe and PBO) and 2.5.2 (out-of-sample validation). Here it is measured with ruido2_estrategias.parquet: the daily returns of 1,000 synthetic “strategies” from 2022 to 2025, in 50 families of 20 variants correlated with each other, like the ones that come out of sweeping the parameters of a single idea. All of them have zero edge except five, each in a different family, and ruido2_verdad.csv says which: the known answer. They are made-up data; no figure in this lesson says anything about a real strategy.

almost alwaysOne ideaTwenty variantsFifty familiesA thousand testsThe best is chosenp < 0.05?Looks like a finding
Mermaid source
flowchart TB
  I[One idea] --> V[Twenty variants]
  V --> F[Fifty families]
  F --> T[A thousand tests]
  T --> M[The best is chosen]
  M --> P{"p < 0.05?"}
  P -- almost always --> E[Looks like a finding]
IN [01]python
import numpy as np
import pandas as pd
from scipy import stats

ruido = pd.read_parquet("ruido2_estrategias.parquet")
verdad = pd.read_csv("ruido2_verdad.csv", index_col="estrategia")
R = ruido.pivot(index="fecha", columns="estrategia",
                values="rendimiento")
n = len(R)
sharpe = R.mean() / R.std() * np.sqrt(252)
t, p = stats.ttest_1samp(R, 0.0)
t = pd.Series(t, index=R.columns)
p = pd.Series(p, index=R.columns)
print(n, "sessions,", R.shape[1], "strategies")
print("with p < 0.05:", (p < 0.05).sum(),
      "| expected if none had an edge:", 0.05 * R.shape[1])
print("of those, with a positive mean return:",
      ((p < 0.05) & (t > 0)).sum())
OUT [01]A thousand strategies and their p-values
1003 sessions, 1000 strategies
with p < 0.05: 46 | expected if none had an edge: 50.0
of those, with a positive mean return: 25

46 strategies clear the 5% threshold in a two-sided test, close to the 50 that pure chance would give, and almost half (21) clear it by being significantly bad. The test is the one for the mean of the daily returns (a Student's t), which is equivalent to testing the Sharpe: with nn sessions, t=r‾/s⋅n=SRannual⋅n/252t = \overline{r}/s \cdot \sqrt{n} = \text{SR}_{\text{annual}} \cdot \sqrt{n / 252}, that is, t≈t \approx annual Sharpe times the square root of the years. Where are the five good ones?

IN [02]python
buenas = verdad.index[verdad["sharpe_real"] > 0]
puesto = sharpe.rank(ascending=False).astype(int)
pd.DataFrame({"sharpe_real": verdad.loc[buenas, "sharpe_real"],
              "sharpe_medido": sharpe[buenas], "t": t[buenas],
              "p": p[buenas], "puesto": puesto[buenas]}).round(4)
OUT [02]The five strategies with an edge
            sharpe_real  sharpe_medido       t       p  puesto
estrategia
e0080               0.5         0.9508  1.8969  0.0581      31
e0133               0.8         1.4586  2.9099  0.0037       3
e0258               0.7         0.6216  1.2402  0.2152     121
e0518               1.5         1.5791  3.1504  0.0017       1
e0751               0.6         0.8320  1.6599  0.0973      56

Two of the five have p < 0.05; the other three, with true Sharpes of 0.5 to 0.7, cannot be told apart from noise in four years. And at the top of the ranking the truth mixes with chance:

IN [03]python
top = sharpe.nlargest(8)
pd.DataFrame({"sharpe": top, "p": p[top.index],
              "familia": verdad.loc[top.index, "familia"],
              "ventaja_real": verdad.loc[top.index, "sharpe_real"] > 0}
             ).round(4)
OUT [03]The top eight by measured Sharpe
            sharpe       p familia  ventaja_real
estrategia
e0518       1.5791  0.0017     f03          True
e0598       1.5030  0.0028     f22         False
e0133       1.4586  0.0037     f37          True
e0316       1.4227  0.0046     f22         False
e0124       1.4105  0.0050     f35         False
e0462       1.4089  0.0050     f35         False
e0542       1.3857  0.0058     f22         False
e0401       1.3549  0.0070     f24         False

The figure at the top ranks the best sixty. The second of the 1,000, e0598, has a measured Sharpe of 1.50 over four years and no edge at all: it is the best of the noise. Six of the top eight are noise. An analyst who chose “the best” would have almost the same chance of ending up with an empty strategy as with the good one, and would have no way of knowing it by looking at the numbers.

Correcting: family-wise error and false discoveries

The classic corrections answer two different questions.

Family-wise error rate (FWER): the probability of making at least one false positive among the mm tests. Bonferroni controls it by requiring pi<α/mp_i < \alpha / m of each one: with m=1,000m = 1{,}000 and α=0.05\alpha = 0.05, p<0.00005p < 0.00005. Holm does the same in a step-down fashion and with somewhat more power: it sorts the p-values from smallest to largest and compares the kk-th with α/(m−k+1)\alpha / (m - k + 1), stopping at the first one that fails.

False discovery rate (FDR): the expected proportion of false positives among the discoveries. It is a milder requirement, designed for screens in which it is accepted that some of the chosen ones are false. Benjamini-Hochberg (1995) sorts the p-values, p(1)≤⋯≤p(m)p_{(1)} \le \dots \le p_{(m)}, and declares the first kk to be discoveries, with

k=max⁡{i:p(i)≤im q}.k = \max \left\{ i : p_{(i)} \le \frac{i}{m}\, q \right\}.

It guarantees FDR ≤ q with independent tests or with positive dependence; for arbitrary dependence, Benjamini-Yekutieli also divides q by ∑i=1m1/i\sum_{i=1}^{m} 1/i (about 7.5 with m=1,000m = 1{,}000).

a single onea proportionpositiveanyWhich error hurts?FWERFDRBonferroni, HolmDependence?Benjamini-HochbergBenjamini-Yekutieli
Mermaid source
flowchart TB
  Q{"Which error hurts?"} -- a single one --> W[FWER]
  Q -- a proportion --> D[FDR]
  W --> B[Bonferroni, Holm]
  D --> H{"Dependence?"}
  H -- positive --> BH[Benjamini-Hochberg]
  H -- any --> BY[Benjamini-Yekutieli]

statsmodels has all four in multipletests:

IN [04]python
from statsmodels.stats.multitest import multipletests

es_buena = R.columns.isin(buenas)
pb = p[es_buena]
filas = {"uncorrected": {"descubrimientos": int((p < 0.05).sum()),
                         "con_ventaja": int((pb < 0.05).sum()),
                         "p_min_ajustado": p.min()}}
for metodo, nombre in [("bonferroni", "Bonferroni"), ("holm", "Holm"),
                       ("fdr_bh", "Benjamini-Hochberg"),
                       ("fdr_by", "Benjamini-Yekutieli")]:
    rechaza, p_aj, _, _ = multipletests(p, alpha=0.05, method=metodo)
    filas[nombre] = {"descubrimientos": int(rechaza.sum()),
                     "con_ventaja": int(rechaza[es_buena].sum()),
                     "p_min_ajustado": p_aj.min()}
pd.DataFrame(filas).T.astype({"descubrimientos": int,
                               "con_ventaja": int})
OUT [04]The four corrections
                     descubrimientos  con_ventaja  p_min_ajustado
uncorrected                       46            2        0.001679
Bonferroni                         0            0        1.000000
Holm                               0            0        1.000000
Benjamini-Hochberg                 0            0        0.829541
Benjamini-Yekutieli                0            0        1.000000

None survives any correction, not even the five good ones. The smallest p-value of the 1,000 (0.0017, that of e0518, which does have an edge) becomes 1 after Bonferroni and a value far above 0.05 after Benjamini-Hochberg. Looking at the Benjamini-Hochberg procedure in the top positions helps to understand why:

IN [05]python
orden = p.sort_values()
k = np.arange(1, 9)
paso = pd.DataFrame(
    {"p": orden.values[:8],
     "umbral_bh": 0.05 * k / len(p),
     "ventaja_real": verdad.loc[orden.index[:8], "sharpe_real"]
     .to_numpy() > 0},
    index=pd.Index(orden.index[:8], name="estrategia"))
t_bonf = stats.t.isf(0.05 / len(p) / 2, n - 1)
print(f"Bonferroni requires |t| > {t_bonf:.2f}, an annual Sharpe of "
      f"{t_bonf * np.sqrt(252 / n):.2f} over {n} sessions")
print(f"uncorrected, |t| > {stats.t.isf(0.025, n - 1):.2f} is enough: "
      f"Sharpe {stats.t.isf(0.025, n - 1) * np.sqrt(252 / n):.2f}")
paso
OUT [05]Benjamini-Hochberg step by step and the Bonferroni threshold
Bonferroni requires |t| > 4.07, an annual Sharpe of 2.04 over 1003 sessions
uncorrected, |t| > 1.96 is enough: Sharpe 0.98
                   p  umbral_bh  ventaja_real
estrategia
e0518       0.001679    0.00005          True
e0598       0.002780    0.00010         False
e0133       0.003695    0.00015          True
e0316       0.004628    0.00020         False
e0124       0.004988    0.00025         False
e0462       0.005038    0.00030         False
e0542       0.005807    0.00035         False
e0401       0.006986    0.00040         False
Fig. — The 40 smallest p-values of the 1,000 strategies against the Benjamini-Hochberg step-up threshold with q = 0.05

The Benjamini-Hochberg threshold for the first p-value is 0.05/1,000=0.000050.05/1{,}000 = 0.00005, the same as Bonferroni's, and it grows slowly: for the eighth, 0.0004. The p-values in the top positions are between 17 and 34 times above their threshold. Translated into Sharpe: uncorrected, a Sharpe of 0.98 is enough over four years; with Bonferroni over 1,000 tests it takes 2.04, and none of the 1,000 gets there, not even the one with a true Sharpe of 1.5.

The price: power and years of data

The corrections protect against false positives and pay for it in power: the probability of detecting an edge that does exist. With t≈t \approx Sharpe · years\sqrt{\text{years}}, the years needed for a real edge to clear a threshold are (tthreshold/SR)2(t_{\text{threshold}} / \text{SR})^2:

IN [06]python
umbrales = {"t 1.96 (one test)": 1.96, "t 3.0 (HLZ)": 3.0,
            f"t {t_bonf:.2f} (Bonferroni, 1,000)": t_bonf}
anos = pd.DataFrame({nombre: {sr: (u / sr) ** 2
                              for sr in [0.5, 0.8, 1.0, 1.5]}
                     for nombre, u in umbrales.items()})
anos.rename_axis("sharpe_verdadero").round(1)
OUT [06]Years of data to clear each threshold
                  t 1.96 (one test)  t 3.0 (HLZ)  t 4.07 (Bonferroni, 1,000)
sharpe_verdadero
0.5                            15.4         36.0                        66.4
0.8                             6.0         14.1                        25.9
1.0                             3.8          9.0                        16.6
1.5                             1.7          4.0                         7.4

A true Sharpe of 0.5, which would be a good strategy in practice, needs 15 years of data to pass a single test at 5% and 66 to pass Bonferroni with 1,000 tests. Even a Sharpe of 1.5 needs more than seven years. These are years of daily data assuming the edge does not change over all that time, something that almost never holds. The conclusion is not that the corrections should be given up, but that testing a thousand things should: the number of tests is a research design decision (lesson 2.2.4) and every extra test raises the bar for all of them.

What the distribution of p-values says

If no strategy had an edge, the p-values would be spread uniformly between 0 and 1: about 50 in each bin of 0.05. The strategies with an edge should pile up near 0. The histogram gives an idea of how many null hypotheses are true, without knowing which:

IN [07]python
conteo, _ = np.histogram(p, bins=20, range=(0, 1))
print("p-values per 0.05 bin:", conteo.tolist())
pi0 = (p > 0.5).mean() / 0.5
print(f"estimated proportion of true nulls (Storey, "
      f"lambda = 0.5): {pi0:.2f}")
OUT [07]Histogram of the 1,000 p-values
p-values per 0.05 bin: [46, 45, 49, 48, 54, 53, 42, 57, 58, 49, 48, 48, 56, 47, 39, 57, 47, 55, 50, 52]
estimated proportion of true nulls (Storey, lambda = 0.5): 1.00
Fig. — Histogram of the p-values of the 1,000 strategies in 0.05 bins: flat, as if none had an edge

The histogram is flat: the first bin has 46 p-values, fewer than the 50 that chance alone would give. Storey's estimate (twice the proportion of p-values above 0.5) comes out close to 1: the data are compatible with practically all the null hypotheses being true, and they are, except five out of a thousand. When there are many real edges, the first bin clearly stands out from the rest; here nothing stands out.

One last caveat: the 20 variants of each family are correlated, so 1,000 tests are not 1,000 independent tests.

IN [08]python
familia = verdad.loc[R.columns, "familia"].to_numpy()
C = R.corr().to_numpy()
diag = np.eye(len(familia), dtype=bool)
misma = (familia[:, None] == familia[None, :]) & ~diag
print(f"mean correlation within the family: {C[misma].mean():.2f};"
      f" between families: {C[~misma & ~diag].mean():.2f}")
mejor_de_familia = sharpe.groupby(familia).max()
print("families whose best variant exceeds a Sharpe of 0.98:",
      (mejor_de_familia > 0.98).sum(), "of", len(mejor_de_familia))
OUT [08]Dependence within the families
mean correlation within the family: 0.50; between families: 0.00
families whose best variant exceeds a Sharpe of 0.98: 13 of 50

With a correlation of 0.5 within each family, the “effective” number of tests is between 50 and 1,000: Bonferroni is even more conservative than necessary. But the underlying problem does not change: in 13 of the 50 families the best variant clears the uncorrected threshold (a Sharpe of 0.98), and only three of those thirteen contain a strategy with an edge; in one of the three, the variant that clears it is not even the good one. Whoever only looks at the best variant of their favorite family does not know that the other nineteen existed. The techniques that take dependence into account (the Deflated Sharpe with an effective number of tests, the PBO, the bootstrap reality check) are the subject of lessons 2.5.4 and 2.5.5.

The “factor zoo”

In academic research on the cross-section of returns, the problem has a name. Harvey, Liu and Zhu (2016) compile hundreds of factors published to explain the expected returns of stocks, argue that with that much data mining the usual criterion of a t above 2 makes no sense and estimate that a new factor should clear a t of 31Note 1 · Abstract of NBER working paper w20592 (October 2014), published in the Review of Financial Studies 29(1), 2016, pp. 5-68: “a newly discovered factor needs to clear a much higher hurdle, with a t-ratio greater than 3.0”.. It is the “factor zoo”.

The level 2 synthetic stock universe makes it possible to do the exercise with the known answer (universo2_caracteristicas.parquet, one row per stock and month-end, point-in-time). The generator plants three premiums: 12-1 momentum, value (book equity over market capitalization) and low volatility. The other characteristics have no premium of their own: quality (ROE), size, one-month reversal and the ESG score, a pure placebo, with two variants (its 1- and 12-month change) like the ones anyone looking for “something” in that column would try. For each characteristic, the monthly rank IC (the Spearman correlation between the characteristic at month-end and the next month's return) and its t:

IN [09]python
car = pd.read_parquet("universo2_caracteristicas.parquet")
car = car.sort_values(["ticker", "fecha"]).reset_index(drop=True)
g = car.groupby("ticker")
# the next month's return includes the final row of each
# delisted stock (no price, with the delisting return)
car["rent_sig"] = g["rentabilidad_mes"].shift(-1)
con_precio = car["cierre"].notna()
log_r = np.log1p(car["rentabilidad_mes"].where(con_precio))
mom = log_r.groupby(car["ticker"]).transform(
    lambda s: s.shift(1).rolling(11).sum())
car["momentum_12_1"] = np.expm1(mom)
pn = car["patrimonio_neto"].where(car["patrimonio_neto"] > 0)
car["value"] = np.log(pn / car["capitalizacion"])
car["calidad_roe"] = car["roe"]
car["tamano"] = np.log(car["capitalizacion"])
car["reversion_1m"] = car["rentabilidad_mes"]
car["esg"] = car["puntuacion_esg"]
car["esg_cambio_1m"] = g["puntuacion_esg"].diff()
car["esg_cambio_12m"] = g["puntuacion_esg"].diff(12)

# low volatility: 252-session standard deviation from daily prices
aj = pd.read_parquet("universo2_ajustados.parquet")
aj = aj.sort_values(["ticker", "fecha"])
rd = aj.groupby("ticker")["cierre_ajustado"].pct_change()
aj["vol"] = rd.groupby(aj["ticker"]).transform(
    lambda s: s.rolling(252, min_periods=126).std())
car = car.merge(aj[["fecha", "ticker", "vol"]], how="left",
                on=["fecha", "ticker"])
car["baja_vol"] = -car["vol"]
car = car[car["cierre"].notna()]    # signals only with a price

def ic_mensual(col):
    d = car.dropna(subset=[col, "rent_sig"])
    return d.groupby("fecha")[[col, "rent_sig"]].apply(
        lambda x: x[col].corr(x["rent_sig"], method="spearman"))

senales = ["momentum_12_1", "value", "baja_vol", "calidad_roe",
           "tamano", "reversion_1m", "esg", "esg_cambio_1m",
           "esg_cambio_12m"]
zoo = {}
for s in senales:
    ic = ic_mensual(s)
    t_ic = ic.mean() / ic.std() * np.sqrt(len(ic))
    zoo[s] = {"meses": len(ic), "ic_medio": ic.mean(), "t": t_ic,
              "p": 2 * stats.norm.sf(abs(t_ic))}
zoo = pd.DataFrame(zoo).T
zoo["prima_real"] = ["yes", "yes", "yes", "no", "no", "no", "placebo",
                     "placebo", "placebo"]
zoo.round(4)
OUT [09]Nine characteristics and their ICs
                meses  ic_medio       t       p prima_real
momentum_12_1   227.0    0.0282  2.9587  0.0031        yes
value           239.0    0.0254  3.0688  0.0021        yes
baja_vol        233.0    0.0130  1.0602  0.2890        yes
calidad_roe     239.0   -0.0324 -4.5933  0.0000         no
tamano          239.0   -0.0064 -0.8239  0.4100         no
reversion_1m    238.0    0.0246  2.8755  0.0040         no
esg             239.0    0.0103  1.5370  0.1243    placebo
esg_cambio_1m   238.0    0.0058  0.9728  0.3306    placebo
esg_cambio_12m  227.0    0.0065  0.9939  0.3203    placebo
Fig. — A factor zoo with a known answer: t of the monthly rank IC for nine characteristics of the synthetic universe, 2006–2025

The table has the three errors of the zoo at once:

  • Chance. The ESG placebo gives a t of 1.54: it does not reach 1.96, but with a few more variants (another window, another universe, another period) one of them would pass, and its p of 0.12 already “suggests” something in an undemanding report.
  • Confounding. The characteristic with the highest t of the nine is quality, at −4.59, and its true premium is zero. ROE is earnings over book equity, and in this universe its rank correlation with value is −0.79: it inherits the value premium with the sign flipped. It clears the t > 3 threshold and any multiple testing correction, because it is not chance.
  • The effects that go unseen. Low volatility has a planted premium and gives a t of 1.06: volatile stocks have more beta, and in raw returns the two things offset each other. It only shows up when controlling for beta (lesson 2.3.5). One-month reversal (t = 2.88) has no premium of its own: it inherits the continuation of momentum, of post-earnings drift and of the bankruptcies.

The corrections on these nine tests fix none of that:

IN [10]python
for metodo in ["bonferroni", "fdr_bh"]:
    rechaza = multipletests(zoo["p"].astype(float), alpha=0.05,
                            method=metodo)[0]
    print(f"{metodo:10}", list(zoo.index[rechaza]))
print("pass t > 3:", list(zoo.index[zoo["t"].abs() > 3]))

def z_rangos(s):
    r = s.rank()
    return stats.norm.ppf((r - 0.5) / r.count())

def fama_macbeth(cols):
    """t of the mean of each month's cross-sectional slopes."""
    d = car.dropna(subset=cols + ["rent_sig"]).copy()
    for c in cols:
        d[c] = d.groupby("fecha")[c].transform(z_rangos)
    pend = []
    for _, x in d.groupby("fecha"):
        X = np.column_stack([np.ones(len(x)), x[cols]])
        b = np.linalg.lstsq(X, x["rent_sig"], rcond=None)[0]
        pend.append(b[1:])
    pend = pd.DataFrame(pend, columns=cols)
    return pend.mean() / pend.std() * np.sqrt(len(pend))

fm = ["momentum_12_1", "value", "baja_vol", "calidad_roe", "tamano",
      "esg"]
pd.DataFrame({
    "una_a_una": pd.concat([fama_macbeth([c]) for c in fm]),
    "value_y_calidad": fama_macbeth(["value", "calidad_roe"]),
    "todas": fama_macbeth(fm)}).reindex(fm).round(2)
OUT [10]Corrections on the zoo and Fama-MacBeth regressions
bonferroni ['momentum_12_1', 'value', 'calidad_roe', 'reversion_1m']
fdr_bh     ['momentum_12_1', 'value', 'calidad_roe', 'reversion_1m']
pass t > 3: ['value', 'calidad_roe']
               una_a_una  value_y_calidad  todas
momentum_12_1       4.43              NaN   5.79
value               2.30            -0.15   2.10
baja_vol            1.02              NaN   0.95
calidad_roe        -3.26            -1.80   0.17
tamano              0.05              NaN   0.29
esg                 1.67              NaN   1.63

Bonferroni and Benjamini-Hochberg on the nine leave the same four: momentum, value, quality and one-month reversal. Two of the four have no premium of their own, and low volatility, which does have one, is missing. The t > 3 threshold leaves only value and quality: one real and one false, and momentum, which is real, is out. The Fama-MacBeth regressions (each month, a cross-sectional regression of the next month's return on the rank scores of the signals, and the t of the mean of the slopes) tell the full story. One at a time, quality gives −3.26. Controlling only for value does not fix it: with value and quality together, quality stays at −1.80 and value at −0.15. With two signals correlated at −0.79, the regression attributes the effect to the wrong one. Only with all the signals at once does quality fall to 0.17, while value stays at 2.10 and momentum rises to 5.79, because value, negatively correlated with it, no longer dampens it (part of that momentum, according to the data README, comes from the four bankruptcies, whose prices drift downward for more than a year). Low volatility is still not visible (0.95: it lacks the control for beta) and the ESG placebo gives 1.63, close to the threshold. What separates the real from the false here is the known answer; with real data, a prior economic hypothesis and other markets and periods (lesson 2.2.1).

Common mistakes

  • Counting only the tests that worked. Bonferroni with m=3m = 3 because “only three are presented” corrects nothing if forty were tried.
  • Reading “not significant” as “does not work”. In this sample, the strategy with a true Sharpe of 0.8 comes out with p = 0.004 and the one with 0.6, with p = 0.097: similar edges on either side of the threshold. Absence of evidence is not evidence of absence.
  • Choosing the correction after seeing the results. FWER or FDR, and which α, are decided beforehand.
  • Correcting and forgetting confounding. Quality passes any correction.
  • Trusting the best of a family. Twenty variants of the same idea are twenty tests.
  • Repeating the study on the same data. Every return to the same sample is another test, even if it is not in the code. The only clean sample is the one that has not been looked at (lesson 2.5.2).

Exercises

  1. Repeat Benjamini-Hochberg on the 1,000 strategies with q = 0.10, 0.20 and 0.30. How many discoveries are there in each case and how many are strategies with an edge?
Solution
IN [11]python
for q in [0.10, 0.20, 0.30]:
    rechaza = multipletests(p, alpha=q, method="fdr_bh")[0]
    print(f"q = {q:.2f}: {rechaza.sum()} discoveries, "
          f"{rechaza[es_buena].sum()} with an edge")
OUT [11]Benjamini-Hochberg with a higher q
q = 0.10: 0 discoveries, 0 with an edge
q = 0.20: 0 discoveries, 0 with an edge
q = 0.30: 0 discoveries, 0 with an edge

Not even with q = 0.30 are there discoveries: the first Benjamini-Hochberg threshold would be 0.30/1,000 = 0.0003, and the smallest p-value is 0.0017; the threshold grows with the rank, but the following p-values grow faster. Raising q is accepting a larger proportion of false ones among the chosen in exchange for power, and even so it is not enough. The problem with this data is not the method: four years do not contain enough information to separate Sharpes of 0.5 to 1.5 from noise among a thousand candidates.

  1. Pick the 20 strategies with the best Sharpe in 2022-2023 and measure their Sharpe in 2024-2025. How many stay above zero? What happens to the ones with an edge?
Solution
IN [12]python
primera = R.loc[:"2023-12-31"]
segunda = R.loc["2024-01-01":]
sr = lambda x: x.mean() / x.std() * np.sqrt(252)
elegidas = sr(primera).nlargest(20).index
fuera = sr(segunda[elegidas])
print(f"Mean Sharpe of the 20 chosen: in sample "
      f"{sr(primera[elegidas]).mean():.2f}, out {fuera.mean():.2f}")
print("out of sample with Sharpe > 0:", (fuera > 0).sum(), "of 20")
print("with an edge among the chosen:",
      list(elegidas[elegidas.isin(buenas)]))
print("Out-of-sample Sharpe of the five good ones:",
      sr(segunda[buenas]).round(2).to_dict())
OUT [12]The best of the first half, in the second
Mean Sharpe of the 20 chosen: in sample 2.01, out 0.10
out of sample with Sharpe > 0: 12 of 20
with an edge among the chosen: ['e0133', 'e0518']
Out-of-sample Sharpe of the five good ones: {'e0080': 1.33, 'e0133': -0.04, 'e0258': 0.33, 'e0518': 0.85, 'e0751': 1.01}

In sample the 20 chosen have a mean Sharpe of 2.01, because they were chosen for that; out of sample, 0.10, and 12 of the 20 stay above zero, little more than chance would give. Only two of the five with an edge make the selection (e0133 and e0518), and out of sample they give −0.04 and 0.85: with two years, even a real edge may not show. The other three, which were not chosen, give between 0.33 and 1.33 in the second half. It is the simplest form of validation (lesson 2.5.2) and the reason why the in-sample Sharpe of a chosen strategy is not an estimate of its future Sharpe.

  1. A colleague has tried 40 variants of an indicator and presents the best with t = 2.6 (p = 0.009). What would you tell them?
Solution

That with 40 tests the p-value of the best is not 0.009. With Bonferroni, the 5% threshold would be 0.05/40 = 0.00125, a t of about 3.2; with Holm, the same for the first one. By chance, with 40 variants with no edge about 2 are expected with p < 0.05, and the best of 40 independent nulls passes p = 0.009 with a probability of 1−0.99140≈30%1 - 0.991^{40} \approx 30\%. If the variants are correlated, the effective number of tests is smaller and the problem somewhat milder, but it is still there. I would ask for the log of the 40 tests, a hypothesis for why it should work, the corrected t or a Deflated Sharpe (lesson 2.5.4) and a validation on data not used to choose.

Footnotes

  1. Abstract of NBER working paper w20592 (October 2014), published in the Review of Financial Studies 29(1), 2016, pp. 5-68: “a newly discovered factor needs to clear a much higher hurdle, with a t-ratio greater than 3.0”. ↩

Each answer is folded under its question

Self-assessment

  1. You test 1,000 strategies with no edge at all with a 5% test. How many do you expect to come out “significant”?

    • ANone
    • BAbout 50: 5% of the nulls are rejected by chance
    • CAbout 5
    • DIt depends on the Sharpe of each one
    Show answer

    Answer B. About 50: 5% of the nulls are rejected by chance

    That is the definition of the significance level. In the course data 46 come out with p < 0.05, of which only two have a real edge; the other 44 are chance.

  2. What does the Bonferroni correction control, and what does the Benjamini-Hochberg one control?

    • ABoth control the same thing with different formulas
    • BBonferroni, power; Benjamini-Hochberg, size
    • CBonferroni only works with independent tests and Benjamini-Hochberg with any
    • DBonferroni, the probability of making at least one false positive (FWER); Benjamini-Hochberg, the expected proportion of false positives among the discoveries (FDR)
    Show answer

    Answer D. Bonferroni, the probability of making at least one false positive (FWER); Benjamini-Hochberg, the expected proportion of false positives among the discoveries (FDR)

    Bonferroni requires p < α/m of each test. Benjamini-Hochberg sorts the p-values and accepts the largest k with p_(k) ≤ k·q/m: it allows some false positives in exchange for more power. Under arbitrary dependence, the safe version is Benjamini-Yekutieli.

  3. The best of the 1,000 strategies has a measured Sharpe of 1.58 over four years and p = 0.0017. After Bonferroni it is not significant. Does it mean it has no edge?

    • ANo: it means that with 1,000 tests and four years there is not enough evidence. In fact it has an edge (true Sharpe 1.5), but it would need t > 4.07, about seven years of data with that Sharpe
    • BYes: Bonferroni proves it is noise
    • CYes, because the Sharpe is below 2
    • DNo, because the p-value is below 0.05
    Show answer

    Answer A. No: it means that with 1,000 tests and four years there is not enough evidence. In fact it has an edge (true Sharpe 1.5), but it would need t > 4.07, about seven years of data with that Sharpe

    Failing to reject is not proving the null. With t ≈ Sharpe·√years, clearing 4.07 with a Sharpe of 1.5 requires (4.07/1.5)² ≈ 7.4 years; with a Sharpe of 0.5, 66 years. The corrections protect against false positives at the price of not seeing small edges.

  4. In the course's stock universe, quality (ROE) has the highest t of all the characteristics (−4.59) and its true premium is zero. What explains it?

    • AChance, which a Bonferroni correction would remove
    • BA data error
    • CConfounding: ROE has a correlation of −0.79 with value, which does have a premium, and inherits its effect with the opposite sign. No multiple testing correction fixes it
    • DThat quality has a negative premium
    Show answer

    Answer C. Confounding: ROE has a correlation of −0.79 with value, which does have a premium, and inherits its effect with the opposite sign. No multiple testing correction fixes it

    Its t of −4.59 clears any multiple testing threshold, including the t > 3 of Harvey, Liu and Zhu. Corrections deal with chance, not with confounding. Controlling only for value is not enough (quality stays at −1.80); with all the planted signals in the regression it falls to 0.17. And with real data what is needed, above all, is an economic hypothesis.

  5. What do Harvey, Liu and Zhu (2016) propose for a new factor in the cross-section of returns?

    • AUse t > 2, as always
    • BDo not publish new factors
    • CUse only data from outside the US
    • DRequire a higher threshold, t > 3, because hundreds of factors have been tested on the same data
    Show answer

    Answer D. Require a higher threshold, t > 3, because hundreds of factors have been tested on the same data

    Their abstract (NBER w20592; Review of Financial Studies, 2016) argues that with so much data mining the usual criterion of t > 2 makes no sense and estimates that a new factor should clear t > 3.

Key points

  • With 1,000 strategies with no edge, about 50 pass p < 0.05. In the course data 46 pass, and only two of them have a real edge.
  • The best of the noise looks like skill: the second-best of the 1,000 strategies (Sharpe 1.50) has no edge at all.
  • Bonferroni and Holm control the probability of any false positive; Benjamini-Hochberg, the proportion of false ones among the discoveries. Here none survives, not even the five good ones.
  • The threshold is demanding because evidence grows with the square root of time: t ≈ Sharpe·√years. A Sharpe of 0.5 needs 66 years to clear Bonferroni with 1,000 tests.
  • In a factor zoo chance is not the only problem: no correction solves confounding (quality versus value) or effects that cannot be seen without controls (low volatility).

Resources

Documentation

Data