Custom rendering
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.
On this page
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(); }
}
}
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.
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). |
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.