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.
- Module
- 2.3 · Strategy families
- Duration
- 65 min
- Prerequisites
- 1.7.1 · Technical indicators in Python, 1.7.2 · Your own implementation versus TA-Lib: validating results, 1.1.5 · Costs, settlement, leverage and margin
- 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
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.
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.
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())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:
where is the session open, the previous close and the close of bar j.
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)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.4In 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, . If the gap tends to fill, . 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).
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}]") 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 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 , 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.
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()) 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.8The 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:
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 : 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.
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) 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.60Trading 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 per side, the net per trade is the gross minus , and the break-even cost per side is half the gross:
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") >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 sideWith 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.
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)}") 5 minutes: b = +0.0005 t = +0.10 n = 70889
30 minutes: b = -0.0083 t = -0.66 n = 10979Neither 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
nsessions 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:
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:
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) 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.63In 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.
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) 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.784No 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.
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) 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.043Without 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:
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) 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.409One-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.
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) 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.15The 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
- 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
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}") down: 169 trades, +8.52 bp, t = +2.60
up: 173 trades, +6.45 bp, t = +1.93Both 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.
- 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
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")exit 09:45: +2.54 bp per trade
exit 10:00: +7.48 bp per trade
exit 10:30: +7.56 bp per tradeExiting 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).
- 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
Each answer is folded under its question
Self-assessment
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.
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.
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).
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.
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.