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.

Fig. — NOVA (adjusted), first half of 2025: close with Bollinger Bands (20 sessions, 2 deviations) and, below, the 14-session RSI
Level 1 lessons
  1. 1.1 · How markets work

    1. 1.1.1 Market participants and trading venues
    2. 1.1.2 Sessions, auctions, holidays and calendars
    3. 1.1.3 Order book: bid, ask, spread and depth
    4. 1.1.4 Order types
    5. 1.1.5 Costs, settlement, leverage and margin
  2. 1.2 · Asset classes

    1. 1.2.1 Stocks: splits, dividends and corporate actions
    2. 1.2.2 ETFs: replication, tracking error, leveraged ETFs and UCITS
    3. 1.2.3 Futures: specifications, margin, roll and contango
    4. 1.2.4 Forex: pairs, pips, lots, rollover and CFDs
    5. 1.2.5 Options: calls, puts, intrinsic value and time value
    6. 1.2.6 Crypto: spot, perpetuals, funding and custody
    7. 1.2.7 Fixed income and interest rates; CFDs and leveraged products
  3. 1.3 · Market data sources

    1. 1.3.1 Types of market data: EOD, bars, ticks, order book and reference
    2. 1.3.2 Free market data sources
    3. 1.3.3 Retail and prosumer market data APIs
    4. 1.3.4 Professional market data providers
    5. 1.3.5 Crypto and on-chain data
    6. 1.3.6 Brokers as market data sources
    7. 1.3.7 Market data licensing and terms of use
    8. 1.3.8 How to choose a market data provider
  4. 1.4 · Financial data engineering

    1. 1.4.1 Downloading over REST and websockets: pagination, rate limits and retries
    2. 1.4.2 Data cleaning: missing values, bad ticks, gaps and time zones
    3. 1.4.3 Adjusting for splits and dividends: adjusted and unadjusted prices
    4. 1.4.4 Universes without survivorship bias: delistings, mergers and symbol changes
    5. 1.4.5 Building bars: time, tick, volume, dollar and imbalance
    6. 1.4.6 Continuous futures series: roll rules, difference adjustment and ratio adjustment
    7. 1.4.7 Storage: CSV, Parquet, partitioning, DuckDB, TimescaleDB and ArcticDB
    8. 1.4.8 Reproducible pipeline: download, validation, incremental updates and quality tests
  5. 1.5 · Fundamental and macro data

    1. 1.5.1 Financial statements, ratios, XBRL and SEC EDGAR
    2. 1.5.2 Macro data, economic calendars, estimates and transcripts
  6. 1.6 · Financial exploratory analysis

    1. 1.6.1 Returns, volatility, rolling correlations and drawdown
    2. 1.6.2 Stylized facts: fat tails, clustering, autocorrelation and leverage
    3. 1.6.3 Seasonality: day of the week, month, turn of the month and intraday sessions
  7. 1.7 · Programmed technical analysis

    1. 1.7.1 Technical indicators in Python
    2. 1.7.2 Your own implementation versus TA-Lib: validating results
    3. 1.7.3 Candlestick patterns and support and resistance
    4. 1.7.4 Multi-timeframe analysis without look-ahead

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.

Adjusted OHLCVIndicator at tSignal at tOrder at t+1
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.

FamilyIndicatorsWhat it measuresInputTypical use
TrendSMA, EMA, KAMA, MACD, ADXDirection and strength of the moveClose (ADX: high, low, close)Regime filters, crossovers, trend following
MomentumRSI, stochastic, MACD histogramRecent speed versus the usualClose (stochastic: high and low)Overbought and oversold, divergences
VolatilityATR, Bollinger, Keltner, DonchianSize of the moveHigh, low, closePosition sizing, stops, breakouts
VolumeOBV, VWAP, volume profileHow much is traded and at what pricePrice 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 1−D/Ct−11 - D / C_{t-1} 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.

IN [01]python
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"]
OUT [01]NOVA adjusted for splits and dividends
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  13974110

The 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:

IN [02]python
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)
OUT [02]The split in the true range
            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.37

On 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 nn sessions weights the last nn closes equally:

SMAt=1n∑i=0n−1Ct−i\mathrm{SMA}_t = \frac{1}{n} \sum_{i=0}^{n-1} C_{t-i}

The exponential moving average (EMA) is recursive: it blends today's close with yesterday's EMA, with α=2/(n+1)\alpha = 2/(n+1). Its memory is infinite, but the weight of a close from kk sessions ago decays as (1−α)k(1-\alpha)^k:

EMAt=α Ct+(1−α) EMAt−1\mathrm{EMA}_t = \alpha\, C_t + (1 - \alpha)\, \mathrm{EMA}_{t-1}

Kaufman's adaptive moving average (KAMA) changes its α\alpha every day according to the efficiency ratio ERtER_t: the net move over nn sessions divided by the sum of each session's moves. With a clean trend ERER is 1 and the average approaches a fast EMA (2 sessions); with sideways noise ERER tends to 0 and the average barely moves (EMA of 30):

ERt=∣Ct−Ct−n∣∑i=0n−1∣Ct−i−Ct−i−1∣st=(ERt (αr−αl)+αl)2KAMAt=KAMAt−1+st (Ct−KAMAt−1)ER_t = \frac{|C_t - C_{t-n}|}{\sum_{i=0}^{n-1} |C_{t-i} - C_{t-i-1}|} \qquad s_t = \left(ER_t\,(\alpha_r - \alpha_l) + \alpha_l\right)^2 \qquad \mathrm{KAMA}_t = \mathrm{KAMA}_{t-1} + s_t\,(C_t - \mathrm{KAMA}_{t-1})

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 n−1n-1 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.

IN [03]python
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)
OUT [03]SMA, EMA and KAMA
                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.04

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

IN [04]python
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")
OUT [04]Lag of each average on a ramp
SMA 20: 9.50 sessions
EMA 20: 9.50 sessions
KAMA 10: 1.25 sessions

The 20-session SMA and EMA have the same lag, (n−1)/2=9.5(n-1)/2 = 9.5 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 (ER=1ER = 1), stays 1.25 sessions behind: s=(2/3)2s = (2/3)^2 and the lag of an EMA with that coefficient is (1−s)/s(1-s)/s. 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 α=1/n\alpha = 1/n (not 2/(n+1)2/(n+1)):

RSt=U‾tD‾tRSIt=100−1001+RStU‾t=U‾t−1+Ut−U‾t−1nRS_t = \frac{\overline{U}_t}{\overline{D}_t} \qquad \mathrm{RSI}_t = 100 - \frac{100}{1 + RS_t} \qquad \overline{U}_t = \overline{U}_{t-1} + \frac{U_t - \overline{U}_{t-1}}{n}

where Ut=max⁡(Ct−Ct−1,0)U_t = \max(C_t - C_{t-1}, 0) and Dt=max⁡(Ct−1−Ct,0)D_t = \max(C_{t-1} - C_t, 0). 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:

%Kt=100⋅Ct−min⁡(Lt−13..t)max⁡(Ht−13..t)−min⁡(Lt−13..t)\%K_t = 100 \cdot \frac{C_t - \min(L_{t-13..t})}{\max(H_{t-13..t}) - \min(L_{t-13..t})}
IN [05]python
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)
OUT [05]RSI, MACD and stochastic
              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.64

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

IN [06]python
r = rsi(c)
print(f"RSI > 70: {(r > 70).mean():.1%} of sessions")
print(f"RSI < 30: {(r < 30).mean():.1%} of sessions")
OUT [06]Time at the RSI extremes
RSI > 70: 7.3% of sessions
RSI < 30: 8.0% of sessions

Over 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 TRt=max⁡(Ht−Lt, ∣Ht−Ct−1∣, ∣Lt−Ct−1∣)TR_t = \max(H_t - L_t,\ |H_t - C_{t-1}|,\ |L_t - C_{t-1}|) 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.

IN [07]python
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)
OUT [07]ATR, Bollinger, Keltner and Donchian
             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.54

The 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:

Fig. — NOVA (adjusted), November 2024 to April 2025: Keltner Channel around the close and, below, the 14-session ATR in dollars

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 (+DM+DM: how much the high rises versus yesterday, if larger than how much the low falls) with the negative one (−DM-DM), divides them by the ATR to get +DI+DI and −DI-DI, and smooths the normalized distance between the two:

DXt=100⋅∣+DIt−−DIt∣+DIt+−DItADXt=Wilder14(DX)tDX_t = 100 \cdot \frac{|{+DI_t} - {-DI_t}|}{{+DI_t} + {-DI_t}} \qquad \mathrm{ADX}_t = \mathrm{Wilder}_{14}(DX)_t
IN [08]python
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%}")
OUT [08]ADX, +DI and −DI
            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; +DI+DI and −DI-DI 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:

IN [09]python
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")
OUT [09]Breakouts with and without shift(1)
Not shifted: 0 closes above
Shifted one session: 148 above, 125 below

Today'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.

Fig. — NOVA (adjusted), second half of 2025: close and Donchian Channel of the previous 20 sessions; blue, closes above the prior high; orange, below the prior low

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:

IN [10]python
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")
OUT [10]OBV
OBV at 2025-12-31: -375.2 M shares
Change in 2025: -0.8 M

The 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:

VWAPt=∑i≤tpi qi∑i≤tqi\mathrm{VWAP}_t = \frac{\sum_{i \le t} p_i\, q_i}{\sum_{i \le t} q_i}

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

IN [11]python
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}")
OUT [11]VWAP with and without auctions and block trades
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.311
Fig. — NOVA, 2 June 2025 session (New York time): cumulative VWAP with all trades and, below, its difference from the VWAP of continuous trading only (no auctions or blocks)

During 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, (H+L+C)/3(H + L + C)/3, 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:

IN [12]python
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")
OUT [12]Volume profile, POC and value area
Continuous: POC 53.45, value area 53.15-53.90 (72.2% of volume)
All: POC 53.10 with 967,099 shares
Fig. — NOVA volume profile on 2 June 2025, continuous trading: shares traded in each 5-cent bin; light grey, the value area (70% of volume around the POC)

In 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 tt only uses data up to tt. 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.

yesnoFull seriesIndicatorSeries up to tIndicator up to tEqual up to t?No look-aheadUses the future
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):

IN [13]python
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')}")
OUT [13]Truncation test
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  False

The 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:

IN [14]python
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))
OUT [14]Backward adjustment also changes with the future
Adjusted price today / adjusted in 2023: 0.9328 to 0.9328
RSI equal: True
SMA equal: False

The 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, a MinMaxScaler fitted 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. span versus alpha, ddof=0 versus ddof=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

  1. The Bollinger Band width, (Bsup−Binf)/SMA20(B_{sup} - B_{inf}) / \mathrm{SMA}_{20}, 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
IN [15]python
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}")
OUT [15]Band squeeze in 2025
Minimum width: 3.2% on 2025-08-04
Close: 51.76; 20 sessions later: 52.26 (+1.0%)
Range: 50.36-57.00

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

  1. 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
IN [16]python
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}")
OUT [16]VWAP without the closing auction
All: 53.3191
Without the closing auction: 53.3412
Continuous only: 53.3503

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

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

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

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

  3. Wilder's RSI smooths the up and down moves with an exponential average. Which α does a 14-session RSI use in pandas?

    • Aalpha=1/14
    • Bspan=14, that is, α = 2/15
    • Ccom=14
    • Dhalflife=14
    Show answer

    Answer A. alpha=1/14

    Wilder smooths with α = 1/n (in pandas, alpha=1/14, equivalent to com=13). With span=14 the RSI responds almost twice as fast and matches no platform's.

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

  5. 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 rolling and ewm without 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.

Resources

Data