Lesson 2.3.2 · 65 min

Mean reversion: RSI and Bollinger, intraday reversal and gaps

Buying what has just fallen and selling what has just risen is the most intuitive strategy and the one that survives costs worst. This lesson measures a gap-fill effect planted in 5-minute bars, compares it with the rest of the session, which has none, and tests RSI and Bollinger rules on 120 stocks where there is no reversion to exploit.

Fig. — Gap fill in KAPA: mean move in the first half hour against the opening gap, sessions grouped into twenty bins by gap size (2022-2025)
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

Reverting to the mean: where the edge could come from

A mean reversion strategy bets that a recent move will be partly undone: it buys what has just fallen and sells what has just risen. It is the opposite of the trend following of lesson 2.3.1, and its economic logic is different. When a seller in a hurry pushes the price below its value, someone has to buy from them: whoever does so provides liquidity and is paid for it by the price coming back. If the move was an overreaction to news, whoever bets against it collects the correction.

Sharp moveHurry to sellNew informationReverts: winsContinues: losesMinus costs
Mermaid source
flowchart TB
  M[Sharp move] --> L[Hurry to sell]
  M --> N[New information]
  L --> R[Reverts: wins]
  N --> C[Continues: loses]
  R --> K[Minus costs]
  C --> K

The difficulty lies in the last box. A reversion edge is usually small per trade and traded a lot, so costs are half the story. In real data there is evidence of short-term reversal in stocks: Jegadeesh documented in 1990 a highly significant negative autocorrelation in the monthly returns of individual US stocks1Note 1 · N. Jegadeesh, “Evidence of Predictable Behavior of Security Returns”, Journal of Finance 45(3), 1990, 881-898. DOI 10.1111/j.1540-6261.1990.tb05110.x., and Lehmann, that same year, that a week's winners tended to lose the next week and the losers to recover, also after correcting for bid-ask spreads and with plausible costs2Note 2 · B. N. Lehmann, “Fads, Martingales, and Market Efficiency”, Quarterly Journal of Economics 105(1), 1990; NBER working paper version w2533.. In the data for this lesson, on the other hand, we know exactly where there is reversion and where there is not.

The opening gap in KAPA

indice2_5min.parquet has 1,003 NYSE sessions from 2022 to 2025 in 5-minute bars. The open of each bar is the close of the previous one, except for the first of each session, which carries the overnight gap: how much the price moved between yesterday's close and today's open.

IN [01]python
import numpy as np
import pandas as pd
import statsmodels.api as sm

b = pd.read_parquet("indice2_5min.parquet")
b["ny"] = b["ts"].dt.tz_convert("America/New_York")
b["dia"] = b["ny"].dt.date
b["k"] = b.groupby("dia").cumcount()        # bar 0 = 09:30
barras = b.groupby("dia").size()
print(len(barras), "sessions;", barras.value_counts().to_dict())
print("first bar:",
      b.loc[b["k"] == 0, "ny"].dt.strftime("%H:%M").unique().tolist())
OUT [01]5-minute bars in New York time
1003 sessions; {78: 994, 42: 9}
first bar: ['09:30']

There are 994 full sessions of 78 bars (from 09:30 to 16:00) and 9 half sessions of 42 (until 13:00), and the first bar of all of them starts at 09:30 New York time: the conversion from UTC respects daylight saving time (lesson 1.1.2). Counting the bars per session is the cheapest check that the time zone and the calendar are right.

For each session you need the gap and the move over the first half hour, split into the first bar and the next five, and, as a control, the bars from 10:00 to 10:30 and the rest of the day:

ht=ln⁡AtCt−1,rt(0−6)=ln⁡Ct(6)At,rt(2−6)=ln⁡Ct(6)Ct(1)h_t = \ln\frac{A_t}{C_{t-1}}, \qquad r^{(0-6)}_t = \ln\frac{C^{(6)}_t}{A_t}, \qquad r^{(2-6)}_t = \ln\frac{C^{(6)}_t}{C^{(1)}_t}

where AtA_t is the session open, Ct−1C_{t-1} the previous close and Ct(j)C^{(j)}_t the close of bar j.

IN [02]python
g = b.groupby("dia")
cierre_k = lambda k: b[b["k"] == k].set_index("dia")["cierre"]
d = pd.DataFrame({"apertura": g["apertura"].first(),
                  "cierre": g["cierre"].last(),
                  "c1": cierre_k(0), "c6": cierre_k(5),
                  "c12": cierre_k(11)})
d["hueco"] = np.log(d["apertura"] / d["cierre"].shift())
d["r0_6"] = np.log(d["c6"] / d["apertura"])
d["r2_6"] = np.log(d["c6"] / d["c1"])
d["r7_12"] = np.log(d["c12"] / d["c6"])
d["resto"] = np.log(d["cierre"] / d["c12"])
d = d.dropna()
print(len(d), "sessions with a gap")
(1e4 * d[["hueco", "r0_6", "r7_12", "resto"]]).describe().loc[
    ["mean", "std", "min", "max"]].round(1)
OUT [02]Gap and moves of each session
1002 sessions with a gap
       hueco   r0_6  r7_12  resto
mean     3.8   -0.3    0.8    3.8
std     60.8   47.9   26.4   79.8
min   -189.3 -245.5  -83.7 -322.4
max   1028.7  188.2  143.5  285.4

In basis points, the gap has a standard deviation of 61 (with an extreme day of +1,029, a gap of more than 10%), the first half hour moves 48 and the next half hour, 26: the generator's U-shaped intraday profile, with volatility concentrated at the open. The means are almost zero in every window, as befits a market without visible drift at this scale.

Does the gap fill? A regression with robust errors

The question is a regression of the first half-hour move on the gap, rt(0−6)=a+b ht+etr^{(0-6)}_t = a + b\,h_t + e_t. If the gap tends to fill, b<0b < 0. The standard errors are computed robust to heteroskedasticity (HC1), because days with a large gap are also days of high volatility (lesson 2.1.1).

IN [03]python
def regresion(y, x="hueco"):
    m = sm.OLS(d[y], sm.add_constant(d[x])).fit(cov_type="HC1")
    return pd.Series({"b": m.params[x], "ee": m.bse[x],
                      "t": m.tvalues[x], "p": m.pvalues[x]})

tabla = pd.DataFrame({v: regresion(v)
                      for v in ["r0_6", "r2_6", "r7_12", "resto"]}).T
print(tabla.round(3).to_string())
b_est, ee = tabla.loc["r0_6", ["b", "ee"]]
print(f"distance to the truth (−0.10): {(b_est + 0.10) / ee:+.2f} se")
print(f"95% interval: [{b_est - 1.96 * ee:.3f}, "
      f"{b_est + 1.96 * ee:.3f}]")
OUT [03]Gap coefficient in each window
           b     ee      t      p
r0_6  -0.132  0.038 -3.518  0.000
r2_6  -0.108  0.023 -4.768  0.000
r7_12  0.027  0.016  1.668  0.095
resto -0.027  0.044 -0.613  0.540
distance to the truth (−0.10): -0.86 se
95% interval: [-0.206, -0.059]

The whole first half hour gives b=−0.132b = -0.132 with t = −3.5. The generator's truth is −0.10: the estimate is 0.86 standard errors away, perfectly compatible. But the 95% interval goes from −0.206 to −0.059: with four years of sessions, we know that the gap tends to fill, not by how much. From the second bar (the one that can be traded), the coefficient is −0.108 with a smaller error (0.023): what is planted in those five bars is −0.10⋅5/6≈−0.083-0.10 \cdot 5/6 \approx -0.083, also less than one and a half standard errors away.

The two control windows have no effect in the generator, and their estimates reflect that only halfway: the rest of the day gives −0.027 (t = −0.6), nothing, but the half hour from 10:00 to 10:30 gives +0.027 with t = 1.67 and a p-value of 0.095. It is not planted. It is what any control looks like: most of the time it comes out close to zero and, now and then, close to the threshold. If someone had tested ten windows of the session and published the one from 10:00 to 10:30 as “gap continuation”, they would have a result as convincing as it is false.

The figure at the top shows the same relationship without a model: the sessions grouped into twenty buckets by gap size, with the mean gap of each bucket and the mean first half-hour move. The line with slope −0.132 is the estimate; the one with −0.10, the generator's truth.

IN [04]python
grupo = pd.qcut(d["hueco"], 20, labels=False)
binned = (1e4 * d.groupby(grupo)[["hueco", "r0_6"]].mean()).round(1)
print(binned.iloc[[0, 1, 9, 10, 18, 19]].to_string())
OUT [04]Mean move by gap bucket
       hueco  r0_6
hueco
0     -100.9   9.4
1      -65.6  -3.1
9       -1.2   6.1
10       4.9   0.0
18      73.6 -11.4
19     150.9 -11.8

The bucket of most negative gaps (−101 basis points on average) recovers 9.4 in the first half hour, and the bucket of most positive gaps (+151) gives back 11.8: 9% and 8% of the gap, close to the planted 10%. In the middle, with gaps of one or five basis points, the mean move is noise of ±6 points. The effect only shows where the gap is large.

From a coefficient to a rule

A gap-fill rule takes the bet opposite to the gap during the first half hour. It is decided with the first price of the session, but executed on the next bar, at the close of the first 5-minute bar, and closed at the close of the sixth:

09:30 gap09:35 enter10:00 exitRest: nothing
Mermaid source
flowchart LR
  A[09:30 gap] --> E[09:35 enter]
  E --> S[10:00 exit]
  S --> X[Rest: nothing]

The expected gain per trade, if the effect is linear, is 0.10⋅56⋅∣ht∣0.10 \cdot \tfrac{5}{6} \cdot |h_t|: proportional to the size of the gap. That is why it makes sense to trade only large gaps, which pay more per trade even though there are fewer of them.

IN [05]python
def regla(umbral):
    s = d[d["hueco"].abs() > umbral]
    return -np.sign(s["hueco"]) * s["r2_6"]   # against the gap

filas = []
for umbral in (0.0, 0.002, 0.004, 0.006, 0.008):
    pnl = regla(umbral)
    t = pnl.mean() / pnl.std() * np.sqrt(len(pnl))
    filas.append({"umbral_pb": 1e4 * umbral, "operaciones": len(pnl),
                  "bruto_pb": 1e4 * pnl.mean(), "t": t,
                  "aciertos": (pnl > 0).mean()})
pd.DataFrame(filas).round(2)
OUT [05]Gain per trade by minimum gap size
   umbral_pb  operaciones  bruto_pb     t  aciertos
0        0.0          999      4.00  3.18      0.55
1       20.0          623      4.39  2.66      0.56
2       40.0          342      7.48  3.19      0.60
3       60.0          183      6.96  1.93      0.57
4       80.0          112     11.50  2.30      0.60

Trading every gap, 999 trades earn 4.0 basis points on average gross (t = 3.2), with a 55% hit rate. With gaps of more than 40 basis points, 342 trades remain that earn 7.5, and with more than 80, 112 that earn 11.5: the gain per trade grows with the threshold, as an effect proportional to the gap predicts, but the number of trades falls and the t does not improve (2.3 at the highest threshold). The 0.55-0.60 hit rate is a reminder that a real statistical edge looks a lot like flipping a coin.

Each trade pays two sides: the entry and the exit. With a cost cc per side, the net per trade is the gross minus 2c2c, and the break-even cost per side is half the gross:

IN [06]python
costes_pb = np.arange(0, 6.5, 0.5)
sens = pd.DataFrame({
    f">{1e4 * u:.0f} bp": [1e4 * regla(u).mean() - 2 * c
                           for c in costes_pb]
    for u in (0.0, 0.004, 0.008)}, index=costes_pb)
sens.index.name = "coste_lado_pb"
print(sens.iloc[::2].round(2).to_string())
for u in (0.0, 0.004, 0.008):
    print(f"break-even with gaps > {1e4 * u:.0f} bp: "
          f"{1e4 * regla(u).mean() / 2:.2f} bp per side")
OUT [06]Sensitivity to costs
               >0 bp  >40 bp  >80 bp
coste_lado_pb
0.0              4.0    7.48    11.5
1.0              2.0    5.48     9.5
2.0              0.0    3.48     7.5
3.0             -2.0    1.48     5.5
4.0             -4.0   -0.52     3.5
5.0             -6.0   -2.52     1.5
6.0             -8.0   -4.52    -0.5
break-even with gaps > 0 bp: 2.00 bp per side
break-even with gaps > 40 bp: 3.74 bp per side
break-even with gaps > 80 bp: 5.75 bp per side
Fig. — Gap-fill rule in KAPA: net gain per trade by cost per side, for three minimum gap sizes

With every gap, break-even is at 2 basis points per side: from there on the rule loses. Filtering gaps of more than 40 basis points it withstands 3.7, and with more than 80, 5.8. The figure shows the three lines: each basis point of cost per side takes two off the net of each trade.

The rest of the session: a control with a scare

The generator has no effect outside the first half hour. Besides the 10:00 to 10:30 window already in the table, the most general control is the autocorrelation of intraday returns: if a 5-minute bar, or a half hour, predicted the next one, there would be intraday reversal (or continuation) to exploit.

IN [07]python
b["r"] = np.log(b["cierre"] / b["apertura"])
tarde = b[b["k"] >= 6].copy()
tarde["previa"] = tarde.groupby("dia")["r"].shift(1)
media_h = (tarde.assign(h=tarde["k"] // 6)
           .groupby(["dia", "h"])["r"].sum().rename("r").to_frame())
media_h["previa"] = media_h.groupby(level="dia")["r"].shift(1)

for nombre, df in (("5 minutes", tarde), ("30 minutes", media_h)):
    df = df.dropna(subset=["r", "previa"])
    m = sm.OLS(df["r"], sm.add_constant(df["previa"])
               ).fit(cov_type="HC1")
    print(f"{nombre:>10}: b = {m.params['previa']:+.4f}  "
          f"t = {m.tvalues['previa']:+.2f}  n = {len(df)}")
OUT [07]Intraday reversal after 10:00
 5 minutes: b = +0.0005  t = +0.10  n = 70889
30 minutes: b = -0.0083  t = -0.66  n = 10979

Neither does a 5-minute bar predict the next one (b = +0.0005, t = 0.1, with 70,889 pairs) nor does a half hour predict the next (b = −0.008, t = −0.7). Outside the open there is no intraday reversal in this data, and a rule that looked for it would only pay costs. It is also a control of the analysis itself: if the bar alignment code were wrong (one bar mixed up with the next), a spurious autocorrelation would show up here.

RSI and Bollinger bands on stocks

The two most popular reversion rules in technical analysis are applied to daily prices (lesson 1.7.1):

  • Wilder's RSI (relative strength index): it compares the average gains with the average losses of the last n sessions on a scale from 0 to 100. Below 30 is called “oversold”; above 70, “overbought”. The 2-session variant, with thresholds of 10 and 90, is the very short-term version.
  • Bollinger bands: a 20-session average plus and minus two standard deviations. A close below the lower band is read as an excess to the downside.

A reversion rule is a two-state machine:

RSI below 30RSI above 50OUTIN
Mermaid source
stateDiagram-v2
  [*] --> OUT
  OUT --> IN: RSI below 30
  IN --> OUT: RSI above 50

TA-Lib computes both indicators with the same formulas as most platforms (lesson 1.7.2). They are applied to the adjusted prices of the 120 stocks:

IN [08]python
import talib

aj = pd.read_parquet("universo2_ajustados.parquet")
P = aj.pivot(index="fecha", columns="ticker",
             values="cierre_ajustado")
R = P.pct_change(fill_method=None)

def por_accion(f):
    return P.apply(lambda s: pd.Series(
        f(s.to_numpy(dtype=float)), index=s.index))

rsi14 = por_accion(lambda x: talib.RSI(x, timeperiod=14))
rsi2 = por_accion(lambda x: talib.RSI(x, timeperiod=2))
banda_alta = por_accion(lambda x: talib.BBANDS(x, 20, 2, 2)[0])
media20 = por_accion(lambda x: talib.BBANDS(x, 20, 2, 2)[1])
banda_baja = por_accion(lambda x: talib.BBANDS(x, 20, 2, 2)[2])
ejemplo = pd.DataFrame({"cierre": P["QXAJ"], "rsi14": rsi14["QXAJ"],
                        "rsi2": rsi2["QXAJ"],
                        "banda_baja": banda_baja["QXAJ"]})
ejemplo.loc["2020-03-16":"2020-03-20"].round(2)
OUT [08]RSI and bands with TA-Lib
            cierre  rsi14   rsi2  banda_baja
fecha
2020-03-16   11.06  26.68  13.75       10.48
2020-03-17   10.62  24.53   6.84       10.13
2020-03-18    9.49  20.06   1.91        9.56
2020-03-19    9.25  19.25   1.46        9.05
2020-03-20    9.22  19.15   1.38        8.63

In the week of March 16, 2020, in the middle of the script's crash, the stock QXAJ has a 14-session RSI below 30 every day and a 2-session RSI that falls as low as 1.38, and it closes below its lower band on March 18. It is exactly the situation a reversion rule would buy, and in which a trend rule would sell.

The evaluation has to separate the rule from the market. A long portfolio of stocks wins in this data even if the rule is useless, because the market rises on average. That is why the return is measured in excess of the equal-weighted universe on the same day: if the rule knows nothing, the excess is zero. Each day, the portfolio splits the capital equally among the stocks in the IN state, trades at the next close and pays half the spread on each change. The t uses HAC (Newey-West) errors with 5 lags, because positions last several days and the daily results are not independent.

IN [09]python
car = pd.read_parquet("universo2_caracteristicas.parquet")
media_horq = (car.pivot(index="fecha", columns="ticker",
                        values="horquilla_pb")
              .reindex(P.index, method="ffill") / 2 / 1e4)

def estado(entra, sale):
    s = pd.DataFrame(np.where(entra, 1.0, np.where(sale, 0.0, np.nan)),
                     index=P.index, columns=P.columns)
    return s.ffill().fillna(0.0)

def evalua(senal, excluir=()):
    cols = [c for c in P.columns if c not in excluir]
    pos = senal[cols].shift(1)                 # at the next close
    w = pos.div(pos.sum(axis=1).replace(0, np.nan), axis=0).fillna(0)
    ex = R[cols].sub(R[cols].mean(axis=1), axis=0)   # excess
    bruto = (w.shift(1) * ex).sum(axis=1)
    giro = (w - w.shift()).abs()
    coste = (giro * media_horq[cols]).sum(axis=1)
    activo = w.shift(1).sum(axis=1) > 0
    m = sm.OLS(bruto[activo], np.ones(activo.sum())).fit(
        cov_type="HAC", cov_kwds={"maxlags": 5})
    return pd.Series({
        "bruto_anual": 252 * bruto[activo].mean(),
        "t": m.tvalues.iloc[0],
        "coste_anual": 252 * coste[activo].mean(),
        "neto_anual": 252 * (bruto - coste)[activo].mean(),
        "giro_diario": giro.sum(axis=1)[activo].mean()})

reglas = {
    "rsi14 <30 to >50": estado(rsi14 < 30, rsi14 > 50),
    "rsi2 <10 to >50": estado(rsi2 < 10, rsi2 > 50),
    "bollinger lower to mid": estado(P < banda_baja, P > media20),
    "rsi2 >90 to <50 (long)": estado(rsi2 > 90, rsi2 < 50),
}
res = pd.DataFrame({k: evalua(v) for k, v in reglas.items()}).T
res.round(3)
OUT [09]Backtest of the reversion rules
                        bruto_anual      t  coste_anual  neto_anual  giro_diario
rsi14 <30 to >50             -0.054 -3.267        0.008      -0.062        0.111
rsi2 <10 to >50               0.005  0.314        0.043      -0.038        0.616
bollinger lower to mid       -0.022 -1.118        0.021      -0.044        0.268
rsi2 >90 to <50 (long)        0.035  1.449        0.064      -0.029        0.784

No rule wins net. The 14-session RSI one loses 5.4% a year against the universe gross, with t = −3.3: it is not that it fails to win, it is that it is significantly right the wrong way round. RSI(2) earns 0.5% gross (t = 0.3, nothing) and pays 4.3% in costs, because it changes 62% of the portfolio every day. Bollinger loses 2.2% gross. And the opposite rule, buying overbought stocks with RSI(2) above 90, earns 3.5% gross (t = 1.4), which its 6.4% costs turn into a loss. The reversion rule loses and the continuation rule wins a little: the sign of what is in the data is the opposite of what was being looked for.

Continuation, not reversion

Why does a rule that buys oversold stocks lose? Because in this data there is no short-term reversion and there is some continuation. The universe has three planted sources of continuation: the momentum premium (lesson 2.3.1), the post-earnings drift (lesson 2.3.5) and, above all, four guided bankruptcies: during the 320 sessions before each bankruptcy the price is driven toward a last price of between 0.30 and 1.50 USD. A stock like that spends months oversold, and the rule buys it again and again.

IN [10]python
quiebras = ["QXQT", "QXKV", "QXZT", "QXYX"]
sin_q = pd.DataFrame({k: evalua(v, excluir=quiebras)
                      for k, v in reglas.items()}).T
sin_q[["bruto_anual", "t", "neto_anual"]].round(3)
OUT [10]The same rules without the four bankruptcies
                        bruto_anual      t  neto_anual
rsi14 <30 to >50             -0.025 -1.289      -0.034
rsi2 <10 to >50               0.021  1.224      -0.026
bollinger lower to mid        0.005  0.235      -0.016
rsi2 >90 to <50 (long)        0.016  0.732      -0.043

Without the four bankruptcies, the 14-session RSI goes from −5.4% to −2.5% (t = −1.3), RSI(2) from +0.5% to +2.1% (t = 1.2) and Bollinger from −2.2% to +0.5%. None is significant and all of them lose net. More than half of the negative result of the 14-session RSI was a single thing: buying stocks on their way to bankruptcy. Be careful with the remedy, though: removing the bankruptcies after the fact is survivorship bias (lesson 1.4.4). It helps to understand the result, not to improve it: in real time you do not know which stock is going to go bankrupt.

The classic reversal in the literature is cross-sectional: buying last week's or last month's losers against the winners. It is the same exercise as the momentum of lesson 2.3.1, with the sign flipped and a shorter horizon:

IN [11]python
def perdedoras(n):
    rk = (P / P.shift(n) - 1).rank(axis=1, pct=True)
    return (rk <= 0.1).astype(float)

rev = pd.DataFrame({f"losers {n}d": evalua(perdedoras(n),
                                           excluir=quiebras)
                    for n in (5, 21)}).T
rev.round(3)
OUT [11]5- and 21-session losers
            bruto_anual      t  coste_anual  neto_anual  giro_diario
losers 5d        -0.007 -0.262        0.071      -0.078        0.823
losers 21d        0.001  0.021        0.037      -0.036        0.409
Fig. — Reversion rules on 120 synthetic stocks with no planted reversion (excluding the four bankruptcies): gross and net annual excess, net of half spread, 2006-2025

One-week losers return −0.7% a year against the universe (t = −0.3) and one-month losers, +0.1% (t = 0.0): nothing in either, as befits data without planted reversion. With costs, −7.8% and −3.6%. The figure brings together the six rules without bankruptcies: almost zero gross bars and negative net bars, from −1.6% to −7.8%.

Costs decide

The giro_diario column is the fraction of the portfolio that changes every day. Multiplied by the cost of one unit of turnover and by 252 sessions it gives the annual cost, and divided the other way round it gives the break-even spread: the maximum half spread at which the rule would not lose money if its gross return were the measured one.

IN [12]python
todas = pd.concat([sin_q, rev])
todas["equilibrio_pb"] = (1e4 * todas["bruto_anual"]
                          / (252 * todas["giro_diario"]))
todas["horquilla_media_pb"] = 1e4 * float(np.nanmean(media_horq))
todas[["bruto_anual", "giro_diario", "equilibrio_pb",
       "horquilla_media_pb"]].round(2)
OUT [12]Break-even half spread of each rule
                        bruto_anual  giro_diario  equilibrio_pb  horquilla_media_pb
rsi14 <30 to >50              -0.03         0.13          -7.60                3.15
rsi2 <10 to >50                0.02         0.69           1.23                3.15
bollinger lower to mid         0.01         0.28           0.76                3.15
rsi2 >90 to <50 (long)         0.02         0.79           0.83                3.15
losers 5d                     -0.01         0.82          -0.34                3.15
losers 21d                     0.00         0.41           0.06                3.15

The universe's half spread is 3.15 basis points on average. To break even, RSI(2) would need a half spread of 1.2 points, Bollinger of 0.8 and the long RSI(2) rule of 0.8: between a quarter and two fifths of what it really costs. The RSI(14) and 5-day loser rules have no break-even because they lose gross. It is the general shape of the problem: a daily edge of a few basis points, traded with a turnover of 30-80% a day, needs costs of a fraction of a basis point, which only market makers have, along with whoever trades with limit orders and earns the spread instead of paying it (lesson 2.3.6).

What this changes in your backtests

  • Measure the effect before the rule. A regression with robust errors says whether there is something and how much; a rule with thresholds chosen after seeing the result adds degrees of freedom.
  • Execute on the next bar. In minute-level rules, entering at the price that defines the signal is the most common way to inflate the result.
  • Compute the break-even cost. With an edge of a few basis points per trade, the question is not whether it wins gross, but what cost per side it can withstand.
  • Compare with the universe, not with zero. A long rule in stocks wins if the market rises; what measures its value is the excess.
  • Look at what the rule buys. An oversold rule buys stocks in free fall; a few bankruptcies can decide the result.

Exercises

  1. Repeat the gap-fill rule trading only gaps down (buy) and only gaps up (sell), with gaps of more than 40 basis points. Is the effect symmetric?
Solution
IN [13]python
grandes = d[d["hueco"].abs() > 0.004]
for lado, s in (("down", grandes[grandes["hueco"] < 0]),
                ("up", grandes[grandes["hueco"] > 0])):
    pnl = -np.sign(s["hueco"]) * s["r2_6"]
    t = pnl.mean() / pnl.std() * np.sqrt(len(pnl))
    print(f"{lado:>9}: {len(pnl)} trades, "
          f"{1e4 * pnl.mean():+.2f} bp, t = {t:+.2f}")
OUT [13]Gaps up and gaps down
     down: 169 trades, +8.52 bp, t = +2.60
       up: 173 trades, +6.45 bp, t = +1.93

Both sides win: 8.5 basis points on gaps down (t = 2.6) and 6.5 on gaps up (t = 1.9). The generator plants a symmetric effect (−0.10 × gap), so the 2-point difference between the two sides is noise: with 170 trades per side, the standard error of each mean is about 3 points. Choosing only the side “that works best” would be fitting the rule to noise.

  1. Change the exit of the gap rule: instead of 10:00, exit at the close of the third bar (09:45) or of the twelfth (10:30). What do you expect given the planted effect, and what comes out?
Solution
IN [14]python
c3 = b[b["k"] == 2].set_index("dia")["cierre"]
s = d[d["hueco"].abs() > 0.004]
salidas = {"09:45": np.log(c3.reindex(s.index) / s["c1"]),
           "10:00": s["r2_6"],
           "10:30": np.log(s["c12"] / s["c1"])}
for k, r in salidas.items():
    pnl = -np.sign(s["hueco"]) * r
    print(f"exit {k}: {1e4 * pnl.mean():+.2f} bp per trade")
OUT [14]Other exits
exit 09:45: +2.54 bp per trade
exit 10:00: +7.48 bp per trade
exit 10:30: +7.56 bp per trade

Exiting at 09:45 collects only two of the five bars with an effect, and the gain falls to 2.5 basis points per trade. Exiting at 10:30, after the half hour without an effect, it stays at 7.6, practically the same as at 10:00 (7.5): the bars from 10:00 to 10:30 add nothing expected, only variance and, in a real rule, more exposure time. The exit that best captures an effect is the one that matches its duration, and here it is known because it is planted; with real data it would have to be estimated out of sample (lesson 2.5.2).

  1. A website claims that “RSI(2) below 10 has a 70% hit rate at 5 days on the S&P 500”. List what you would check before trading it.
Solution

First, what the hits are measured against: in an index that rises in the long run, any 5-day purchase is right more than half the time, so it has to be compared with the hit rate of an arbitrary 5-day purchase. Second, the size of the hits against that of the misses: a 70% hit rate of small wins with large losses can lose money. Third, execution: if the signal is at the close, the purchase is at the next close (or the open), not at the same close. Fourth, costs with the rule's real turnover: in this lesson, an RSI(2) rule changes more than half of the portfolio every day and its annual cost is on the order of its gross return or more. Fifth, how many variants were tested (thresholds, horizons, filters) before arriving at the published one (lesson 2.1.8). And finally, whether the period includes some stretch without reversion: in real data short-term reversal exists in the literature, but it varies a lot across eras and markets.

Footnotes

  1. N. Jegadeesh, “Evidence of Predictable Behavior of Security Returns”, Journal of Finance 45(3), 1990, 881-898. DOI 10.1111/j.1540-6261.1990.tb05110.x. ↩

  2. B. N. Lehmann, “Fads, Martingales, and Market Efficiency”, Quarterly Journal of Economics 105(1), 1990; NBER working paper version w2533. ↩

Each answer is folded under its question

Self-assessment

  1. In KAPA, the regression of the first half-hour return on the gap gives −0.132 with a standard error of 0.038. The planted effect is −0.10. What do you conclude?

    • AThat the generator is wrong: it should come out at exactly −0.10
    • BThat the estimate is compatible with the truth (it is less than one standard error away) and that with 1,002 sessions the error of a coefficient like this is almost 40% of its value
    • CThat the real effect is −0.132
    • DThat the gap has no effect
    Show answer

    Answer B. That the estimate is compatible with the truth (it is less than one standard error away) and that with 1,002 sessions the error of a coefficient like this is almost 40% of its value

    An estimate is the truth plus noise. The distance from −0.132 to −0.10 is 0.032, less than one standard error. What the error does say is that, with four years of data, the coefficient could be between −0.06 and −0.21: the size of the effect is poorly known even though its sign is clear.

  2. The rule sells the gap up at the close of the first bar (09:35) and closes at 10:00. Why not enter at the 09:30 open?

    • ABecause at 09:30 the exchange is closed
    • BBecause the first bar has no volume
    • CBecause the gap is only known with the first price of the session: entering at that same price assumes the order is filled at the instant the signal is observed, and in practice it is filled later
    • DBecause of commissions
    Show answer

    Answer C. Because the gap is only known with the first price of the session: entering at that same price assumes the order is filled at the instant the signal is observed, and in practice it is filled later

    It is the rule for the whole level: a decision made with data from one bar is executed on the next. The part of the effect in the first bar is lost (a sixth of what was planted), but the result is achievable. Entering at the price that defines the signal is a small and very common look-ahead bias.

  3. The 10:00 to 10:30 return on the gap gives a coefficient of +0.027 with t = 1.67. What is it?

    • ANoise: the generator has no effect after 10:00; a t of 1.67 appears by chance quite often, and more so if many windows are tested
    • BA continuation of the gap that should be traded
    • CProof that the gap fill reverses
    • DA time zone error
    Show answer

    Answer A. Noise: the generator has no effect after 10:00; a t of 1.67 appears by chance quite often, and more so if many windows are tested

    It is the control and has no effect by construction. A t of 1.67 has a two-sided p-value of ≈ 0.10: if you test ten windows of the session, you can expect to find one like this. That is why controls are looked at with the same rigor as the signal (lesson 2.1.8).

  4. In the stock universe, buying when the 14-session RSI drops below 30 returns −5.4% a year against the universe (t = −3.3). What is going on?

    • AThat the RSI is computed wrong
    • BThat you should always sell instead of buying
    • CThat the RSI works the other way round in every market
    • DThat in this data there is no reversion but there is continuation: stocks that fall keep falling a little (momentum, post-earnings drift and, above all, four bankruptcies guided downward for more than a year)
    Show answer

    Answer D. That in this data there is no reversion but there is continuation: stocks that fall keep falling a little (momentum, post-earnings drift and, above all, four bankruptcies guided downward for more than a year)

    Without the four bankruptcies, the result drops to −2.5% and stops being significant (t = −1.3). The rule was systematically buying stocks heading to zero. In real data there is evidence of weekly and monthly reversal (Lehmann, Jegadeesh), but here it is not planted: the lesson is not to assume the effect you are looking for.

  5. The RSI(2) rule changes 62% of the portfolio every day and the universe's half spread is about 3 basis points. Roughly how much does it cost per year?

    • AAround 4-5%: 0.62 × 3 bp × 252 sessions
    • BLess than 0.1%
    • CAround 50%
    • DNothing, because it is a long portfolio
    Show answer

    Answer A. Around 4-5%: 0.62 × 3 bp × 252 sessions

    Daily turnover times cost per unit times sessions: 0.62 × 0.0003 × 252 ≈ 4.7%. That is more than eight times its gross return (+0.5%). In few-day rules, the cost is not a detail of the backtest: it is the result.

Key points

  • Short-term mean reversion is the most intuitive family and the most sensitive to costs: it earns little per trade and trades a lot.
  • The gap fill planted in KAPA is recovered with a regression with robust errors (−0.132 against −0.10, t = −3.5), with a standard error of 0.038 that leaves the size of the effect poorly measured.
  • The rule that enters at 09:35 and exits at 10:00 earns about 4 basis points per trade gross; with 2 basis points per side it no longer earns anything if every gap is traded.
  • The rest of the session has no effect, but gives a t of 1.67 in one window: controls also produce near false positives.
  • RSI and Bollinger on stocks without planted reversion do not win: what they measure is the continuation inherited from the bankruptcies and from momentum, and the costs of their daily turnover take between 0.8% and 7.1% a year.

Resources

Data