Lesson 1.7.1 · 75 min
Technical indicators in Python
A technical indicator turns past prices and volumes into a number. This lesson codes the most widely used ones in pandas, one per family, on NOVA's adjusted series and on a trade-by-trade session, and shows how to check that none of them uses information from the future.
- Module
- 1.7 · Programmed technical analysis
- Duration
- 75 min
- Prerequisites
- 0.5.3 · Selection, filtering, groupby, merge, pivot, resample and moving windows, 1.1.1 · Market participants and trading venues, 1.4.3 · Adjusting for splits and dividends: adjusted and unadjusted prices
- Author
- Jesús Cuesta
- Updated
Level 1 lessons
1.1 · How markets work
1.2 · Asset classes
- 1.2.1 Stocks: splits, dividends and corporate actions
- 1.2.2 ETFs: replication, tracking error, leveraged ETFs and UCITS
- 1.2.3 Futures: specifications, margin, roll and contango
- 1.2.4 Forex: pairs, pips, lots, rollover and CFDs
- 1.2.5 Options: calls, puts, intrinsic value and time value
- 1.2.6 Crypto: spot, perpetuals, funding and custody
- 1.2.7 Fixed income and interest rates; CFDs and leveraged products
1.3 · Market data sources
- 1.3.1 Types of market data: EOD, bars, ticks, order book and reference
- 1.3.2 Free market data sources
- 1.3.3 Retail and prosumer market data APIs
- 1.3.4 Professional market data providers
- 1.3.5 Crypto and on-chain data
- 1.3.6 Brokers as market data sources
- 1.3.7 Market data licensing and terms of use
- 1.3.8 How to choose a market data provider
1.4 · Financial data engineering
- 1.4.1 Downloading over REST and websockets: pagination, rate limits and retries
- 1.4.2 Data cleaning: missing values, bad ticks, gaps and time zones
- 1.4.3 Adjusting for splits and dividends: adjusted and unadjusted prices
- 1.4.4 Universes without survivorship bias: delistings, mergers and symbol changes
- 1.4.5 Building bars: time, tick, volume, dollar and imbalance
- 1.4.6 Continuous futures series: roll rules, difference adjustment and ratio adjustment
- 1.4.7 Storage: CSV, Parquet, partitioning, DuckDB, TimescaleDB and ArcticDB
- 1.4.8 Reproducible pipeline: download, validation, incremental updates and quality tests
1.5 · Fundamental and macro data
1.6 · Financial exploratory analysis
1.7 · Programmed technical analysis
What an indicator is and what it does not promise
A technical indicator is a function that takes past prices and volumes and returns one number per session: an average, a ratio, a range. It predicts nothing on its own. It summarizes a property of the series —where it is heading, how strongly, how much it moves, how much volume comes with it— in a form that can be compared across days and across assets. Whether that property makes money is a different question, and it is answered with an honest backtest (modules 2 and 3), not with a chart.
Coding them yourself has two advantages. First, you know exactly what each one computes: the “14-period RSI” of two platforms can give different values, and lesson 1.7.2 shows why. Second, you control time: every value must be computable with what was known at the close of its session, and the signal it produces can only be executed in the next session.
Mermaid source
flowchart LR D[Adjusted OHLCV] --> I[Indicator at t] I --> S[Signal at t] S --> E[Order at t+1]
Classic indicators fall into four families. Combining two from the same family (RSI and stochastic, for example) adds little: they measure almost the same thing.
| Family | Indicators | What it measures | Input | Typical use |
|---|---|---|---|---|
| Trend | SMA, EMA, KAMA, MACD, ADX | Direction and strength of the move | Close (ADX: high, low, close) | Regime filters, crossovers, trend following |
| Momentum | RSI, stochastic, MACD histogram | Recent speed versus the usual | Close (stochastic: high and low) | Overbought and oversold, divergences |
| Volatility | ATR, Bollinger, Keltner, Donchian | Size of the move | High, low, close | Position sizing, stops, breakouts |
| Volume | OBV, VWAP, volume profile | How much is traded and at what price | Price and volume (VWAP and profile: trades) | Confirmation, execution benchmark, price zones |
The data: adjusted NOVA
NOVA's daily series (nova_diario_sin_ajustar.csv) holds the prices that were actually traded, from 2018 to 2025. Over
that period NOVA paid 33 dividends and did a 4-for-1 split on August 23, 2021 (nova_eventos.csv). To compute
indicators the series has to be adjusted backward, with the multiplicative method from lesson 1.4.3: each dividend
multiplies the earlier prices by and the split divides them by 4. Volume is adjusted only for the
split (multiplied by 4 before the date), because a dividend does not change the number of shares.
import numpy as np
import pandas as pd
bruto = pd.read_csv("nova_diario_sin_ajustar.csv",
parse_dates=["fecha"], index_col="fecha")
eventos = pd.read_csv("nova_eventos.csv", parse_dates=["fecha_ex"])
def factores(bruto, eventos):
"""Price factor (splits and dividends) and volume factor (splits)."""
cierre_ant = bruto["cierre"].shift(1)
f_precio = pd.Series(1.0, index=bruto.index)
f_volumen = pd.Series(1.0, index=bruto.index)
for e in eventos.itertuples():
antes = bruto.index < e.fecha_ex
if e.tipo == "split":
f_precio[antes] /= e.ratio
f_volumen[antes] *= e.ratio
else:
f_precio[antes] *= 1 - e.importe / cierre_ant[e.fecha_ex]
return f_precio, f_volumen
f_precio, f_volumen = factores(bruto, eventos)
px = bruto[["apertura", "maximo", "minimo", "cierre"]].mul(
f_precio, axis=0)
px.columns = ["o", "h", "l", "c"]
px["v"] = (bruto["volumen"] * f_volumen).astype("int64")
o, h, l, c, v = (px[k] for k in ["o", "h", "l", "c", "v"])
print(f"{len(px)} sessions; factor on 2018-01-02: "
f"{f_precio.iloc[0]:.4f}")
px.round(2).loc["2021-08-19":"2021-08-24"]2011 sessions; factor on 2018-01-02: 0.2196
o h l c v
fecha
2021-08-19 54.72 57.51 54.40 55.05 9886444
2021-08-20 54.53 54.94 52.04 54.81 8813464
2021-08-23 55.07 55.19 53.08 54.40 13427377
2021-08-24 54.47 54.91 52.37 54.40 13974110The first day's factor, 0.2196, combines the split (÷ 4) and eight years of dividends, which cut it by another 12%. Around the split, the adjusted series is continuous: on August 20 it closed at 240.00 USD unadjusted and 54.81 adjusted; on the 23rd, the first day with the new shares, it opened at 60.28 unadjusted and 55.07 adjusted. Without the adjustment, any range or level indicator breaks:
def rango_verdadero(h, l, c):
c_ant = c.shift(1)
tramos = pd.concat([h - l, (h - c_ant).abs(),
(l - c_ant).abs()], axis=1)
return tramos.max(axis=1, skipna=False)
tr = pd.DataFrame({
"sin_ajustar": rango_verdadero(bruto["maximo"], bruto["minimo"],
bruto["cierre"]),
"ajustado": rango_verdadero(h, l, c),
})
tr.loc["2021-08-19":"2021-08-25"].round(2) sin_ajustar ajustado
fecha
2021-08-19 13.65 3.12
2021-08-20 13.20 3.01
2021-08-23 181.89 2.10
2021-08-24 2.78 2.54
2021-08-25 3.69 3.37On August 23, 2021 NOVA moved 2.30 USD between its high and its low (60.41 − 58.11), but the unadjusted true range is
181.89: the distance between the previous close, 240.00, and the day's low. An unadjusted 14-session ATR is worth 28.50
USD that day and drags the jump along for weeks: a stop at “two ATRs” would sit 57 USD away from a price of 60. With the
adjusted series, that day's true range is 2.10, like any other. The whole lesson uses px, the adjusted series.
Moving averages: SMA, EMA and KAMA
The simple moving average (SMA) of sessions weights the last closes equally:
The exponential moving average (EMA) is recursive: it blends today's close with yesterday's EMA, with . Its memory is infinite, but the weight of a close from sessions ago decays as :
Kaufman's adaptive moving average (KAMA) changes its every day according to the efficiency ratio : the net move over sessions divided by the sum of each session's moves. With a clean trend is 1 and the average approaches a fast EMA (2 sessions); with sideways noise tends to 0 and the average barely moves (EMA of 30):
In pandas, the SMA is rolling(n).mean() and the EMA, ewm(span=n, adjust=False). adjust=False is the recursion
above; with adjust=True (the default) pandas normalizes the weights of the finite window it sees, and the first
values change. min_periods=n leaves the first empty, where the average does not yet have enough data. KAMA
cannot be fully vectorized: the coefficient changes every day and the recursion needs the previous value. The
efficiency ratio can be vectorized, and the remaining loop runs over NumPy arrays, not pandas rows.
def sma(x, n):
return x.rolling(n).mean()
def ema(x, n):
return x.ewm(span=n, adjust=False, min_periods=n).mean()
def kama(x, n=10, rapida=2, lenta=30):
cambio = (x - x.shift(n)).abs()
ruido = x.diff().abs().rolling(n).sum()
er = cambio / ruido
a_r, a_l = 2 / (rapida + 1), 2 / (lenta + 1)
s = ((er * (a_r - a_l) + a_l) ** 2).to_numpy()
p = x.to_numpy()
out = np.full(len(p), np.nan)
out[n] = p[n]
for i in range(n + 1, len(p)):
out[i] = out[i - 1] + s[i] * (p[i] - out[i - 1])
return pd.Series(out, index=x.index), er
k, er = kama(c)
medias = pd.DataFrame({"c": c, "sma20": sma(c, 20),
"ema20": ema(c, 20), "kama": k, "er": er})
medias.loc["2025-12-24":].round(2) c sma20 ema20 kama er
fecha
2025-12-24 69.67 67.93 68.37 68.34 0.64
2025-12-26 68.43 67.84 68.37 68.34 0.08
2025-12-29 68.22 67.83 68.36 68.34 0.14
2025-12-30 68.17 67.82 68.34 68.33 0.27
2025-12-31 68.19 67.92 68.32 68.33 0.04In the last sessions of 2025 NOVA moves sideways and the efficiency ratio is low: KAMA barely changes (68.34, 68.34, 68.34, 68.33, 68.33) while the close falls from 69.67 to 68.19. The SMA and the EMA follow the price more nervously.
How much lag does each average have? On a perfectly linear trend it can be measured exactly: the average stays a fixed number of sessions behind the price.
rampa = pd.Series(np.arange(200.0))
for nombre, media in [("SMA 20", sma(rampa, 20)),
("EMA 20", ema(rampa, 20)),
("KAMA 10", kama(rampa)[0])]:
print(f"{nombre}: {rampa.iloc[-1] - media.iloc[-1]:.2f} sessions")SMA 20: 9.50 sessions
EMA 20: 9.50 sessions
KAMA 10: 1.25 sessionsThe 20-session SMA and EMA have the same lag, sessions: the EMA is not “faster” because it is exponential, but because it reacts sooner to a sudden change (the latest data point weighs more). KAMA, with the ramp's clean trend (), stays 1.25 sessions behind: and the lag of an EMA with that coefficient is . Paying less lag in a trend in exchange for standing still in noise is its whole design.
Momentum: RSI, MACD and stochastic
Wilder's RSI compares the average of the up moves with the average of the down moves over the last sessions. Wilder smooths with his own average, which is an EMA with (not ):
where and . Appel's MACD is the difference between two EMAs (12 and 26 sessions), with a signal line (a 9-period EMA of the MACD itself) and a histogram (the difference between the two). Lane's stochastic places the close within the range of the last 14 sessions, from 0 (at the low) to 100 (at the high), smoothed twice with 3-period averages:
def wilder(x, n):
return x.ewm(alpha=1 / n, adjust=False, min_periods=n).mean()
def rsi(x, n=14):
d = x.diff()
sube = wilder(d.clip(lower=0), n)
baja = wilder((-d).clip(lower=0), n)
return 100 - 100 / (1 + sube / baja)
def macd(x, rapida=12, lenta=26, senal=9):
linea = ema(x, rapida) - ema(x, lenta)
s = ema(linea, senal)
return pd.DataFrame({"macd": linea, "senal": s,
"hist": linea - s})
def estocastico(c, h, l, n=14, k=3, d=3):
minimo, maximo = l.rolling(n).min(), h.rolling(n).max()
k_rapido = 100 * (c - minimo) / (maximo - minimo)
k_lento = k_rapido.rolling(k).mean()
return pd.DataFrame({"k": k_lento, "d": k_lento.rolling(d).mean()})
momento = pd.concat([rsi(c).rename("rsi"), macd(c),
estocastico(c, h, l)], axis=1)
momento.tail(4).round(2) rsi macd senal hist k d
fecha
2025-12-26 51.50 0.49 0.37 0.12 83.25 86.86
2025-12-29 50.37 0.42 0.38 0.04 73.88 82.26
2025-12-30 50.09 0.35 0.37 -0.02 64.89 74.01
2025-12-31 50.21 0.30 0.36 -0.06 58.13 65.64At the end of 2025, NOVA is in no man's land: RSI around 50, MACD positive but below its signal (negative histogram since December 30) and the stochastic falling from the upper zone. Three momentum indicators, three consistent readings: they move together because they measure almost the same thing.
The figure at the top of the page puts the RSI below the price in the first half of 2025. NOVA rises from 53 to 70 USD until February 25, falls to 44 on April 10 and partly recovers. The RSI does not go above 70 in the whole half (the February peak stays below it), and spends 11 sessions below 30, with a low of 22.5 on April 8, two sessions before the price low. “Oversold” does not mean “about to rise”: the RSI was below 30 from March 26 while the price kept falling. A rule that buys when the RSI crosses back above 30 is a hypothesis to be tested, not a law.
r = rsi(c)
print(f"RSI > 70: {(r > 70).mean():.1%} of sessions")
print(f"RSI < 30: {(r < 30).mean():.1%} of sessions")RSI > 70: 7.3% of sessions
RSI < 30: 8.0% of sessionsOver the whole series, the RSI spends 7.3% of sessions above 70 and 8.0% below 30: the extremes are rare by construction, and a signal that only fires at them will have few trades (and few trades are little evidence, lesson 0.1.2).
Volatility: ATR, Bollinger, Keltner and Donchian
The true range extends the session's range with the gap from the previous close, and the ATR is its Wilder average. Bollinger Bands are the 20-period SMA plus and minus two standard deviations of the last 20 closes; the Keltner channel (in its modern version) is a 20-period EMA plus and minus two ATRs; the Donchian channel is the highest high and the lowest low of the last 20 sessions. All three are “the price plus and minus a measure of its range”; what changes is how it is measured.
def atr(h, l, c, n=14):
return wilder(rango_verdadero(h, l, c), n)
def bollinger(x, n=20, k=2):
media = sma(x, n)
sd = x.rolling(n).std(ddof=0)
inf, sup = media - k * sd, media + k * sd
return pd.DataFrame({"media": media, "sup": sup, "inf": inf,
"pct_b": (x - inf) / (sup - inf)})
def keltner(h, l, c, n=20, n_atr=10, k=2):
centro, a = ema(c, n), atr(h, l, c, n_atr)
return pd.DataFrame({"centro": centro, "sup": centro + k * a,
"inf": centro - k * a})
def donchian(h, l, n=20):
return pd.DataFrame({"sup": h.rolling(n).max(),
"inf": l.rolling(n).min()})
bb = bollinger(c)[["sup", "inf", "pct_b"]]
kc = keltner(h, l, c)[["sup", "inf"]]
dc = donchian(h, l)
vol = pd.concat([atr(h, l, c).rename("atr"), bb.add_prefix("bb_"),
kc.add_prefix("kc_"), dc.add_prefix("dc_")], axis=1)
vol.tail(3).round(2) atr bb_sup bb_inf bb_pct_b kc_sup kc_inf dc_sup dc_inf
fecha
2025-12-29 1.39 70.71 64.96 0.57 71.08 65.64 70.27 64.54
2025-12-30 1.34 70.69 64.95 0.56 70.93 65.75 70.27 64.54
2025-12-31 1.30 70.70 65.13 0.55 70.82 65.83 70.27 64.54The Bollinger standard deviation uses ddof=0 (the population one), which is the convention of Bollinger and of
TA-Lib; the pandas default is ddof=1, and with 20 data points the band would come out 2.6% wider (lesson 1.7.2
measures that difference). pct_b places the close within the band: 0 at the lower one, 1 at the upper one; on
December 31, 2025 it is 0.55, almost in the middle.
The ATR is in dollars per share and is the most widely used volatility measure for sizing positions and placing stops (module 2). The figure shows the Keltner channel and, below it, the ATR, over a stretch in which NOVA's volatility doubles:
From November 2024 to April 2025 the 14-session ATR goes from 1.61 to 3.11 USD, and as a share of the price, from 2.8% to 6.6%. The Keltner channel widens with it. In February, a 15% rise in a single session (the 4th) takes the close above the channel; in March and April, the fall keeps it below for weeks: over those six months NOVA closes 8 times above and 19 below.
ADX: trend strength
Wilder's ADX does not say where the price is heading, but whether it is heading anywhere. It compares the positive directional movement (: how much the high rises versus yesterday, if larger than how much the low falls) with the negative one (), divides them by the ATR to get and , and smooths the normalized distance between the two:
def adx(h, l, c, n=14):
sube, baja = h.diff(), -l.diff()
dm_mas = sube.where((sube > baja) & (sube > 0), 0.0)
dm_menos = baja.where((baja > sube) & (baja > 0), 0.0)
a = atr(h, l, c, n)
di_mas = 100 * wilder(dm_mas, n) / a
di_menos = 100 * wilder(dm_menos, n) / a
dx = 100 * (di_mas - di_menos).abs() / (di_mas + di_menos)
return pd.DataFrame({"di_mas": di_mas, "di_menos": di_menos,
"adx": wilder(dx, n)})
fuerza = adx(h, l, c)
print(fuerza.tail(3).round(1))
print(f"Sessions with ADX > 25: {(fuerza['adx'] > 25).mean():.1%}") di_mas di_menos adx
fecha
2025-12-29 22.8 22.7 12.0
2025-12-30 21.9 21.8 11.2
2025-12-31 22.0 20.8 10.6
Sessions with ADX > 25: 47.3%At the end of 2025 NOVA's ADX is at 10.6, no trend; and almost tied. Over the whole series, the ADX exceeds 25 (the usual “there is a trend” threshold) in 47.3% of sessions: a filter that permissive lets through almost half the days. The ADX is used as a regime filter (trade breakouts only when there is a trend), not as a signal.
Donchian and the shift you cannot leave out
A classic breakout rule (that of Dennis's Turtles) buys when the price exceeds the high of the last 20 sessions. Coded carelessly, it gives no signal at all:
dc = donchian(h, l)
print("Not shifted:", (c > dc["sup"]).sum(), "closes above")
print("Shifted one session:",
(c > dc["sup"].shift(1)).sum(), "above,",
(c < dc["inf"].shift(1)).sum(), "below")Not shifted: 0 closes above
Shifted one session: 148 above, 125 belowToday's channel includes today's high, and the close never exceeds the high of its own session: zero breakouts. The
correct rule compares today's close with the channel of the previous 20 sessions (shift(1)): 148 closes above and
125 below in eight years. The opposite mistake is worse: comparing yesterday's close with a channel that already
includes today's high gives “perfect” breakouts that could not have been seen.
In the second half of 2025, NOVA's rise from 52.68 to 69.40 USD between August 13 and October 3 produces one breakout after another (15 closes above the previous high); the first, on August 13. Then the price stays inside the channel, with two closes below the previous low in October and two new highs in mid-November. A breakout system lives on a few stretches like this and suffers in sideways markets.
Volume: OBV, VWAP and profile
Granville's OBV (on-balance volume) accumulates volume with the sign of the change in the close: it adds the volume of up days and subtracts that of down days. Its level means nothing (it depends on the day the sum starts); what matters is its slope, or whether it diverges from the price:
def obv(c, v):
return (np.sign(c.diff()).fillna(0) * v).cumsum()
ob = obv(c, v)
print(f"OBV at 2025-12-31: {ob.iloc[-1] / 1e6:,.1f} M shares")
cambio = ob.iloc[-1] - ob.loc[:"2024"].iloc[-1]
print(f"Change in 2025: {cambio / 1e6:,.1f} M")OBV at 2025-12-31: -375.2 M shares
Change in 2025: -0.8 MThe level, −375 million shares, is an artifact of where the sum starts. What is informative is the change: in 2025 NOVA's OBV barely moves (−0.8 million) while the adjusted price rises from 56.28 to 68.19, 21%. This is what technical analysis calls a divergence. Whether it means anything is measured, like any other signal, with subsequent returns and many cases, not with one.
The VWAP (volume-weighted average price) is a session's average price weighted by volume:
It is the benchmark against which institutional execution is measured: an algorithm that buys “at VWAP” tries to pay that average price. It is computed on trades, not on bars, and lesson 1.1.1 already warned: which trades go in changes the result. With NOVA's session on June 2, 2025, all trades versus continuous trading only (no auctions or block trades):
op = pd.read_parquet("nova_operaciones.parquet")
def vwap(df):
pq = (df["precio"] * df["tamano"]).cumsum()
return pq / df["tamano"].cumsum()
op["vwap_todo"] = vwap(op)
continua = op[op["condicion"] == "regular"]
op["vwap_continua"] = vwap(continua).reindex(op.index).ffill()
antes_cierre = op.iloc[-2]
final = op.iloc[-1]
print(f"Before the closing auction: {antes_cierre['vwap_todo']:.3f}"
f" versus {antes_cierre['vwap_continua']:.3f}")
print(f"At the end of the day: {final['vwap_todo']:.3f}"
f" versus {final['vwap_continua']:.3f}")
barras = pd.read_parquet("nova_barras_5min.parquet")
dia = barras[barras["ts"].dt.date == pd.Timestamp("2025-06-02").date()]
tipico = (dia["maximo"] + dia["minimo"] + dia["cierre"]) / 3
aprox = (tipico * dia["volumen"]).sum() / dia["volumen"].sum()
print(f"Approximation with 5-min bars: {aprox:.3f}")Before the closing auction: 53.341 versus 53.350
At the end of the day: 53.319 versus 53.350
Approximation with 5-min bars: 53.311During the day, the two versions move almost together: the dark-pool block trades move the all-trades VWAP by a few tenths of a cent each time (the steps in the figure). Just before the closing auction the difference is one cent. The auction, 885,500 shares at 53.12, well below the day's VWAP, takes it to 3.1 cents. For an algorithm that has bought 200,000 shares, that is a 6,200 USD difference depending on which VWAP it is compared with. The approximation with 5-minute bars and the typical price, , gives 53.311: close, but it matches neither of the two.
The volume profile distributes the day's volume across price levels instead of across time: how many shares traded in each 5-cent bucket. The bucket with the most volume is the POC (point of control) and the value area is the contiguous interval around the POC that holds 70% of the volume, built by adding each time the neighboring bucket with the most volume:
def perfil(df, paso=0.05):
tramo = (np.floor(df["precio"] / paso + 1e-9) * paso).round(2)
return df.groupby(tramo)["tamano"].sum()
def area_valor(p, frac=0.70):
i = lo = hi = int(np.argmax(p.to_numpy()))
acumulado = p.iloc[i]
while acumulado / p.sum() < frac:
arriba = p.iloc[hi + 1] if hi + 1 < len(p) else -1
abajo = p.iloc[lo - 1] if lo > 0 else -1
if arriba >= abajo:
hi, acumulado = hi + 1, acumulado + arriba
else:
lo, acumulado = lo - 1, acumulado + abajo
return p.index[lo], p.index[hi] + 0.05, acumulado / p.sum()
p_cont = perfil(continua)
lo_va, hi_va, cubre = area_valor(p_cont)
print(f"Continuous: POC {p_cont.idxmax():.2f}, value area "
f"{lo_va:.2f}-{hi_va:.2f} ({cubre:.1%} of volume)")
p_todo = perfil(op)
print(f"All: POC {p_todo.idxmax():.2f} with "
f"{p_todo.max():,} shares")Continuous: POC 53.45, value area 53.15-53.90 (72.2% of volume)
All: POC 53.10 with 967,099 sharesIn continuous trading, NOVA's POC that day is the 53.45 to 53.50 bucket, and the value area goes from 53.15 to 53.90: 72.2% of the volume (the algorithm overshoots 70% when adding the last bucket). With all trades, the POC jumps to 53.10: the closing auction, 885,500 shares at a single price, outweighs any bucket of continuous trading. An “all-in” profile flags as a zone of interest a price nobody chose in the order book: the auction cross set it. The volume profile is used as a map of support and resistance; lesson 1.7.3 explains why those maps deserve suspicion.
The truncation test: no indicator may see the future
All the indicators in this lesson follow the rule from the beginning: their value at only uses data up to . The way to check it, instead of assuming it, is the truncation test: compute the indicator on the full series and on the series cut at a date, and compare the values up to that date. If the indicator uses the future, the two computations do not match.
Mermaid source
flowchart TB
A[Full series] --> B[Indicator]
A --> C[Series up to t]
C --> D[Indicator up to t]
B --> E{Equal up to t?}
D --> E
E -- yes --> F[No look-ahead]
E -- no --> G[Uses the future]The cell applies the test to each indicator and to two “indicators” with look-ahead that show up in real code: a centered mean (it smooths very nicely on a chart) and a normalization with the mean and standard deviation of the whole series (the typical z-score computed before splitting the data):
def sin_anticipacion(f, datos, corte):
completo = f(datos).loc[:corte]
truncado = f(datos.loc[:corte])
return np.allclose(completo, truncado, equal_nan=True)
pruebas = {
"SMA 20": lambda d: sma(d["c"], 20),
"EMA 20": lambda d: ema(d["c"], 20),
"KAMA": lambda d: kama(d["c"])[0],
"RSI 14": lambda d: rsi(d["c"]),
"MACD": lambda d: macd(d["c"])["hist"],
"ATR 14": lambda d: atr(d["h"], d["l"], d["c"]),
"ADX 14": lambda d: adx(d["h"], d["l"], d["c"])["adx"],
"Donchian": lambda d: donchian(d["h"], d["l"])["sup"],
"Centered mean": lambda d: d["c"].rolling(21, center=True).mean(),
"Global z-score": lambda d: (d["c"] - d["c"].mean()) / d["c"].std(),
}
for nombre, f in pruebas.items():
print(f"{nombre:15} {sin_anticipacion(f, px, '2023-06-30')}")SMA 20 True
EMA 20 True
KAMA True
RSI 14 True
MACD True
ATR 14 True
ADX 14 True
Donchian True
Centered mean False
Global z-score FalseThe eight indicators pass; the two impostors do not. The test is cheap and worth keeping in the project's tests (lesson 0.4.5) for every new indicator, with several cutoff dates. There is one trap it does not catch: the adjusted series itself. The adjustment factors are computed with later dividends, so the 2023 adjusted price in a 2025 file is not the one that was seen in 2023:
hasta = bruto.loc[:"2023-06-30"]
conocidos = eventos[eventos["fecha_ex"] <= "2023-06-30"]
f_2023, _ = factores(hasta, conocidos)
c_2023 = hasta["cierre"] * f_2023
cociente = c.loc[:"2023-06-30"] / c_2023
print(f"Adjusted price today / adjusted in 2023: {cociente.min():.4f}"
f" to {cociente.max():.4f}")
print("RSI equal:", np.allclose(rsi(c).loc[:"2023-06-30"], rsi(c_2023),
equal_nan=True))
print("SMA equal:", np.allclose(sma(c, 20).loc[:"2023-06-30"],
sma(c_2023, 20), equal_nan=True))Adjusted price today / adjusted in 2023: 0.9328 to 0.9328
RSI equal: True
SMA equal: FalseThe dividends paid after June 2023 scale all the earlier prices by the same factor, 0.9328. An indicator that depends only on ratios between prices (RSI, returns, %b) does not change; one that is in dollars (SMA, ATR, Donchian levels) does. For relative signals it does not matter. For a rule with an absolute level (“buy if the price goes above 50 USD”, a stop at a fixed price, a support), it does: you have to use the prices as they were on each date, or readjust on each date of the backtest (lessons 1.4.3 and 1.4.4).
Typical mistakes
- Unadjusted prices. NOVA's split turns a normal day into a true range of 181.89 USD. Adjust before computing.
- Centered windows or global normalizations.
center=True,mean()of the whole series, aMinMaxScalerfitted on all the data: look-ahead. The truncation test catches them. - Channels and extremes without
shift(1). Comparing with a high that includes the current session gives zero breakouts or, with the shift the wrong way round, impossible breakouts. - The warm-up period. The first values of an EMA or an RSI depend on how the recursion is initialized. With NOVA's series from 2018 it does not matter in 2025, but on a short history it does (lesson 1.7.2).
- Unstated conventions.
spanversusalpha,ddof=0versusddof=1, Keltner with EMA and ATR or with SMA and average range: two “14-period RSIs” may not match. Document the convention next to the function. - Optimizing the parameters. Trying 50 average lengths and keeping the best is a multiple-testing problem (lesson 1.7.3), not a discovery.
Exercises
- The Bollinger Band width, , measures relative volatility. Compute its 2025 minimum for NOVA (the moment of maximum “squeeze”) and say what happened to the price over the next 20 sessions.
Solution
bb = bollinger(c)
ancho = ((bb["sup"] - bb["inf"]) / bb["media"]).loc["2025"]
fecha = ancho.idxmin()
despues = c.loc[fecha:].iloc[:21]
ini, fin = despues.iloc[0], despues.iloc[-1]
print(f"Minimum width: {ancho.min():.1%} on {fecha:%Y-%m-%d}")
print(f"Close: {ini:.2f}; 20 sessions later: {fin:.2f}"
f" ({fin / ini - 1:+.1%})")
print(f"Range: {despues.min():.2f}-{despues.max():.2f}")Minimum width: 3.2% on 2025-08-04
Close: 51.76; 20 sessions later: 52.26 (+1.0%)
Range: 50.36-57.00The tightest squeeze of 2025 is 3.2%, on August 4. Over the next 20 sessions NOVA breaks upward (the first Donchian breakout, on August 13), touches 57.00 and comes back: 20 sessions later it closes at 52.26, 1.0% higher. The squeeze does not say which way the price will break, only that recent volatility is low compared with its average. A single observation proves nothing: to know whether the squeeze anticipates large moves you would have to measure, across all squeezes, the volatility of the following sessions against that of randomly chosen sessions.
- Compute the VWAP of the June 2, 2025 session excluding only the closing auction (keeping the opening auction and the block trades). How much of the 3.1-cent difference does the closing auction explain on its own?
Solution
sin_cierre = op[op["condicion"] != "subasta_cierre"]
v_sin_cierre = vwap(sin_cierre).iloc[-1]
print(f"All: {op['vwap_todo'].iloc[-1]:.4f}")
print(f"Without the closing auction: {v_sin_cierre:.4f}")
print(f"Continuous only: {op['vwap_continua'].iloc[-1]:.4f}")All: 53.3191
Without the closing auction: 53.3412
Continuous only: 53.3503Without the closing auction, the VWAP goes from 53.3191 to 53.3412: the auction explains 2.2 of the 3.1 cents. The remaining 0.9 up to the continuous version (53.3503) come from the block trades and the opening auction. All three VWAPs are legitimate; what is not legitimate is comparing an execution with one of them without saying which.
- A colleague computes NOVA's RSI with unadjusted prices and says it does not matter because the RSI “is a ratio”. Is he right? Think about what happens on the day of the split.
Solution
Half right. The RSI does not change if the whole series is multiplied by the same number (that is why a later dividend adjustment does not alter it), but the split does not scale the whole series: it cuts a −180 USD jump into a single session. That “down move” enters Wilder's average of the down moves with a weight of 1/14 and dominates it for weeks: the unadjusted RSI goes from 63.2 on August 18, 2021 to 21.8 on the 24th and stays between 22 and 26 until the end of September, a month in which the adjusted series falls 3% and its RSI moves between 38 and 66. With the adjusted series, that day is a normal session. Ratios are immune to scaling the whole series, not to a step in the middle of it.
Each answer is folded under its question
Self-assessment
With a 20-session Donchian channel computed with
rolling(20).max()on the highs, how many times does NOVA close above the channel in 2018-2025?- AAbout 150, one per breakout
- BIt depends on the dividend adjustment
- CEvery session in an uptrend
- DNever: the channel includes the session's own high, and the close never exceeds the high
Show answer
Answer D. Never: the channel includes the session's own high, and the close never exceeds the high
The high of the last 20 sessions includes today's, and today's close is less than or equal to its high. The breakout rule compares with the channel of the previous sessions:
rolling(20).max().shift(1). With that shift, NOVA has 148 closes above it.What sets a 20-session EMA apart from a 20-session SMA?
- AThe EMA uses only the last 20 sessions and the SMA the whole history
- BNothing: they are two names for the same average
- CThe EMA weights every past session with geometrically decaying weights; the SMA weights the last 20 equally and the earlier ones not at all
- DThe EMA uses future prices to reduce lag
Show answer
Answer C. The EMA weights every past session with geometrically decaying weights; the SMA weights the last 20 equally and the earlier ones not at all
The EMA is recursive: each value blends today's price (weight α = 2/21) with yesterday's EMA. Its memory is infinite but fades; with the same number of sessions, its lag on a linear trend is the same as the SMA's, (n − 1)/2 sessions, and it reacts sooner to a sudden change.
Wilder's RSI smooths the up and down moves with an exponential average. Which α does a 14-session RSI use in pandas?
- A
alpha=1/14 - B
span=14, that is, α = 2/15 - C
com=14 - D
halflife=14
Show answer
Answer A.
alpha=1/14Wilder smooths with α = 1/n (in pandas,
alpha=1/14, equivalent tocom=13). Withspan=14the RSI responds almost twice as fast and matches no platform's.- A
NOVA's VWAP on June 2, 2025 is 53.319 with all trades and 53.350 with continuous trading only. What explains almost all of the difference?
- AA rounding error
- BThe closing auction: 885,500 shares at 53.12, below the day's VWAP, pulling the average down
- CThe internalizer's trades
- DThe opening auction
Show answer
Answer B. The closing auction: 885,500 shares at 53.12, below the day's VWAP, pulling the average down
Just before 16:00 the difference is one cent; the closing auction, almost 10% of the day's volume at a single price, takes it to 3.1 cents. Anyone comparing an execution with “the VWAP” has to say which one.
A centered moving average (
rolling(21, center=True)) smooths better than a regular one. Why is it no good for a signal?- ABecause each day's value uses the next ten days: it fails the truncation test
- BBecause it is slower to compute
- CBecause pandas does not allow it on series with dates
- DBecause it only works with odd windows
Show answer
Answer A. Because each day's value uses the next ten days: it fails the truncation test
Centering the window puts ten prices that are not yet known into today's value. On a chart it looks nice; in a backtest it is pure look-ahead. Computed on the series cut at a date, its last ten values come out empty or different.
Key points
- An indicator at session t can only use data up to t; the order it triggers is executed at t + 1. That rule, not the indicator, is what separates a useful backtest from a misleading one.
- Almost all classic indicators are moving averages (simple, exponential or Wilder's) of prices, ranges or volumes: pandas computes them with
rollingandewmwithout loops. KAMA, recursive with a coefficient that changes every day, needs one. - The families measure different things: trend (moving averages, MACD, ADX), momentum (RSI, stochastic), volatility (ATR, Bollinger, Keltner, Donchian) and volume (OBV, VWAP, profile). Combining two from the same family adds little information.
- Indicators are computed on adjusted prices: unadjusted, NOVA's 2021 split gives a true range of 181.89 USD in a session that moved 2.30.
- The truncation test detects look-ahead: the indicator computed on the series cut at a date must match the one computed on the whole series up to that date.
- The VWAP and the volume profile depend on which trades are included: the closing auction moves NOVA's VWAP by 3.1 cents and shifts the POC from 53.45 to 53.10.