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 through timeToX / priceToY so 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, default required): Unique key for the layer; same name in two indicators will collide.
  • z (str, default "normal"): bottom, normal, or top. 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, default None): Fill color (hex). Used by fill() and text() (when stroke=False).
  • stroke (str, default None): Stroke color (hex). Used by stroke() and text(stroke=True).
  • line_width (float, default None): Line thickness in CSS pixels.
  • alpha (float, default None): Global alpha 0.0-1.0. Compounds with hex alpha.
  • font (str, default None): CSS font shorthand, e.g. "12px Inter".
  • text_align (str, default None): left, center, right, start, end.
  • text_baseline (str, default None): top, middle, bottom, alphabetic, hanging.
  • dash (list[float], default None): 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, default required): (x, y) tuple. x is ms or px; y is price or px; either component accepts a sentinel string.
  • space (str, default "chart"): chart or pixel.

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, default required): (x, y) for the top-left corner.
  • w_px (float, default required): Width in CSS pixels.
  • h_px (float, default required): Height in CSS pixels.
  • space (str, default "chart"): chart or pixel.

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, default required): (x, y) center point.
  • radius_px (float, default required): Radius in CSS pixels.
  • start_rad (float, default 0.0): Start angle in radians.
  • end_rad (float, default 2*pi): End angle in radians.
  • space (str, default "chart"): chart or pixel.

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, default required): Anchor point.
  • s (str, default required): String to draw.
  • space (str, default "chart"): chart or pixel.
  • stroke (bool, default False): 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()