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
- 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 a
ChartIndicatorSeriescollection 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.
Inside a backtest, the engine provides a ready-to-use
chart_indicatorscollector — not precomputed indicators:from quantcraft.backtest.runtime import chart_indicators
chart_indicatorsis aChartIndicatorSeriesinstance. Anything you.add(...)on it (with points you built) is automatically merged into the final result. You only need to construct your ownChartIndicatorSeries()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:
| Key | Type | Notes |
|---|---|---|
time | number / datetime / ISO string | Normalized to Unix seconds (int). Use the bar's timestamp from bars[i]["t"] (or coerce_chart_time(t) if you want to convert manually). |
value | number | Must be finite — NaN / 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
barsargument ofon_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(...):
| 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". Omit for histograms. |
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. |
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
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 — 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:
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 clientmerge_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:
| 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
pointstochart_indicators.add(...). - Compute in
on_finish. It's the natural place for whole-run series and gives you the chronologicalbarslist aligned to the execution window. - Use bar timestamps for
time. Backtest bartis often an ISO string (e.g."2024-06-10T13:30:00+00:00"). Usecoerce_chart_time(b["t"])— do not assumeint(b["t"])works. - Drop NaNs early. Series with
NaN/infvalues get those rows dropped silently — skip them in your code so the visible series stays continuous. - Stable
ids help styling. Reusing the sameidacross 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. Setline_styleonly forrender="line".
