Child indicators
Child indicators
Compose indicators from other indicators. A child is initialised and backfilled before its parent reads it, so the parent always sees up-to-date child output. MACD, for example, is a fast EMA minus a slow EMA.
On this page
Composition, not copy-paste
Many indicators are built from simpler ones: MACD is two EMAs, Bollinger Bands are an SMA plus a standard-deviation band, Stochastic %D is an SMA of %K. Instead of repeating the EMA maths inside MACD, you attach a child EMA and read its output.
You create a child by passing this as the parent argument to its constructor,
from your OnInit (after base.OnInit(ctx)) or later. The
IndicatorBase constructor calls parent.AttachChild(this) for you, which
requires the parent to have a context, so a child cannot be created from the parent's constructor.
The child's own OnInit and OnDataLoaded run after your OnInit
returns and before your OnDataLoaded, so your backfill loop reads a fully backfilled child.
ISeries<double>. The convention is a public Output property;
multi-output indicators expose named series such as Upper, Middle and
Lower. You read it bars-ago, like any other series.Example: a composed MACD
MACD is fast EMA minus slow EMA, with a signal EMA of that difference. The example ships its own tiny
EMA as the child. It is declared internal: the plugin loader only lists public indicator
types, so the helper stays out of the indicator picker.
Call AdvanceChildren(ctx) at the top of OnBarUpdate. On a chart, live
dispatch calls OnBarUpdate only on top-level indicators, so a composite drives its
attached children once per live update with this helper. Each child is gated by its own
Calculate mode. On hosts that walk the child tree themselves the call returns immediately,
so it is always safe to call.
ComposedMacd.csusing SabrTrader.Pipeline.Indicators;
using SabrTrader.Pipeline.Plots;
using SabrTrader.Pipeline.Series;
[IndicatorDescription("MACD: the difference between a fast and a slow EMA, with a signal line.")]
[IndicatorCategory(IndicatorCategory.Momentum)]
[IndicatorInput(IndicatorInputKind.Scalar)]
public sealed class ComposedMacd : IndicatorBase, IIndicatorPanelHint
{
private EmaLine _fast = null!, _slow = null!;
private readonly ChunkedSeries<double> _macd = new();
private readonly ChunkedSeries<double> _signal = new();
private readonly ChunkedSeries<double> _hist = new();
[IndicatorParameter(DisplayName = "Fast", Group = "Parameters", Order = 0)] public int Fast { get; set; } = 12;
[IndicatorParameter(DisplayName = "Slow", Group = "Parameters", Order = 1)] public int Slow { get; set; } = 26;
[IndicatorParameter(DisplayName = "Signal", Group = "Parameters", Order = 2)] public int Signal { get; set; } = 9;
public PanelPlacement DefaultPanel => PanelPlacement.NewSubpanel;
public ComposedMacd(IIndicator? parent = null, ISeries<double>? input = null) : base(parent, input)
{
AddPlot(new Plot("Histogram", _hist, PlotStyle.Histogram, new ChartColor(120, 120, 120), 1.0));
AddPlot(new Plot("MACD", _macd, PlotStyle.Line, new ChartColor(33, 150, 243), 1.5));
AddPlot(new Plot("Signal", _signal, PlotStyle.Line, new ChartColor(244, 67, 54), 1.5));
}
public override void OnInit(IIndicatorContext ctx)
{
base.OnInit(ctx); // resolves Input and Bars
_fast = new EmaLine(this, Input, Fast);
_slow = new EmaLine(this, Input, Slow);
}
public override void OnDataLoaded(IIndicatorContext ctx)
{
// Both children are backfilled before this runs.
for (int i = Input.Count - 1; i >= 0; i--)
Step(_fast.Output[i] - _slow.Output[i]);
}
public override void OnBarUpdate(IIndicatorContext ctx)
{
AdvanceChildren(ctx); // drive the attached children for this live update
Step(_fast.Output[0] - _slow.Output[0]);
}
private void Step(double macd)
{
double k = 2.0 / (Signal + 1);
double signal = _signal.Count == 0 ? macd : _signal[0] + k * (macd - _signal[0]);
_macd.Append(macd);
_signal.Append(signal);
_hist.Append(macd - signal);
}
}
// Minimal EMA child. Internal, so it does not appear in the indicator picker.
internal sealed class EmaLine : IndicatorBase
{
private readonly ChunkedSeries<double> _out = new();
private readonly int _period;
public ISeries<double> Output => _out;
public EmaLine(IIndicator? parent, ISeries<double> input, int period) : base(parent, input)
=> _period = period;
public override void OnDataLoaded(IIndicatorContext ctx)
{
for (int i = Input.Count - 1; i >= 0; i--) Step(Input[i]);
}
public override void OnBarUpdate(IIndicatorContext ctx) => Step(Input[0]);
private void Step(double x)
{
double k = 2.0 / (_period + 1);
_out.Append(_out.Count == 0 ? x : _out[0] + k * (x - _out[0]));
}
}
Feeding a child a different input
The IndicatorBase constructor is (IIndicator? parent = null, ISeries<double>?
input = null, IBarSeries? bars = null). Pass whatever series the child should read: your resolved
Input, a price projection such as Bars.High or Bars.Typical, or
another child's Output. Pass bars for a child that reads OHLC. When both are
null the child defaults to the primary series (Bars(0) and its close).
Children can be stacked into chains. Siblings are initialised and advanced in attach order, so create an upstream child before the child that reads it.
chaining childrenvar onHigh = new EmaLine(this, Bars.High, 14); // EMA of the highs
var smoothed = new EmaLine(this, new IndicatorOutputSeries(onHigh.Output, Bars), 5); // EMA of that EMA
When you hand a computed series to a child, wrap it in
new IndicatorOutputSeries(series, Bars). The wrapper implements
IBarSourcedSeries, whose SourceBars tells the framework which bar series the
values belong to, so the child gets the right primary series along an indicator-of-indicator chain.
Price projections such as Bars.High carry this already. A series without it makes the
child fall back to the parent's primary series.
Disposal & the tree guards
Disposing a parent disposes every child in reverse-attach order, and disposing a child detaches it from
its parent, so leave child disposal to the framework. Your
OnDispose only releases what you allocated directly (timers, native handles).
When the user edits parameters, the framework disposes all children and runs your OnInit
again, so creating children in OnInit rebuilds exactly the tree the new parameters need.
AttachChild throws InvalidOperationException when the child would form a
cycle, when the child is already attached to a different parent, or when the chain would grow deeper
than IndicatorBase.MaxChildDepth (16). DetachChild removes a child without
disposing it.