Skip to content
Single .md

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), call ctx.label(...), ctx.plotshape(...), ctx.bgcolor(...), ctx.box(...), one call per bar. This is the Pine-like surface: ctx.label is label.new.
  • Declarative — from the df= branch of main(), 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 indicatorparams 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 (labels returned under series, an #RRGGBBAA colour, a bgcolor array 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 under declaration), never nested inside series.

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=.

python
# ── 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_*):

styleBody positionPointer
label_downAbove the anchorPoints down onto it (default)
label_upBelow the anchorPoints up onto it
label_leftLeft of the anchorPoints right
label_rightRight of the anchorPoints left
label_centerCentred on the anchorNo pointer
noneCentred, no pillNo 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:

python
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.

python
# ── 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 DECLARATION

Anchor by time, not by index. The declarative label's X coordinate is point.time in unix milliseconds — the candle's own df["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 read df["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()

python
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 boolTrue 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:

python
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 bar

Declarative, 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). bgcolor aligns 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:

python
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

PrimitiveImperative (on_bar)Declarative keyPine analogueAnchored by
Text labelctx.label(...)labelslabel.new()(time, price)
Shape / markerctx.plotshape() / ctx.plotchar()shapesplotshape() / plotchar()current bar (bool→close, or a price)
Column backgroundctx.bgcolor()bgcolor (per-bar array)bgcolor()one entry per candle
Per-bar plot colourctx.plot(..., color=) varyingcolorSeries on a plotplot(color=series)one entry per candle
Box / zonectx.box(...)boxesbox.new()(bar, price) corners
Floating linectx.line(...)linesline.new()(bar, price) endpoints
Polylinectx.polyline(...)polylinespolyline.new()list of (bar, price)
Band fill (plots)ctx.fill(...)fillsfill()two plot sources
Band fill (lines)ctx.linefill(...)linefillslinefill.new()two line handles
Dashboard tablectx.table(...)tablestable.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.

python
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 DECLARATION

Draw where you trade. Build the label list from the same series the orders key off (here Indicator.sma, the chart's own native kernel — see Indicator). 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

PrimitiveCap per scriptPast the cap
labels500 (max_labels_count)ctx.label returns a no-op handle
boxes500dropped
lines500dropped
polylines200dropped
linefills200dropped

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. Return labels, shapes, bgcolor, … at the top level of the dict (or under declaration) — never inside series.
  • 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 — read df["time"].
  • A label never lands: point is missing time or price. Both are required.
  • Background shows on the wrong bars: the bgcolor array length differs from len(df). It aligns by index — build it [None] * len(closes) and fill.
  • The pill is invisible but the text shows (or vice-versa): color sets the background pill; textcolor sets the glyph. Set both. In the declarative form the names are bgColor and textColor.
  • A plot colour is ignored: an 8-digit #RRGGBBAA is rejected on a plot — keep line/histogram colours 6-digit #RRGGBB. (bgcolor is 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