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
- You compute one or more indicator series (e.g. SMA from closes, RSI from your formula) as a list of
{"time": ..., "value": ...}rows. - You add each series to the runtime collector with display options (which pane, line vs. histogram, optional name/color/symbol).
- At the end of the backtest, the engine merges that collection into the result under
chart_indicator_series. - 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_initchart_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_indicatorsand notfrom ... import chart_indicators? The runtime handles are per-session, so afrom ... importbinds 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 insys.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:ziptruncates 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.adddrops 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
tthrough verbatim.coerce_chart_timeaccepts ISO strings, epoch seconds/ms, anddatetime. Strings without a timezone — including QuantCraft B2 daily dates like"2024-01-02"— are read as UTC. A naivedatetimeobject, however, is read as local time, which offsets every point. Bar rows carry a UTCt; keep it. addalready normalizes eachtimeand drops non-finite values, so pre-filtering withcoerce_chart_time/math.isnanin your own loop is optional.
Every
returninon_finishis 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:
| Key | Type | Notes |
|---|---|---|
time | number / datetime / ISO string | Normalized to Unix seconds (int). Pass the bar's timestamp — bar["t"] in a callback, bars[i]["t"] in on_finish — through verbatim. |
value | number | Must 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(...):
| Argument | Required | Values |
|---|---|---|
pane | yes | "main" — overlay on the price/candle chart. "separate" — its own pane below the chart (good for RSI, MACD, etc.). |
render | yes | "line" or "histogram". |
line_style | when 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). |
id | no | Stable identifier for the series; auto-generated if you don't provide one. Useful for keeping the same color/order across runs. |
name | no | Display name shown on the chart legend. Defaults to id. |
color | no | Color hint (e.g. "#22c55e"). |
symbol | no | Uppercase 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_id | no | When 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:
panecontrols vertical placement:"main"— sits on top of the candles of its chart."separate"— gets its own pane under the candles.
symbolcontrols 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 — theidandpane_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 clientInside 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:
| Field | Description |
|---|---|
id, name, pane, render, points | Exactly what you passed to add(...). |
line_style | "solid" / "dotted" for lines, null for histograms. |
color | Present only if you passed color=. |
symbol | Present only if you passed symbol= — uppercase ticker so the IDE can place it on the right chart. |
pane_id | Present 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
pointstoci.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 fromon_finish'sbarsonly for indicators you did not track per bar. - Read the collector as
rt.chart_indicators.from quantcraft.process.runtime import chart_indicatorsbinds a snapshot — safe in the root algo file, wrong in a split helper module. - Append
NaNduring warmup, don't skip. Skipping rows in one list and not the other shifts the whole line;adddrops the non-finite rows for you. - Use bar timestamps for
time, verbatim. Backtest bartis 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 assumeint(b["t"])works, and do not rebuild it as a naivedatetime— that is read as local time and offsets every point. on_finishreturns are silent. No series and no error usually means an earlyreturn; print tosys.stderrat each guard.- Not from
qc_ohlcv. It isNoneon a screener-target run and otherwise includes warmup bars the chart never draws. - Stable
ids help styling. Reusing the sameidacross runs lets the chart hold colors and ordering steady, and derivingid/namefrom 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_stylerequired. A line without it raisesValueError. - Screener runs: always pass
symbol. There is no clock symbol to fall back to.
