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:
- "I just want the value the chart shows." Use the pre-injected
Indicatorglobal (no import) —Indicator.smaorIndicator.ema. These are the parity-true, chart-identical path: they call the sametesstrade_corekernels 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 staysO(1)in history length (the O(1) rule). (There is noIndicator.wma; for WMA, usepandas_taor the hand-roll below.) - "I need to read, modify, or extend the math" — or I need a moving average that isn't in the
Indicatorcatalogue (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
Indicatorfirst (no import):Indicator.ema(sdk.candles[-300:], 20)[-1]returns the same series the chart draws, over a bounded window so it staysO(1)per bar.Indicator.emamatchespandas_ta.emato 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 thedf=branch (whole series, runs once), orIndicator.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)
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 outComplexity: 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)
def sma_last(values, period):
if len(values) < period:
return None
return sum(values[-period:]) / periodPerformance 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
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 pointThe chart's EMA is
Indicator.ema(closes, period), which matchespandas_ta.ema— i.e. the strict, SMA-seeded variant (ema_series_strictbelow). 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
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 outNote 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:
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 outThis 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):
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.stateas above (seed from the SMA of the firstperiodcloses to match the chart's strict seed). SeeIndicator— the native layer.
WMA -- Weighted moving average
Linearly decreasing weight: the most recent point weighs the most.
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:
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 outHMA 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()insideon_bar_strategy. It recomputes the whole series every call —O(n·period)per bar,O(n²·period)over a backtest — over asdk.candleshistory 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 fatalProtocolError. Usehma_series()only in thedf=(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):
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:
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):
# 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
| Indicator | Lag | Smoothing | Incremental complexity | Chart-true call (no import) |
|---|---|---|---|---|
| SMA | High | Low | O(1) with running sum | Indicator.sma |
| EMA | Medium | High | O(1) via sdk.state, or bounded-window Indicator.ema | Indicator.ema |
| WMA | Low | Medium | O(period) | — (hand-roll or pandas_ta) |
| HMA | Very low | Very high | O(period) | — (hand-roll or pandas_ta) |
Next steps
- Anatomy of a custom indicator -- the canonical file structure (colors → params → declaration → math → dispatcher) every example should mirror.
Indicator— the native layer -- the parity-true math brain: the five injected methods, the O(1) rule, and exactly how they match the chart (no import).- RSI, MACD and Bollinger Bands -- composite indicators built on top of EMAs.
- SMA Crossover -- full template using SMA.
- MACD Momentum -- template using EMA and signal line.