Labels, shapes and backgrounds
Text labels at an exact (time, price) point are supported natively — this is the Pine label.new() equivalent. If a script has ever dropped its callouts, tags, or shaded zones, it is not because the platform lacks them: it is because the drawing keys were never returned. TessTrade has near-full TradingView / Pine drawing parity — labels, shapes, background shading, boxes, lines, polylines, fills and tables — and every one of them is reachable two ways:
- Imperative — inside
on_bar(ctx), callctx.label(...),ctx.plotshape(...),ctx.bgcolor(...),ctx.box(...), one call per bar. This is the Pine-like surface:ctx.labelislabel.new. - Declarative — from the
df=branch ofmain(), return the drawing as a list under a top-level key:labels,shapes,bgcolor,boxes,lines,polylines,fills. Same primitives, expressed as data.
This page is the drawing contract. It assumes the canonical file structure from Anatomy of a custom indicator — params and colors first, one main() dispatcher — and it complements Plots and series, which covers the numeric lines. A plot is a value per candle; an annotation is a mark at a point. You need both.
Where this is validated. Drawing payloads travel to the frontend inside the declaration and are coerced and rendered there. Python returns them as-is and does no shape-checking — exactly like
series. That is why a mistyped key (labelsreturned underseries, an#RRGGBBAAcolour, abgcolorarray the wrong length) is silently dropped rather than raising: the renderer discards what it cannot parse. Return the keys at the top level of the dict (or underdeclaration), never nested insideseries.
Text labels at a price point
A label is a text pill anchored at a time (a candle's timestamp) and a price. It renders wherever those two coordinates land — not at a fixed screen position, not at an array index.
Imperative — ctx.label()
Inside on_bar(ctx), ctx.label(text, ...) drops a label on the current bar. The anchor time is this bar's time; the price defaults to the bar's close, or set it with y=.
# ── Indicator: Swing tags ─────────────────────────────────────────────
# COLORS FIRST — one place to retheme the callouts.
COLOR_UP_BG = "#22D97A" # green pill for a fresh high
COLOR_DN_BG = "#FF5C70" # red pill for a fresh low
COLOR_TEXT = "#0B0F14" # near-black text on the coloured pill
def on_bar(ctx):
# The base price line so the labels have something to sit against.
ctx.plot("close", ctx.close[0], color="#64748B", width=1)
prev = ctx.close[1]
if prev is None:
return
# A label at THIS bar's high, pointer coming down onto the anchor.
if ctx.close[0] > prev:
ctx.label(
"UP",
y=ctx.high[0], # anchor price (defaults to close if omitted)
color=COLOR_UP_BG, # BACKGROUND pill colour (Pine label.new(color=))
textcolor=COLOR_TEXT, # the glyph colour
size="small", # tiny | small | normal | large
style="label_down", # body above the anchor, pointer down
)
# A label at the low, pointer coming up from below.
elif ctx.close[0] < prev:
ctx.label(
"DN",
y=ctx.low[0],
color=COLOR_DN_BG,
textcolor=COLOR_TEXT,
size="small",
style="label_up", # body below the anchor, pointer up
)color is the background pill colour (it goes on the wire as bgColor, the name the renderer expects); textcolor is the glyph colour. style picks where the body sits relative to the anchor and which way the pointer faces.
Style tokens (Pine label.style_*):
style | Body position | Pointer |
|---|---|---|
label_down | Above the anchor | Points down onto it (default) |
label_up | Below the anchor | Points up onto it |
label_left | Left of the anchor | Points right |
label_right | Right of the anchor | Points left |
label_center | Centred on the anchor | No pointer |
none | Centred, no pill | No pointer |
Omit style and it defaults to label_down, matching the renderer.
Mutating a label after creation
ctx.label(...) returns a handle, so a streaming script can rewrite a label in place — the Pine label.set_text(id, ...) idiom. Stash it in ctx.var and mutate it on later bars:
def on_bar(ctx):
# Create the running-count tag once, on the first bar.
if ctx.barstate.isfirst:
ctx.var("tag", ctx.label("bars: 0", y=ctx.close[0], color="#22D3EE"))
# Rewrite that same label every bar — Pine label.set_text.
handle = ctx.var.tag
handle.set_text(f"bars: {ctx.bar_index}")
handle.set_y(ctx.close[0])The handle exposes set_text(text), set_color(bg), set_textcolor(color), set_size(size), set_y(price), set_x(bar), plus the readers get_text() and get_y(). Past the cap of 500 labels (Pine max_labels_count=500) ctx.label returns a no-op handle, so chained setters stay safe.
Declarative — the labels list
An indicator built as main(df=None, sdk=None, params={}) returns the same labels as data: a list under the top-level labels key, each item anchored by a candle's time (unix milliseconds, straight from df["time"]) and a price.
# ── Indicator: Spike tags (declarative) ───────────────────────────────
COLOR_SPIKE_BG = "#22D3EE"
COLOR_SPIKE_TX = "#0B0F14"
DEFAULT_THRESHOLD_PCT = 2.0
DECLARATION = {
"type": "indicator",
"inputs": [
{"name": "threshold_pct", "label": "Spike %", "type": "float",
"default": DEFAULT_THRESHOLD_PCT, "min": 0.1, "max": 20.0, "step": 0.1},
],
"plots": [
{"name": "close", "source": "close", "type": "line",
"color": "#64748B", "width": 1},
],
"pane": "overlay",
}
def _build_chart(df, params):
pct = float((params or {}).get("threshold_pct", DEFAULT_THRESHOLD_PCT)) / 100.0
times = df["time"].tolist() # unix ms per candle — the label anchor X
closes = df["close"].tolist()
labels = []
bg = [None] * len(closes) # per-bar column shade, one entry per candle
for i in range(1, len(closes)):
prev, curr = closes[i - 1], closes[i]
if prev is None or curr is None:
continue
if curr > prev * (1.0 + pct): # a jump bigger than the threshold
labels.append({
"point": {"time": int(times[i]), "price": curr},
"text": "SPIKE",
"bgColor": COLOR_SPIKE_BG, # background pill (declarative name)
"textColor": COLOR_SPIKE_TX,
"size": "small",
"style": "label_down",
})
bg[i] = "#22D97A22" # tint that whole column faint green
return {
**DECLARATION,
"series": {"close": closes},
"labels": labels, # top-level drawing key
"bgcolor": bg, # top-level per-bar background
}
def main(df=None, sdk=None, params={}):
params = params or {}
if df is not None:
return _build_chart(df, params)
return DECLARATIONAnchor by time, not by index. The declarative label's X coordinate is
point.timein unix milliseconds — the candle's owndf["time"]value, not its position in the array. Using the index (0, 1, 2, …) puts every label near the chart's epoch origin, off-screen. Always readdf["time"].
The declarative field names mirror the wire format: bgColor (background pill), textColor, size, and style (the same tokens as the table above). Both point.time and point.price are required for the label to land.
Shapes and markers
A shape marks a bar with a glyph — a triangle, arrow, cross, circle. It is the Pine plotshape / plotchar surface, and it is anchored to the bar, not to a free price.
Imperative — ctx.plotshape() / ctx.plotchar()
COLOR_BULL = "#22D97A"
def on_bar(ctx):
ctx.plot("close", ctx.close[0], color="#64748B", width=1)
prev = ctx.close[1]
if prev is None:
return
# A green triangle under any bar that closes above the prior close.
if ctx.close[0] > prev:
ctx.plotshape(
ctx.low[0], # a price (or pass True to mark at the close)
shape="triangleup", # circle | triangleup | triangledown | arrowup | ...
location="belowbar", # abovebar | belowbar | absolute
color=COLOR_BULL,
size="tiny",
text="buy", # optional caption
)
# plotchar prints a single glyph instead of a built-in shape.
if ctx.close[0] < prev:
ctx.plotchar(ctx.high[0], char="✖", location="abovebar", color="#FF5C70")plotshape's first argument is the marker's value: pass a price to pin the glyph there, or pass a bool — True marks the bar at its close, False draws nothing. plotchar is the same call whose marker is one character.
Declarative — the shapes list
Returned from main(df=), shapes is a list of marker declarations ({"shape", "color", "size", "text", "char", ...}) with a per-candle value series, exactly like the imperative buffer serialises. Reach for the imperative ctx.plotshape when you can — it wires the value series for you.
Column backgrounds — bgcolor
bgcolor shades the whole vertical column behind a candle — the Pine bgcolor() analogue. (It is distinct from paintbar / barcolor, which recolours the candle body only.)
Imperative, per bar:
def on_bar(ctx):
ctx.plot("close", ctx.close[0], color="#64748B", width=1)
prev = ctx.close[1]
if prev is not None and ctx.close[0] > prev:
ctx.bgcolor("#22D97A22") # faint green column on an up-close bar
elif prev is not None and ctx.close[0] < prev:
ctx.bgcolor("#FF5C7022") # faint red column on a down-close barDeclarative, as an array: return bgcolor as a list the same length as the series, one "#RRGGBB" (or None for no shade) per candle — as shown in the spike example above. Accepted aliases for the key: background, backgroundColors, bg_color.
The array must be exactly
len(df).bgcoloraligns to candles by index, so a short or long array misaligns every shade. Build it as[None] * len(closes)and fill the bars you want.
Per-bar line colours — colorSeries
To colour a plot bar-by-bar (a histogram that flips green/red, a heat-mapped line), set colorSeries on that plot in the plots array — a per-bar array of "#RRGGBB" the same length as the plot's series (Pine plot(color=series)). This is a property of a line, not a standalone annotation; the full recipe, including the colorExpression / colorPositive shortcuts, lives in Plots and series (alias color_series).
Boxes, lines, polylines and fills
The heavier geometric primitives follow the same two-way pattern. Imperatively each returns a handle you can move or trim later — the Pine FVG / order-block idiom of opening a box that extends right, then trimming it when the zone is mitigated:
def on_bar(ctx):
ctx.plot("close", ctx.close[0], color="#64748B", width=1)
if ctx.barstate.isfirst:
# box(left_bar, top_price, right_bar, bottom_price, ...)
ctx.box(0, ctx.high[0], 20, ctx.low[0],
bgcolor="#22D3EE", bgcolor_opacity=0.15,
border_color="#22D3EE", text="zone")
# line(x1_bar, y1_price, x2_bar, y2_price, ...)
ctx.line(0, ctx.close[0], 20, ctx.close[0],
color="#F59E0B", width=2, style="dashed")
# polyline([(bar, price), ...]) — a multi-vertex curve / wedge
ctx.polyline(
[(0, ctx.low[0]), (10, ctx.high[0]), (20, ctx.low[0])],
color="#A78BFA", width=2,
)Declaratively, return boxes, lines, polylines, fills, or linefills as lists of dicts from main(df=). Each caps out (500 boxes / 500 lines / 200 polylines / 200 linefills) and silently ignores anything past the cap.
The drawing primitives at a glance
| Primitive | Imperative (on_bar) | Declarative key | Pine analogue | Anchored by |
|---|---|---|---|---|
| Text label | ctx.label(...) | labels | label.new() | (time, price) |
| Shape / marker | ctx.plotshape() / ctx.plotchar() | shapes | plotshape() / plotchar() | current bar (bool→close, or a price) |
| Column background | ctx.bgcolor() | bgcolor (per-bar array) | bgcolor() | one entry per candle |
| Per-bar plot colour | ctx.plot(..., color=) varying | colorSeries on a plot | plot(color=series) | one entry per candle |
| Box / zone | ctx.box(...) | boxes | box.new() | (bar, price) corners |
| Floating line | ctx.line(...) | lines | line.new() | (bar, price) endpoints |
| Polyline | ctx.polyline(...) | polylines | polyline.new() | list of (bar, price) |
| Band fill (plots) | ctx.fill(...) | fills | fill() | two plot sources |
| Band fill (lines) | ctx.linefill(...) | linefills | linefill.new() | two line handles |
| Dashboard table | ctx.table(...) | tables | table.new() | screen corner |
Using annotations in a strategy
A strategy is the three-branch dispatcher main(df=None, sdk=None, params={}) (see SMA Crossover). The df= branch draws the chart — so put the declarative annotations there, right next to series. The sdk= branch trades, bar by bar. The two never fight: one paints, one orders.
COLOR_ENTRY_BG = "#22D97A"
COLOR_ENTRY_TX = "#0B0F14"
DEFAULT_FAST = 9
DEFAULT_SLOW = 21
DECLARATION = {
"type": "strategy",
"inputs": [
{"name": "fast_period", "type": "int", "default": DEFAULT_FAST,
"min": 1, "max": 100, "step": 1},
{"name": "slow_period", "type": "int", "default": DEFAULT_SLOW,
"min": 2, "max": 200, "step": 1},
],
"plots": [
{"name": "ma_fast", "source": "ma_fast", "type": "line",
"color": "#22D3EE", "width": 2},
{"name": "ma_slow", "source": "ma_slow", "type": "line",
"color": "#F59E0B", "width": 2},
],
"pane": "overlay",
"scale": "none",
}
def _build_chart(df, params):
fast = int((params or {}).get("fast_period", DEFAULT_FAST))
slow = int((params or {}).get("slow_period", DEFAULT_SLOW))
times = df["time"].tolist()
ma_fast = Indicator.sma(df, fast) # native kernel, no import
ma_slow = Indicator.sma(df, slow)
# Tag each up-cross with a label at the fast MA — the same math the
# sdk= branch trades on, so the mark sits exactly where the order fired.
labels = []
for i in range(1, len(times)):
a0, a1 = ma_fast[i - 1], ma_fast[i]
b0, b1 = ma_slow[i - 1], ma_slow[i]
if None in (a0, a1, b0, b1):
continue
if a0 <= b0 and a1 > b1: # fast crossed above slow
labels.append({
"point": {"time": int(times[i]), "price": a1},
"text": "LONG",
"bgColor": COLOR_ENTRY_BG,
"textColor": COLOR_ENTRY_TX,
"size": "small",
"style": "label_up",
})
return {
**DECLARATION,
"series": {"ma_fast": ma_fast, "ma_slow": ma_slow},
"labels": labels,
}
def on_bar_strategy(sdk, params):
fast = int(params.get("fast_period", DEFAULT_FAST))
slow = int(params.get("slow_period", DEFAULT_SLOW))
if len(sdk.candles) < slow + 1:
return
f = Indicator.sma(sdk.candles[-(fast + 1):], fast)[-1]
s = Indicator.sma(sdk.candles[-(slow + 1):], slow)[-1]
if f is None or s is None:
return
if sdk.position == 0 and f > s:
sdk.buy(action="buy_to_open", qty=1, order_type="market")
elif sdk.position > 0 and f < s:
sdk.sell(action="sell_to_close", qty=abs(sdk.position), order_type="market")
def main(df=None, sdk=None, params={}):
params = params or {}
if sdk is not None:
return on_bar_strategy(sdk, params)
if df is not None:
return _build_chart(df, params)
return DECLARATIONDraw where you trade. Build the label list from the same series the orders key off (here
Indicator.sma, the chart's own native kernel — seeIndicator). Then the callout on the chart lands on the exact bar the fill happened, with no drift between what you see and what was traded.
Caps and limits
| Primitive | Cap per script | Past the cap |
|---|---|---|
labels | 500 (max_labels_count) | ctx.label returns a no-op handle |
boxes | 500 | dropped |
lines | 500 | dropped |
polylines | 200 | dropped |
linefills | 200 | dropped |
Plot and line colours are 6-digit hex (#RRGGBB) — an 8-digit #RRGGBBAA is rejected on a plot, so keep line/histogram colours solid. bgcolor is the deliberate exception: it takes a translucent #RRGGBBAA so the column shade sits faintly behind the candles (a solid #RRGGBB would black them out) — that is why the examples above use values like #22D97A22. A box/fill carries its translucency in its own opacity field (e.g. bgcolor_opacity) rather than in the hex.
Common errors
- Labels / shapes vanish entirely: they were returned nested inside
series, or under an unknown key. Returnlabels,shapes,bgcolor, … at the top level of the dict (or underdeclaration) — never insideseries. - Every label is off-screen near the origin: the declarative anchor used the array index instead of
point.time. The X coordinate is unix milliseconds — readdf["time"]. - A label never lands:
pointis missingtimeorprice. Both are required. - Background shows on the wrong bars: the
bgcolorarray length differs fromlen(df). It aligns by index — build it[None] * len(closes)and fill. - The pill is invisible but the text shows (or vice-versa):
colorsets the background pill;textcolorsets the glyph. Set both. In the declarative form the names arebgColorandtextColor. - A plot colour is ignored: an 8-digit
#RRGGBBAAis rejected on a plot — keep line/histogram colours 6-digit#RRGGBB. (bgcoloris the exception and does take a translucent#RRGGBBAA; a box/fill uses its own opacity field.) - Nothing drawn past a few hundred marks: you hit the per-primitive cap (500 labels / boxes / lines, 200 polylines / linefills). Thin out the marks.
Next steps
- Anatomy of a custom indicator — the canonical file structure.
- Plots and
series— the numeric-line contract andcolorSeries. - Panes: overlay vs new pane — where each mark lands.
Indicator— the native layer — the injected math brain (no import).