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-marksandsession-marks-labels) so lines can sit below candles while labels float above. Y_MIN/Y_MAXre-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.
- Open the Indicator panel, click Add, select "Session Marks".
- 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_MAXimport. - 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.