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 hand them to the runtime collector. In the IDE backtest, the engine owns a ChartIndicatorSeries on runtime.chart_indicators and attaches chart.to_payload() to the result as chart_indicator_series. The module normalizes times and stores series — it does not compute indicator values for you.

Series are always handed over from on_finish. The values are best collected as the run walks bars — see Quick start.

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 the runtime collector 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.

Read the collector as a module attribute, not as an imported name:

import quantcraft.process.runtime as rt ci = rt.chart_indicators # assigned by the engine before on_init

chart_indicators is a ChartIndicatorSeries instance. Anything you .add(...) on it (with points you built) is attached to Test Results via the engine's to_payload(). Do not construct a private ChartIndicatorSeries() for IDE Test Results — use the runtime collector.

Why rt.chart_indicators and not from ... import chart_indicators? The runtime handles are per-session, so a from ... import binds a snapshot. In the root algo file that is fine — the engine rebinds the name after loading your script. In a split helper module it is not: helper modules stay cached in sys.modules, so the name keeps whichever session's collector was current at first import, which is wrong for the next run in the same process (forward / bulk). See Module handles vs imported names.


Quick start

The recommended pattern is to record the value where the strategy computes it — in on_bar / on_tick — and use on_finish only to hand the collected series over. The values plotted are then exactly the values the strategy traded on, on the engine's own execution clock, with no recomputation and no realignment.

import sys import quantcraft.process.runtime as rt PERIOD = 45 # symbol -> parallel lists, appended in lockstep so indices can never drift _bar_times: dict[str, list] = {} _sma: dict[str, list[float]] = {} def on_bar(bar_index, bar, fundamentals=None, symbol=None): sym = (symbol or "").upper() # `bar.close[i]` returns None past the start of the loaded series. closes = [bar.close[i] for i in range(PERIOD)] # One append per list, per bar — NaN while warming up, never a skip. value = float("nan") if None in closes else sum(closes) / PERIOD _bar_times.setdefault(sym, []).append(bar["t"]) # UTC bar time, verbatim _sma.setdefault(sym, []).append(value) ... # trade on `value` def on_finish(bars, symbol=None, bars_by_symbol=None): ci = rt.chart_indicators if ci is None: return for sym in sorted(_bar_times): times = _bar_times[sym] vals = _sma.get(sym, []) if len(times) != len(vals): # cheap guard against drift print(f"{sym}: {len(times)} times vs {len(vals)} values", file=sys.stderr) continue # NaN warmup rows and unparseable times are dropped by `add`. points = [{"time": t, "value": v} for t, v in zip(times, vals)] ci.add( points, pane="main", render="line", line_style="solid", id=f"sma_{PERIOD}_{sym}", name=f"SMA({PERIOD}) {sym}", color="#ff9800", symbol=sym, )

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

Derive id and name from the same constant the strategy trades on (PERIOD above), never a hardcoded number — a stale legend is indistinguishable from a stale plot.

For import-path rules (quantcraft.* everywhere; older ide.* paths still resolve for existing scripts), see Reference.


Keeping times and values aligned

add pairs time and value positionally, so a mismatch shifts the whole line silently — it renders, just against the wrong candles.

  • Append the time and the value in the same statement, every bar. Two parallel lists filled at different rates and later zipped is the classic bug: zip truncates to the shorter list, so a warmup gap of N bars pairs the first N times with values belonging to bars N… — the line lands N bars to the left, with no error anywhere.
  • While the indicator is warming up, append float("nan") rather than skipping. add drops non-finite values for you (see Building series), so warmup rows disappear from the plot while the two lists stay the same length.
  • Pass the bar's t through verbatim. coerce_chart_time accepts ISO strings, epoch seconds/ms, and datetime. Strings without a timezone — including QuantCraft B2 daily dates like "2024-01-02" — are read as UTC. A naive datetime object, however, is read as local time, which offsets every point. Bar rows carry a UTC t; keep it.
  • add already normalizes each time and drops non-finite values, so pre-filtering with coerce_chart_time / math.isnan in your own loop is optional.

Every return in on_finish is silent. The run still succeeds and Test Results still renders — just without your series. When a series does not appear, print(..., file=sys.stderr) at each guard; the message shows up in the Output panel.


Alternative — recompute at the end

For an indicator you did not track per bar, derive it in on_finish from bars — the executed window for the clock symbol, oldest first, the same rows the Test Results chart draws — or from bars_by_symbol. Take the timestamps from the same rows you fed the calculation, so a dropped or filtered row cannot desync the mapping:

import quantcraft.process.runtime as rt PERIOD = 20 def on_finish(bars, symbol=None, bars_by_symbol=None): ci = rt.chart_indicators if ci is None or not bars: return closes = [float(b["close"]) for b in bars] points = [ {"time": bars[i]["t"], "value": sum(closes[i - PERIOD + 1 : i + 1]) / PERIOD} for i in range(PERIOD - 1, len(bars)) ] if not points: return ci.add( points, pane="main", render="line", line_style="dotted", id=f"sma_{PERIOD}", name=f"SMA({PERIOD})", symbol=symbol, )

Do not build series from runtime.qc_ohlcv: it is None on a screener-target run, and where it is set it spans the full loaded range including warmup, not the executed window the chart draws.


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). Pass the bar's timestamp — bar["t"] in a callback, bars[i]["t"] in on_finish — through verbatim.
valuenumberMust be finite — NaN / inf rows are dropped, which is how warmup rows disappear from the plot.

Other keys on the dict are ignored, and rows with bad time or non-finite value are silently dropped — including numeric times too small to be epoch seconds (below 100,000,000). A row that isn't a dict at all is an error: add raises TypeError: points[i] must be a dict with 'time' and 'value' keys.


Series options (per add call)

You set these once per series, when you call ci.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". Required for lines — leaving it out raises ValueError: When render is "line", line_style must be "solid" or "dotted". Omit for histograms (ignored).
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. A Screener run has no clock symbol, so always pass symbol there.
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 bands — pane="main", multiple series.
  • RSI / Stochastic / ADX — pane="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 rt.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.
rt.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.

Accumulating per bar handles this for free. on_bar already runs once per ticker, so keying the collected values by symbol — as in Quick start — yields one series per tab with no extra work. on_finish runs only once at the end of the whole run, and its bars argument is the clock symbol only: if you build indicators from bars alone and pass symbol=symbol, every series lands on the clock symbol's chart and the other tabs have no indicator series.

Fix for the recompute path: loop over bars_by_symbol, compute points from each symbol's bars, and pass that ticker on each add:

def on_finish(bars, symbol=None, bars_by_symbol=None): ci = rt.chart_indicators if ci is None: return sources = bars_by_symbol if bars_by_symbol else {(symbol or "").upper(): bars} for ticker, ticker_bars in sources.items(): sma_points = compute_sma(ticker_bars, window=20) # your function ci.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:

import quantcraft.process.runtime as rt RSI_PANE = "rsi" def on_finish(bars, symbol=None, bars_by_symbol=None): ci = rt.chart_indicators if ci is None or not bars: return bar_times = [b["t"] for b in bars] rsi_points = compute_rsi(bars) # your function ci.add( rsi_points, pane="separate", render="line", line_style="solid", id="rsi_14", name="RSI(14)", pane_id=RSI_PANE, ) ci.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", ) ci.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:

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

Building series outside a backtest

to_result_dict(key=DEFAULT_RESULT_KEY) and merge_into(result, key=...) are helpers for notebooks / custom runners that own the result dict — they return {key: to_payload()}, or merge that into result. DEFAULT_RESULT_KEY is "chart_indicator_series". They are not used by the IDE backtest path (the engine reads to_payload() from runtime.chart_indicators).

Other members: ChartIndicatorSeries(max_entries=None) keeps only the newest N series when max_entries is set (bulk runs set this on the shared collector); add(...) returns the stored SeriesEntry; entries() lists the SeriesEntry objects added so far.

from quantcraft.indicator_series import ChartIndicatorSeries # 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

Inside a normal IDE backtest, only use rt.chart_indicators and .add(...) — do not build a separate collector and merge_into a local dict.


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 ci.add(...).
  • Compute where you trade, emit in on_finish. Recording (bar time, value) per symbol as the run walks bars plots exactly what the strategy acted on. Recompute from on_finish's bars only for indicators you did not track per bar.
  • Read the collector as rt.chart_indicators. from quantcraft.process.runtime import chart_indicators binds a snapshot — safe in the root algo file, wrong in a split helper module.
  • Append NaN during warmup, don't skip. Skipping rows in one list and not the other shifts the whole line; add drops the non-finite rows for you.
  • Use bar timestamps for time, verbatim. Backtest bar t is often an ISO string (e.g. "2024-06-10T13:30:00+00:00", or "2024-01-02" for B2 daily bars) and is UTC. Do not assume int(b["t"]) works, and do not rebuild it as a naive datetime — that is read as local time and offsets every point.
  • on_finish returns are silent. No series and no error usually means an early return; print to sys.stderr at each guard.
  • Not from qc_ohlcv. It is None on a screener-target run and otherwise includes warmup bars the chart never draws.
  • Stable ids help styling. Reusing the same id across runs lets the chart hold colors and ordering steady, and deriving id / name from the same constant the strategy uses keeps the legend honest.
  • 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; line = line_style required. A line without it raises ValueError.
  • Screener runs: always pass symbol. There is no clock symbol to fall back to.