Custom Indicators
Built-in indicators (SMA, EMA, RSI, etc.) cover most needs. When they don’t, write your own as a subclass of Indicator.
class MySMA(Indicator): """Simple moving average.""" period = Param(20, min=2, max=200) buffer = Series(period)
def compute(self, close): self.buffer.add(close) if not self.is_ready: return 0 return sum(self.buffer) / len(self.buffer)A custom indicator is a class that:
- Inherits from
Indicator(notStrategy). - Declares
Param()s andSeries()buffers at class level. - Implements
compute(self, <input1>, <input2>, ...)— the parameter names declare what the indicator consumes.
compute() signature
The parameter names in compute() determine the input. Allowed names:
| Name | Maps to |
|---|---|
close |
Source’s close price. |
open |
Source’s open. |
high |
Source’s high. |
low |
Source’s low. |
volume |
Source’s volume. |
session_start |
1 on the first bar of the symbol’s session, 0 on every other bar. |
def compute(self, close): # consumes closedef compute(self, high, low): # consumes high and lowdef compute(self, open, high, low, close, volume): # full OHLCVdef compute(self, high, low, close, volume, session_start): # session-anchoredAny other parameter name → RC2_IND_COMPUTE_SIG.
session_start — anchoring to the session
Persistent state (a class-body number) resets per run, never per session. session_start
is what lets an indicator reset itself: it is a plain decimal input like the five bar fields,
1 on the first bar of the symbol’s session and 0 otherwise. The indicator does the reset
itself, in code you can read:
class SessionVWAP(Indicator): """Volume-weighted average price since the session opened.""" position = ON_CHART period = Param(1, min=1, max=1) tpv = 0.0 vol = 0.0
def compute(self, high, low, close, volume, session_start): if session_start > 0: self.tpv = 0.0 self.vol = 0.0 typical = (high + low + close) / 3 self.tpv = self.tpv + typical * volume self.vol = self.vol + volume if self.vol > 0: return self.tpv / self.vol return closeIt is the session’s boundary, not the calendar’s. The flag reads 1 when this bar and the
previous one belong to different sessions, where a session is the contiguous run of scheduled
windows in the symbol’s session definition. So an evening futures session that crosses midnight
is one session, a maintenance break really does end one and start another, and a settlement
session ends where its own schedule says.
On a daily session every bar is a session start. Each day is a single window with a closed
night between them, so session_start reads 1 on every bar — which is the honest answer: on
daily bars, “since the session opened” means “this bar”.
The first bar is always a session start. There is no earlier bar to be in the same session as, so an indicator begins accumulating on the bar it first sees.
Two things are refused, by name, rather than being answered with a quiet 0:
on=self.trade.volume(a single-field retarget) on an indicator that readssession_start→RC2_IND_ON_SESSION. That form feeds the indicator one number and no session. Useon=self.trade.- A symbol whose session boundary cannot be determined — no session bound to the slot, a
weekly/monthly session (no daily schedule), or a schedule that never closes — stops the run
with
RAAMVM_SESSION_UNBOUND. A session VWAP that silently never resets is worse than one that says it cannot run.
Series(capacity) — rolling buffers
A Series is a fixed-size, FIFO buffer. Sized by a Param (so the capacity is optimizable).
buffer = Series(period) # capacity = self.periodThe buffer is bounded by the param’s max, not its default — the platform pre-allocates worst-case to avoid resizing.
Series API
| Method | Returns | Notes |
|---|---|---|
series.add(value) |
None | Append a new value. Drops oldest if at capacity. |
series[i] |
value | Index from oldest (0) to newest. |
len(series) |
int | Current size. |
sum(series) |
decimal | Sum of all values. |
min(series) / max(series) |
decimal | Smallest / largest value. |
stddev(series) |
decimal | Population standard deviation. 0 below 2 values. |
median(series) |
decimal | Middle value. An even window answers the mean of the two middle values. |
percentile(series, p) |
decimal | The p-th percentile, p in 0-100, interpolating linearly between order statistics. |
rank(series, x) |
decimal | The fraction of the window strictly below x, 0-1. |
series.bars_since_max() |
int | Bars since the highest value. |
series.bars_since_min() |
int | Bars since the lowest value. |
stddev, median, percentile and rank are functions, like sum / len / min / max — median(self.buffer) compiles, self.buffer.median() is RC2_IND_COMPUTE. Only bars_since_max() / bars_since_min() are method calls on the series.
Every scan reads the window and leaves it untouched; the ones that need the values ordered sort a copy. An empty window answers 0.
Order statistics
percentile uses the method NumPy uses by default: position (n - 1) * p / 100 in the sorted window, then a linear interpolation between the two neighbouring values. The edges are therefore exact:
percentile(self.buffer, 0) # == min(self.buffer)percentile(self.buffer, 100) # == max(self.buffer)percentile(self.buffer, 50) # == median(self.buffer)p must be between 0 and 100. A literal outside that range is RC2_IND_PERCENTILE_RANGE at compile time; a p the body computes is checked on the bar and raises rather than being quietly clamped.
rank counts strictly below, so a value never counts for itself. That makes rank(self.buffer, close) * 100 the classic percent rank of the current value inside its own window:
class PercentRank(Indicator): """Where the current close sits inside its own window, 0-100.""" period = Param(20, min=2, max=200) prices = Series(period)
def compute(self, close): self.prices.add(close) if not self.is_ready: return 0 return rank(self.prices, close) * 100A rolling median is one line, and a median absolute deviation — the robust dispersion stddev cannot express — is a second Series refilled each bar:
class MedianAbsoluteDeviation(Indicator): """Median absolute deviation of close over a rolling window.""" period = Param(20, min=2, max=200) prices = Series(period) deviations = Series(period)
def compute(self, close): self.prices.add(close) if len(self.prices) < self.period: return 0 center = median(self.prices) for i in range(0, self.period): self.deviations.add(abs(self.prices[i] - center)) return median(self.deviations)deviations has capacity period and takes exactly period values every bar, so it always holds this bar’s deviations and nothing older.
Params and constructor arguments
A Param() on an indicator becomes a positional constructor argument, in declaration order, and the strategy filling them writes them in that same order. Param(20) declares an integer parameter; Param(2.0) declares a decimal one — the decimal point in the default is what makes the difference.
Either kind may be filled with a literal or with the strategy’s own Param:
class BandWidth(Indicator): """A rolling mean pushed away from itself by a decimal multiple of the bar's distance from it.""" period = Param(20, min=2, max=200) multiplier = Param(2.0, min=0.1, max=5.0) prices = Series(period)
def compute(self, close): self.prices.add(close) if not self.is_ready: return 0 mean = sum(self.prices) / len(self.prices) return mean + self.multiplier * (close - mean)
class BandBreakout(Strategy): """Two bands off one indicator — one tuned by a Param, one fixed by a decimal literal.""" period = Param(20, min=2, max=200) band_mult = Param(0.85, min=0.1, max=5.0) trade = Symbol() sizing = FixedCapital()
def execute(self): tuned = BandWidth(self.period, self.band_mult, on=self.trade) wide = BandWidth(self.period, 2.0, on=self.trade)
if self.trade.close > wide.value and self.trade.is_flat: self.buy(self.trade, reason="Closed above the wide band")
if self.trade.close < tuned.value and self.trade.is_long: self.sell(self.trade, self.trade.position, reason="Closed below the tuned band")0.85 arrives at the indicator as 0.85 — a decimal argument is not rounded to a whole number on the way in. Prefer the Param over the literal wherever you want the value optimizable: band_mult is swept, 2.0 is not.
The first positional argument is always the period, and that one is an integer position — it sizes the rolling buffers and feeds the strategy’s warmup floor, so a decimal there is rejected outright rather than quietly rounded.
self.is_ready
True once all Series in the indicator have reached capacity. Most indicators return 0 (or a sentinel) until ready; the platform suppresses signals during the indicator’s own warmup phase regardless.
def compute(self, close): self.buffer.add(close) if not self.is_ready: return 0 return sum(self.buffer) / len(self.buffer)Single-value vs multi-value
Return a number to make the indicator single-valued (SMA, RSI, etc.) — usable with >, <, crossed_above, crossed_below:
return sum(self.buffer) / len(self.buffer)Return a dict to make it multi-valued (Aroon-style). Field names become attributes you access from a strategy:
return {"up": up_value, "down": down_value, "oscillator": up_value - down_value}Mixing scalar and dict returns in the same compute() is RC2_IND_MIXED_RETURN. Pick one shape per indicator.
Drawing on the chart
An output can also say how the chart draws it: name = self.line(value, color=..., width=..., line_style=..., pane=...). The variable is the output’s name, and the call returns the number, so a strategy reads ind.name exactly as if you had returned it. Everything below is a drawing choice — none of it changes a number a strategy reads.
self.circles, self.step, self.columns. Three more verbs with the same keywords as self.line. circles draws a dot per bar (a trailing stop), step a staircase that holds its level until it changes, columns a bar per bar from zero.
class TrailingDots(Indicator): """A dot under the lowest low of the window.""" position = ON_CHART period = Param(10, min=2, max=200) lows = Series(period)
def compute(self, low): self.lows.add(low) return self.circles(min(self.lows), color=Color.green)visible=False. The output still exists — a strategy reads it and the chart legend shows its value — but the chart draws no line for it and it does not stretch the price scale. Use it for a helper value (a direction flag, a fit quality) that belongs in the legend, not on the chart.
class TrendLine(Indicator): """A moving average, with its own direction as a legend-only value.""" position = ON_CHART period = Param(20, min=2, max=200) buffer = Series(period)
def compute(self, close): self.buffer.add(close) avg = self.line(sum(self.buffer) / len(self.buffer), color=Color.blue) rising = 0 if close > avg: rising = 1 direction = self.line(rising, visible=False) return avg, directionoffset=. Draws the output that many bars to the right (positive — into the future) or to the left (negative). Write a whole number, or an int Param with an optional minus sign, and the chart follows the Param’s current value. The numbers themselves are not shifted: on every bar a strategy reads the value computed on that bar.
class ShiftedMidpoint(Indicator): """The window's midpoint, drawn ahead of price.""" position = ON_CHART period = Param(26, min=2, max=200) highs = Series(period) lows = Series(period)
def compute(self, high, low): self.highs.add(high) self.lows.add(low) ahead = self.line((max(self.highs) + min(self.lows)) / 2, offset=self.period) behind = self.line((max(self.highs) + min(self.lows)) / 2, offset=-3, color=Color.gray) return ahead, behindaccumulates = SUM / accumulates = PRODUCT. A class-level declaration, like position, for an indicator that is a running total from the first bar it sees (SUM — On-Balance Volume) or a running product (PRODUCT — the volume indexes). Its level depends on where the loaded history starts, and this tells the chart so. It changes no value.
class RunningVolume(Indicator): """Volume added on up closes and subtracted on down closes, since the first bar.""" accumulates = SUM total = 0.0 prev_close = 0.0
def compute(self, close, volume): if self.prev_close > 0: if close > self.prev_close: self.total = self.total + volume elif close < self.prev_close: self.total = self.total - volume self.prev_close = close return self.line(self.total)Where custom indicators live
You can use a custom indicator inline in a strategy file (same file) or publish it to your organization’s catalog so other strategies can SMA-style import it by name. Org catalog publishing is covered in the platform docs.
Example: TR (True Range)
class TR(Indicator): """True Range — max of (high-low), |high-prev_close|, |low-prev_close|.""" period = Param(2, min=2, max=100) prev_close_buffer = Series(period)
def compute(self, high, low, close): self.prev_close_buffer.add(close) if not self.is_ready: return 0 prev_close = self.prev_close_buffer[0] return max(high - low, abs(high - prev_close), abs(low - prev_close))A Series capacity must reference a declared Param (optionally offset by an integer, Series(period + 1)). A bare Series(2) is RC2_IND_SERIES.
Use it from a strategy in the same file:
class TR(Indicator): """True Range — max of (high-low), |high-prev_close|, |low-prev_close|.""" period = Param(2, min=2, max=100) prev_close_buffer = Series(period)
def compute(self, high, low, close): self.prev_close_buffer.add(close) if not self.is_ready: return 0 prev_close = self.prev_close_buffer[0] return max(high - low, abs(high - prev_close), abs(low - prev_close))
class VolatilityEntry(Strategy): trade = Symbol() sizing = FixedCapital()
def execute(self): tr = TR(2, on=self.trade) if tr.value > self.trade.close * 0.03 and self.trade.is_flat: self.buy(self.trade, reason="High-volatility entry")Common diagnostics
| Code | What it means |
|---|---|
RC2_IND_NO_COMPUTE |
Indicator class missing compute() method. |
RC2_IND_COMPUTE_SIG |
compute() has an invalid parameter name. |
RC2_IND_COMPUTE |
compute() body contains a disallowed construct. |
RC2_IND_BODY |
Class body contains something other than Param, Series, or allowed declarations. |
RC2_IND_PARAM |
Param used incorrectly inside an indicator. |
RC2_IND_SERIES |
Series() declared incorrectly. |
RC2_IND_SERIES_CAPACITY |
Series capacity isn’t a Param. |
RC2_IND_MIXED_RETURN |
compute() returns both scalar and dict in different paths. |
RC2_IND_INCONSISTENT_OUTPUTS |
Dict returns have inconsistent key sets across paths. |
RC2_IND_METHOD |
Disallowed method defined on an Indicator. |
RC2_IND_PLOT_VISIBLE |
visible= is not True or False. |
RC2_IND_PLOT_OFFSET |
offset= is not a whole number of bars or an int Param. |
RC2_IND_PLOT_ACCUMULATES |
accumulates = is not SUM or PRODUCT. |