Plots and drawings

An indicator draws by calling methods on ctx.plot as it runs — once per bar, from on_bar. There is no return value to build. Each method takes a name as its first argument; that name identifies the drawing across bars, so every call to ctx.plot.series("sma", ...) appends to the same sma line.

This page documents every ctx.plot.* method.

Cross-cutting concepts

  • Names identify drawings. Reuse the same name every bar to extend a drawing; use a fresh name (f"line_{i}") for a distinct one.
  • One call per name per bar. Using the same name twice in a single on_bar — even across different methods, e.g. a series and a fill both called "x" — raises PlotCollisionError. Almost always a typo.
  • Panes. pane="main" overlays the candles; pane="sub" opens a separate pane below for oscillators (RSI, MACD…).
  • Groups. Drawings that share a group_id toggle together from the legend — give the three Bollinger lines and their fill one group and the user hides them with one click.
  • Warm-up gaps. For series, pass value=None on bars where you have no value yet (during helper warm-up). The line shows a gap and stays aligned to bar times.
  • Per-bar colour. Pass color= on every series / fill call. If it's the same colour on every bar, the line is one solid colour; if it changes from bar to bar, the line is drawn multi-coloured. This is how you colour a line per bar.
  • Sparse vs. continuous. series / fill describe a value at every bar. shape / bar_color are sparse: call them only on the bars where the marker or recolour should appear.

ctx.plot.series(...) — lines and histograms

The workhorse. Plots a value per bar as a line, histogram, or area.

ctx.plot.series(
    name,                    # str — drawing id
    value,                   # float or None (None = warm-up gap)
    *,
    style="line",            # "line" | "stepline" | "dots" | "histogram" | "area"
    color="#...",            # colour for this bar; same every bar = solid line
    pane="main",             # "main" | "sub"
    line_width=1.5,
    label=None,              # legend label
    group_id=None,
    opacity=1.0,
    offset=0,                # render-time bar shift (Ichimoku-style)
    future_only=False,       # with offset: draw only the part projected past the last bar
    point_radius=2.0,        # for style="dots"
    price_line_visible=True, # the dotted price line at the right edge
    last_value_visible=True, # the value tag on the price scale
)
def init(self, ctx):
    self.sma = ctx.rolling_mean(source=ctx.close, period=20)
def on_bar(self, ctx):
    ctx.plot.series("sma", value=self.sma.value,
                    color="#2196f3", line_width=2.0, label="SMA(20)")

Per-bar colour — recolour an EMA by trend:

def on_bar(self, ctx):
    v = self.ema.value
    if v is None:
        ctx.plot.series("ema", value=None)
        return
    up = ctx.close > v
    ctx.plot.series("ema", value=v,
                    color="#22c55e" if up else "#ef4444")

ctx.plot.fill(...) — shaded region

A band between two references. top / bottom are each either the name of a series (str) or a scalar price (float).

ctx.plot.fill(
    name, *,
    top=0.0,                 # series name (str) or price (float)
    bottom=0.0,              # series name (str) or price (float)
    color=None,              # may vary per bar
    pane="main",
    fill_opacity=0.1,
    label=None,
    group_id=None,
)
def on_bar(self, ctx):
    if self.bb.mid is None:
        return
    ctx.plot.series("bb_u", value=self.bb.upper, color="#9e9e9e")
    ctx.plot.series("bb_l", value=self.bb.lower, color="#9e9e9e")
    ctx.plot.fill("bb_fill", top="bb_u", bottom="bb_l",
                  color="#2196f3", fill_opacity=0.08)

ctx.plot.shape(...) — markers

A glyph at a price on selected bars. Sparse: call it only on bars where a marker should appear.

ctx.plot.shape(
    name, *,
    price=None,              # y-position
    shape=None,              # see glyphs below
    color=None,
    size=None,
    text=None,               # text drawn with the glyph
    position=None,           # "above_bar" | "below_bar" | "in_bar" | "price"
    pane="main",
    label=None,
    group_id=None,
    tooltip=None,            # dict: {"text": "...", "background": "#...", "color": "#..."}
    time_ms=None,            # override the bar's time anchor (delayed emit)
)

time_ms is how you place a marker on an earlier bar — a pivot is only known a few bars after it printed, so emit it when confirmed and anchor it with ctx.bar_time_ago(n).

Glyphs: arrow_up, arrow_down, circle, triangle_up, triangle_down, cross, pin, diamond, star, flag, square, triangle, label, warning, x_circle.

def on_bar(self, ctx):
    prev = ctx.close[1]
    if prev is None:
        return
    if ctx.close > prev and float(ctx.close) > self.sma.value:
        ctx.plot.shape("buy", price=float(ctx.low),
                       shape="arrow_up", color="#22c55e",
                       position="below_bar")

ctx.plot.rectangle(...) — time × price box

A box spanning a time and/or price region. Set the corners you need; call it every bar with the same values and it stays static, vary the colours and it animates.

ctx.plot.rectangle(
    name, *,
    time_start=None,         # epoch ms
    time_end=None,           # epoch ms
    price_top=None,
    price_bottom=None,
    fill_color=None,
    border_color=None,
    pane="main",
    fill_opacity=0.1,
    border_width=1.0,
    line_style="solid",      # "solid" | "dashed" | "dotted"
    label=None,
    group_id=None,
    evolve=False,            # True when price_top/price_bottom change bar to bar
    appears_at_ms=None,      # in playback, reveal the box only from this time
)

Set evolve=True for a box whose edges move with price (a growing range, a trailing channel); leave it off for a box that is drawn once and stays.

# An RSI overbought/oversold zone in the sub-pane.
def on_bar(self, ctx):
    ctx.plot.series("rsi", value=self.rsi.value, pane="sub")
    ctx.plot.rectangle("ob", price_top=70, price_bottom=30,
                       fill_color="#2196f3", fill_opacity=0.06, pane="sub")

ctx.plot.polyline(...) — connected segments

A static multi-point line. points is a list of (time_ms, price) pairs (time as epoch milliseconds, same as ctx.bar_time); last-write-wins per name (it's a whole-geometry drawing, not per-bar). extend_left / extend_right apply only when points has exactly two entries (the classic trend-line "ray").

ctx.plot.polyline(
    name, *,
    points,                  # list of (time, price) tuples
    color="#...",
    line_width=1.0,
    line_style="solid",      # "solid" | "dashed" | "dotted"
    extend_left=False,
    extend_right=False,
    pane="main",
    label=None,
    group_id=None,
    appears_at_ms=None,      # in playback, reveal the line only from this time
)

Use appears_at_ms for lines anchored to a past pivot: the geometry starts earlier, but the line shouldn't be visible during replay before the bar that confirmed it.

def init(self, ctx):
    self._pivots = []
def on_bar(self, ctx):
    self._pivots.append((ctx.bar_time, float(ctx.close)))
    ctx.plot.polyline("zigzag", points=self._pivots[-20:], color="#9c27b0")

⚠️ Each segment is its own primitive — keep polylines short (tens of points, not thousands). For a dense curve use series instead.

ctx.plot.text(...) — anchored label

A single text annotation pinned to a (time, price). Last-write-wins per name; use a fresh name per annotation.

ctx.plot.text(
    name, *,
    time,                    # epoch ms
    price,
    text,
    color="#...",
    background=None,         # rgba for a chip background
    size=10.0,
    anchor="center",         # "center" | "above" | "below" | "left" | "right"
                             #  | "top-left" | "top-right" | "bottom-left"
                             #  | "bottom-right"
    bold=False,
    pane="main",
    label=None,
    group_id=None,
)

ctx.plot.bar_color(...) — recolour candles

Recolour the candle on selected bars. Sparse: call only on the bars you want recoloured.

ctx.plot.bar_color(
    name, *,
    color,                   # the candle colour (None = leave default)
    scope="body",            # "body" | "border" | "wick" | "all"
    pane="main",
    label=None,
    group_id=None,
)
def on_bar(self, ctx):
    v = self.ema.value
    if v is not None and ctx.close > v:
        ctx.plot.bar_color("trend", color="#22c55e", scope="all")

ctx.plot.background_band(...) — vertical time band

A shaded vertical region between two times. Each call appends one band, so multiple bands per name are normal (session ribbons, regime highlights). Control opacity via an rgba colour.

ctx.plot.background_band(
    name, *,
    time_start,              # epoch ms
    time_end,                # epoch ms
    color,                   # e.g. "rgba(34, 197, 94, 0.25)"
    pane="main",
    label=None,
    group_id=None,
)
def init(self, ctx):
    self.sess = ctx.session_window(anchor="london")
    self._start = None
def on_bar(self, ctx):
    if self.sess.just_started:
        self._start = ctx.bar_time
    elif self._start is not None and not self.sess.in_session:
        ctx.plot.background_band(f"london_{self._start}",
                                 time_start=self._start, time_end=ctx.bar_time,
                                 color="rgba(229,139,57,0.10)")
        self._start = None

ctx.plot.volume_profile(...) — volume by price

A horizontal volume distribution, fed by a ctx.histogram accumulator. anchor is the (start_ms, end_ms) time span the profile covers.

ctx.plot.volume_profile(
    name, *,
    histogram,               # the ctx.histogram(...) accumulator
    anchor,                  # (start_ms, end_ms) tuple
    mode="session",          # "session" | "fixed-range" | "visible-range"
    label=None,
    group_id=None,
    buy_color="#...",
    sell_color="#...",
)

See the worked example under ctx.histogram — feed the accumulator every bar with .add(...), then call volume_profile at the session boundary. The histogram is snapshotted at call time, so later .add(...) calls don't bleed into an already-drawn profile.

Documentation