---
title: "1.7.1 Technical indicators in Python with pandas"
description: "Moving averages, MACD, RSI, stochastic, Bollinger, ATR, ADX, Donchian, Keltner, OBV, VWAP and volume profile, coded in pandas without look-ahead."
url: "https://pythonparatrading.com/en/python-trading-course/level-1/technical-indicators-in-python/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-1/technical-indicators-in-python.png"
---

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

- Course: [Level 1: Financial data, markets and analysis](https://pythonparatrading.com/en/python-trading-course/level-1/)
- Module: 1.7 · Programmed technical analysis
- Duration: 75 min
- Prerequisites: [0.5.3 · Selection, filtering, groupby, merge, pivot, resample and moving windows](https://pythonparatrading.com/en/python-trading-course/level-0/pandas-groupby-merge-resample-rolling/), [1.1.1 · Market participants and trading venues](https://pythonparatrading.com/en/python-trading-course/level-1/market-participants-and-trading-venues/), [1.4.3 · Adjusting for splits and dividends: adjusted and unadjusted prices](https://pythonparatrading.com/en/python-trading-course/level-1/adjusting-prices-for-splits-and-dividends/)

## By the end of this lesson

- Code SMA, EMA, KAMA, MACD, RSI, stochastic, Bollinger, ATR, ADX, Donchian, Keltner and OBV in pandas, vectorized.
- Compute a session's VWAP and a volume profile with its POC and value area, with and without auctions and block trades.
- Classify indicators by family (trend, momentum, volatility, volume) and know what each one measures.
- Detect look-ahead in an indicator with the truncation test and avoid the typical mistakes: unadjusted prices, centered windows and channels without `shift(1)`.

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

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

```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)
```
```output
            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 $n$ sessions weights the last $n$ closes equally:

$$
\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
$\alpha = 2/(n+1)$. Its memory is infinite, but the weight of a close from $k$ sessions ago decays as $(1-\alpha)^k$:

$$
\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**
$ER_t$: the net move over $n$ sessions divided by the sum of each session's moves. With a clean trend $ER$ is 1 and the
average approaches a fast EMA (2 sessions); with sideways noise $ER$ tends to 0 and the average barely moves (EMA of
30):

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

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

```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")
```
```output
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$ 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 = 1$), stays 1.25 sessions behind: $s = (2/3)^2$ and the lag of an EMA with that coefficient is
$(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 $\alpha = 1/n$ (not $2/(n+1)$):

$$
RS_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 $U_t = \max(C_t - C_{t-1}, 0)$ and $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:

$$
\%K_t = 100 \cdot \frac{C_t - \min(L_{t-13..t})}{\max(H_{t-13..t}) - \min(L_{t-13..t})}
$$

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

```python
r = rsi(c)
print(f"RSI > 70: {(r > 70).mean():.1%} of sessions")
print(f"RSI < 30: {(r < 30).mean():.1%} of sessions")
```
```output
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** $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.

```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)
```
```output
             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.* Two panels sharing a time axis, November 2024 to April 2025. Top, NOVA's adjusted close and the Keltner Channel (20-session EMA plus and minus two 10-session ATRs): the price moves between 53 and 62 until January, jumps above the channel in February up to 70 and falls below it in March and April down to 44. Bottom, the 14-session ATR, rising from 1.61 to 3.11 USD; the high, highlighted, is the last day, 29 April.

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

$$
DX_t = 100 \cdot \frac{|{+DI_t} - {-DI_t}|}{{+DI_t} + {-DI_t}}
\qquad
\mathrm{ADX}_t = \mathrm{Wilder}_{14}(DX)_t
$$

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

```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")
```
```output
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.* NOVA's adjusted close from August to December 2025 with the Donchian Channel of the previous 20 sessions, as steps. Between 13 August, the first breakout, highlighted, at 52.68 USD, and 3 October the price rises to 69.40 with 15 closes above the prior high, in blue. In October there are two closes below the prior low, in orange, and in mid-November two new highs; afterwards the price stays inside the channel, between 65 and 70.

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:

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

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

```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}")
```
```output
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).* Two panels of NOVA's 2 June 2025 session, 9:30 to 16:00 New York time. Top, the last price every 5 minutes, rising from 53.35 to about 53.93 in mid-morning and falling to about 52.55 around 15:10 before closing at 53.12, and the much smoother cumulative VWAP, between 53.32 and 53.56; six dotted lines mark the blocks. Bottom, the difference between the VWAP with all trades and the continuous-trading VWAP: it moves between +0.3 and −1 cent, with steps at each block, and the closing auction takes it to −3.1 cents, highlighted.

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$, gives 53.311: close, but it matches neither of the two.

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

A vendor's VWAP may or may not include the closing auction, block trades and off-hours trades, and it almost never says
so in the field name. Before comparing an execution with “the VWAP”, you have to know which one it is (lesson 1.3.1).

</aside>

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:

```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")
```
```output
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).* Horizontal bars with the shares NOVA traded in continuous trading on 2 June 2025 in each 5-cent bin, from 52.55 to 53.95 USD. The highest-volume bin, the POC, highlighted, is 53.45, with about 659,000 shares; the value area, in light grey, runs from 53.15 to 53.90. Next come 53.35, with 566,000, and 53.40, 53.70 and 53.75, with just over 500,000 each; at the day's low, 52.55, there is another bin of 250,000.

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 $t$ only uses data up to $t$. 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
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):

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

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

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

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

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

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

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

   - A. About 150, one per breakout
   - B. It depends on the dividend adjustment
   - C. Every session in an uptrend
   - D. Never: the channel includes the session's own high, and the close never exceeds the high

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

   - A. The EMA uses only the last 20 sessions and the SMA the whole history
   - B. Nothing: they are two names for the same average
   - 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
   - D. The EMA uses future prices to reduce lag

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

   - A. `alpha=1/14`
   - B. `span=14`, that is, α = 2/15
   - C. `com=14`
   - D. `halflife=14`

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

   - A. A rounding error
   - B. The closing auction: 885,500 shares at 53.12, below the day's VWAP, pulling the average down
   - C. The internalizer's trades
   - D. The opening auction

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

   - A. Because each day's value uses the next ten days: it fails the truncation test
   - B. Because it is slower to compute
   - C. Because pandas does not allow it on series with dates
   - D. Because it only works with odd windows

   *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

### Research

- [Your first system in Python: moving-average crossover on SPY](https://pythonparatrading.com/en/first-system-moving-average-crossover/) · Research-0018 · 7 min
- [Volatility Estimators for Trading](https://pythonparatrading.com/en/volatility-estimators-for-trading/) · Research-0002 · 4 min

### Documentation

- [pandas: DataFrame.ewm (span, alpha, adjust and min_periods)](https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.ewm.html) · pandas.pydata.org
- [pandas: DataFrame.rolling (windows, center and min_periods)](https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.rolling.html) · pandas.pydata.org
- [TA-Lib: function index by group (reference definitions)](https://ta-lib.org/functions/) · ta-lib.org

### Data

- [Level 1 data: NOVA daily, unadjusted, 2018-2025 (CSV)](https://pythonparatrading.com/curso/datos/nivel-1/nova_diario_sin_ajustar.csv) · Download · nova_diario_sin_ajustar.csv
- [Level 1 data: NOVA corporate actions (CSV)](https://pythonparatrading.com/curso/datos/nivel-1/nova_eventos.csv) · Download · nova_eventos.csv

Previous lesson: [1.6.3 · Seasonality: day of the week, month, turn of the month and intraday sessions](https://pythonparatrading.com/en/python-trading-course/level-1/seasonality-and-calendar-effects/)

Next lesson: [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/)
