Indicator reference — TA-Lib, driven bar by bar¶
The lookup surface for topstep_backtest.indicators: the generic form, the named
wrappers, what reaching past them costs, and the two properties (ready, warm)
you have to keep apart to get sim/live parity.
Every indicator in the framework is TA-Lib. Nothing in this repo implements an
indicator formula, so there is no second implementation to drift from the
reference one — topstep_backtest.indicators is a typed, causal driver over
the C library, not a reimplementation of it.
This file is the reference. AGENTS.md is where the failure modes live — the rules an agent has to obey when writing or changing a strategy (float-vs-Decimal containment, the silent-swallow class of bug, registration hazards). Read that before writing code; read this to look something up.
Three properties make a batch C library safe to drive bar by bar. They are the whole argument for this design, and each is asserted in the test suite rather than assumed:
- Causal by construction.
update(bar)appends to a buffer holding only already-closed bars 0…t and reads the LAST element of TA-Lib's output. The adapter structurally cannot see bar t+1 — the no-look-ahead invariant holds for free. - Streaming == batch, bit for bit. TA-Lib recomputes from index 0 on every
call and every function it exposes is causal, so
FUNC(bars[:t+1])[-1] == FUNC(bars)[t]exactly — max difference 0.0. This is asserted for EVERY wrappable function rather than for a sample:test_streaming_equals_batch_across_the_whole_libraryparametrises over all 152 at their defaults, discovered at runtime, so a function nobody thought to enumerate cannot quietly violate it.STREAMING_CASESadditionally cross-checks a curated set against TA-Lib's top-level API, which independently pins the parameter plumbing. Feeding a prefix cannot produce a number a full-series batch run would not. The one class of exception is the functions whose output is an offset into the array they were handed rather than a value — they are refused at construction rather than silently reinterpreted (§3). - Bounded history is a parity decision. See §6 — it is what makes
Ema(20)the same number in sim and live, and it is not an optimisation. Parity begins when the window is FULL (warm), not when the first value exists (ready); the two are different properties and §6 is where the difference is spelled out.
1. The generic form¶
name— any TA-Lib function name, case-insensitively ("rsi"=="RSI"). An unknown name raisesValueError: unknown TA-Lib function 'NOPE'. Nine of the 161 are refused outright, each for a reason (§3).**params— TA-Lib's own parameter names, not ours:timeperiod,fastperiod,slowperiod,signalperiod,nbdevup,nbdevdn,matype,fastk_period,acceleration, … They are validated against the function's declared signature at construction, soTalibIndicator("EMA", periods=10)raisesEMA takes no parameter(s) ['periods']; it accepts ['timeperiod']instead of silently running the default period. int/float coercion follows what the function declares (nbdevis a float,timeperiodis not) and refuses to be lossy (§9).price=— redirect a single-series function onto another bar field (§5).history=— override the trailing window (§6). Must be an integer:Ema(20, history=600.5)raiseshistory must be an integer, got 600.5.
The instance surface:
| member | type | meaning |
|---|---|---|
.lookback |
int |
bars needed before a value exists — TA-Lib's own lookback + 1 (TA-Lib reports the index of the first valid output; the count is one more) |
.ready |
bool |
lookback bars seen and the current row is finite — a value exists |
.warm |
bool |
the window is FULL (history_bars bars seen) — the value no longer depends on where this run started. This, not ready, is where sim/live parity begins (§6) |
.value |
Decimal |
the FIRST output for the most recent bar; raises NotReadyError if read early |
.get(output) |
Decimal |
a named output; KeyError naming .outputs if the name is wrong |
.line(output) |
TalibLine |
a Cross-compatible view of one named output (carries .warm too) |
.lines() |
Iterator[TalibLine] |
one view per output, in TA-Lib's order |
.outputs |
tuple[str, ...] |
output names, TA-Lib's order (('macd', 'macdsignal', 'macdhist')) |
.history_bars |
int |
size of the retained trailing window (§6) |
.function_name |
str |
the wrapped function, upper-cased |
.update(bar) |
None |
called for you once per matching bar by use() |
_compute is lazy and memoised per bar: a strategy that never reads a value
pays only for the buffer append, and reading all three MACD lines costs one
TA-Lib call, not three.
2. Named wrappers¶
Typed spellings of the functions strategies reach for most. Each is a thin
TalibIndicator subclass — Ema(20) is TalibIndicator("EMA",
timeperiod=20), just autocompletable and pyright-checked. All take history=.
| constructor | TA-Lib fn | reads | outputs | lookback |
|---|---|---|---|---|
Sma(period) |
SMA |
close | real |
period |
Ema(period) |
EMA |
close | real |
period |
Rsi(period) |
RSI |
close | real |
period + 1 |
Atr(period) |
ATR |
high/low/close | real |
period + 1 |
StdDev(period) |
STDDEV (nbdev=1, population) |
close | real |
period |
Highest(period) |
MAX redirected onto bar.high |
high | real |
period |
Lowest(period) |
MIN redirected onto bar.low |
low | real |
period |
Adx(period=14) |
ADX |
high/low/close | real |
2 × period |
Obv() |
OBV |
close, volume | real |
1 |
Macd(fast=12, slow=26, signal=9) |
MACD |
close | macd, macdsignal, macdhist |
slow + signal - 1 (34) |
BBands(period=20, *, deviations=2.0) |
BBANDS (nbdevup = nbdevdn = deviations) |
close | upperband, middleband, lowerband |
period |
Stoch(fastk=5, slowk=3, slowd=3) |
STOCH |
high/low/close | slowk, slowd |
fastk + slowk + slowd - 2 (9) |
Cross(a, b) is not a TA-Lib function — it is a pure-Python comparator over
two value series (§7).
Obv carries the sharpest of the window caveats: OBV accumulates without decay,
so its level is anchored to history_bars ago rather than to inception. Use
its slope or its divergence, never the absolute number — an inception-anchored
OBV could not be reproduced live in any case. StdDev and BBands carry the
other one: TA-Lib's naive variance formula makes them ~1e-6 relative at index
price levels, so a band touch is not exact (§8). §6 has the full picture of
which families the window is and is not transparent to.
3. Reaching the rest of the library¶
The named wrappers are a convenience, not the boundary. Anything TA-Lib computes is one line away through the generic form:
from topstep_backtest.indicators import TalibIndicator, talib_function_names
ultosc = TalibIndicator("ULTOSC", timeperiod1=7, timeperiod2=14, timeperiod3=28)
sar = TalibIndicator("SAR", acceleration=0.02, maximum=0.2)
engulf = TalibIndicator("CDLENGULFING") # outputs ('integer',): signed score, 0 = absent
wma = TalibIndicator("WMA", timeperiod=20)
bb_ema = TalibIndicator("BBANDS", timeperiod=20, nbdevup=2.0, nbdevdn=2.0, matype=1)
talib_function_names() # 161 names on the pinned build, sorted; 61 of them CDL*
The 61 CDL* pattern functions emit a single integer output whose SIGN is the
direction and whose MAGNITUDE is TA-Lib's own confidence — do not assume ±100.
The magnitude also depends on the DATA, not just the function, which is the
real reason to compare against zero. Measured over 400,000 bars on the pinned
build:
| Regime | Union of every CDL* value |
|---|---|
Continuous walk (open != close) |
[-200, -100, 0, 100, 200] |
0.25 tick grid (open == close on ~5% of bars) |
[-200, -100, -80, 0, 80, 100, 200] |
CDLENGULFING, CDLHARAMI and CDLHARAMICROSS score ±100 on a continuous
series but ±80 once flat bars appear; CDLHIKKAKE and CDLHIKKAKEMOD reach
±200; CDLDOJI and CDLHAMMER stay at +100. Futures bars ARE on a tick grid,
so the ±80 case is the one this framework actually hits — a strategy written
against == 100 would silently stop firing on real data while passing on
synthetic continuous prices. Test > 0 / < 0.
152 of the 161 construct at their defaults; the other nine are refused at
construction, each because it could only fail later and in silence. All nine
raise ValueError from __init__, naming the reason:
| refused | why |
|---|---|
EXP, COSH, SINH |
overflow to inf at futures price levels (~5,000) |
ACOS, ASIN |
out of domain once prices leave [-1, 1] |
MAVP |
needs a non-bar periods input an OHLCV feed cannot supply |
MAXINDEX, MININDEX, MINMAXINDEX |
return an OFFSET into the array passed in, not a value |
The first five would have been the worst kind of bug: a permanently non-finite
row reads as "not ready", so SymbolStrategy would swallow every bar and
on_bar would never fire — a strategy that silently never trades, with no
exception anywhere. The adapter therefore probes each configured function once
on a price-realistic synthetic series at construction and rejects a
non-finite result (EXP() produces no finite value at futures price levels
(~5000), so it could never become ready), rather than merely checking that the
call did not raise.
The index-valued three are refused because a bounded buffer changes what their
answer MEANS: before the window fills the offset counts from the series start,
after it fills it is a window-relative position that shifts every bar — the
number silently changes basis mid-backtest. Use the value-returning forms
(MAX/MIN/MINMAX) instead.
4. Multi-output indicators¶
.value is always the FIRST output. Named outputs come from .get(); a
Cross-compatible view of one output comes from .line():
from topstep_backtest.indicators import Cross, Macd
from topstep_backtest.protocols import Bar
from topstep_backtest.strategy import SymbolStrategy
class MacdCross(SymbolStrategy):
def __init__(self, contract_id: str, *, size: int = 2) -> None:
super().__init__(contract_id)
self.macd = self.use(Macd(12, 26, 9)) # register the OWNER
self.cross = self.use(Cross(self.macd.line("macd"), self.macd.line("macdsignal")))
self.size = size
async def on_bar(self, bar: Bar) -> None:
# self.macd.value == self.macd.get("macd"); self.macd.get("macdhist") is
# the histogram — all three come from ONE memoised TA-Lib call per bar.
if self.cross.up and self.position.flat and not self.working_orders:
await self.buy(self.size, stop_loss_ticks=40, take_profit_ticks=80)
elif self.cross.down and self.position.is_long:
await self.close()
Crossing two lines of one indicator is the case the TalibLine indirection
exists for. A line has no state of its own — it reads whatever its owner last
computed — so:
use()the owner, never the line.use(macd.line("macd"))raisesa TalibLine cannot be use()-registered — it has no state of its own and would never advance; register the indicator that owns it and keep the line only as a Cross input.TalibLinedeliberately has noupdatemethod, so it cannot satisfy theIndicatorprotocol either: this is a type error and a runtime error, not a silently-forever-gated strategy.use()resolves aCrossinput throughTalibLine.owner, so the registration-order check below passes oncemacditself is registered. One owner computes all of its lines together, so one registration is correct.
5. price= — redirecting a single-series function¶
TA-Lib functions that declare a single price input default to close.
price= points one at another bar field:
TalibIndicator("MAX", price="high", timeperiod=20) # rolling 20-bar high == Highest(20)
TalibIndicator("SMA", price="volume", timeperiod=20) # average volume
Valid fields are open/high/low/close/volume. Functions that declare
their own multi-field inputs refuse it: TalibIndicator("ATR", price="high")
raises price= only applies to single-series functions; ATR takes
high/low/close. Highest/Lowest are exactly this mechanism, pre-spelled.
6. history_bars / history= — bounded history is the parity decision¶
Each indicator keeps the last history_bars bars, not everything since
inception. That is not a memory optimisation:
An unbounded buffer would make every value depend on where the series happened to start. A live session warms up from a finite history fetch, so it could never reproduce a backtest that began two years earlier —
Ema(20)would be a slightly different number on each side, forever, with no way to close the gap. With a fixed window the value is a pure function of the lasthistory_barsbars, so both sides agree exactly once both are warm.
ready is not warm — parity begins at the window, not at the lookback¶
These are two different properties and conflating them is the one residual parity hole:
ready— a value EXISTS. It flips atlookback(TA-Lib's own).warm— the buffer is FULL. It flips athistory_bars, and only from there is the value independent of where this run started.
Bars in [lookback, history_bars) are cold-start dependent: real, finite,
usable numbers that a live session warm-started from a full window would not
reproduce. Ema(20) is ready on bar 20 and warm on bar 1,280 — that is 1,260
bars of values that are correct-for-this-run but not parity-exact.
The concrete live instruction: preload indicator.history_bars bars before
taking the first signal, and sim/live agree bit for bit. Gate on warm (not
ready) wherever exact sim/live agreement matters; the use() ready-gate is a
warmup gate, not a parity gate. Fewer bars and the two sides are merely close —
that is the difference between a parity gate and a plausibility argument.
TalibLine reports its owner's warm, so a MACD line answers for the MACD.
How the window is sized¶
Normally max(512, 64 × lookback). But functions whose memory is set by a
RATE rather than by a period get an explicitly derived window instead,
because lookback says nothing about them — SAR's lookback is 2 no matter how
small acceleration is, so the usual sizing would hand it 512 bars when its
memory runs to tens of thousands:
| indicator | lookback |
history_bars |
sizing rule |
|---|---|---|---|
Obv() |
1 | 512 | the floor |
Ema(12) |
12 | 768 | 64 × lookback |
Ema(26) |
26 | 1664 | 64 × lookback |
Rsi(14) |
15 | 960 | 64 × lookback |
Macd() |
34 | 2176 | 64 × lookback |
Sma(200) |
200 | 12800 | 64 × lookback |
TalibIndicator("SAR") |
2 | 2000 | derived from acceleration=0.02 |
TalibIndicator("SAR", acceleration=0.001) |
2 | 40000 | derived from acceleration |
TalibIndicator("MAMA", slowlimit=0.01) |
33 | 4000 | derived from slowlimit |
TalibIndicator("KAMA", timeperiod=30) |
31 | 9000 | fixed adaptive-smoother floor |
The rate-driven rule is 40 / rate bars (40 e-folds puts the truncated tail
below float64 resolution, since ln(2**-53) ≈ -36.7); KAMA gets a flat 9,000
because its smoothing constant floors at (2/31)² ≈ 0.00416 in choppy data,
which its lookback gives no hint of. So never assume history_bars is
max(512, 64 × lookback) — read it off the instance.
"Windowed == unbounded" holds for some families, not all¶
The window being equal to an unbounded run is a bonus on top of parity, and it is not universal. Which class a function falls into changes only that bonus:
- Exponentially decaying recursions — EMA, Wilder RSI/ATR/ADX, MACD,
DEMA/TEMA, T3, MAMA, HT_TRENDLINE — reach a point past which the truncated
tail is below float64 resolution, and the window sits above it. These ARE
bit-identical to an unbounded run, asserted (not assumed) by
RECURSIVE_CASESintests/property/test_indicator_props.py, which covers exactly this family. - Accumulators —
OBV,AD— sum with weight 1.0 forever, so their LEVEL is window-relative by construction. Use slope or divergence, never the absolute number. - Running-sum functions —
SMAand everything built on it (STOCH'sslowd,CCI,MFI,ACCBANDS,BETA,ADOSC) — carry float rounding that depends on where the sum began, so they sit within an ulp or so of an unbounded run rather than provably on it. - Adaptive smoothers —
KAMA,HT_DCPERIOD— can drop their effective smoothing constant far below whatlookbacksuggests, which is why they are sized by the rules above rather than by64 × lookback.
None of this weakens backtest/live parity, which is what the framework actually depends on: both sides run the SAME window over the SAME bars, so both get the same number whichever class the function is in. What varies is only whether that number also happens to equal an unbounded run's.
history=¶
Overrides the window when you want it pinned to a number your live warm-up can actually fetch:
Ema(26, history=2000) # .history_bars == 2000
Ema(20, history=5) # ValueError: history=5 is below EMA's lookback of
# 20 bars; the indicator could never become ready
Ema(20, history=600.5) # ValueError: history must be an integer, got 600.5
7. Registration, warmup, and Cross¶
self.use(indicator)registers and returns it. Registered indicators are auto-update()d once per matching bar and count toward the ready-gate.- Registration order == update order.
Crossreadsa.value/b.valueon update, so its inputs must beuse()d before it.use()enforces this:Cross(a, b)itself always constructs, butself.use(cross)raisesValueErrorwhen its inputs are not already registered, because otherwise it would silently compare one-bar-stale values with no error at all. use()refuses three more wiring mistakes, each of which used to fail far from its cause:- A
Crossover anotherCross. It type-checks (aCrosshaslookback/ready/update) but has no.value, so it died with anAttributeErroron the first bar both inner inputs happened to be ready — potentially hours into a run. Now:Cross input <Cross …> exposes no '.value', so the Cross would die mid-run on the first bar both inputs are ready. - A non-
Indicator. Typed callers cannot reach this (useis bound toIndicator), but an untyped one got a bareAttributeErrora bar later. NowTypeError: … is not an Indicator (needs lookback/ready/update). - A
Crossthat was neveruse()-registered. It never updates, soup/downreadFalsefor the whole run and the strategy takes zero trades in silence. Once both its inputs arereadythat is provably a wiring mistake, soup/downnow raiseRuntimeError: this Cross has never been updated although both inputs are ready …instead of lying. A registered-but-not-yet-drivenCrossis never accused. on_baris gated until every registered indicator isready(require_ready=True).warmupdefaults to the largest registered lookback; pin it explicitly across a parameter sweep so every parameter set is scored over the same window.report.bars_gatedreports what was actually swallowed.Cross(a, b)fires only on the resolving bar.upwhena − bgoes from ≤ 0 to > 0,downfrom ≥ 0 to < 0; an exacta == btouch fires on the bar that resolves it, not the touch.lookback == max(a.lookback, b.lookback) + 1— it needs a previous and a current comparison. (With float64 values an exact tie is vanishingly rare; the zero-sign branch is a correctness guarantee, not a common path.)Indicatoris a runtime-checkableProtocol(lookback/ready/update), so a hand-written indicator that satisfies it is stilluse()-able.Crossneeds onlyValueSource(lookback/ready/value). Neither protocol imports TA-Lib.
7b. session= — which bars an indicator is computed from¶
On a 24h tape every indicator sees Asia, London and New York by default. That is
often wrong, and use() takes a session= to say so:
from topstep_backtest import NEW_YORK
self.trend = self.use(Ema(50)) # continuous — every bar
self.atr = self.use(Atr(14), session=NEW_YORK) # NY bars only
session=None (the default) is exactly today's behaviour: an unscoped strategy
computes byte-identical values to one written before sessions existed.
Which to scope is not uniform, and the split has a reason. Dispersion
measures — Atr, StdDev, Rsi, Stoch, BBands — describe how much price
moves per bar, and that is a property of the session. Asia on MNQ is thin and
range-compressed, so an Atr(14) on 24h 5-minute bars read at 09:30 ET is
computed almost entirely from pre-market bars: it understates New York
volatility at the open, the most volatile minutes of the day and precisely
when stop distance is being sized. The bias decays over ~70 minutes, so the
contamination sits exactly where it hurts.
Level measures — Sma, Ema — answer where price is, and price genuinely
traded overnight. An NY-only Ema is anchored to yesterday's 16:00 close and
blind to a London repricing, so it reports "trend up" into a market that has
already moved.
Levels are continuous across sessions; dispersion is not. Apply that to any indicator you add later.
A third option is often better than either: consume the overnight as discrete
levels — Highest/Lowest scoped to ASIA give you the Asia range — rather
than as smoother input, which keeps the information without contaminating a
rolling cadence.
A scoped indicator warms in its own cadence. It needs history_bars bars
of its session, not of the tape. On 5-minute bars an NY-scoped Sma(30) sees
78 bars a session, so its 1920 updates span ~25 trading days against ~7 for
the same indicator on a 24h feed — session-scoping makes warmup roughly 3.5×
more expensive in calendar days. Read it off strategy.history_bars_by_session,
and check warmth with strategy.warm, which counts each indicator's own
updates. bars_seen >= history_bars cannot express two cadences and will
overstate warmth.
strategy.registrations reports each indicator with its scope, its update count
and whether it is warm — the first thing to print when a scoped strategy is not
trading.
A Cross inherits its inputs' scope. Passing a conflicting session=
raises, and inputs with different scopes are refused outright: they advance on
different bars, so comparing them compares values sampled at unrelated instants.
Leaving a Cross continuous over scoped inputs would have it re-read unchanged
values on most bars — inheritance removes that trap rather than documenting it.
Scoping needs 24h data to mean anything. The shipped data/sample_mnq_1m.csv is
RTH-only (390 bars/day, 09:30–15:59 ET); for synthetic bars use
synthetic_bars(..., hours="globex"). Worked end to end in
examples/session_scoped.py.
8. Precision and enforcement — what the numbers are and are not¶
Four properties of the values themselves, each of which has surprised someone:
- Values are float-precise, not Decimal-exact, and are NOT tick-snapped.
TA-Lib computes in float64; the
Decimalyou get back is that float via its shortest round-trippingrepr, deliberately left off the tick grid — an indicator level is not a tradeable price and quantizing it would be a lie. Float arithmetic is deterministic, so reruns are still byte-equal and the determinism gate still holds. Never route an indicator value into grid math without an explicitcore.money.round_to_tick; money still comes frombarandctx. - RSI on a run of identical closes reads 0, not 100. TA-Lib returns
0.0when every close is equal. All-gains reads 100 and all-losses reads 0. - Per-function minimum periods are enforced at construction. TA-Lib
validates parameter ranges in C, each function differs, and nothing in the
abstract API exposes the limits — so the adapter probes the configured
function once on synthetic bars at construction rather than hard-coding a
table that would drift.
Rsi(1),StdDev(1),Highest(1),Lowest(1)andAdx(1)raiseValueError: TA-Lib rejected RSI(timeperiod=1): … (TA_BAD_PARAM);Sma(1),Ema(1),Atr(1)are fine.period < 1still raisesperiod must be >= 1, got 0before TA-Lib is consulted at all. STDDEV/VAR/BBANDSare only ~1e-6 relative at index price levels. TA-Lib uses the naiveE[x²] − E[x]²variance, which cancels catastrophically when the mean is large relative to the spread: measured worst relative error is 1.9e-9 at 5,000, 2.4e-8 at 20,000 and 6.7e-7 at 100,000. Harmless for a threshold read; aCrosson a Bollinger edge can flip on that noise, so never treat a band touch as exact.
Declared warmup follows TA-Lib's own lookback and nothing else: Sma(3),
Ema(3), StdDev(3), Highest(3) all need 3 bars; Rsi(3) and Atr(3) need
4 (their first delta/true-range consumes a bar).
9. Parameters that would silently mean something else¶
Coercion to TA-Lib's declared type refuses to be lossy. A Sma(14.7) that
became SMA(14), or a matype=2.9 that became WMA when DEMA was meant, is the
exact "silently uses something else" failure the constructor promises not to
have. Both raise:
Sma(14.7) # ValueError: SMA parameter timeperiod is an
# integer; got 14.7 (truncating it would
# silently change the indicator)
TalibIndicator("MA", timeperiod=10, matype=2.9) # ValueError: MA parameter matype is an integer
Sma(True) # ValueError: SMA parameter timeperiod must be a number, got True
Sma(float("nan")) # ValueError
Sma(14.0) # fine — an INTEGRAL float loses nothing
Booleans are refused outright (True is not a period), and history= must be
an integer (§6).
10. Thread-safety — safe to build here, drive there¶
talib's Function object stores its configured parameters in a
threading.local. An indicator built on one thread and driven on another
therefore used to compute with TA-Lib's defaults: an Ema(50) quietly
returning Ema(30), no error raised, and a price=-redirected indicator
quietly reading closes instead of highs. Parallel parameter sweeps across a
thread pool are an obvious use of a backtester, so this was the difference
between trustworthy and worthless sweep results.
The adapter treats the Function as stateless: parameters are held here as
plain data and re-passed on every call, and price= is applied by choosing
which bar field fills the input slot rather than by mutating input_names.
- Safe: construct an indicator on one thread, drive it on another.
- Not safe: driving a SINGLE instance from two threads at once — it holds per-bar state (buffers, the memoisation flag). One instance per worker.