Documentation

Indicator Series

When a backtest finishes, the Test results view in the IDE renders charts of your run. Indicator series let you attach extra time series you computed yourself — moving averages, RSI, MACD histograms, custom signals — to those charts so they show up next to the OHLCV candles, in their own panes, or per-symbol on multi-symbol runs.

QuantCraft does not provide built-in indicators. There is no SMA, MACD, or RSI API in the platform. You implement the math (or use your own library), build a list of {"time": ..., "value": ...} rows, then use ChartIndicatorSeries (via chart_indicators in a backtest) to send those points to the chart. The module normalizes times, stores series, and merges them into the result under chart_indicator_series — it does not compute indicator values for you.

Populate series from on_finish or earlier callbacks as described in Strategy lifecycle.

The IDE turns each entry into a line or histogram overlay on the corresponding chart pane.


How it works

  1. You compute one or more indicator series (e.g. SMA from closes, RSI from your formula) as a list of {"time": ..., "value": ...} rows.
  2. You add each series to a ChartIndicatorSeries collection with display options (which pane, line vs. histogram, optional name/color/symbol).
  3. At the end of the backtest, the engine merges that collection into the result under chart_indicator_series.
  4. The IDE chart in Test Results renders each series with the options you chose.

Inside a backtest, the engine provides a ready-to-use chart_indicators collector — not precomputed indicators:

from quantcraft.backtest.runtime import chart_indicators

chart_indicators is a ChartIndicatorSeries instance. Anything you .add(...) on it (with points you built) is automatically merged into the final result. You only need to construct your own ChartIndicatorSeries() outside a backtest run.


Quick start

The usual pattern: compute the indicator from the chronological bars list in on_finish, then register it with chart_indicators for plotting:

from quantcraft.backtest.runtime import chart_indicators from quantcraft.indicator_series import coerce_chart_time def on_finish(bars, symbol=None, bars_by_symbol=None): if not bars: return times = [coerce_chart_time(b["t"]) for b in bars] closes = [float(b["close"]) for b in bars] # You compute the indicator — QuantCraft does not provide SMA for you sma_points = [] window = 20 for i in range(window - 1, len(closes)): avg = sum(closes[i - window + 1 : i + 1]) / window sma_points.append({"time": times[i], "value": avg}) chart_indicators.add( sma_points, pane="main", render="line", line_style="dotted", id="sma_20", name="SMA(20)", )

The result chart then shows your SMA(20) as a dotted overlay on the candle chart.

Legacy from ide.backtest.runtime import …, from ide.indicator_series_module import …, and from quantcraft.indicator_series_module import … still work.


Building series — the points list

Every series is a list of dicts you built after running your own indicator logic. Each row needs:

KeyTypeNotes
timenumber / datetime / ISO stringNormalized to Unix seconds (int). Use the bar's timestamp from bars[i]["t"] (or coerce_chart_time(t) if you want to convert manually).
valuenumberMust be finiteNaN / inf rows are dropped.

Other keys on the dict are ignored, and rows with bad time or non-finite value are silently dropped.

Tip: prefer building series from the bars argument of on_finish(bars, ...) (chronological OHLC, oldest first) rather than from intra-loop state. It keeps your indicator and chart aligned to the same execution window.


Series options (per add call)

You set these once per series, when you call chart_indicators.add(...):

ArgumentRequiredValues
paneyes"main" — overlay on the price/candle chart. "separate" — its own pane below the chart (good for RSI, MACD, etc.).
renderyes"line" or "histogram".
line_stylewhen render="line""solid" or "dotted". Omit for histograms.
idnoStable identifier for the series; auto-generated if you don't provide one. Useful for keeping the same color/order across runs.
namenoDisplay name shown on the chart legend. Defaults to id.
colornoColor hint (e.g. "#22c55e").
symbolnoUppercase ticker (e.g. "MSFT"). For multi-symbol backtests, pins the series to that symbol's chart. Omit for the clock / primary symbol.
pane_idnoWhen pane="separate", series with the same pane_id share one sub-pane (e.g. RSI plus dotted 30/70 reference lines). Omit for one series per pane. Ignored when pane="main".

Where each series shows up

Two settings together decide where the series renders:

  • pane controls vertical placement:
    • "main" — sits on top of the candles of its chart.
    • "separate" — gets its own pane under the candles.
  • symbol controls which chart in a multi-symbol run:
    • omitted — attaches to the primary / clock symbol's chart (this matches the default for single-symbol runs).
    • "AAPL" (etc.) — attaches only to that ticker's chart.

For single-symbol backtests you can ignore symbol entirely; everything goes on the one chart.


Lines vs. histograms

These are display choices for series you already computed. Examples of what you might plot:

Line overlays (render="line")

Use for continuous series that align with closes:

  • Moving averages (SMA, EMA) — usually pane="main".
  • Bollinger / Keltner bandspane="main", multiple series.
  • RSI / Stochastic / ADXpane="separate" so the price scale isn't squashed.

Pick line_style:

  • "solid" for primary indicators.
  • "dotted" for secondary / reference series (a slow MA next to a fast one, an upper band).
# rsi_points must come from your own RSI calculation chart_indicators.add(rsi_points, pane="separate", render="line", line_style="solid", id="rsi_14", name="RSI(14)")

Histograms (render="histogram")

Use for bar/column style series (positive and negative values rendered as bars):

  • MACD histogram, volume-style scores, signal strength — after you compute them.
chart_indicators.add( [{"time": t, "value": v} for t, v in zip(hist_times, hist_vals)], pane="separate", render="histogram", id="macd_hist", name="MACD Hist", )

line_style is not used for histograms — leave it out.


Multi-symbol backtests

In multi-symbol runs, every loaded ticker has its own chart in Test Results. Use symbol="..." to send a series to a specific chart, and call add once per ticker. You still compute per ticker yourself:

def on_finish(bars, symbol=None, bars_by_symbol=None): if not bars_by_symbol: return for ticker, ticker_bars in bars_by_symbol.items(): sma_points = compute_sma(ticker_bars, window=20) # your function chart_indicators.add( sma_points, pane="main", render="line", line_style="solid", id=f"sma_20_{ticker.lower()}", name="SMA(20)", symbol=ticker, )

Series without a symbol go on the clock / primary chart. Series with a symbol go only on that ticker's chart.

Tip: keep the same name (e.g. "SMA(20)") across symbols — the id and pane_id (when used) should be unique per symbol.


Grouping series on one sub-pane (pane_id)

When pane="separate", each add(...) call normally gets its own sub-pane. Pass the same pane_id to stack related series together — for example RSI with dotted overbought/oversold lines:

from quantcraft.backtest.runtime import chart_indicators from quantcraft.indicator_series import coerce_chart_time RSI_PANE = "rsi" def on_finish(bars, symbol=None, bars_by_symbol=None): if not bars: return bar_times = [coerce_chart_time(b["t"]) for b in bars] rsi_points = compute_rsi(bars) # your function chart_indicators.add( rsi_points, pane="separate", render="line", line_style="solid", id="rsi_14", name="RSI(14)", pane_id=RSI_PANE, ) chart_indicators.add( [{"time": t, "value": 70.0} for t in bar_times], pane="separate", render="line", line_style="dotted", id="rsi_ob", name="Overbought", pane_id=RSI_PANE, color="#94a3b8", ) chart_indicators.add( [{"time": t, "value": 30.0} for t in bar_times], pane="separate", render="line", line_style="dotted", id="rsi_os", name="Oversold", pane_id=RSI_PANE, color="#94a3b8", )

For multi-symbol runs, use a per-symbol pane_id (e.g. f"rsi_{ticker}") and a unique id per ticker (e.g. f"rsi_{ticker.lower()}").


Multiple series at once

You can add as many series as you want — call add(...) once per series, each with points you computed:

chart_indicators.add(sma20_points, pane="main", render="line", line_style="solid", id="sma_20", name="SMA(20)") chart_indicators.add(sma50_points, pane="main", render="line", line_style="dotted", id="sma_50", name="SMA(50)") chart_indicators.add(rsi_points, pane="separate", render="line", line_style="solid", id="rsi_14", name="RSI(14)") chart_indicators.add(macd_hist, pane="separate", render="histogram", id="macd", name="MACD Hist")

Building series outside a backtest

If you're not in a backtest (for example you're shaping a result dict in your own script and want to attach chart overlays), construct a ChartIndicatorSeries yourself and merge it into the result:

from quantcraft.indicator_series import ChartIndicatorSeries, coerce_chart_time # Points come from your own indicator logic, not from this module points = [ {"time": 1_700_000_000, "value": 150.0}, {"time": 1_700_008_640, "value": 151.2}, ] series = ChartIndicatorSeries() series.add( points, pane="main", render="line", line_style="dotted", id="sma_20", name="SMA(20)", ) test_result = {"success": True, "stdout": "..."} test_result = series.merge_into(test_result) # test_result["chart_indicator_series"] -> ready for the chart client

merge_into(result) shallow-copies the result and sets the chart_indicator_series key for you. Inside a normal backtest you don't need to do this — the engine already merges chart_indicators for you.


Result payload shape (for reference)

Each entry in chart_indicator_series ends up as:

FieldDescription
id, name, pane, render, pointsExactly what you passed to add(...).
line_style"solid" / "dotted" for lines, null for histograms.
colorPresent only if you passed color=.
symbolPresent only if you passed symbol= — uppercase ticker so the IDE can place it on the right chart.
pane_idPresent only when pane="separate" and you passed a non-empty pane_id=.

Each points row is normalized to just {"time": int, "value": float}.


Tips and gotchas

  • QuantCraft plots; you compute. Use pandas, numpy, TA-Lib, or your own loops — then pass the resulting points to chart_indicators.add(...).
  • Compute in on_finish. It's the natural place for whole-run series and gives you the chronological bars list aligned to the execution window.
  • Use bar timestamps for time. Backtest bar t is often an ISO string (e.g. "2024-06-10T13:30:00+00:00"). Use coerce_chart_time(b["t"]) — do not assume int(b["t"]) works.
  • Drop NaNs early. Series with NaN / inf values get those rows dropped silently — skip them in your code so the visible series stays continuous.
  • Stable ids help styling. Reusing the same id across runs lets the chart hold colors and ordering steady.
  • Pick the right pane. Bounded indicators (RSI 0–100) belong in "separate" — putting them on "main" will warp the price scale.
  • Histogram = no line_style. Set line_style only for render="line".