03. Custom canvas drawing

Walks through the custom_layer() API. You will end with a session_marks.py indicator that draws a vertical dashed line at every N-minute boundary and a small text label at each one. This is the path to use when no built-in plot type fits: per-bar shapes, free-form rectangles, regime overlays.

The custom canvas system is a 2D drawing surface that runs after the chart paints. You build a layer, set a style, emit drawing operations (move_to, line_to, rect, text, ...), and call .emit() once. Coordinates are chart-space (timestamp_ms, price) by default, with viewport sentinels (Y_MIN, Y_MAX, X_MIN, X_MAX) that auto-resolve to the visible edges.

Step 1. Coordinate spaces and sentinels

Drawing in chart space vs pixel space, and how the four viewport sentinels work.

custom_layer operations take tuples (x, y). By default x is a timestamp in ms and y is a price.

When you want to anchor at the visible edges of the chart instead of a fixed price/time, use the sentinels: Y_MIN (bottom of visible price range), Y_MAX (top), X_MIN (left edge), X_MAX (right edge). They re-resolve every time the user pans or zooms, so a line drawn from (ts, Y_MIN) to (ts, Y_MAX) always spans the full visible price range.

Notes

  • Sentinels are simple sentinel constants, not functions. Just import them: from quant_charts import Y_MIN, Y_MAX, X_MIN, X_MAX.
  • For pixel-space drawing (HUD-style overlays that ignore zoom), the layer supports a coord_space option. Most indicators stay in chart space.

Step 2. Layer lifecycle

Build, style, draw, emit. Each custom_layer() call returns a fresh CanvasLayer; you can have many per indicator (each with its own z-order).

Example

from quant_charts import custom_layer, Y_MIN, Y_MAX

layer = custom_layer("session-boundaries", z="top")
layer.style(stroke="#FFFFFF", line_width=1.0, alpha=0.3, dash=[4.0, 3.0])
# ... drawing ops ...
layer.emit()

Notes

  • The first arg is a unique layer name within the indicator. Reusing the name in a re-run replaces the previous layer.
  • z="top" draws above candles. z="bottom" draws under them.
  • .style() sets defaults the layer reuses for every shape. Call again to switch mid-stream.

Step 3. Drawing primitives

The set of operations a layer supports.

Path-style: .begin() opens a path, .move_to((x, y)) jumps without drawing, .line_to((x, y)) extends, .close() closes the path back to the start, .stroke() strokes it with the current style, .fill() fills it.

Shape-style: .rect((x1, y1), (x2, y2)) for a rectangle, .arc((cx, cy), radius, start_rad, end_rad) for an arc.

Text: .text((x, y), "label", font_size=11, color="#fff", align="center").

Example

# Vertical line
layer.begin().move_to((ts, Y_MIN)).line_to((ts, Y_MAX)).stroke()

# Filled rectangle (highlight a session)
layer.style(fill="#7aa2f720")
layer.rect((session_start, Y_MIN), (session_end, Y_MAX)).fill()

# Text label at the top of the chart
layer.text((ts, Y_MAX), "RTH", font_size=10, color="#a1a1aa", align="center")

Step 4. The full session_marks indicator

Vertical lines every N minutes, with the time as a label. Save as session_marks.py under indicators/.

Example

import numpy as np
from quant_charts import indicator, input, custom_layer, Y_MIN, Y_MAX


@indicator(
    name="Session Marks",
    description="Vertical lines + time labels at every N-minute boundary.",
    overlay=True,
    data_mode="ohlc",
    required_columns=["timestamp"],
)
class SessionMarks:
    minutes = input.int(15, "Minutes per mark", min=1, max=240)
    line_color = input.color("#a1a1aa", "Line Color")
    label_color = input.color("#c5c5cd", "Label Color")
    show_labels = input.bool(True, "Show Labels")

    def calculate(self, df):
        ts = np.asarray(df["timestamp"], dtype=np.int64)
        if ts.size == 0:
            return

        bucket_ms = self.minutes * 60_000
        floored = (ts // bucket_ms) * bucket_ms
        mask = np.empty(ts.size, dtype=bool)
        mask[0] = True
        mask[1:] = floored[1:] != floored[:-1]
        marks = floored[mask]

        layer = custom_layer("session-marks", z="bottom")
        layer.style(stroke=self.line_color, line_width=1.0, alpha=0.35, dash=[4.0, 3.0])
        for ms in marks.tolist():
            layer.begin().move_to((int(ms), Y_MIN)).line_to((int(ms), Y_MAX)).stroke()

        if self.show_labels:
            label_layer = custom_layer("session-marks-labels", z="top")
            for ms in marks.tolist():
                # ms -> HH:MM in ET; cheap formatter (no datetime dep)
                seconds = (int(ms) // 1000) % 86400
                hh = (seconds // 3600) % 24
                mm = (seconds // 60) % 60
                label = f"{hh:02d}:{mm:02d}"
                label_layer.text((int(ms), Y_MAX), label,
                                 font_size=10, color=self.label_color, align="center")

Notes

  • Two layers (session-marks and session-marks-labels) so lines can sit below candles while labels float above.
  • Y_MIN/Y_MAX re-resolve every paint, so the lines auto-fit to whatever zoom level the user is on.
  • No dataframe column added, no plot() call: the layer is the only output of this indicator.

Step 5. Run it

Save, validate, attach.

  1. Open the Indicator panel, click Add, select "Session Marks".
  2. Try minutes=5, 15, 60. Toggle labels off for a cleaner view at small timeframes.

Notes

  • If the chart looks unchanged, check the in-app terminal panel for validation errors. A common one is forgetting Y_MIN/Y_MAX import.
  • Custom layers are part of the indicator output, so all the analyzer-side caching, sweep parallelism, and re-run paths work the same as for plot() indicators.