Custom rendering

Indicators

Custom rendering INTERFACE

When plots aren't enough — footprint cells, volume-profile bars, zone shading, text chips, on-chart buttons — paint directly on the chart with an immediate-mode renderer.

When you need it

Plots cover the common case: one value per bar, drawn as a line or histogram. Custom rendering is for everything else — per-cell footprint grids, volume-profile columns, session shading, structural zones, text chips, countdown timers. You add it by implementing IChartCustomRender alongside your normal indicator code; the lifecycle (OnInit / OnBarUpdate / …) still runs, and a paint callback is added to the chart's render pass.

OnCustomRender

OnCustomRender(IIndicatorRenderContext ctx) is called once per frame. You read pixel transforms and the renderer off the context and draw. Here is a complete session-shading overlay that paints behind the candles.

SessionShade.csusing TradeStrike.Pipeline.Indicators;
using TradeStrike.Pipeline.Plots;

[IndicatorDescription("Shades the regular-session region of each day behind the candles.")]
[IndicatorCategory(IndicatorCategory.Structure)]
[IndicatorInput(IndicatorInputKind.None)]
public sealed class SessionShade : IndicatorBase, IChartCustomRender
{
    [IndicatorParameter(DisplayName = "Shade", Group = "Display")]
    public ChartColor Shade { get; set; } = new(33, 150, 243, 24);

    public SessionShade(IIndicator? parent = null) : base(parent) { }

    // Paint behind the bars so the candles draw on top of the shading.
    public CustomRenderLayer Layer => CustomRenderLayer.BelowBars;

    public void OnCustomRender(IIndicatorRenderContext ctx)
    {
        var bars = ctx.Bars;
        if (bars is null) return;

        var vp = ctx.Viewport;
        ctx.Renderer.PushClip(ctx.PlotArea.X, ctx.PlotArea.Y, ctx.PlotArea.Width, ctx.PlotArea.Height);
        try
        {
            for (int i = vp.FirstVisibleBarIdx; i <= vp.LastVisibleBarIdx; i++)
            {
                if (!bars.TryGet(bars.Count - 1 - i, out var bar)) continue;
                if (!IsRegularSession(bar.StartUtc, ctx.DisplayTimeZone)) continue;

                double cx = vp.BarIndexToPixelX(i);
                double w  = vp.BarSpacingPx;
                ctx.Renderer.FillRect(cx - w / 2, ctx.PlotArea.Y, w, ctx.PlotArea.Height, Shade);
            }
        }
        finally { ctx.Renderer.PopClip(); }
    }
}
This is a hot path. OnCustomRender runs up to ~60×/sec. Keep it allocation-free — precompute heavy state in OnBarUpdate and only read it here. Clip to ctx.PlotArea so your graphics don't bleed over the axis strips.

The render context

IIndicatorRenderContext is a fresh per-frame object — never cache it across frames.

Member What it gives you
Renderer The IChartRenderer — your drawing surface.
Viewport A ChartViewport with PriceToPixelY / BarIndexToPixelX (and inverses), BarSpacingPx, visible-bar range.
PlotArea A PlotAreaRect (X, Y, Width, Height, plus Right/Bottom) of the candle pane, excluding axis strips.
Bars The primary bar series being rendered (may be null on an empty tab).
Now Wall-clock at frame start, in the bar timeline — use this instead of DateTime.UtcNow for countdowns.
ContentTopPx / HeaderInsetPx The first safe Y below the per-panel legend — anchor top-aligned content to ContentTopPx.
DisplayTimeZone The chart's display zone for time-of-day bucketing.
Drawings Read-only list of the user's drawing tools (inspect anchors; never mutate).
BeginScope(category, detail) Open a nested perf sub-scope so a complex renderer shows named children in the Task Manager.

The renderer

IChartRenderer is a platform-neutral 2D surface. Coordinates are already in pixels (origin top-left, X right, Y down) — the viewport does the bar/price → pixel transforms. The host brackets every frame with BeginFrame/EndFrame; you only call the drawing primitives:

IChartRenderer primitives (excerpt)void DrawLine(double x0, double y0, double x1, double y1, ChartColor color, double widthPx);
void FillRect(double x, double y, double widthPx, double heightPx, ChartColor color);
void FillPolygon(ReadOnlySpan<(double X, double Y)> points, ChartColor color);
void DrawRoundedRectOutline(double x, double y, double w, double h, double radiusPx, ChartColor color, double strokeWidthPx);
void DrawCandle(double centerX, double bodyTopY, double bodyBottomY, double wickTopY, double wickBottomY,
                double bodyWidthPx, bool bullish, CandleStyle style);
void DrawText(double x, double y, string text, ChartColor color, double sizePx,
              string? fontFamily = null, bool bold = false, bool italic = false);
void DrawTextCentered(double centerX, double baselineY, string text, ChartColor color, double sizePx, ...);
void DrawTextRotated(double x, double y, double angleDegrees, string text, ChartColor color, double sizePx, ...);
(double WidthPx, double HeightPx) MeasureText(string text, double sizePx, string? fontFamily = null, ...);
void PushClip(double x, double y, double widthPx, double heightPx);
void PopClip();

FillPolygon takes a span — stack-allocate small polygons to avoid per-frame allocations. PushClip/PopClip must be paired 1:1 and nest by intersection.

Z-order & replacing the candles

IChartCustomRender.Layer (a CustomRenderLayer) chooses where your paint sits: BelowBars draws before the candles (the candles occlude it — backdrops, shading, profiles), AboveBars draws on top (markers, labels; the default). To take over the bar drawing entirely — custom-coloured candles, heatmap cells — return true from SuppressDefaultBarRendering; the chart then skips its default bar pass for that frame and you own the candles.

Just recolouring candles? Don't suppress and redraw — implement IBarColorSource instead (see below). It keeps the chart's fast candle path and only asks you for an override colour per visible bar.

Optional renderer capabilities

Beyond the core primitives, the renderer may implement richer capability interfaces. Cast and check at runtime — the production Skia renderer implements all of them, but a test recorder might not.

capability castif (ctx.Renderer is IGradientRenderer gradient)
    gradient.FillPolygonGradient(/* polygon */ poly, startX, startY, startColor, endX, endY, endColor);
else
    ctx.Renderer.FillPolygon(poly, fallbackColor);   // graceful fallback
Interface Adds
IGradientRenderer Gradient-filled polygons (heatmap cells, fades).
IPolylineRenderer One stroked open polyline through many points (cheaper than N DrawLine calls).
IEllipseRenderer Filled axis-aligned ellipses / circles.
ISphereRenderer Shaded 3D-ball circles (bubble glyphs).
IBatchCandleRenderer Draw a whole span of candles with one shared style.
IPaintedCandleRenderer Draw one candle in an override colour (paint-bars).
Namespace. The renderer capability interfaces live in TradeStrike.Pipeline.Charts.Rendering (alongside IChartRenderer), not in TradeStrike.Pipeline.Indicators — add using TradeStrike.Pipeline.Charts.Rendering; when you cast to one.

Other render & interaction hooks

An indicator can implement any combination of these companions to IChartCustomRender:

Interface What it does
IChartBackgroundRender OnRenderBackground(ctx) — an extra below-bars pass, for an indicator that paints both a backdrop and a foreground layer.
IPricePanelOverlayRender OnRenderPricePanel(ctx) — paint on the main price panel even from a sub-panel indicator (drop Buy/Sell markers on the candles).
IBarColorSource PaintBarsEnabled + TryGetBarColor(barIndex, in bar, out color) — recolour candles per bar on the fast path.
IChartClickReceiver OnChartClick(ctx) — react to a left-click; return true to consume it (on-chart buttons).
IChartMouseMoveReceiver OnChartMouseMove(ctx) — hover-driven decorations; a NaN position signals the mouse left the chart.

Painting from a tick or timer? You don't poll the chart — you request repaints and let the chart coalesce them. That's the next page.