How to Build Your First SabrTrader Indicator in C#

Create your first indicator for SabrTrader: define, code, visualize, configure, deploy

Building your first SabrTrader indicator is one of the most satisfying afternoons a .NET developer can spend. You start with an idea, something like "smooth the price so I can see the trend," and a couple hundred lines of C# later it's a live line drawn over real market candles, updating tick by tick, indistinguishable from the tools that ship with the platform. This tutorial walks the whole thing, from an empty class library to a working custom trading indicator plotted on a live chart. I'm assuming no prior SabrTrader experience. If you can read C# and describe what you want, you can follow along.

By the end you'll have a complete Simple Moving Average (SMA) indicator built from scratch, with a user-tunable period parameter, a plotted line, correct history-and-live calculation, and clean deployment into the running platform. On the way you'll pick up the SabrTrader indicator model, the lifecycle the runtime drives your code through, how the plugin system finds your DLL, and how to grow a working indicator with extra plots and threshold lines. Everything here uses the real SabrTrader Plugin SDK surface. It's the same set of contracts the platform's 200+ built-in indicators are written against.

Who this tutorial is for

This is a beginner-to-finish walkthrough for .NET / C# developers who want to build a trading indicator in C# for the SabrTrader desktop trading platform. You don't need to be a quant or a market-microstructure expert. You do need to be comfortable creating a .NET class library, editing a .csproj, and reading ordinary object-oriented C#. If you've ever written a NinjaTrader indicator the concepts will feel familiar, because SabrTrader deliberately mirrors the bars-ago indexing and lifecycle model. They're not the same API though, so read the code here instead of porting from memory.

Tip. Every standard indicator that ships with SabrTrader is open source under the MIT license. Once you finish this tutorial, the best next move is to open a built-in indicator close to what you want to build and copy its shape. This article teaches you the shape.

What you'll build: a custom SMA indicator

A Simple Moving Average is the "hello world" of technical indicators, and that's exactly why it's a good first project. It exercises the four moving parts that show up in nearly every indicator you'll write on a trading platform SDK.

  • An output series, a writable store the indicator appends one value to per bar.
  • A parameter, the averaging period, tagged so the settings dialog renders an editor for it.
  • A plot, the line drawn on the chart, registered once.
  • Two lifecycle methods. One computes the whole history in a single pass; the other appends each new live bar.

We'll start with something even simpler, a trivial MedianPrice that gives us the minimal skeleton and proves the toolchain works end to end. Then we grow it into the full SMA, then enhance it.

Prerequisites

The toolchain is the standard .NET one. There are only a few things to have in place before you write any plugin code.

  • The .NET 10 SDK. SabrTrader plugins target net10.0 (or net10.0-windows when you need WPF, which only a few rendering-heavy plugins do). A plugin can never target a framework higher than the host that loads it, so net10.0 is both the floor and the ceiling. Install the .NET 10 SDK from Microsoft if you don't already have it.
  • An IDE or editor. Visual Studio 2022+, JetBrains Rider, or VS Code with the C# Dev Kit all work. There's nothing SabrTrader-specific to install into the IDE itself.
  • SabrTrader installed. You need a running host to load your plugin into, and its install folder is where the built DLL ultimately lands. A source/developer build works too; the deploy target is just a different folder.
  • The SabrTrader.Sdk NuGet package. This single meta-package is the one dependency every plugin compiles against. More on it below.

Referencing the SDK

SabrTrader.Sdk is a single meta-package. It ships no engine code of its own. It exists to pull in the contract assemblies a plugin author compiles against, plus one MSBuild target that can auto-deploy your build. Installing this one package is all it takes to start writing any plugin kind. It brings in three contract assemblies as transitive dependencies:

  • SabrTrader.Pipeline.Contracts: indicators, plots, series, bars, ticks, market depth, drawing tools and data providers. This is the one that matters for us.
  • SabrTrader.Pipeline.Trading.Contracts: orders, positions, accounts, risk and ATM bracket types (used when you build a strategy).
  • SabrTrader.Pipeline.Strategies.Contracts: the Strategy base class and its runtime context.

Pure contracts, no engine. The SDK ships only interfaces, abstract base classes, attributes, enums and data records. There's zero runtime engine code in it. The host provides the engine at load time. This is the technical reason your deployed plugin must never carry copies of these assemblies. We'll come back to this "golden rule" when we deploy.

Pin the version you build against (0.1.0 at the time of writing) and upgrade deliberately. Don't float it, so a new SDK never changes your plugin's behaviour without you choosing it.

Understanding the indicator model

Before we write code, spend two minutes on the mental model. It makes every later step click.

An indicator is a small, stateful object that reads a bar series and produces output. Usually that's one or more plotted lines, but it can also be a headless filter or an alert. You implement it by subclassing IndicatorBase, the SDK's ready-made implementation of the IIndicator contract. Both live in the SabrTrader.Pipeline.Indicators namespace. The base class gives you empty virtual lifecycle hooks, the plot and line lists, child-indicator management and idempotent disposal, so you override only the few methods you actually need.

The IIndicator contract

You almost never implement IIndicator directly. You subclass IndicatorBase. But it's worth seeing the shape, because every feature maps back to a member here. These are the real signatures from the contract:

public interface IIndicator : IDisposable
{
    string ContentKey { get; }                  // stable identity for dedup + persistence
    CalculationMode Calculate { get; }          // OnBarClose (default) / OnPriceChange / OnEachTick
    bool ProcessesMarketData { get; }           // opt into raw OnMarketData ticks
    bool ProcessesMarketDepth => false;         // opt into depth / MBO callbacks

    IReadOnlyList<IPlot> Plots { get; }          // renderable outputs
    IReadOnlyList<IIndicatorLine> Lines => Array.Empty<IIndicatorLine>();  // threshold lines

    void OnInit(IIndicatorContext ctx);
    void OnDataLoaded(IIndicatorContext ctx);
    void OnBarUpdate(IIndicatorContext ctx);
    void OnMarketData(in Tick tick, IIndicatorContext ctx);
    void OnDataSeriesReady(int seriesIndex, IIndicatorContext ctx);
    void OnDispose();

    ImmutableArray<IIndicator> Children { get; }  // parent-owned child tree
    void AttachChild(IIndicator child);
    void DetachChild(IIndicator child);
}

IndicatorBase implements all of these. It also exposes two source-series properties you read from: Input (an ISeries, defaulting to Bars(0).Close) and Bars (an IBarSeries, defaulting to Bars(0)), plus the helpers AddPlot(...) and AddLine(...). Everything you reach through a callback (series, instrument metadata, logging, alerts) comes from the IIndicatorContext passed to each hook.

The single most important guarantee

An indicator instance is single-threaded with respect to its callbacks. The runtime guarantees that no two lifecycle methods ever run at the same time for the same instance. That means you can store rolling state (running sums, ring buffers, the previous bar's value) in plain instance fields, with no locks and no volatile. This is what makes indicator code so clean compared with general multithreaded C#.

The lifecycle hooks

The framework drives your indicator through a fixed sequence of callbacks. You override the ones you care about and leave the rest as the base class's no-ops. Getting the order straight is what lets you compute history in one efficient pass and then react to live bars one at a time.

  • OnInit(ctx) fires once, immediately after construction and after saved parameter values have been applied. Validate parameters and allocate state here. Always call base.OnInit(ctx) first so Input and Bars are defaulted before your code reads them.
  • OnDataLoaded(ctx) fires once, after the full history is loaded into every subscribed series. Compute every historical bar in a single pass. This replaces NinjaTrader's per-historical-bar loop.
  • OnBarUpdate(ctx) fires live, at the cadence set by Calculate. Compute the newest value. It never fires during backfill, regardless of mode.
  • OnMarketData(in tick, ctx) fires per live tick, only if ProcessesMarketData is true. For VWAP, orderflow and other raw-trade logic.
  • OnDataSeriesReady(i, ctx) fires when a series added via ctx.AddDataSeries finishes backfilling (multi-timeframe indicators).
  • OnDispose() fires once, at teardown. Release unmanaged resources; child indicators are disposed for you.

Put simply: OnDataLoaded hands you the entire history at once so you can loop over it efficiently, and OnBarUpdate then delivers each new live bar individually. If you have side effects that must never fire while replaying history (sounds, alerts, broker calls), gate them on ctx.IsLive (or its inverse, ctx.IsHistorical).

The calculation cadence itself is controlled by the Calculate property, a CalculationMode enum with three values. OnBarClose is the default: it fires once per closed bar, uses the least CPU, and is the right choice for SMA/EMA/RSI. OnPriceChange fires whenever the bar's close changes. OnEachTick fires on every tick, which costs the most CPU and is what you want for tick-precision VWAP and footprint work. Our SMA is a closed-bar smoother, so we just leave it at the default.

Project setup: create the class library

A plugin is a normal .NET class library. Create one and add the SDK package. We'll build an indicator here because it's the quickest thing to see on screen, but the project shape is identical for every plugin kind.

dotnet new classlib -n MyFirstPlugin -f net10.0
cd MyFirstPlugin
dotnet add package SabrTrader.Sdk --version 0.1.0

Now open the generated .csproj and make it look like the one below. The two DeployToAlgoStudioPlugins properties are optional but worth setting now. They come from an MSBuild target inside the SDK package and copy your built DLL straight into the host's Plugins folder on every build, so you never copy files by hand while developing.

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <LangVersion>latest</LangVersion>

    <!-- Opt into the SDK's auto-deploy target -->
    <DeployToAlgoStudioPlugins>true</DeployToAlgoStudioPlugins>
    <!-- Point it at the Plugins folder next to the host EXE you run -->
    <AlgoStudioProPluginsFolder>C:\Program Files\SabrTrader\Plugins</AlgoStudioProPluginsFolder>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="SabrTrader.Sdk" Version="0.1.0" />
  </ItemGroup>

</Project>

About the property names. The opt-in property is DeployToAlgoStudioPlugins and the override is AlgoStudioProPluginsFolder. The names are historical and unchanged for compatibility, so use them exactly as written even though the product is now called SabrTrader. Most indicators don't need WPF, so stay on plain net10.0 unless you hit a specific reason to switch to net10.0-windows.

Your first (trivial) indicator: the skeleton that compiles and loads

Before the full SMA, let's write the smallest indicator that actually draws something. Create MedianPrice.cs. It plots each bar's median price, the midpoint between high and low, and it's a complete, working custom chart indicator. Read it once, then we'll pick it apart.

using SabrTrader.Pipeline.Indicators;
using SabrTrader.Pipeline.Plots;
using SabrTrader.Pipeline.Series;

namespace MyFirstPlugin;

[IndicatorDescription("Median price: the (high + low) / 2 midpoint of each bar.")]
public sealed class MedianPrice : IndicatorBase
{
    // The indicator owns the writable backing store; the plot reads it.
    private readonly ChunkedSeries<double> _output = new();

    // Canonical ctor shape: parent for child composition (null = standalone),
    // input for an explicit source series. The host calls this, then applies settings.
    public MedianPrice(IIndicator? parent = null, ISeries? input = null)
        : base(parent, input)
    {
        // Register one plotted line. AddPlot is called once, from the ctor.
        AddPlot(new Plot("Median", _output, PlotStyle.Line, new ChartColor(91, 157, 255), 1.5));
    }

    // History: one pass over the whole backfill, oldest bar first so values
    // land in chronological order (i is barsAgo: Count-1 = oldest, 0 = newest).
    public override void OnDataLoaded(IIndicatorContext ctx)
    {
        var bars = ctx.Bars(0);
        for (int i = bars.Count - 1; i >= 0; i--)
            _output.Append((bars[i].High + bars[i].Low) / 2.0);
    }

    // Live: one new value each time a bar closes.
    public override void OnBarUpdate(IIndicatorContext ctx)
    {
        var b = ctx.Bars(0)[0];
        _output.Append((b.High + b.Low) / 2.0);
    }
}

That's the entire plugin. Notice what's not there: no registration code, no manifest, no host configuration. Discovery is purely by type. The loader sees a public class deriving from IndicatorBase and registers it. The [IndicatorDescription] text becomes the blurb users read in the Add-Indicator dialog. OnDataLoaded and OnBarUpdate are virtual lifecycle hooks; you override only the ones you need. ChunkedSeries is the SDK's writable, append-only series. Appends are O(1) and it's NaN-aware, so warmup bars render as gaps. You hand it to a Plot and the chart reads it; you never expose it directly.

If this compiles and appears on a chart, your toolchain is proven end to end. Now let's build the real thing.

Building the SMA step by step

Here's the complete Simple Moving Average in full. Every type, attribute and method below is real public surface from SabrTrader.Pipeline.Indicators, SabrTrader.Pipeline.Plots and SabrTrader.Pipeline.Series. Read it once, then we'll go member by member.

using System;
using SabrTrader.Pipeline.Indicators;
using SabrTrader.Pipeline.Plots;
using SabrTrader.Pipeline.Series;

namespace MyFirstPlugin;

[IndicatorDescription("Simple moving average. The unweighted mean of the input " +
    "over the last N bars, the canonical price smoother.")]
[IndicatorCategory(IndicatorCategory.Trend)]
[IndicatorInput(IndicatorInputKind.Scalar)]
public sealed class Sma : IndicatorBase
{
    // The indicator owns the writable backing store; the plot reads it.
    private readonly ChunkedSeries<double> _output = new();

    [IndicatorParameter(DisplayName = "Period", Description = "Number of bars to average.",
        Group = "Parameters", Order = 0, MinValue = 1, MaxValue = 1000, Step = 1)]
    public int Period { get; set; } = 14;

    // Single ctor of the canonical shape: parent for child composition (null = standalone),
    // input for an explicit source series. The derived body sets parameters + plots.
    public Sma(IIndicator? parent = null, ISeries? input = null, int period = 14)
        : base(parent, input)
    {
        Period = period;
        AddPlot(new Plot($"SMA({Period})", _output, PlotStyle.Line,
            new ChartColor(33, 150, 243), 1.5));
    }

    public override void OnInit(IIndicatorContext ctx)
    {
        base.OnInit(ctx);                       // defaults Input to Bars(0).Close
        if (Period < 1)
            throw new ArgumentException("Period must be >= 1.");
    }

    // History: one pass over the whole backfill, oldest bar first.
    public override void OnDataLoaded(IIndicatorContext ctx)
    {
        int count = Input.Count;
        for (int i = count - 1; i >= 0; i--)    // i is barsAgo: count-1 = oldest, 0 = newest
            _output.Append(Average(barsAgo: i));
    }

    // Live: one new closed bar, append its value.
    public override void OnBarUpdate(IIndicatorContext ctx)
    {
        _output.Append(Average(barsAgo: 0));
    }

    // Shared maths so history and live can never drift apart.
    private double Average(int barsAgo)
    {
        if (Input.Count - barsAgo < Period)
            return double.NaN;                  // not enough bars yet -> a gap in the plot

        double sum = 0;
        for (int i = 0; i < Period; i++)
            sum += Input[barsAgo + i];
        return sum / Period;
    }
}

Roughly a hundred lines, and it's a genuinely useful, production-shaped indicator. Let's understand every part.

The class attributes

Three attributes sit above the class:

  • [IndicatorDescription] supplies the one-paragraph blurb the Add-Indicator and settings dialogs show. It's required on every shipped indicator; an empty description throws.
  • [IndicatorCategory(IndicatorCategory.Trend)] places it in a family for grouping and filtering. The enum values are Unspecified, Trend, Momentum, Volume, Volatility, OrderFlow, Structure, Custom.
  • [IndicatorInput(IndicatorInputKind.Scalar)] declares that this indicator reads the scalar Input stream. That also makes the universal "Input series" picker appear, so a user can run the SMA on High, Median, Volume, or even another indicator's plot. (Bar-based indicators that read OHLC directly declare IndicatorInputKind.Bars instead and get no picker.)

The output series

ChunkedSeries is the SDK's writable, single-writer / multi-reader series. It grows without bound (a multi-day tick session never silently drops history), appends are O(1) amortised, and it's NaN-aware. Appending double.NaN round-trips cleanly so warmup bars render as gaps. You never expose it directly; you hand it to a plot, and the chart reads it from the render thread. That's exactly why the Average helper returns double.NaN until enough bars exist. The first thirteen bars of a 14-period SMA are simply blank, which is the correct behaviour.

The parameter

[IndicatorParameter] on a public read/write property does three things. It renders an editor row in the settings dialog. It persists the value into the saved chart template. And it becomes part of the indicator's content key, the identity that makes SMA(14) and SMA(50) distinct instances (and makes two requests with the same key resolve to the same shared instance, a neat de-duplication trick). DisplayName defaults to the property name; Description becomes the editor tooltip; MinValue, MaxValue and Step drive the numeric spinner. Those bounds are advisory. The editor enforces them, but a programmatic assignment is never silently clamped, which is why we still validate in OnInit.

Every parameter needs a real default. The host constructs the indicator with its defaults first, then applies the saved value. A parameter with no sensible default will misbehave the moment a template is loaded. Here the default is 14.

The editor control is inferred from the property's CLR type: int/double become a numeric spinner, bool a checkbox, string a text box, any enum a dropdown, and ChartColor a colour picker. You rarely need to override it.

The constructor and the plot

Every IndicatorBase subclass exposes one constructor of the canonical shape (IIndicator? parent = null, ISeries? input = null, /* your params */), passing parent and input straight to base(...). That single : base(parent, input) line is the only plumbing. It registers the indicator as a child of parent when one is supplied, and stores your input series. Inside the body you set your parameters and call AddPlot.

The Plot constructor takes (string name, ISeries values, PlotStyle style, ChartColor color, double lineWidthPx). ChartColor is plain RGBA; new ChartColor(33, 150, 243) is a pleasant blue. PlotStyle selects the rendering primitive: Line (the default, a continuous line through consecutive non-NaN values), Dashed, Dotted, Area, Histogram, Dots, Step, and Candle.

Why the plot is added in the constructor. The Plots list is append-only and reference-stable for the indicator's lifetime. The chart caches it and the settings dialog binds to it. Register every plot up front (constructor or OnInit) and never resize the list afterward. You may freely mutate a plot's colour, style and visibility at runtime; you may not add or remove plots later.

OnInit: validate and size buffers

OnInit runs exactly once, after construction and after the saved parameter value has been applied, so it's the right place to validate and to size buffers. Call base.OnInit(ctx) first. The base implementation defaults Input to Bars(0).Close (and Bars to Bars(0)) when you didn't inject an explicit source, and resolves the "Input series" picker if the user changed it. Read Input only after that call. This ordering trips people up: read a parameter or Input in the constructor and you'll see only the defaults, because the host applies saved values between construction and OnInit.

OnDataLoaded: the history pass

When OnDataLoaded fires, the entire backfill is already in the series. You compute every historical value in one loop. Indexing is bars-ago: Input[0] is the most recent bar and Input[Input.Count - 1] is the oldest, so to append in chronological order you walk barsAgo from Count - 1 down to 0. This single-pass design is a deliberate performance win. It lets you compute an entire multi-year history in one tight loop instead of paying per-bar callback overhead thousands of times.

OnBarUpdate: the live pass

OnBarUpdate fires once per closed bar in live trading (the default OnBarClose cadence). The newest bar is at barsAgo: 0; you append exactly one value. Notice how both passes call the same Average helper. Sharing the maths is the single best habit for keeping history and live output identical. If your history path and live path ever compute values differently, you'll get a subtle discontinuity at the "join" between backfill and real time, and it's maddening to debug. Share the function and it can't happen.

Reading bars and the Bar struct

Our SMA reads a scalar Input, but many indicators read whole bars. Every lifecycle hook receives an IIndicatorContext, and ctx.Bars(0) is the primary bar series. A Bar is a readonly record struct, allocation-free and passed by value:

public readonly record struct Bar(
    DateTime StartUtc,   // bar open time
    DateTime EndUtc,     // moment the bar closed
    double Open,
    double High,
    double Low,
    double Close,
    long Volume,
    long TickCount);

Note there's no single Time field: the open and close instants are split into StartUtc and EndUtc. If you only want one column, the closes say, IBarSeries exposes cached ISeries projections so you read a scalar stream without materialising a bar per access: Close, Open, High, Low, Volume, plus the derived Median (HL2), Typical (HLC3), Weighted and OHLC4. In fact IndicatorBase.Input defaults to Bars(0).Close using exactly this mechanism, which is why an indicator whose maths reads Input automatically follows whatever the user picks in the "Input series" editor.

Building and deploying the plugin

Time to see it run. Build in Release. Because we set DeployToAlgoStudioPlugins=true, the build copies MyFirstPlugin.dll into the host's Plugins folder and logs where it went.

dotnet build -c Release
SabrTrader.Sdk: deploying plugin 'MyFirstPlugin.dll' to 'C:\Program Files\SabrTrader\Plugins'

Where the host looks

The runtime only ever looks in one place: a folder named Plugins sitting next to the running executable. There's no registry entry, no search path, and no setting that points elsewhere. The scan path is computed as Path.Combine(AppContext.BaseDirectory, "Plugins"). For an installed copy that's under the install directory (e.g. C:\Program Files\SabrTrader\Plugins\); for a local developer build it's inside the build output next to SabrTrader.Desktop.exe.

Watch out: the default deploy folder is not the scan folder. If you leave AlgoStudioProPluginsFolder out, the target deploys to %AppData%\AlgoStudioPro\Plugins, which is not the folder the host scans. Your plugin then builds cleanly yet never appears. If your plugin compiles but is missing in the app, set AlgoStudioProPluginsFolder explicitly to the Plugins folder next to SabrTrader.Desktop.exe.

If you prefer to copy by hand

You don't have to use the MSBuild target. Leave DeployToAlgoStudioPlugins off and copy the DLL yourself after each build:

copy bin\Release\net10.0\MyFirstPlugin.dll "C:\Program Files\SabrTrader\Plugins\"

The golden rule of deployment

Copy only your plugin's own DLL (and optionally its .pdb, so exceptions carry readable stack traces in the host log). Never ship the SDK contract assemblies, never ship YourPlugin.deps.json or runtimeconfig.json, and never copy the whole bin folder. Each plugin loads into its own isolated AssemblyLoadContext, and the contract assembly that defines IIndicator is force-resolved from the host so that typeof(IIndicator) is the same type on both sides. If you ship your own copy of a contract DLL, your Sma : IndicatorBase implements a different IIndicator than the host knows, the assignability check fails, and your plugin is silently skipped. The fix is always the same: deploy just your DLL.

There is no hot reload

Plugin discovery happens once, at startup. The host scans the Plugins folder, loads each DLL, registers what it finds, then never re-scans. So your inner development loop is three steps: build, deploy the DLL, and restart SabrTrader. Editing and rebuilding while the host is running changes nothing on screen. The assembly that already loaded stays loaded for the process lifetime. If "my changes don't take effect" is your symptom, this is almost always why.

Seeing it on a chart

With the DLL deployed, the workflow is "build, then relaunch":

  • Close SabrTrader if it's running.
  • Launch it. During startup the plugin loader scans the Plugins folder and registers your indicator into the shared catalog.
  • Open a chart, click Add Indicator, and find Sma in the list. Its description is the text from [IndicatorDescription], and it sits under the Trend category.
  • Add it. A blue moving-average line is drawn over your candles, on both history and live, with an editable Period field in its settings.

Change the period to 50 and watch the line smooth out. The host recomputes the entire history through OnReset → OnInit → OnDataLoaded with the new value. That recompute-on-parameter-change flow is exactly why we allocate and validate in OnInit rather than the constructor.

If it doesn't appear. Open the Control Center Log tab and look for lines tagged Plugins. The loader records every load and registration there, so a missing dependency or a wrongly-deployed contract DLL is visible immediately.

Enhancing your indicator

A single moving average is useful, but the whole point of building your own SabrTrader indicator is that you can extend it freely. Let's add three enhancements that each teach a real API: a second plot, runtime restyling, and threshold lines.

Adding a second plot (a fast/slow pair)

An indicator can own several plots. MACD has three, Bollinger Bands have three. Adding a second SMA line turns our indicator into a classic fast/slow crossover study. Register both plots in the constructor, in draw order (earlier plots render behind later ones), and keep a second output series and second parameter:

private readonly ChunkedSeries<double> _fast = new();
private readonly ChunkedSeries<double> _slow = new();

[IndicatorParameter(DisplayName = "Fast period", Group = "Parameters", Order = 0,
    MinValue = 1, MaxValue = 1000, Step = 1)]
public int FastPeriod { get; set; } = 14;

[IndicatorParameter(DisplayName = "Slow period", Group = "Parameters", Order = 1,
    MinValue = 1, MaxValue = 1000, Step = 1)]
public int SlowPeriod { get; set; } = 50;

public SmaCross(IIndicator? parent = null, ISeries? input = null) : base(parent, input)
{
    AddPlot(new Plot("Fast", _fast, PlotStyle.Line, new ChartColor(33, 150, 243), 1.5));
    AddPlot(new Plot("Slow", _slow, PlotStyle.Line, new ChartColor(244, 67, 54), 1.5));
}

Then compute both averages in OnDataLoaded and OnBarUpdate using two period-parameterised helpers. It's the same single-pass pattern as before, just twice. Because each plot is an independent series, the chart draws two lines with their own colours and the settings dialog exposes both periods automatically.

Runtime restyling

IPlot is mutable on purpose. The settings dialog recolours and restyles plots at runtime without reconstructing the indicator; the chart re-reads the properties on the next render pass. A property change triggers a repaint, not a recalculation. You can drive this yourself:

IPlot slow = Plots[1];
slow.Color       = new ChartColor(255, 152, 0);
slow.Style       = PlotStyle.Dashed;
slow.LineWidthPx = 2.0;
slow.Visible     = false;     // hide without removing the plot

This is precisely why exposing a colour as an [IndicatorParameter] of type ChartColor works: the dialog writes the new colour onto the plot and the next frame shows it. Add a ChartColor SlowColor { get; set; } parameter, copy it onto Plots[1].Color in OnInit, and users can recolour your slow line from the settings dialog with zero extra rendering code.

Threshold lines for oscillators

Our SMA lives on the price panel, but if you go on to build a bounded oscillator like RSI or Stochastic, you'll want static reference levels like 30 and 70. Those are threshold lines, SabrTrader's equivalent of NinjaTrader's AddLine(). A threshold line is a horizontal reference at a fixed Y-value with no time component. It isn't data, so it isn't a plot. Declare lines from the constructor or OnInit with the protected AddLine helper:

[IndicatorDescription("Relative Strength Index, a bounded 0-100 momentum oscillator.")]
[IndicatorCategory(IndicatorCategory.Momentum)]
[IndicatorInput(IndicatorInputKind.Scalar)]
public sealed class Rsi : IndicatorBase, IIndicatorPanelHint
{
    private readonly ChunkedSeries<double> _rsi = new();

    // RSI is on a 0-100 scale, so it gets its own subpanel.
    public PanelPlacement DefaultPanel => PanelPlacement.NewSubpanel;

    public Rsi(IIndicator? parent = null, ISeries? input = null) : base(parent, input)
    {
        AddPlot(new Plot("RSI", _rsi, PlotStyle.Line, new ChartColor(156, 39, 176), 1.5));

        // Overbought / oversold reference bands. Dashed reads as "guide, not data".
        AddLine("Overbought", 70, new ChartColor(120, 120, 120), 1.0, PlotStyle.Dashed);
        AddLine("Oversold",   30, new ChartColor(120, 120, 120), 1.0, PlotStyle.Dashed);
    }

    // ... OnDataLoaded / OnBarUpdate compute the oscillator into _rsi ...
}

The AddLine convenience overload takes (name, value, color, lineWidthPx = 1.0, style = PlotStyle.Line, participatesInAutoScale = true). That last argument controls whether the line's value is folded into the panel's Y-axis auto-scale. Keep it true for bounded oscillators so the bands stay on screen, and set it false for unbounded indicators like ADX where a distant reference line would squash the real reading into a sliver. Like plots, lines are mutable at runtime, so the settings dialog can restyle them.

Notice the second thing this example demonstrates: panel placement. By default your plots overlay the price panel (PanelPlacement.PriceOverlay), which is correct for an SMA drawn in price units. But RSI runs 0 to 100 and would be invisible on the price axis, so it implements the optional IIndicatorPanelHint interface and returns PanelPlacement.NewSubpanel to request its own panel below the candles. The interface is deliberately separate from IIndicator so only the handful of types that need it take the dependency.

Troubleshooting common issues

Almost every "it doesn't work" report on a first indicator comes down to one of a small handful of causes. Here's the checklist.

  • My plugin doesn't show up in the Add-Indicator list. The loader silently skips any type that fails a discovery rule. Your type must be public (not internal, not nested-private), concrete (not abstract, not an open generic), and zero-arg constructable, meaning either a real parameterless constructor or one where every parameter has a default value. The canonical Sma(IIndicator? parent = null, ISeries? input = null, int period = 14) passes because it's callable with no arguments; a constructor with even one required parameter fails and the type is dropped. This is by far the most common cause.
  • It built but still isn't there. You almost certainly deployed to the wrong folder. Remember the default %AppData%\AlgoStudioPro\Plugins is not the scan path. Set AlgoStudioProPluginsFolder to the Plugins folder next to SabrTrader.Desktop.exe, and check the Control Center Log tab for Plugins-tagged lines.
  • My changes don't take effect. There's no hot reload. You have to fully restart SabrTrader to load a new build.
  • The plugin loads but nothing draws. Confirm you actually registered a plot with AddPlot in the constructor, and that you're appending to the series that plot wraps. An indicator with zero plots is refused as having no observable output. If the line is blank at the start of the chart, that's correct; your warmup bars are NaN until enough history exists.
  • Parameters read as their defaults. You read them in the constructor. Move parameter and Input reads into OnInit, after base.OnInit(ctx). The host applies saved values between construction and OnInit.
  • History and live values disagree at the join. Your OnDataLoaded and OnBarUpdate compute differently. Route both through one shared helper (our Average method) so they can't drift.
  • The plugin is skipped with no error. You probably shipped a contract DLL alongside your plugin. Deploy only your own DLL (and optionally its PDB); the host owns the contract assemblies.

Frequently asked questions

What language and framework do I need to build a SabrTrader indicator?

C# targeting net10.0, built with the .NET 10 SDK. A plugin is an ordinary .NET class library that references the single SabrTrader.Sdk NuGet package. You only need net10.0-windows if your indicator does custom WPF rendering, which most don't.

Do I need to write any registration or manifest code?

No. There's no manifest, no registration call, and no entry-point method. Discovery is purely by type: the loader inspects each DLL in the Plugins folder, sees a public concrete class deriving from IndicatorBase, and registers it automatically. Implement the contract, drop the DLL, restart.

Why does my indicator recompute its whole history when I change a parameter?

Because a math-affecting parameter changes every value the indicator produces. When the user edits it, the host re-runs OnReset → OnInit → OnDataLoaded so the entire history recomputes with the new value. Allocating buffers and validating in OnInit (not the constructor) is what makes that flow work correctly.

Can my indicator run on something other than the close price?

Yes. By tagging the class [IndicatorInput(IndicatorInputKind.Scalar)] and reading Input in your maths, you automatically get the universal "Input series" picker. Users can then run your SMA on High, Low, Median (HL2), Typical (HLC3), Volume, or even another indicator's output, with no extra code from you.

How do I add reference levels like RSI's 30 and 70?

Use the protected AddLine helper from your constructor or OnInit. Threshold lines are horizontal, fixed-Y references rather than per-bar data, so they use a different mechanism from plots. A value that moves from bar to bar (a trailing band, a dynamic stop) is a plot, not a line.

Is there a hot-reload so I don't have to restart every time?

No. Plugin discovery runs once at startup and the host never re-scans. The development loop is build, then deploy the DLL, then restart SabrTrader. This keeps assembly identity and isolation guarantees simple and predictable.

Next steps

You've built, deployed and extended a complete .NET trading indicator. Nice work. From here, the natural directions are:

  • Read the built-ins. The 200+ standard indicators are open source; find one close to what you want and study its shape. An EMA, a Bollinger Band, or a MACD (composed from child EMA indicators) are all excellent next reads.
  • Go deeper on the docs. The official Your first indicator reference and the wider SDK hub cover parameters, plots, panel placement, multi-timeframe series, orderflow and custom rendering in full.
  • Build a strategy. Once you can read a series and compute a signal, automating entries and exits is the logical next step. See our companion walkthrough, How to Build Your First SabrTrader Strategy.
  • See what the platform offers. Explore the full feature set your indicators plug into, and review pricing when you're ready to trade with them live.

Building a custom trading indicator on a real trading platform SDK is a skill that compounds. The SMA you just wrote is the template underneath most line indicators you'll ever build: declare a parameter, register a plot, fill a series in two lifecycle methods. Get that loop down and the whole platform opens up. Every chart study, oscillator and orderflow tool is the same handful of moves, scaled up. Now go build something only you would think of.