Custom Canvas
Free-form 2D canvas overlays via custom_layer(...). For visuals no predefined plot type covers: TF boundaries, R-multiple labels, candle-style overlays, custom histograms.
custom_layer
custom_layer(name, z="normal")
Build a free-form 2D canvas overlay on the chart.
Returns a CanvasLayer builder. Chain .style(...).begin().move_to(...).line_to(...).stroke() (etc.) and finish with .emit(). Each call to emit() registers the layer with the per-calculation buffer; the indicator wrapper picks it up automatically.
Two coordinate spaces:
- chart: tuples
(ts_ms, price)get resolved throughtimeToX / priceToYso pan/zoom respects them. - pixel: raw CSS pixels for HUDs that stay glued to the viewport.
Viewport-edge sentinels (Y_MIN, Y_MAX, X_MIN, X_MAX) save you from knowing the price scale - drop them into the y component to span the visible price range.
Use this when none of the predefined plot types fit: candle-style overlays, custom histograms with arbitrary geometry, text annotations, vertical TF boundaries, R-multiple labels above trades, anything 2D.
Parameters
name(str, defaultrequired): Unique key for the layer; same name in two indicators will collide.z(str, default"normal"):bottom,normal, ortop. Controls draw order vs. price series.
Returns
CanvasLayer
Example
from quant_charts import indicator, custom_layer, Y_MIN, Y_MAX
@indicator("TF Boundaries", overlay=True, data_mode="ohlc")
class TfBoundaries:
minutes = input.int(1, "Boundary Minutes", min=1, max=60)
def calculate(self, df):
ts = np.asarray(df["timestamp"], dtype=np.int64)
bucket = self.minutes * 60_000
floored = (ts // bucket) * bucket
boundaries = floored[np.r_[True, floored[1:] != floored[:-1]]]
layer = custom_layer("tf-boundaries", z="top")
layer.style(stroke="#ffffff44", line_width=1.0, dash=[4, 3])
for ms in boundaries:
layer.begin().move_to((int(ms), Y_MIN)).line_to((int(ms), Y_MAX)).stroke()
layer.emit()
Notes
emit()MUST be called or the layer is dropped silently.- Calling
emit()twice on the same layer registers it twice - almost always a bug. - Hard cap of 50,000 ops per layer at the renderer; unknown ops are ignored.
CanvasLayer.style
CanvasLayer.style(*, fill?, stroke?, line_width?, alpha?, font?, text_align?, text_baseline?, dash?)
Set sticky drawing style. Call before path ops or between strokes.
Style is sticky: every following stroke/fill/text uses the most recent values. Calling style() BEFORE the first path op folds into the layer defaults; after that, each call emits an explicit setStyle op so style changes mid-sequence work.
alpha is clamped to 0.0-1.0. dash is a [on, off] pixel array per HTML5 canvas convention.
Parameters
fill(str, defaultNone): Fill color (hex). Used byfill()andtext()(whenstroke=False).stroke(str, defaultNone): Stroke color (hex). Used bystroke()andtext(stroke=True).line_width(float, defaultNone): Line thickness in CSS pixels.alpha(float, defaultNone): Global alpha 0.0-1.0. Compounds with hex alpha.font(str, defaultNone): CSS font shorthand, e.g."12px Inter".text_align(str, defaultNone):left,center,right,start,end.text_baseline(str, defaultNone):top,middle,bottom,alphabetic,hanging.dash(list[float], defaultNone): Dash pattern as[on_px, off_px, ...].[]= solid.
Returns
CanvasLayer (chainable)
Example
layer.style(stroke="#7aa2f7", line_width=2.0, dash=[4, 4])
layer.begin().move_to((ts0, p0)).line_to((ts1, p1)).stroke()
layer.style(stroke="#e0af68") # mid-sequence change emits setStyle op
layer.begin().move_to((ts1, p1)).line_to((ts2, p2)).stroke()
CanvasLayer.move_to / line_to
CanvasLayer.move_to / line_to(point, *, space="chart")
Move pen / draw line to a point.
point is (ts_ms, price) in chart space, or (x_px, y_px) in pixel space. Strings select viewport sentinels: Y_MIN/Y_MAX on the y component span the visible price range; X_MIN/X_MAX on the x component span the visible time range.
Parameters
point(tuple, defaultrequired):(x, y)tuple. x is ms or px; y is price or px; either component accepts a sentinel string.space(str, default"chart"):chartorpixel.
Returns
CanvasLayer (chainable)
Example
# vertical line from bottom to top of viewport at ts_ms
layer.begin()
layer.move_to((ts_ms, Y_MIN))
layer.line_to((ts_ms, Y_MAX))
layer.stroke()
CanvasLayer.rect
CanvasLayer.rect(top_left, w_px, h_px, *, space="chart")
Draw an axis-aligned rectangle.
Width and height are CSS pixels even when top_left is in chart space - keeps HUD-like badges the same size at any zoom level.
Parameters
top_left(tuple, defaultrequired):(x, y)for the top-left corner.w_px(float, defaultrequired): Width in CSS pixels.h_px(float, defaultrequired): Height in CSS pixels.space(str, default"chart"):chartorpixel.
Returns
CanvasLayer (chainable)
Example
layer.style(fill="#08080bcc", stroke="#1f1f26")
layer.rect((10, 10), 120, 24, space="pixel") # 120x24 px corner badge
layer.fill()
layer.style(fill="#f4f4f5", font="12px Inter")
layer.text((16, 26), "TF: tick", space="pixel")
CanvasLayer.arc
CanvasLayer.arc(center, radius_px, start_rad=0, end_rad=2*pi, *, space="chart")
Draw an arc / circle.
Radius is always CSS pixels. Defaults trace a full circle; pass partial angles for arcs.
Parameters
center(tuple, defaultrequired):(x, y)center point.radius_px(float, defaultrequired): Radius in CSS pixels.start_rad(float, default0.0): Start angle in radians.end_rad(float, default2*pi): End angle in radians.space(str, default"chart"):chartorpixel.
Returns
CanvasLayer (chainable)
CanvasLayer.text
CanvasLayer.text(point, s, *, space="chart", stroke=False)
Draw a text string.
Uses font, text_align, text_baseline from the current style. Set stroke=True to outline the text instead of filling.
Parameters
point(tuple, defaultrequired): Anchor point.s(str, defaultrequired): String to draw.space(str, default"chart"):chartorpixel.stroke(bool, defaultFalse): If True, outline-only (no fill).
Returns
CanvasLayer (chainable)
CanvasLayer.begin / close / stroke / fill
CanvasLayer.begin / close / stroke / fill()
Path-control ops mirroring the HTML5 canvas API.
begin() starts a new path; close() connects the last point back to the first; stroke() paints the path with the current stroke style; fill() fills it. All chainable.
Returns
CanvasLayer (chainable)
Example
# triangle entry marker
layer.style(fill="#22c55e88", stroke="#22c55e", line_width=1)
layer.begin()
layer.move_to((entry_ts, entry_price - 0.5))
layer.line_to((entry_ts + 60_000, entry_price))
layer.line_to((entry_ts, entry_price + 0.5))
layer.close()
layer.fill()
layer.stroke()
CanvasLayer.emit
CanvasLayer.emit()
Register the layer with the per-calculation buffer.
MUST be called at the end of building a layer or it gets dropped. Returns the spec dict (rarely useful) - the side effect is the registration.
Returns
dict (the spec; usually ignored)
Y_MIN / Y_MAX / X_MIN / X_MAX
Viewport-edge sentinels.
Pass these as the y or x component of a point to mean "viewport top/bottom edge" or "viewport left/right edge" without knowing the price scale or visible time range. The renderer resolves them at draw time, so they pan and zoom correctly.
Valid placements:
Y_MIN/Y_MAX-> y component (chart-space points)X_MIN/X_MAX-> x component
Mixing axes (e.g. Y_MIN in the x slot) raises ValueError.
Example
from quant_charts import Y_MIN, Y_MAX
# vertical line that always spans the full visible price range
layer.move_to((ts, Y_MIN)).line_to((ts, Y_MAX)).stroke()