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 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 (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 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:

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 ​