Skip to content

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:

  1. 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.
  2. 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_library parametrises over all 152 at their defaults, discovered at runtime, so a function nobody thought to enumerate cannot quietly violate it. STREAMING_CASES additionally 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).
  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

TalibIndicator(name, *, price=None, history=None, **params)
  • name — any TA-Lib function name, case-insensitively ("rsi" == "RSI"). An unknown name raises ValueError: 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, so TalibIndicator("EMA", periods=10) raises EMA takes no parameter(s) ['periods']; it accepts ['timeperiod'] instead of silently running the default period. int/float coercion follows what the function declares (nbdev is a float, timeperiod is 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) raises history 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")) raises a 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. TalibLine deliberately has no update method, so it cannot satisfy the Indicator protocol either: this is a type error and a runtime error, not a silently-forever-gated strategy.
  • use() resolves a Cross input through TalibLine.owner, so the registration-order check below passes once macd itself 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 last history_bars bars, 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 at lookback (TA-Lib's own).
  • warm — the buffer is FULL. It flips at history_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_CASES in tests/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 — SMA and everything built on it (STOCH's slowd, 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 what lookback suggests, which is why they are sized by the rules above rather than by 64 × 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. Cross reads a.value/b.value on update, so its inputs must be use()d before it. use() enforces this: Cross(a, b) itself always constructs, but self.use(cross) raises ValueError when 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 Cross over another Cross. It type-checks (a Cross has lookback/ready/update) but has no .value, so it died with an AttributeError on 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 (use is bound to Indicator), but an untyped one got a bare AttributeError a bar later. Now TypeError: … is not an Indicator (needs lookback/ready/update).
  • A Cross that was never use()-registered. It never updates, so up/down read False for the whole run and the strategy takes zero trades in silence. Once both its inputs are ready that is provably a wiring mistake, so up/down now raise RuntimeError: this Cross has never been updated although both inputs are ready … instead of lying. A registered-but-not-yet-driven Cross is never accused.
  • on_bar is gated until every registered indicator is ready (require_ready=True). warmup defaults to the largest registered lookback; pin it explicitly across a parameter sweep so every parameter set is scored over the same window. report.bars_gated reports what was actually swallowed.
  • Cross(a, b) fires only on the resolving bar. up when a − b goes from ≤ 0 to > 0, down from ≥ 0 to < 0; an exact a == b touch 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.)
  • Indicator is a runtime-checkable Protocol (lookback / ready / update), so a hand-written indicator that satisfies it is still use()-able. Cross needs only ValueSource (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 Decimal you get back is that float via its shortest round-tripping repr, 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 explicit core.money.round_to_tick; money still comes from bar and ctx.
  • RSI on a run of identical closes reads 0, not 100. TA-Lib returns 0.0 when 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) and Adx(1) raise ValueError: TA-Lib rejected RSI(timeperiod=1): … (TA_BAD_PARAM); Sma(1), Ema(1), Atr(1) are fine. period < 1 still raises period must be >= 1, got 0 before TA-Lib is consulted at all.
  • STDDEV/VAR/BBANDS are only ~1e-6 relative at index price levels. TA-Lib uses the naive E[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; a Cross on 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.