trader.indicators package

Technical indicators as pure functions over list[Bar].

Every function returns a list the same length as its input, with None for positions where the indicator is not yet defined. Index-for-index alignment means a strategy can read series[i] alongside bars[i] without offset arithmetic, which is where this kind of code usually goes wrong.

requirements.md §4.1 (issue #109): this module must not hand-roll its own indicator math. Every function below delegates the actual computation to talib (TA-Lib 0.7.1, already installed via Homebrew on this machine and shipped as a prebuilt PyPI wheel for macOS arm64 – no C build step). talib works in numpy.float64; every function here converts Bar.close/.high/ .low to float at its own INPUT boundary, calls the matching talib function, and converts the result back to Decimal (or, for a dimensionless value like RSI, plain float) at its own OUTPUT boundary – the same convert-at-the-boundary pattern this codebase already uses for brokers/ and marketdata/ adapters. No talib float ever reaches a caller.

ema() is the one deliberate exception, and stays hand-rolled – see its own docstring for why: talib.EMA cannot reproduce its “defined from index 0” contract without changing what AlligatorStrategy can safely assume about warmup_bars(), the same “no standard off-the-shelf equivalent for this specific convention” carve-out research/strategies.py uses for PSAR, the Markov signal and the VWAP scalp.

Price-valued outputs are Decimal (they are money). Dimensionless ones such as RSI are float.

class trader.indicators.BollingerBands(upper, middle, lower)[source]

Bases: object

Upper, middle, and lower band. All price-valued, so all Decimal.

Parameters:
  • upper (Decimal)

  • middle (Decimal)

  • lower (Decimal)

upper: Decimal
middle: Decimal
lower: Decimal
trader.indicators.atr(bars, period)[source]

Average True Range, via talib.ATR (Wilder’s smoothing, issue #63; delegated to talib, issue #109).

Confirmed empirically 2026-09-01: talib.ATR reproduces this module’s old hand-rolled Wilder ATR byte-for-byte on every fixture in tests/indicators/test_atr.py – same seeding (a simple average of the first period true ranges), same first-defined index (period), same true-range formula (max(high-low, |high-prev_close|, |low-prev_close|)). No hardcoded test value needed to change for this function.

Parameters:
  • bars (Sequence[Bar])

  • period (int)

Return type:

list[Decimal | None]

trader.indicators.bollinger(bars, period, num_std)[source]

Bollinger bands: SMA of the close plus/minus num_std population stdevs, via talib.BBANDS (matype=0 – a simple moving average middle band, matching this function’s own always-SMA convention; nbdevup / nbdevdn both num_std, matching this function’s own single-parameter symmetric-band shape).

period must be >= 2, the same talib floor rsi documents: a single point has no spread to measure a deviation against.

Parameters:
  • bars (Sequence[Bar])

  • period (int)

  • num_std (int)

Return type:

list[BollingerBands | None]

trader.indicators.donchian_high(bars, period)[source]

Highest high of the period bars ENDING AT i-1, excluding bar i.

Via talib.MAX, shifted forward one bar – see _shift_forward_one.

Parameters:
  • bars (Sequence[Bar])

  • period (int)

Return type:

list[Decimal | None]

trader.indicators.donchian_low(bars, period)[source]

Lowest low of the period bars ending at i-1, excluding bar i.

Via talib.MIN, shifted forward one bar – see _shift_forward_one.

Parameters:
  • bars (Sequence[Bar])

  • period (int)

Return type:

list[Decimal | None]

trader.indicators.ema(bars, period)[source]

Exponential moving average of the close, alpha = 2 / (period + 1).

Deliberately still hand-rolled – the one exception to this module’s “delegate to talib” rule, same status as research/strategies.py’s PSAR, Markov and VWAP-scalp families: not because talib lacks an EMA (it does not), but because talib’s EMA cannot reproduce this function’s contract without a real behavioural change downstream.

Confirmed empirically 2026-09-01: talib.EMA – even under talib.set_compatibility(1) (Metastock mode, which seeds the recursion from the first raw value exactly as this function does, rather than an SMA of the first period values) – still reports nan for the first period - 1 indices, because talib’s lookback is fixed at period - 1 independent of the compatibility mode; only the seed differs. This function’s contract, which AlligatorStrategy depends on, is that ema is defined from index 0 (ewm(adjust=False)’s convention, no leading None run at all – see test_ema_is_defined_from_the_first_bar_unlike_ sma). Adopting talib’s leading-None run here would silently understate AlligatorStrategy.warmup_bars() by period - 1 bars for jaw/teeth/lips each (their warmup_bars() computation assumes ema has no warmup of its own beyond period + shift), which is exactly the class of behavioural regression the issue’s own carve-out for Alligator’s “custom fan logic” was written to avoid touching. talib has no parameter that reports the values it already computes internally for those leading indices, so there is no way to get talib’s own math AND this contract at once.

Stdlib-Decimal recursion, unchanged: seeded with the first close (out[0] = bars[0].close) and then out[i] = (1 - alpha) * out[i - 1] + alpha * close[i], matching pandas’ ewm(span=period, adjust=False) .mean(). A caller that wants a “trust this only once it has had time to converge” guarantee (as AlligatorStrategy does) enforces that itself; ema reports what pandas would report at every index.

Parameters:
  • bars (Sequence[Bar])

  • period (int)

Return type:

list[Decimal]

trader.indicators.macd(bars, fast_period, slow_period, signal_period)[source]

The MACD line and its signal line, via talib.MACD (issue #109).

Added for MacdStrategy (src/trader/strategies/macd.py), which previously hand-rolled its own EMA/MACD recursion internally rather than going through this module at all. talib.MACD computes exactly the two series MacdStrategy needs – macd = EMA(close, fast) - EMA(close, slow), signal = EMA(macd, signal_period) – with the same SMA-seeded-EMA convention MacdStrategy’s own former hand-rolled _ema helper used, so the underlying algorithm is unchanged; only the engine computing it is.

Both macd_line and signal_line are Decimal (a MACD value is a difference of two prices – money) and index-aligned with bars, None before each series is defined. fast_period/slow_period/ signal_period must each be >= 2 – talib’s own floor, the same reasoning rsi/bollinger document: an EMA “difference” needs at least two periods’ worth of smoothing to mean anything.

Parameters:
  • bars (Sequence[Bar])

  • fast_period (int)

  • slow_period (int)

  • signal_period (int)

Return type:

tuple[list[Decimal | None], list[Decimal | None]]

trader.indicators.rsi(bars, period)[source]

Relative Strength Index, via talib.RSI (Wilder’s smoothing).

One deliberate value change from this module’s old hand-rolled version: a perfectly flat run (zero gains AND zero losses) now reads 0.0, not a “neutral” 50.0 – that was this module’s own hand-picked convention for the undefined 0/0 case, and talib’s real, shipped convention differs. Confirmed empirically 2026-09-01: talib.RSI([7,7,7,7], timeperiod=2)[-1] == 0.0. Every other case this module’s tests pinned (monotonic up -> 100.0, monotonic down -> 0.0, and the hand-derived Wilder-seeded 50.0/75.0 two-step example) matched talib exactly, unchanged.

period must be >= 2: talib rejects timeperiod=1 outright (RSI is undefined over a single point – there is no prior close to diff against within the window).

Parameters:
  • bars (Sequence[Bar])

  • period (int)

Return type:

list[float | None]

trader.indicators.sma(bars, period)[source]

Simple moving average of the close, via talib.SMA.

Parameters:
  • bars (Sequence[Bar])

  • period (int)

Return type:

list[Decimal | None]

trader.indicators.stdev(bars, period)[source]

Population standard deviation of the close, via talib.STDDEV.

nbdev=1 is talib’s own default multiplier – the Bollinger convention (population, not sample) this function has always documented; verified 2026-09-01 against talib.STDDEV([1,2,3,4,5], timeperiod=5, nbdev=1) == sqrt(2), the same population-variance value this module’s own hand-rolled version always produced for that fixture.

Parameters:
  • bars (Sequence[Bar])

  • period (int)

Return type:

list[Decimal | None]