---
title: "2.3.2 Mean reversion: RSI, Bollinger and gaps"
description: "Opening gap fill on 5-minute bars, RSI and Bollinger on stocks with controls, and why costs kill short-term mean reversion."
url: "https://pythonparatrading.com/en/python-trading-course/level-2/mean-reversion-and-gaps/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-2/mean-reversion-and-gaps.png"
---

# 2.3.2 · 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.

- Course: [Level 2: Systematic strategies and rigorous backtesting](https://pythonparatrading.com/en/python-trading-course/level-2/)
- Module: 2.3 · Strategy families
- Duration: 65 min
- Prerequisites: [1.7.1 · Technical indicators in Python](https://pythonparatrading.com/en/python-trading-course/level-1/technical-indicators-in-python/), [1.7.2 · Your own implementation versus TA-Lib: validating results](https://pythonparatrading.com/en/python-trading-course/level-1/validating-indicators-against-ta-lib/), [1.1.5 · Costs, settlement, leverage and margin](https://pythonparatrading.com/en/python-trading-course/level-1/trading-costs-settlement-leverage-and-margin/)

## By the end of this lesson

- Measure the partial fill of the opening gap with a regression with robust errors and compare the estimate with the planted effect.
- Turn that effect into a rule with next-bar execution and compute its break-even cost per side.
- Use the rest of the session as a control and recognize a false positive close to the threshold.
- Code RSI and Bollinger band rules with TA-Lib, evaluate them against the universe with HAC errors and separate what is reversion from what is inherited continuation.
- Explain why the turnover of few-day rules makes costs dominate the result.

## 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
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 stocks[^1], 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 costs[^2]. In the data for this lesson, on the other hand, we know exactly where there is
reversion and where there is not.

<aside class="callout" data-kind="aviso">

**Synthetic data with a planted effect**. In the 5-minute bars of the fictitious ETF KAPA, the expected return of the
first six bars (09:30-10:00) is `−0.10 × gap`: the opening gap tends to fill by 10%. Outside that half hour, returns
have no autocorrelation. In the 120 stocks of the universe **no reversion at all is planted**: the RSI and Bollinger
rules are tested where there should be nothing.

</aside>

## 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.

```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())
```
```output
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:

$$
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 $A_t$ is the session open, $C_{t-1}$ the previous close and $C^{(j)}_t$ the close of bar `j`.

```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)
```
```output
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, $r^{(0-6)}_t = a + b\,h_t + e_t$. If the gap tends
to fill, $b < 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).

```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}]")
```
```output
           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.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 \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.

```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())
```
```output
       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:

```mermaid
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 \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.

```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)
```
```output
   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 $c$ per side, the net per trade is the gross minus
$2c$, and the **break-even cost** per side is half the gross:

```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")
```
```output
               >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.* Three falling lines with slope −2: each basis point of cost per side takes two off the net. With all gaps the rule starts at 4.0 basis points and crosses zero at 2 per side; with gaps above 40 (highlighted) it starts at 7.5 and crosses at 3.7; with gaps above 80 it starts at 11.5 and crosses at 5.8.

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.

<aside class="callout" data-kind="riesgo">

The cost of a very liquid ETF on an index can be one basis point per side or less in the spread, but at 09:35 the
spread is usually wider than at midday (lesson 1.1.3) and a market order also pays slippage. A break-even margin of 2
to 4 basis points is eaten up by very little. The rule also concentrates risk in the most volatile part of the session:
one loss on a news day can wipe out many small gains.

</aside>

## 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.

```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)}")
```
```output
 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:

```mermaid
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:

```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)
```
```output
            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.

```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)
```
```output
                        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.

```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)
```
```output
                        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:

```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)
```
```output
            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.* Six pairs of horizontal bars, gross on top and net below. Gross between −2.5% and +2.1%, none significant. All net values negative: RSI 14, −3.4%; RSI 2 below 10, −2.6% (highlighted); Bollinger, −1.6%; RSI 2 above 90, −4.3%; 5-day losers, −7.8%; 21-day losers, −3.6%.

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.

```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)
```
```output
                        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.**

```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}")
```
```output
     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.

2. 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.**

```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")
```
```output
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).

3. 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.

[^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.

## 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?

   - A. That the generator is wrong: it should come out at exactly −0.10
   - 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
   - C. That the real effect is −0.132
   - D. That the gap has no effect

   *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?

   - A. Because at 09:30 the exchange is closed
   - B. Because the first bar has no volume
   - 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
   - D. Because of commissions

   *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?

   - 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
   - B. A continuation of the gap that should be traded
   - C. Proof that the gap fill reverses
   - D. A time zone error

   *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?

   - A. That the RSI is computed wrong
   - B. That you should always sell instead of buying
   - C. That the RSI works the other way round in every market
   - 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)

   *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?

   - A. Around 4-5%: 0.62 × 3 bp × 252 sessions
   - B. Less than 0.1%
   - C. Around 50%
   - D. Nothing, because it is a long portfolio

   *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

### Research

- [Seasonality in Python II - Backtesting](https://pythonparatrading.com/en/seasonality-intraday-backtesting/) · Research-0025 · 8 min

### Documentation

- [Jegadeesh (1990): “Evidence of Predictable Behavior of Security Returns”, Journal of Finance 45(3), 881-898](https://doi.org/10.1111/j.1540-6261.1990.tb05110.x) · doi.org
- [Lehmann: “Fads, Martingales, and Market Efficiency” (NBER w2533; Quarterly Journal of Economics 105(1), 1990)](https://www.nber.org/papers/w2533) · nber.org
- [TA-Lib for Python: momentum indicators (RSI) and overlap studies (BBANDS)](https://ta-lib.github.io/ta-lib-python/func_groups/momentum_indicators.html) · ta-lib.github.io

### Books

- [Ernest P. Chan: Algorithmic Trading: Winning Strategies and Their Rationale (Wiley, 2013), chapter 4, “Buy-on-Gap Model”](https://www.wiley.com/en-us/Algorithmic+Trading%3A+Winning+Strategies+and+Their+Rationale-p-9781118460146) · wiley.com

### Data

- [Level 2 data: KAPA 5-minute bars, 2022-2025 (Parquet)](https://pythonparatrading.com/curso/datos/nivel-2/indice2_5min.parquet) · Download · indice2_5min.parquet

Previous lesson: [2.3.1 · Trend following and momentum](https://pythonparatrading.com/en/python-trading-course/level-2/trend-following-and-momentum/)

Next lesson: [2.3.3 · Pairs trading and statistical arbitrage: distance, cointegration and Kalman](https://pythonparatrading.com/en/python-trading-course/level-2/pairs-trading-statistical-arbitrage/)
