Skip to main content
ATK Pine Script®

build_visuals and the ctx Render Stage

Keep build_visuals render-only — what belongs here, what does not, and the render-only starter pattern with frame.attrs config.

Keep build_visuals(...) Render-Only#

What belongs here#

Reading the last rows, picking anchors, unpacking static attrs config, and emitting ctx.* or ctx.atk.* intents.

What does not belong here#

TA calls, resampling, groupby logic, frame mutation, and any preparation that could have been done deterministically in the frame builder.

Practical test: if you could delete build_visuals and still preserve all computed columns and strategy intent, your stage split is probably correct. If deleting it removes calculations, those calculations belong earlier in build_indicator_frame.

Render-Only Starter Pattern#

def build_indicator_frame(df, params=None):
    frame = df.copy().reset_index(drop=True)
    frame["ema_fast"] = ta.ema(frame["close"], 20)
    frame["buy_signal"] = ta.crossover(frame["close"], frame["ema_fast"]).fillna(False)
    frame.attrs["label_style"] = {"color": "#2962ff", "style": "label_down"}
    return frame


def build_visuals(frame, params=None, ctx=None):
    if frame is None or frame.empty or ctx is None:
        return None
    last = frame.iloc[-1]
    style = dict(frame.attrs.get("label_style") or {})
    if bool(last.get("buy_signal", False)):
        return ctx.label.new(
            key="last_buy_label",
            x=int(last["index"]),
            y=float(last["low"]),
            text="BUY",
            **style,
        )
    return None

frame.attrs for Static Config#

The safe rule is simple: colors, headers, labels, and other stable config can live in frame.attrs. Coordinates, tail-derived anchors, and current slice geometry should be derived from the frame passed into build_visuals. Otherwise historic or sliced rendering can reuse stale payloads.