Skip to content
Single .md

Implementing SMA and EMA

Moving averages are the foundation of a large portion of strategies. There are two ways to get them, and they answer two different questions:

  1. "I just want the value the chart shows." Use the pre-injected Indicator global (no import) — Indicator.sma or Indicator.ema. These are the parity-true, chart-identical path: they call the same tesstrade_core kernels the live chart renders with, so what you compute equals what the chart draws — no drift, no "right in backtest, wrong on the chart". For a per-bar strategy, feed a boundedsdk.candles[-N:] window so each call stays O(1) in history length (the O(1) rule). (There is no Indicator.wma; for WMA, use pandas_ta or the hand-roll below.)
  2. "I need to read, modify, or extend the math" — or I need a moving average that isn't in the Indicator catalogue (WMA and HMA below, and everything in RSI, MACD and Bollinger Bands). Then the hand-rolled reference implementations on this page are exactly what you want. They are exact, sandbox-safe, and meant to be read and changed.

Parity-true alternative. If you only need the moving-average value, reach for Indicator first (no import): Indicator.ema(sdk.candles[-300:], 20)[-1] returns the same series the chart draws, over a bounded window so it stays O(1) per bar. Indicator.ema matches pandas_ta.ema to floating-point precision — that is, the strict, SMA-seeded EMA described below, not the relaxed first-value seed. The hand-rolled versions on this page remain the right reference whenever you need to inspect or modify the math, or for moving averages outside the catalogue.

For the canonical file layout — colors → params → declaration → math → dispatcher, with type:"color" inputs correctly wired into plot colors — see Anatomy of a custom indicator. Everything below is the math you drop into that structure.

SMA -- Simple moving average

The arithmetic mean of the last period closes.

Want the chart's SMA in one line? Indicator.sma(df, period) in the df= branch (whole series, runs once), or Indicator.sma(sdk.candles[-(period+1):], period)[-1] in the per-bar branch (last value over a bounded window) — identical to the line the chart renders. The reference below is for when you want to own or tweak the math.

"Full series" version (for plots)

python
def sma_series(values, period):
    """Returns a list the same length as values; None during warmup."""
    out = []
    running_sum = 0.0
    for i, v in enumerate(values):
        running_sum += v
        if i + 1 < period:
            out.append(None)
            continue
        if i + 1 > period:
            running_sum -= values[i - period]
        out.append(running_sum / period)
    return out

Complexity: O(n). The naive version with sum(values[i-p+1:i+1]) is O(n * p) and should be avoided on long series. The running_sum trick keeps it at O(n).

"Last point" version (for on_bar_strategy)

python
def sma_last(values, period):
    if len(values) < period:
        return None
    return sum(values[-period:]) / period

Performance is irrelevant here: typical period is less than 100 and sum() is trivial. (For chart-identical values, Indicator.sma(sdk.candles[-(period+1):], period)[-1] reads a bounded window in O(period).)

Usage

python
closes = [c["close"] for c in sdk.candles]
sma20 = sma_last(closes, 20)
if sma20 is None:
    return  # warmup

if sdk.candles[-1]["close"] > sma20:
    # price above the moving average
    ...

EMA -- Exponential moving average

Weights exponentially: recent points carry more weight. Classic formula:

alpha = 2 / (period + 1)
ema[i] = alpha * values[i] + (1 - alpha) * ema[i-1]
ema[0] = values[0]   # seed: first value becomes the initial point

The chart's EMA is Indicator.ema(closes, period), which matches pandas_ta.ema — i.e. the strict, SMA-seeded variant (ema_series_strict below). The relaxed first-value seed (ema_series) is a fine, simpler approximation for learning and quick backtests, but it is not what the chart draws.

"Full series" version

python
def ema_series(values, period):
    """EMA aligned by candle. First point is the seed (=values[0])."""
    if not values:
        return []
    alpha = 2.0 / (period + 1.0)
    out = [float(values[0])]
    for v in values[1:]:
        out.append(alpha * v + (1.0 - alpha) * out[-1])
    return out

Note on warmup: unlike SMA, this relaxed EMA does not return None. It uses the first value itself as the seed. The first period points are less precise because the exponential weighting is still settling, but they are valid values.

If you want to match the chart, enforce strict warmup: return None for the first period - 1 points and seed from the SMA of the first period values:

python
def ema_series_strict(values, period):
    if len(values) < period:
        return [None] * len(values)
    alpha = 2.0 / (period + 1.0)
    # Seed = SMA of the first `period` values
    seed = sum(values[:period]) / period
    out = [None] * (period - 1) + [seed]
    for v in values[period:]:
        out.append(alpha * v + (1.0 - alpha) * out[-1])
    return out

This strict, SMA-seeded version is what pandas_ta.ema does — and therefore what Indicator.ema and the chart do. If you just want that value, call Indicator.ema(values, period) and skip the hand-roll; reach for ema_series_strict when you want to read or modify the warm-up/seed behaviour. The relaxed ema_series above is a learning-friendly approximation, not chart parity.

Incremental "last point" version

EMA is naturally incremental: each step depends only on the previous one. It can be cached in sdk.state (which persists across bars):

python
def on_bar_strategy(sdk, params):
    period = int((params or {}).get("period", 20))
    alpha = 2.0 / (period + 1.0)

    if not isinstance(sdk.state, dict):
        sdk.state = {}
    if "ema" not in sdk.state:
        sdk.state["ema"] = None

    last_close = sdk.candles[-1]["close"]
    ema = sdk.state["ema"]
    if ema is None:
        sdk.state["ema"] = last_close  # seed
        return

    ema = alpha * last_close + (1 - alpha) * ema
    sdk.state["ema"] = ema

    # ... logic uses `ema`

Gain: O(1) per candle instead of O(n). For 5m strategies running over months, this makes a difference.

Two ways to get this O(1) win without hand-managing state — both no import:

  • Chart parity, simplest: read a bounded window with the injected global — Indicator.ema(sdk.candles[-300:], period)[-1]. A fixed window is O(1) in history length and converges to the full-history value.
  • Bit-exact: keep the recursive value in sdk.state as above (seed from the SMA of the first period closes to match the chart's strict seed). See Indicator — the native layer.

WMA -- Weighted moving average

Linearly decreasing weight: the most recent point weighs the most.

python
def wma_last(values, period):
    if len(values) < period:
        return None
    weights = list(range(1, period + 1))  # 1, 2, 3, ..., period
    window = values[-period:]
    total = sum(v * w for v, w in zip(window, weights))
    return total / sum(weights)

WMA is not in the Indicator catalogue (which is sma, ema, rsi, macd, bollinger), so the reference above — or pandas_ta.wma — is how you get it. wma_last reads only a bounded values[-period:] window, so it is safe to call per bar.

HMA -- Hull Moving Average

Smoother than EMA, less laggy than SMA. HMA is not in the Indicator catalogue, so this hand-rolled version (built from the WMA above) is exactly the kind of thing this page exists for — own the math, or reach for pandas_ta:

python
def hma_series(values, period):
    half = max(1, period // 2)
    sqrt_p = max(1, int(period ** 0.5))

    wma_half = [None] * len(values)
    wma_full = [None] * len(values)

    # Rolling WMA (see WMA implementation above, adapted to a series)
    for i in range(len(values)):
        if i + 1 >= half:
            w = list(range(1, half + 1))
            win = values[i - half + 1 : i + 1]
            wma_half[i] = sum(v * ww for v, ww in zip(win, w)) / sum(w)
        if i + 1 >= period:
            w = list(range(1, period + 1))
            win = values[i - period + 1 : i + 1]
            wma_full[i] = sum(v * ww for v, ww in zip(win, w)) / sum(w)

    # Raw = 2 * WMA_half - WMA_full, then WMA of raw with sqrt(period)
    raw = []
    for h, f in zip(wma_half, wma_full):
        raw.append(2 * h - f if h is not None and f is not None else None)

    # Final WMA on raw (ignores None during warmup)
    out = [None] * len(values)
    for i in range(len(values)):
        window = [r for r in raw[max(0, i - sqrt_p + 1) : i + 1] if r is not None]
        if len(window) == sqrt_p:
            w = list(range(1, sqrt_p + 1))
            out[i] = sum(v * ww for v, ww in zip(window, w)) / sum(w)

    return out

HMA is more complex to implement, but it is suitable for scripts that need a smooth average. For the "something better than SMA" case, EMA is usually enough — and for plain EMA you can use Indicator.ema for chart parity.

⚠️ Never call hma_series() inside on_bar_strategy. It recomputes the whole series every call — O(n·period) per bar, O(n²·period) over a backtest — over a sdk.candles history that grows every frame. That is exactly the per-bar overrun that blows the ~800 ms frame budget and can abort the run with a fatal ProtocolError. Use hma_series() only in the df= (chart) branch, which runs once. For the per-bar trading decision, use the incremental last-point version below (O(period) per bar over a bounded window — never the full history).

Incremental "last point" version

HMA needs a WMA over the last half closes, a WMA over the last period closes, and a final WMA over the last sqrt(period) values of raw = 2·WMA_half − WMA_full. Only that last small buffer of raw values has to persist across bars — keep it in sdk.state and read closes from a bounded window (sdk.candles[-period:]), so each bar is O(period), not O(n·period):

python
def hma_last(sdk, period):
    """Latest HMA point in O(period) per bar.

    Keeps only a small rolling buffer of `raw` values in sdk.state and reads
    a bounded window of closes (never the full growing history)."""
    half   = max(1, period // 2)
    sqrt_p = max(1, int(period ** 0.5))

    raw_buf = sdk.state.setdefault("hma_raw", [])  # sdk.state persists across bars

    closes = [c["close"] for c in sdk.candles[-period:]]  # bounded window
    if len(closes) < period:
        return None  # not enough history for WMA_full yet

    def _wma(window):
        w = range(1, len(window) + 1)
        return sum(v * k for v, k in zip(window, w)) / sum(w)

    raw = 2.0 * _wma(closes[-half:]) - _wma(closes)  # closes == last `period`
    raw_buf.append(raw)
    if len(raw_buf) > sqrt_p:
        raw_buf = raw_buf[-sqrt_p:]        # keep only the last sqrt(period) values
        sdk.state["hma_raw"] = raw_buf     # store the trimmed buffer back
    if len(raw_buf) < sqrt_p:
        return None                 # final-WMA warm-up
    return _wma(raw_buf)

Because it reads exactly one new bar's tail each frame and carries only the sqrt(period)-length raw buffer in sdk.state, this is O(period) per bar — the "Incremental complexity" the summary table quotes for HMA — and it stays inside the per-bar budget as history grows.

With numpy

Using np, the implementation fits in a few lines:

python
import numpy as np  # already available as the global `np`

def sma_last_np(values, period):
    if len(values) < period:
        return None
    return float(np.mean(values[-period:]))


def ema_series_np(values, period):
    alpha = 2.0 / (period + 1.0)
    arr = np.asarray(values, dtype=float)
    # numpy has no native EMA; emulating with `lfilter` would be ideal, but simpler:
    out = np.empty_like(arr)
    out[0] = arr[0]
    for i in range(1, len(arr)):
        out[i] = alpha * arr[i] + (1 - alpha) * out[i - 1]
    return out.tolist()

Caveat: even with numpy, the loop is still necessary for EMA, and this uses the relaxed first-value seed (not chart parity). scipy is not available in the sandbox. If you want the chart-true value, use Indicator.ema; if per-bar cost matters, read it over a bounded window (Indicator.ema(sdk.candles[-300:], period)[-1]) or cache the recursive value in sdk.state as shown above.

Using pandas_ta

A subset of popular functions is available (import pandas_ta as ta, or the pre-injected ta):

python
# Requires conversion to a pandas Series
close_series = pd.Series([c["close"] for c in sdk.candles])
sma = ta.sma(close_series, length=20)  # returns Series
if sma is not None and not pd.isna(sma.iloc[-1]):
    last_sma = float(sma.iloc[-1])

pandas_ta is the right tool for any indicator outside the Indicator catalogue (WMA, HMA, ATR, …). For SMA and EMA, prefer Indicator.sma/Indicator.ema: they already match pandas_ta to floating-point precision under the project's golden-vector tests, so you get the exact chart series for free — with no import. Note that calling ta.* on the full sdk.candles history every bar is the O(n²) trap; pass a bounded window if you must use it per bar.

Summary table

IndicatorLagSmoothingIncremental complexityChart-true call (no import)
SMAHighLowO(1) with running sumIndicator.sma
EMAMediumHighO(1) via sdk.state, or bounded-window Indicator.emaIndicator.ema
WMALowMediumO(period)— (hand-roll or pandas_ta)
HMAVery lowVery highO(period)— (hand-roll or pandas_ta)

Next steps