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