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. aseriesand afillboth called"x"— raisesPlotCollisionError. 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_idtoggle 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, passvalue=Noneon 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 everyseries/fillcall. 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/filldescribe a value at every bar.shape/bar_colorare 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
seriesinstead.
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.