Lesson 1.7.2 · 60 min
Your own implementation versus TA-Lib: validating results
Two correct implementations of the same RSI can differ by 20 points in their first sessions. This lesson compares the indicators from lesson 1.7.1 with TA-Lib, separates the differences that fade away (seeds and warm-up) from the ones that do not (conventions and bugs), and turns the comparison into tests.
- Module
- 1.7 · Programmed technical analysis
- Duration
- 60 min
- Prerequisites
- 1.7.1 · Technical indicators in Python, 0.4.5 · Professional logging and testing with pytest
- 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
Why compare with a reference
Lesson 1.7.1 coded a dozen indicators in pandas. The formulas are right, but “they are right” is a claim that has to be
checked: a wrong ddof, a span where an alpha belonged or a missing shift give believable, wrong numbers. The
cheapest way to check is to compare them with a reference implementation, written by others and used by many people.
In Python the reference is TA-Lib, the C library for technical analysis (more than 200 functions) with its Cython
wrapper.
Comparing is not demanding equality. Two correct implementations can differ in how they start, and an incorrect one can match almost always. What tells one case from the other is how the difference evolves:
Mermaid source
flowchart TB
P[Own indicator] --> D[Difference]
R[TA-Lib] --> D
D --> Q{Tends to zero?}
Q -- yes --> S[Seed]
Q -- no --> C{Is it constant?}
C -- yes --> V[Convention]
C -- no --> E[Bug]A difference that fades session after session is a different seed: recursive averages remember how they were initialized, and that memory is erased over time. A constant difference, in level or in proportion, is a different convention (population versus sample, initial value of a sum): neither is a bug, but you have to pick one and say so. A difference that neither converges nor is constant is a bug.
The implementations from lesson 1.7.1
Each lesson runs in a fresh process, so the first cell reloads adjusted NOVA and the second repeats the functions from lesson 1.7.1, unchanged:
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):
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(len(px), "sessions,", px.index[0].date(), "to",
px.index[-1].date())2011 sessions, 2018-01-02 to 2025-12-31def sma(x, n):
return x.rolling(n).mean()
def ema(x, n):
return x.ewm(span=n, adjust=False, min_periods=n).mean()
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 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)
def atr(h, l, c, n=14):
return wilder(rango_verdadero(h, l, c), n)
def bollinger(x, n=20, k=2, ddof=0):
media = sma(x, n)
sd = x.rolling(n).std(ddof=ddof)
return pd.DataFrame({"media": media, "sup": media + k * sd,
"inf": media - k * sd})
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()})
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 wilder(dx, n)
def obv(c, v):
return (np.sign(c.diff()).fillna(0) * v).cumsum()
def kama(x, n=10, rapida=2, lenta=30):
er = (x - x.shift(n)).abs() / x.diff().abs().rolling(n).sum()
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)TA-Lib from Python
Since version 0.6.5, the TA-Lib package on PyPI publishes wheels that include the C library, for Linux, macOS and
Windows (those for 0.8.2, for CPython 3.11 to 3.15): uv add ta-lib is enough, with nothing to compile. The course uses
0.8.2 (the exact version is in curso-code/uv.lock). It is imported as talib:
import talib
print("TA-Lib", talib.__version__)
grupos = talib.get_function_groups()
print(sum(len(f) for f in grupos.values()), "functions")
pd.Series({g: len(f) for g, f in grupos.items()}).sort_values()TA-Lib 0.8.2
201 functions
Cycle Indicators 5
Price Transform 6
Volatility Indicators 7
Statistic Functions 11
Volume Indicators 12
Math Operators 12
Math Transform 15
Overlap Studies 25
Momentum Indicators 47
Pattern Recognition 61
dtype: int64The functions take NumPy arrays (or pandas Series, in which case they return Series with the same index) and the
parameters as keyword arguments: talib.RSI(cierre, timeperiod=14). The first values, those of the lookback, come
out empty. A requirement that surprises people: the arrays have to be float64. A Parquet file saved as float32 to
save space fails:
talib.SMA(c.to_numpy().astype("float32"), timeperiod=20)Exception: input array type is not doubleThe best-known pure-Python alternative, pandas-ta, is not in the course environment, and not by choice: its version
0.4.71b0 (September 2025) pins numba==0.61.2, and that numba version requires numpy<2.3 and only publishes wheels up
to Python 3.13. With the course's numpy 2.5 and Python 3.14, those requirements cannot be met. In an environment with
Python 3.13 and numpy 2.2, it would be used like this:
import pandas_ta as ta # does not install with numpy >= 2.3 or Python 3.14
rsi_pta = ta.rsi(px["c"], length=14) # Series RSI_14
bandas_pta = ta.bbands(px["c"], length=20, std=2) # DataFrame
atr_pta = ta.atr(px["h"], px["l"], px["c"], length=14)The checks in this lesson apply equally to pandas-ta, to Polars or to any other reference: what is compared is numbers, not libraries.
The first comparison
A comparison function summarizes four things: how many empty values each version has, the maximum difference, the difference in the last session and the first session from which the difference no longer exceeds :
C, H, L = c.to_numpy(), h.to_numpy(), l.to_numpy()
def comparar(propio, ref, tol=1e-8):
a, b = np.asarray(propio, float), np.asarray(ref, float)
d = np.abs(a - b)
malas = np.flatnonzero(~(d <= tol) & ~np.isnan(b))
return {"nan_propio": int(np.isnan(a).sum()),
"nan_talib": int(np.isnan(b).sum()),
"dif_max": np.nanmax(d), "dif_final": d[-1],
"coincide_desde": malas[-1] + 1 if len(malas) else 0}
def resumen(casos):
t = pd.DataFrame({k: comparar(*par) for k, par in casos.items()}).T
enteros = ["nan_propio", "nan_talib", "coincide_desde"]
return t.astype({k: int for k in enteros})
macd_ta, senal_ta, _ = talib.MACD(C)
casos = {
"SMA 20": (sma(c, 20), talib.SMA(C, 20)),
"Bollinger upper": (bollinger(c)["sup"], talib.BBANDS(C, 20)[0]),
"Stochastic %K": (estocastico(c, h, l)["k"],
talib.STOCH(H, L, C, 14, 3, 0, 3, 0)[0]),
"EMA 20": (ema(c, 20), talib.EMA(C, 20)),
"RSI 14": (rsi(c), talib.RSI(C, 14)),
"ATR 14": (atr(h, l, c), talib.ATR(H, L, C, 14)),
"ADX 14": (adx(h, l, c), talib.ADX(H, L, C, 14)),
"KAMA 10": (kama(c), talib.KAMA(C, 10)),
"MACD": (macd(c)["macd"], macd_ta),
"Bollinger ddof=1": (bollinger(c, ddof=1)["sup"],
talib.BBANDS(C, 20)[0]),
"OBV": (obv(c, v), talib.OBV(C, v.to_numpy().astype(float))),
}
resumen(casos) nan_propio nan_talib dif_max dif_final coincide_desde
SMA 20 19 19 8.526513e-14 0.000000e+00 0
Bollinger upper 19 19 4.206413e-12 1.094236e-12 0
Stochastic %K 15 17 1.421085e-13 4.973799e-14 0
EMA 20 19 19 5.830524e-01 0.000000e+00 198
RSI 14 14 14 1.990207e+01 7.105427e-15 303
ATR 14 14 14 6.979309e-02 4.440892e-16 227
ADX 14 27 27 4.008239e+00 1.953993e-14 324
KAMA 10 10 10 7.225559e-01 0.000000e+00 214
MACD 25 33 3.762379e-01 0.000000e+00 257
Bollinger ddof=1 19 19 4.732804e-01 7.238154e-02 2011
OBV 0 0 1.123733e+07 1.123733e+07 2011The table is read in blocks.
- SMA, Bollinger and stochastic match from the first value except for floating-point noise (differences of the order of or smaller, lesson 0.3.1). For the stochastic, TA-Lib leaves two more empty values: it does not return the slow %K until it can also compute the %D. It is an alignment convention, not a calculation one.
- EMA, RSI, ATR, ADX, KAMA and MACD start far apart (19.9 RSI points, 4.0 ADX points) and end up equal: a final difference of . They are recursive averages with different seeds. TA-Lib's MACD, moreover, has 33 empty values and ours 25.
- Bollinger with
ddof=1and OBV never converge: the final difference is not zero. They are conventions.
Seeds: why recursive averages take a while to match
An EMA needs a previous value to compute the first one. ewm(adjust=False) uses the first data point of the series as
the seed and applies the recursion from there; min_periods only hides the first results. TA-Lib seeds with the
simple mean of the first data points and starts the recursion at data point . From then on, both versions
apply the same formula, and the difference between them is multiplied by every session:
With , the difference is divided by ten every 23 sessions. For the RSI and the ATR, with the Wilder
average (), every 31. The figure at the top of the page shows it on a log scale: straight lines that drop
an order of magnitude every 23 or every 31 sessions (the EMA's reaches , floating-point noise, before session
330), except Bollinger with ddof=1, which stays up.
If you want exact equality, replicating the seed is enough. The trick with pandas: a series with empty values up to
position , the simple mean of the first data points at and the original data after that. ewm skips the
leading empty values and starts the recursion at the seed:
def sembrar(x, n, inicio=0):
"""Simple mean of x[inicio:inicio+n] at inicio+n-1; NaN before."""
s = x.astype(float).copy()
s.iloc[: inicio + n - 1] = np.nan
s.iloc[inicio + n - 1] = x.iloc[inicio:inicio + n].mean()
return s
def ema_ta(x, n):
return sembrar(x, n).ewm(span=n, adjust=False).mean()
def wilder_ta(x, n):
# the first change (or true range) is at position 1
return sembrar(x, n, inicio=1).ewm(alpha=1 / n, adjust=False).mean()
def rsi_ta(x, n=14):
d = x.diff()
sube = wilder_ta(d.clip(lower=0), n)
baja = wilder_ta((-d).clip(lower=0), n)
return 100 - 100 / (1 + sube / baja)
def atr_ta(h, l, c, n=14):
return wilder_ta(rango_verdadero(h, l, c), n)
exactos = {
"EMA 20": (ema_ta(c, 20), talib.EMA(C, 20)),
"RSI 14": (rsi_ta(c), talib.RSI(C, 14)),
"ATR 14": (atr_ta(h, l, c), talib.ATR(H, L, C, 14)),
}
resumen(exactos) nan_propio nan_talib dif_max dif_final coincide_desde
EMA 20 19 19 2.842171e-14 0.000000e+00 0
RSI 14 14 14 4.263256e-14 7.105427e-15 0
ATR 14 14 14 2.664535e-15 4.440892e-16 0Now they match from the first value and with the same empty values. The MACD has one more subtlety: TA-Lib aligns the two EMAs. It does not seed the fast one (12) with the first 12 closes, but with the 12 that end where the slow one's (26) seed ends, and the signal line (9) with the first 9 MACD values. That is why its first value comes out at position 33 = 25 + 8:
def ema_desde(x, n, fin):
"""EMA seeded with the simple mean of x[fin-n+1 .. fin]."""
s = x.astype(float).copy()
s.iloc[:fin] = np.nan
s.iloc[fin] = x.iloc[fin - n + 1: fin + 1].mean()
return s.ewm(span=n, adjust=False).mean()
ingenuo = ema_ta(c, 12) - ema_ta(c, 26)
alineado = ema_desde(c, 12, 25) - ema_desde(c, 26, 25)
senal = ema_desde(alineado, 9, 33)
resumen({
"Naive MACD": (ingenuo, macd_ta),
"Aligned MACD": (alineado.where(senal.notna()), macd_ta),
"Aligned signal": (senal, senal_ta),
}) nan_propio nan_talib dif_max dif_final coincide_desde
Naive MACD 25 33 8.057404e-02 0.000000e+00 129
Aligned MACD 33 33 4.973799e-14 0.000000e+00 0
Aligned signal 33 33 3.996803e-14 8.104628e-15 0Seeding each EMA with its own first data points (the “naive” MACD) leaves a difference of 0.08 that fades away; with the aligned seed, equality is exact from the first value. Should you always copy TA-Lib's conventions? No. You should know them: if the system is computed with pandas and monitored with a platform that uses TA-Lib, the first weeks of any new series will not match, and it is worth knowing why before someone asks.
Conventions that do not fade
The Bollinger difference with ddof=1 does not tend to zero because it is not a seed: it is the sample standard
deviation (divides by ) versus the population one (divides by ). The ratio between the two is constant,
. The OBV one is something else: TA-Lib starts the sum with the first session's volume and our version
with zero.
ancho_0 = bollinger(c)["sup"] - bollinger(c)["media"]
ancho_1 = bollinger(c, ddof=1)["sup"] - bollinger(c)["media"]
cociente = (ancho_1 / ancho_0).dropna()
print(f"Width ddof=1 / ddof=0: {cociente.min():.6f} to "
f"{cociente.max():.6f}; square root of 20/19 = {np.sqrt(20 / 19):.6f}")
dif_obv = talib.OBV(C, v.to_numpy().astype(float)) - obv(c, v)
print(f"OBV TA-Lib - own: from {dif_obv.min():,.0f}"
f" to {dif_obv.max():,.0f}")
print(f"Volume of the first session: {v.iloc[0]:,}")Width ddof=1 / ddof=0: 1.025978 to 1.025978; square root of 20/19 = 1.025978
OBV TA-Lib - own: from 11,237,332 to 11,237,332
Volume of the first session: 11,237,332Neither of the two is a bug. Bollinger defined his bands with the population deviation and TA-Lib follows it; pandas uses the sample one by default because it is the statistician's. The OBV has no meaningful level, so it does not matter where the sum starts: its slope is the same. What is a bug is not knowing which one you are using.
| Difference | Example | Does it converge? | What to do |
|---|---|---|---|
| Seed of a recursive average | EMA, RSI, ATR, ADX, KAMA | Yes, geometrically | Discard the warm-up or replicate the seed |
| Output alignment | MACD (33 versus 25 empty), stochastic | Yes, or they already match | Compare only where both have a value |
| Calculation convention | ddof=0 versus ddof=1 | No, fixed ratio | Choose and document |
| Level convention | OBV with or without the first volume | No, fixed offset | Use differences, not levels |
| Bug | span instead of alpha, a missing shift | No, and not constant either | Fix it |
The unstable period
The seed is not just a difference between libraries. With the same library, the RSI of a session depends on where the series starts, because the seed is computed with the first data available. This is what TA-Lib calls the unstable period. You can see it by computing NOVA's RSI on the full series and on the series starting 500 sessions later:
completo = talib.RSI(C, 14)[500:]
tardio = talib.RSI(C[500:], 14)
dif = np.abs(tardio - completo)
for k in [14, 50, 100, 200]:
print(f"{k:>3} sessions after the start: {dif[k]:.6f} points") 14 sessions after the start: 8.931394 points
50 sessions after the start: 1.427800 points
100 sessions after the start: 0.013082 points
200 sessions after the start: 0.000045 pointsAlmost nine RSI points on the first value, one hundredth after 100 sessions. If one system starts with two months of
history and another with ten years, for weeks they do not see the same RSI. TA-Lib lets you hide that stretch with
set_unstable_period, which adds empty values at the start of the functions flagged as unstable:
normal = talib.RSI(C, 14)
talib.set_unstable_period("RSI", 100)
con_inestable = talib.RSI(C, 14)
talib.set_unstable_period("RSI", 0) # the setting is global: reset to 0
print("Empty:", np.isnan(normal).sum(), "->",
np.isnan(con_inestable).sum())
print("Maximum difference where there is a value:",
np.nanmax(np.abs(con_inestable - normal)))
from talib import abstract
inestables = [f for f in talib.get_functions()
if "Function has an unstable period"
in abstract.Function(f).info["function_flags"]]
print(len(inestables), "unstable functions:", ", ".join(inestables))Empty: 14 -> 114
Maximum difference where there is a value: 0.0
23 unstable functions: HT_DCPERIOD, HT_DCPHASE, HT_PHASOR, HT_SINE, HT_TRENDMODE, ADX, CMO, DX, MINUS_DI, MINUS_DM, PLUS_DI, PLUS_DM, RSI, EMA, HT_TRENDLINE, KAMA, MAMA, RMA, T3, HA, ATR, NATR, RVIWith an unstable period of 100, the RSI goes from 14 to 114 empty values and the ones it returns are identical: TA-Lib keeps computing from the first data point and only hides the results that depend on the seed. It does not make the RSI converge sooner; what it does is not show the values that have not converged yet. The list of unstable functions is the expected one: all those with an exponential or Wilder average inside (EMA, RSI, ATR, ADX and its components, KAMA, T3), the Hilbert transforms and a few new ones from version 0.8 (RMA, HA, RVI).
How much warm-up does each indicator need? It can be measured with the same comparison: how many sessions it takes the series that starts 500 sessions later to match the full one with a relative error below 0.01%:
funciones = {
"SMA 20": lambda C, H, L: talib.SMA(C, 20),
"Bollinger 20": lambda C, H, L: talib.BBANDS(C, 20)[0],
"EMA 20": lambda C, H, L: talib.EMA(C, 20),
"KAMA 10": lambda C, H, L: talib.KAMA(C, 10),
"ATR 14": lambda C, H, L: talib.ATR(H, L, C, 14),
"RSI 14": lambda C, H, L: talib.RSI(C, 14),
"MACD": lambda C, H, L: talib.MACD(C)[0],
"ADX 14": lambda C, H, L: talib.ADX(H, L, C, 14),
}
filas = {}
for nombre, f in funciones.items():
completo = f(C, H, L)[500:]
tardio = f(C[500:], H[500:], L[500:])
escala = np.nanmean(np.abs(completo))
rel = np.abs(tardio - completo) / escala
malas = np.flatnonzero(~(rel < 1e-4))
filas[nombre] = {"lookback": int(np.isnan(tardio).sum()),
"coincide_desde": int(malas[-1] + 1)}
calentamiento = pd.DataFrame(filas).T
calentamiento["inestable"] = (calentamiento["coincide_desde"]
- calentamiento["lookback"])
calentamiento lookback coincide_desde inestable
SMA 20 19 19 0
Bollinger 20 19 19 0
EMA 20 19 45 26
KAMA 10 10 50 40
ATR 14 14 120 106
RSI 14 14 120 106
MACD 33 110 77
ADX 14 27 142 115The SMA and Bollinger have no unstable period: with 20 data points, their value is already final. Recursive averages need extra sessions for the seed to stop showing at 0.01%: 26 for the 20-period EMA, 40 for KAMA, 77 for the MACD, 106 for the 14-period RSI and ATR, and 115 for the ADX, which chains two Wilder averages. The Wilder average forgets slowly (), hence the rule of thumb: load at least ten times the period of the slowest average before the first session that counts (150 sessions for a 14-period RSI) and check it with this cell for the specific combination of indicators.
From backtest to real time: the streaming API
A live system does not recompute the RSI of the whole history with each new bar. TA-Lib 0.8 comes with a streaming API:
you open a handle with the history (one pass) and then each closed bar is added with update, in constant time. The
documentation guarantees that each value is identical to the normal function's; it is checked like everything else:
from talib import stream
s = stream.RSI(C[:-5], timeperiod=14) # history without the last 5
en_vivo = [s.update(x) for x in C[-5:]] # 5 closed bars, one at a time
print("Stream: ", np.round(en_vivo, 4))
print("Function:", np.round(talib.RSI(C, 14)[-5:], 4))
print("peek with a bar in progress:", round(s.peek(C[-1] * 1.01), 4),
"| stored value:", round(s.value, 4))Stream: [58.7029 51.5 50.3727 50.0916 50.2113]
Function: [58.7029 51.5 50.3727 50.0916 50.2113]
peek with a bar in progress: 54.241 | stored value: 50.2113update adds a closed bar and stores the state; peek computes the value a bar that is still forming would give,
without storing anything. That is the distinction that matters in real time: a signal computed with peek on the
forming bar uses a price that is not yet the close, and in a backtest with closed bars that signal does not exist
(lesson 1.7.4).
Mermaid source
flowchart LR H[History] --> O[stream.RSI] O --> U[update: closed] O --> P[peek: in progress]
From the comparison to tests
What was checked by hand is lost as soon as someone touches a function. Tests pin it down. With pytest (lesson 0.4.5),
a test is a function whose name starts with test_ and that uses assert; the cell defines four kinds of test for the
indicators and runs them the way pytest would:
CALENTAMIENTO = 300
def tras_calentamiento(x):
return np.asarray(x, float)[CALENTAMIENTO:]
def test_igual_que_talib_tras_calentamiento():
for propio, ref in [(ema(c, 20), talib.EMA(C, 20)),
(rsi(c), talib.RSI(C, 14)),
(atr(h, l, c), talib.ATR(H, L, C, 14))]:
np.testing.assert_allclose(tras_calentamiento(propio),
tras_calentamiento(ref),
rtol=0, atol=1e-6)
def test_semilla_exacta_y_mismos_vacios():
propio, ref = rsi_ta(c).to_numpy(), talib.RSI(C, 14)
assert (np.isnan(propio) == np.isnan(ref)).all()
np.testing.assert_allclose(propio, ref, rtol=0, atol=1e-10)
def test_rangos_validos():
r = rsi(c).dropna()
assert r.between(0, 100).all()
assert (atr(h, l, c).dropna() > 0).all()
b = bollinger(c).dropna()
assert (b["sup"] >= b["media"]).all()
def test_sin_anticipacion():
corte = "2022-12-30"
for f in (rsi, lambda x: ema(x, 20), lambda x: sma(x, 20)):
completo, truncado = f(c).loc[:corte], f(c.loc[:corte])
np.testing.assert_allclose(completo, truncado, equal_nan=True)
tests = [test_igual_que_talib_tras_calentamiento,
test_semilla_exacta_y_mismos_vacios,
test_rangos_validos, test_sin_anticipacion]
for t in tests:
t()
print("PASSED", t.__name__)PASSED test_igual_que_talib_tras_calentamiento
PASSED test_semilla_exacta_y_mismos_vacios
PASSED test_rangos_validos
PASSED test_sin_anticipacionFour kinds of test, four different properties: equality with the reference where there should be equality (with an
absolute tolerance, not ==), exact equality and the same empty values when the convention has been replicated,
ranges the indicator cannot violate, and the truncation test from lesson 1.7.1. In the project they live in a test
file and are parametrized by indicator:
import numpy as np
import pytest
import talib
from mi_sistema.indicadores import atr, ema, rsi
from mi_sistema.datos import nova_ajustada
PX = nova_ajustada()
C, H, L = (PX[k].to_numpy() for k in ["c", "h", "l"])
@pytest.mark.parametrize("propio, ref", [
(lambda: ema(PX["c"], 20), lambda: talib.EMA(C, 20)),
(lambda: rsi(PX["c"]), lambda: talib.RSI(C, 14)),
(lambda: atr(PX["h"], PX["l"], PX["c"]),
lambda: talib.ATR(H, L, C, 14)),
])
def test_igual_que_talib(propio, ref):
np.testing.assert_allclose(propio().to_numpy()[300:], ref()[300:],
rtol=0, atol=1e-6)And they are run with uv run pytest tests/test_indicadores.py. If tomorrow someone changes alpha=1/n to span=n in
the Wilder average, the first test fails with the exact difference and position.
Your own, TA-Lib or both
| Own implementation (pandas) | TA-Lib | |
|---|---|---|
| Transparency | Total: the formula is in plain sight | C code; conventions in the documentation and in the code |
| Dependencies | Only pandas and NumPy | A binary wheel (includes the C library) |
| Coverage | Whatever you code | More than 200 functions, 61 candlestick patterns |
| Input types | Whatever pandas accepts | float64 required |
| Real time | Recompute or code the recursion | Streaming API with update and peek |
| Custom or modified indicators | Immediate | No: you have to step outside the library |
In practice both are used: TA-Lib as the reference in the tests and for the standard indicators, and your own implementation when you need a variant (an ATR in percent, an RSI on another series, an indicator that does not exist) or when you want to understand every number. What is not advisable is mixing them without knowing it: a system that computes the signal with one and the stop with the other carries two conventions at once.
Exercises
- Your KAMA from lesson 1.7.1 differs from
talib.KAMAby 0.72 at the start. Find TA-Lib's seed and write a version that matches exactly. Hint: look at how many empty valuestalib.KAMAleaves and try seeding with a close.
Solution
def kama_ta(x, n=10, rapida=2, lenta=30):
er = (x - x.shift(n)).abs() / x.diff().abs().rolling(n).sum()
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)
previo = p[n - 1] # seed: the close before the first output
for i in range(n, len(p)):
previo = previo + s[i] * (p[i] - previo)
out[i] = previo
return pd.Series(out, index=x.index)
resumen({"Own KAMA": (kama(c), talib.KAMA(C, 10)),
"KAMA TA-Lib seed": (kama_ta(c), talib.KAMA(C, 10))}) nan_propio nan_talib dif_max dif_final coincide_desde
Own KAMA 10 10 7.225559e-01 0.0 214
KAMA TA-Lib seed 10 10 7.105427e-15 0.0 0TA-Lib leaves 10 empty values and its first value, at position 10, already applies the recursion: it starts from the close at position 9. The version from lesson 1.7.1 copied the close at position 10 without smoothing. Both are KAMA; only the second is TA-Lib's.
- Make your stochastic have the same empty values as
talib.STOCHand write a test that checks it along with the equality of the values.
Solution
def estocastico_ta(c, h, l, n=14, k=3, d=3):
e = estocastico(c, h, l, n, k, d)
return e.where(e["d"].notna()) # %K only where there is also a %D
def test_estocastico_como_talib():
k_ta, d_ta = talib.STOCH(H, L, C, 14, 3, 0, 3, 0)
e = estocastico_ta(c, h, l)
for propio, ref in [(e["k"], k_ta), (e["d"], d_ta)]:
propio = propio.to_numpy()
assert (np.isnan(propio) == np.isnan(ref)).all()
np.testing.assert_allclose(propio, ref, rtol=0, atol=1e-9,
equal_nan=True)
test_estocastico_como_talib()
print("PASSED", "test_estocastico_como_talib")PASSED test_estocastico_como_talibTA-Lib returns both outputs aligned: neither until the last one exists (%D, which needs 14 + 2 + 2 sessions). It is enough to hide the %K where the %D is empty. An alignment convention is replicated in one line; what matters is that the test also checks the position of the empty values, not just the values.
- Your team puts into production a system that computes the RSI with pandas and monitors positions with a platform that uses TA-Lib. On the first day, with two weeks of history loaded, the two RSIs differ by 6 points. What is going on and what would you change?
Solution
It is the unstable period, multiplied by two different seeds: with ten sessions of history, neither the pandas RSI
(seeded with the first change) nor TA-Lib's (seeded with the mean of the first 14) has forgotten its seed, and on top of
that the history does not even reach the lookback. Three changes: load enough history (ten times the period of the
slowest average, about 150 sessions for a 14-period RSI) before the first signal; use the same convention in both
places (rsi_ta in the system or TA-Lib in both); and add a test that compares the two calculations after the warm-up
and fails if they differ by more than a tolerance.
Each answer is folded under its question
Self-assessment
Your 14-period RSI and TA-Lib's differ by 19.9 points on the first value and by 0.00000000000001 at the end of the series. What does it indicate?
- AThat they use different seeds for the Wilder average; the difference fades geometrically and does not affect values far from the start
- BThat one of the two is wrong
- CThat TA-Lib rounds
- DThat the RSI is not deterministic
Show answer
Answer A. That they use different seeds for the Wilder average; the difference fades geometrically and does not affect values far from the start
The Wilder average is recursive: the first value carries the seed, and its weight is multiplied by 13/14 every session. TA-Lib seeds with the simple mean of the first 14 changes;
ewmstarts with the first change. After about 240 sessions the difference is below 10⁻⁶.Your Bollinger Bands and TA-Lib's always differ by the same proportion, 2.6% of the width, at the beginning and at the end. What is the most likely cause?
- AA very long warm-up
- BThe dividend adjustment
- CThe standard deviation: sample (
ddof=1, the pandas default) versus population (ddof=0, TA-Lib's) - DTA-Lib uses an EMA instead of an SMA
Show answer
Answer C. The standard deviation: sample (
ddof=1, the pandas default) versus population (ddof=0, TA-Lib's)With 20 data points, √(20/19) = 1.0260. A difference that does not converge is not a seed: it is a different convention, or a bug. You have to find it and decide which one you want.
What does
talib.set_unstable_period('RSI', 100)do?- AComputes the RSI with 100 sessions instead of 14
- BLeaves 100 more values empty at the start; the values it does return are the same as without the setting
- CStarts the calculation 100 sessions later, which is why all the values change
- DSmooths the RSI with a 100-period average
Show answer
Answer B. Leaves 100 more values empty at the start; the values it does return are the same as without the setting
TA-Lib keeps computing from the first data point and only hides the first values, which depend on the seed. On NOVA it goes from 14 to 114 empty values and the maximum difference from the normal RSI is exactly 0.
You compute NOVA's RSI with TA-Lib starting the series 500 sessions later. What do you expect?
- AThe same values from the first one
- BDifferent values forever
- CAn error for lack of data
- DDifferent values at first that converge to those of the full series: after 100 sessions the difference is one hundredth
Show answer
Answer D. Different values at first that converge to those of the full series: after 100 sessions the difference is one hundredth
That is the unstable period: the value of a recursive average depends on where the history starts. On NOVA, 8.93 points of difference in the first RSI, 1.43 after 50 sessions, 0.013 after 100. With little history, two systems looking at “the same RSI” see different numbers.
Why does
talib.SMAfail with afloat32array?- ABecause TA-Lib functions work with
double(float64) and do not convert the type for you - BBecause TA-Lib only accepts Python lists
- CBecause float32 does not support NaN
- DBecause the window is too long
Show answer
Answer A. Because TA-Lib functions work with
double(float64) and do not convert the type for youThe C library operates in double precision and the Python wrapper requires
float64arrays (or Series holding them). A Parquet file saved asfloat32to save space has to be converted first withastype('float64').- ABecause TA-Lib functions work with
Key points
- Your own implementation is validated against a reference one by measuring four things: empty values, maximum difference, final difference and the session from which they match.
- Recursive averages (EMA, Wilder, KAMA) depend on their seed: the differences fade geometrically. Replicating TA-Lib's seed, EMA, RSI, ATR and MACD match to 10⁻¹³.
- The unstable period is not a TA-Lib bug: an RSI's value depends on where the series starts.
set_unstable_periodonly hides the first values; it does not change them. - A difference that does not converge is a convention (
ddof, initial OBV value, MACD alignment) or a bug. You have to find it, decide and document it. - The comparison becomes tests: equality after the warm-up, same position of the empty values, valid ranges and the truncation test.
- pandas-ta 0.4.71b0 pins
numba==0.61.2, which requiresnumpy<2.3and has no wheels for Python 3.14: it does not install in the course environment. TA-Lib 0.8.2 does, with the C library included.