Building your first SabrTrader strategy is the point where algorithmic trading stops being theory and turns into code you can actually run. In this tutorial you'll write a complete automated trading strategy from an empty file to a backtested, deployable plugin. It's a plain moving average crossover strategy: go long when a fast SMA crosses above a slow SMA, exit when it crosses back below. Along the way you'll pick up how to build a trading strategy in C# on a modern algorithmic trading platform, how to declare parameters and indicators, how to submit orders without getting burned, and how to backtest a trading strategy before it ever touches a live account.
Educational disclaimer. This is educational material for developers, not financial advice. Automated trading can lose you a lot of money. A backtest is a simulation over past data. It's not a promise about the future, and a profitable backtest can still bleed money live. Don't run a strategy with real capital until you understand how it behaves, how bad its worst drawdown gets, and the venue you're trading on. Trade only with money you can afford to lose.
What we'll build and who this is for
By the end you'll have a single self-contained C# class, SmaCrossoverStrategy, that the SabrTrader host discovers on its own, runs in the Strategy Analyzer for backtesting, and launches live from the Control Center. The same class runs unchanged in both modes. That isn't an accident. It's the central design promise of the SabrTrader strategy model, and once you understand why it holds you'll write better strategies.
This is for developers who know C# but haven't touched the SabrTrader SDK yet. You don't need any algo-trading background, though you should recognise words like moving average, long/short, and stop-loss. If you've already read the companion piece on how to build your first SabrTrader indicator, the strategy model will feel familiar: a base class, a handful of lifecycle hooks, and some protected helpers.
We'll build it in layers, and each layer is a section of this article:
- A minimal skeleton that compiles and gets picked up by the catalog.
- Parameters for the fast and slow periods, so the values are tunable and optimizable.
- Two SMA indicators declared inside the strategy.
- The trading logic in the on-bar hook, using position-aware managed orders.
- Protective stops and targets, first with a raw bracket, then with a full ATM plan.
- Deployment, backtesting, live/sim running, troubleshooting, and an FAQ.
Prerequisites
Before you write a line of code, get these in place:
- SabrTrader installed. The desktop algorithmic trading platform hosts your strategy, gives you the Strategy Analyzer for backtesting, and the Control Center for live runs. The platform features and pricing pages spell out what each tier includes.
-
The SabrTrader SDK / contracts reference. Your plugin project references the strategy contracts assembly, which exposes the
Strategybase class and everything else in this tutorial. The full API is on the SDK hub, and the strategy quick-start is at Your first strategy. - An IDE and the .NET SDK. Visual Studio, Rider, or VS Code with the C# toolchain. A strategy is just a class library that produces a plugin DLL.
- Basic C# fluency. Classes, fields, overrides, and lambdas. Nothing exotic.
Understanding the strategy model
Everything here starts from one base class. A strategy subclasses Strategy in the SabrTrader.Pipeline.Strategies namespace, overrides a few lifecycle hooks, and calls the protected helpers (bars, position, orders, logging) to get its work done. You never see the host plumbing. There's no data feed to wire up, no trading service to inject, no runner to start. Every helper delegates to an IStrategyContext that the runner injects before your first callback. That single seam is what lets the identical class run against a live broker or a backtest engine with no code change.
How a strategy differs from an indicator
An indicator computes values and draws on a chart. It never trades. A strategy consumes indicators, reasons about your position, and submits orders. If you've built an indicator, think of a strategy as the layer sitting on top: it can declare the same indicator types you build elsewhere in the SDK, read their output bar by bar, and then act on it by entering and exiting the market.
Three invariants that shape how you write code
The runtime guarantees three things. Get these into your head and most of the concurrency and state questions answer themselves:
- One instance = one run. Every backtest or live launch constructs a fresh instance. Fields hold per-run state. The runner never resets your fields. To run again, it builds a new object. That's why you do per-run setup in a lifecycle hook and never in the constructor.
-
Single-threaded. Every
On…hook runs on the run's single, serialised strategy thread. No locks, novolatile. (The one exception is custom chart rendering viaRenderingStrategy, which is out of scope here.) -
Same code, both modes. Since the strategy depends only on the
IStrategyContextseam, it runs deterministically in a backtest and live against a real broker. Same class, same bytes, same behaviour.
Order operations are fire-and-forget.PlaceOrder,CancelOrder,ModifyOrderandFlattennever block on a broker round-trip. The outcome shows up later throughOnOrderUpdate,OnFillandOnPositionUpdate. Don't write code that assumes an order filled the instant you asked for it.
The lifecycle hooks
A strategy is defined entirely by the hooks it overrides. The runner calls them in a fixed order: declare once, start once, a callback per bar, with order/fill/position events interleaved as the market responds, and a clean-shutdown hook at the end. Every override is optional. A hook you don't override is a no-op.
-
OnInitialize()is called once, first, before any bar. Declare parameters and indicators, and set theCalculatemode here. -
OnStart()is called once, after overrides are applied, before bars flow. Read final parameter values, do warm-up, and callUseAtmhere if you use ATM. -
OnBar()is called per bar (according to yourCalculatemode). This is where your entry/exit logic lives. -
OnOrderUpdate(Order)fires when an order's state changed (working, filled, rejected). -
OnFill(Fill)fires on one execution. Use it for per-fill bookkeeping such as commission tracking. -
OnPositionUpdate(Position)fires when the net position changed. Use it to track PnL and reconcile state. -
OnStop()is clean shutdown. Release external resources. It is not called on a fault.
Declare vs. read. Declare parameters and indicators inOnInitialize, but read their final values fromOnStartonward. The host applies parameter overrides between those two hooks, so a value you read insideOnInitializeis still the default. Declarations added later thanOnInitializeare ignored: the indicator host is built once, betweenOnInitializeandOnStart.
The run state machine
The runner drives the lifecycle and isolates faults. An exception thrown in any hook moves the run to Faulted and releases its subscriptions. It never escapes to crash the host. The state machine is one-way and linear: Created → Initialized → Running → Stopped, with Faulted as an off-ramp reachable from Initialized or Running. Both Stopped and Faulted are terminal. OnStop runs on a clean stop but not on a fault, because after a fault the strategy is in an unknown state and running teardown could make things worse. You can read the current state any time via the State property (a StrategyState).
Project setup
A strategy ships as a plugin DLL, an ordinary .NET class library that references the SabrTrader strategy contracts. Create a class library, add the reference, and add the two using directives you'll need for the crossover strategy. The Strategy base class and the parameter/indicator helpers come from SabrTrader.Pipeline.Strategies. The [StrategyMetadata] attribute comes from SabrTrader.Pipeline.Strategies.Catalog. The built-in Sma indicator lives in SabrTrader.Pipeline.Indicators.Builtin.
using SabrTrader.Pipeline.Indicators.Builtin; // Sma
using SabrTrader.Pipeline.Strategies; // Strategy, parameter & indicator helpers
using SabrTrader.Pipeline.Strategies.Catalog; // [StrategyMetadata]
That's all there is to configure. No manifest to edit, no registration call to make. Discovery is entirely reflective, which we'll see in a moment.
A minimal strategy skeleton
Start with the smallest thing that compiles and gets discovered. A launchable strategy has to be a public, non-abstract Strategy subclass with a public parameterless constructor, because the catalog has to new one per run. Decorate it with [StrategyMetadata] to give traders a friendly name and description instead of a humanised class name.
using SabrTrader.Pipeline.Strategies;
using SabrTrader.Pipeline.Strategies.Catalog;
[StrategyMetadata(
DisplayName = "SMA Crossover",
Description = "Long while a fast SMA is above a slow SMA; flat when it crosses back below.")]
public sealed class SmaCrossoverStrategy : Strategy
{
protected override void OnInitialize()
{
// Declarations go here (parameters, indicators). Empty for now.
}
protected override void OnBar()
{
// Trading logic goes here. Empty for now, a no-op strategy.
}
}
Compile that into your plugin DLL, drop it in the plugins folder, and it shows up in the catalog on its own. Discovery is reflective: at load time the host scans every loaded assembly for launchable Strategy subclasses and adds each one. For each launchable type it also builds a throwaway probe instance and runs its OnInitialize once, purely to capture the parameters it declares. That's how a launch form knows your knobs and their optimization ranges without running the strategy. Discovery is defensive: an assembly that fails to load, a constructor that throws, or an OnInitialize that throws will never break the catalog. The bad type is just skipped.
Constructor rule. Since the host instantiates your strategy for every run, the class has to stay newable with no arguments. Do all per-run setup in OnInitialize, not in the constructor. The protected helpers throw if you touch them from the constructor, because the run context isn't bound yet.
If you leave off [StrategyMetadata] the catalog still lists the strategy, but under a humanised class name: SmaCrossoverStrategy becomes "Sma Crossover", dropping a trailing "Strategy". The attribute is optional but worth adding to anything you ship. There's more on the SDK hub.
Adding parameters
Hard-coded periods make for a rigid strategy. We want the fast and slow lengths, plus the order quantity, to be inputs the user can set and the optimizer can sweep. Declare them in OnInitialize using the *Parameter helpers, store the returned handle in a field, and read .Value wherever you need it.
There are two flavours, and the difference matters. Only the numeric helpers can be optimized. IntParameter and DoubleParameter each have an extra overload taking (name, default, min, max, step), the optimizable form that lets the Strategy Analyzer sweep the value across the declared range during a parameter search. Their plain (name, default) form, along with BoolParameter, StringParameter and FilePathParameter, is fixed configuration: the user can set them but the optimizer won't vary them.
[StrategyMetadata(
DisplayName = "SMA Crossover",
Description = "Long while a fast SMA is above a slow SMA; flat when it crosses back below.")]
public sealed class SmaCrossoverStrategy : Strategy
{
private StrategyParameter<int> _fastPeriod = null!;
private StrategyParameter<int> _slowPeriod = null!;
private StrategyParameter<int> _quantity = null!;
protected override void OnInitialize()
{
// (name, default, min, max, step) is the optimizable form.
_fastPeriod = IntParameter("FastPeriod", 10, 2, 100, 1);
_slowPeriod = IntParameter("SlowPeriod", 30, 5, 400, 1);
_quantity = IntParameter("Quantity", 1, 1, 100, 1);
}
}
Each helper returns a typed StrategyParameter<T> with Value, DefaultValue, ValueType, and an implicit conversion to T, so inside OnBar you can write either _fastPeriod.Value or just _fastPeriod. The full set of helpers:
-
IntParameter(name, default, min, max, step)is an optimizable int. -
IntParameter(name, default)is a fixed int (no sweep). -
DoubleParameter(name, default, min, max, step)is an optimizable double. -
DoubleParameter(name, default)is a fixed double. -
BoolParameter(name, default)is a checkbox toggle. -
StringParameter(name, default)is free text. -
FilePathParameter(name, default, fileFilter)is a path with a Browse button.
Override flow. Defaults are declared inOnInitialize. The host applies any parameter overrides afterOnInitializeand beforeOnStart. The optimizer drives a different override map per run. A key that matches no declared parameter, or a value that can't be coerced to the parameter's type, gets logged and skipped, and the parameter keeps its default instead of faulting the run. Parameters are inputs, not mutable state: a strategy never writes.Valueitself. Declaring two parameters whose names differ only in case throws at declaration time. That's a bug, surfaced loudly.
Behind the scenes the optimizable overloads build an OptimizationRange(Min, Max, Step), a record stored in decimal so user-specified steps like 0.5 or 0.25 stay exact across a sweep rather than drifting. Its Validated(name) guard rejects a backwards range or a non-positive step. You rarely touch this directly. The host reads it through the parameter's IsOptimizable and OptimizationRange to build a sweep. For the full picture see the Parameters reference on the SDK hub.
Creating the indicators inside the strategy
A strategy doesn't compute moving averages by hand. It reuses the same indicator types you build elsewhere in the SDK. Declare one with DeclareIndicator(factory) inside OnInitialize, passing a lambda that constructs and configures the indicator (any IndicatorBase subclass). The framework feeds the instance the same bars your strategy sees, so at runtime you just read its current value.
Why a factory instead of a ready-made instance? Timing. The factory runs after parameter overrides are applied, so an indicator sized by a parameter picks up this run's value. That's what makes a declared indicator correct under an optimizer sweep, where each pass rebuilds it from that pass's overrides. Notice how the SMA periods below read straight from the parameter handles we just declared.
private StrategyIndicator<Sma> _fast = null!;
private StrategyIndicator<Sma> _slow = null!;
protected override void OnInitialize()
{
_fastPeriod = IntParameter("FastPeriod", 10, 2, 100, 1);
_slowPeriod = IntParameter("SlowPeriod", 30, 5, 400, 1);
_quantity = IntParameter("Quantity", 1, 1, 100, 1);
// Factories run AFTER overrides land, so each period picks up this run's value.
_fast = DeclareIndicator(() => new Sma { Period = _fastPeriod.Value });
_slow = DeclareIndicator(() => new Sma { Period = _slowPeriod.Value });
}
DeclareIndicator returns a typed StrategyIndicator<T> handle. Hold it in a field. You can't be handed the indicator instance at declaration time because it doesn't exist yet: the factory hasn't run. By the time OnStart runs, the runner has built and backfilled every declared indicator over the run's pre-run history, so its values are usable right away. Read .Instance from OnStart onward. Reading it earlier (inside OnInitialize) throws instead of returning a half-built indicator. The handle's IsReady is true once the instance is bound.
How you read a value depends on the indicator. There's no single universal accessor. Some indicators expose a convenience .Value (the built-in Sma does). Others publish their result as an ISeries<double> Output read with Output[0] for the latest and Output[1] for the previous bar. Many provide both. The Sma we're using exposes both Value and Output, which we'll use for crossover detection in a moment.
Guard your reads during warm-up.IsReadybeing true means the indicator is built, not that it has enough bars to mean anything. An SMA over 30 bars is useless at bar 3. Gate onBarCountbefore you trust a value.
The trading logic: detecting the crossover
Now the interesting part. We want to go long when the fast SMA crosses above the slow SMA, and go back to flat when it crosses back below. Two design decisions keep this clean and safe: reading bar and position state correctly, and making the entry edge-triggered so we act only at the crossover, not on every bar the condition holds.
Reading bar and position state
Your decision logic needs the price series and your current exposure. BarCount tells you how much history exists, so use it to skip warm-up. CurrentBar is the most recently closed bar. GetBar(barsAgo) walks back, with GetBar(0) the current bar and GetBar(1) the one before. For position, the base class exposes your exposure three ways: IsFlat, IsLong, IsShort. That trio is what makes the edge-triggered pattern read so cleanly. For finer detail, the nullable Position carries the signed Quantity (positive long, negative short), AverageEntryPrice, UnrealizedPnL and RealizedPnLSession.
Two ways to express the signal
The simplest formulation is a level check with a position guard: be long while fast > slow, flat otherwise. Because EnterLong is a no-op when you're already long and ExitLong is a no-op when you're not long, guarding with IsFlat / IsLong means the strategy issues an order only at the transition, not on every bar the condition holds:
protected override void OnBar()
{
// Skip warm-up: the slow SMA needs at least SlowPeriod bars to be meaningful.
if (BarCount < _slowPeriod.Value) return;
// Let a prior managed intent settle before issuing another.
if (HasPendingManagedOrder) return;
double fast = _fast.Instance.Value;
double slow = _slow.Instance.Value;
if (fast > slow && IsFlat) EnterLong(_quantity.Value);
else if (fast < slow && IsLong) ExitLong();
}
This is the version shown in the official Your first strategy quick-start, and it's correct: the position guards make it edge-triggered. But it enters on the first bar after the averages happen to be in the right order, which on a cold start might be a bar where the cross happened a while ago. If you want a strict crossover (fast was at or below slow last bar and is strictly above this bar), read the indicator's Output series, which lets you look one bar back with Output[1]:
protected override void OnBar()
{
if (BarCount < _slowPeriod.Value + 1) return; // need a previous bar too
if (HasPendingManagedOrder) return;
double fastNow = _fast.Instance.Output[0];
double slowNow = _slow.Instance.Output[0];
double fastPrev = _fast.Instance.Output[1];
double slowPrev = _slow.Instance.Output[1];
bool crossedUp = fastPrev <= slowPrev && fastNow > slowNow;
bool crossedDown = fastPrev >= slowPrev && fastNow < slowNow;
if (crossedUp && IsFlat) EnterLong(_quantity.Value);
else if (crossedDown && IsLong) ExitLong();
}
Both are real, working formulations. Use the level check for simplicity, the crossover check when you care about the precise transition bar.
Why managed orders
Notice we never computed how many contracts to buy or which side to sell. That's the whole point of managed orders: position-aware intent. Instead of doing the reversal arithmetic yourself, you express the position you want and the framework works out the order:
-
EnterLong(qty): flat enters long; short reverses to long (covers the short and opens the long in a single intent); already long is a no-op. -
EnterShort(qty)is the mirror ofEnterLong. -
ExitLong()closes a long via a market order; a no-op when flat or short. -
ExitShort()closes a short; a no-op when flat or long.
Each managed call routes through a single in-flight order. While that order is live (submitted but not yet terminal) HasPendingManagedOrder is true and every managed call is a no-op. That stops a strategy firing on each bar from stacking duplicate entries before the first one fills. The framework clears the flag the instant the managed order goes terminal, so reading the flag (as we do at the top of OnBar) is exactly how you decide whether to wait.
Managed helpers use market orders. They move to and from a position with market orders. For resting limits, custom OCO, or absolute-price brackets, drop down to raw orders. For multi-target scale-outs with auto-breakeven and trailing, layer an ATM plan on top. ATM is orthogonal to managed intent: you can keep using EnterLong for the intent and let the ATM protect the resulting position.
The complete crossover strategy so far
Putting the pieces together, here's the full, working moving average crossover strategy, a genuinely useful SabrTrader strategy in one screen:
using SabrTrader.Pipeline.Indicators.Builtin;
using SabrTrader.Pipeline.Strategies;
using SabrTrader.Pipeline.Strategies.Catalog;
[StrategyMetadata(
DisplayName = "SMA Crossover",
Description = "Long while a fast SMA is above a slow SMA; flat when it crosses back below.")]
public sealed class SmaCrossoverStrategy : Strategy
{
private StrategyParameter<int> _fastPeriod = null!;
private StrategyParameter<int> _slowPeriod = null!;
private StrategyParameter<int> _quantity = null!;
private StrategyIndicator<Sma> _fast = null!;
private StrategyIndicator<Sma> _slow = null!;
protected override void OnInitialize()
{
_fastPeriod = IntParameter("FastPeriod", 10, 2, 100, 1);
_slowPeriod = IntParameter("SlowPeriod", 30, 5, 400, 1);
_quantity = IntParameter("Quantity", 1, 1, 100, 1);
_fast = DeclareIndicator(() => new Sma { Period = _fastPeriod.Value });
_slow = DeclareIndicator(() => new Sma { Period = _slowPeriod.Value });
Calculate = CalculationMode.OnBarClose; // evaluate once per closed bar
}
protected override void OnBar()
{
if (BarCount < _slowPeriod.Value) return;
if (HasPendingManagedOrder) return;
double fast = _fast.Instance.Value;
double slow = _slow.Instance.Value;
if (fast > slow && IsFlat) EnterLong(_quantity.Value);
else if (fast < slow && IsLong) ExitLong();
}
}
Note the Calculate = CalculationMode.OnBarClose line in OnInitialize. The calculation cadence controls how often OnBar fires: on bar close (the default and the right choice for a crossover), on each price change, or on each tick. On-bar-close evaluation gives you clean, non-repainting signals. You decide once, on confirmed data, per closed bar.
Adding stops and targets
Our strategy enters and exits on the signal, but it has no protective stop. If the market gaps against an open long, the position rides until the fast average finally crosses back down. Real strategies protect every position. There are two levels of protection in SabrTrader: a one-shot raw bracket attached to an entry, and a full ATM plan that auto-brackets every position you open. We'll cover both.
Option A: a raw order with a server-side bracket
When managed intent isn't enough, submit an OrderRequest directly with PlaceOrder. Unlike managed intent, a raw order doesn't reason about your position. It does exactly what you ask, so you own the lifecycle. To get a protective stop and target without managing the one-cancels-other logic yourself, attach a BracketSpec to the entry. The provider places the bracket server-side and cancels the remaining leg when one fills. Bracket prices are absolute, not offsets. The strategy knows the chart, and absolute prices keep the spec venue-neutral.
using SabrTrader.Pipeline.Trading;
// Market entry with a server-side bracket (absolute prices).
PlaceOrder(new OrderRequest(
Instrument: Instrument,
Side: OrderSide.Buy,
Type: OrderType.Market,
Quantity: 5m,
Bracket: new BracketSpec(StopLossPrice: 4340m, TakeProfitPrice: 4360m)));
The request draws on a small set of enums: OrderSide (Buy, Sell), OrderType (Market, Limit, Stop, StopLimit), TimeInForce (Day, Gtc, Ioc, Fok), and the lifecycle OrderState (Pending → Working → PartiallyFilled → Filled, or Cancelled / Rejected / Expired / Stale). A BracketSpec needs at least one of its two prices, and for a buy the stop has to be below the target (flipped for a sell), validated at construction. The request's Instrument must equal the run's Instrument: a strategy trades only the instrument it runs on.
A resting limit and the modify/cancel/flatten verbs round out the raw surface:
// Resting limit order:
PlaceOrder(new OrderRequest(Instrument, OrderSide.Buy, OrderType.Limit, 2m, LimitPrice: 4350.25m));
ModifyOrder(orderId, new OrderModification(NewLimitPrice: 4351m));
CancelOrder(orderId);
Flatten(); // close this run's open position now
Results arrive asynchronously. OnOrderUpdate(Order) hands you the live order snapshot. Beyond State it carries FilledQuantity, AverageFillPrice, RejectReason, the originally submitted OriginalRequest, and IsTerminal. Orders are keyed by OrderId, so keep the id to modify or cancel later. OnFill(Fill) carries each raw execution: Quantity, Price, Commission, TimestampUtc. Use OnFill for per-execution bookkeeping and OnOrderUpdate for state transitions:
protected override void OnOrderUpdate(Order order)
{
if (order.State == OrderState.Rejected)
Log($"rejected: {order.RejectReason}");
else if (order.IsTerminal && order.State == OrderState.Filled)
Log($"filled {order.FilledQuantity} @ {order.AverageFillPrice}");
}
protected override void OnFill(Fill fill)
=> Log($"exec {fill.Quantity} @ {fill.Price} (comm {fill.Commission})");
Option B: a full ATM plan
For anything past a single stop and target, reach for Advanced Trade Management. You describe a plan once with UseAtm(…), in OnInitialize or OnStart, and from then on every position the strategy opens is automatically bracketed to that plan: a protective stop plus scale-out targets, with break-even and trailing applied as the trade develops. The framework handles the OCO and the trailing, so your OnBar shrinks to just emitting entries. ATM is orthogonal to managed intent, so you keep calling EnterLong and let the ATM protect the fill.
The plan, an AtmStrategy, is one or more brackets (each a slice of the position with its own stop and target) plus one embedded stop strategy that governs break-even and trailing. Bracket quantities are relative shares, so one template scales to any position size:
using SabrTrader.Pipeline.Trading.Atm;
protected override void OnStart()
{
var atm = new AtmStrategy(
Name: "1:3 + auto-BE",
Unit: AtmParameterUnit.Ticks,
Brackets: new[]
{
new AtmBracket(Quantity: 0.5m, StopLoss: 10m, ProfitTarget: 30m), // scale half at 3R
new AtmBracket(Quantity: 0.5m, StopLoss: 10m, ProfitTarget: 0m), // runner (0 = no target)
},
StopStrategy: new AtmStopStrategy(
StopOrderType: AtmStopOrderType.StopMarket,
StopLimitOffsetTicks: 0m,
AutoBreakeven: new AtmAutoBreakeven(ProfitTrigger: 15m, Plus: 5m),
AutoTrail: null));
UseAtm(atm, tickSize: 0.25); // tickSize / pointValue optional if the run config provides them
}
The plan composes from a handful of small records:
-
AtmBracket(Quantity, StopLoss, ProfitTarget)is one target slice.ProfitTarget = 0is a runner, exited only by its stop, and itsIsRunnerreports this. Every bracket must carry a positive stop. -
AtmAutoBreakeven(ProfitTrigger, Plus): once the trade movesProfitTriggerticks in favour, move every stop to entry ±Plusticks (positive locks profit, negative leaves room). -
AtmAutoTrail(Steps): up toAtmAutoTrail.MaxSteps(3) progressively tighteningAtmAutoTrailStep(ProfitTrigger, StopLoss, Frequency)steps, ordered by ascending trigger. -
AtmTimeExit(TimeSpan CloseAt, string TimeZoneId)is an optional EOD-style time exit that flattens at a wall-clock time in a named zone.
The stop order type is set on AtmStopStrategy.StopOrderType: StopMarket (a native stop-market at the venue), StopLimit (needs a positive StopLimitOffsetTicks, caps slippage at the risk of a non-fill), or Simulated (no resting order at the venue: the engine watches price and submits on touch, which works on venues without a native stop such as crypto spot, but only protects while the engine runs). AtmStopStrategy.StaticMarket is a ready-made plain stop-market with no break-even or trail. Read HasAtmBracket to know when a live bracket is in place.
CallUseAtmonce. It throws if called twice, and it needs a positive tick size (passed explicitly or resolved from the run config / instrument metadata). Configure the plan inOnInitializeorOnStart, never per bar. Calling it also claims position management for the run, so a manual Chart-Trader or SuperDOM ATM on the same account and instrument defers rather than placing a second, parallel bracket.
Every distance in the plan is read through the AtmParameterUnit you pass: Ticks (as above), Currency (cash per contract), Percent (of entry price), or absolute Price distance. For strategies that size the stop from live volatility, two helpers adjust the geometry of the next bracketed entry without rebuilding the plan: SetAtmBracketGeometry(stopOffset, targetOffset) replaces the offsets for the next entry, and SetNextStopPrice(double? stopPrice) sets an absolute stop price for the next entry (honoured only when the level is on the protective side of the fill). For example, sizing the stop from ATR at entry:
protected override void OnBar()
{
if (IsFlat && signal)
{
double atr = _atr.Instance.Output[0];
SetAtmBracketGeometry(stopOffset: atr / TickSize!.Value, targetOffset: 2 * atr / TickSize.Value);
EnterLong(1); // the next fill is bracketed with the freshly-sized stop/target
}
}
For our SMA crossover, the cleanest upgrade is to declare a fixed-tick ATM plan in OnStart and leave OnBar exactly as it is. The entries stay identical, but every long is now protected by a stop and a target with auto-breakeven, all OCO-managed for you.
Building and deploying the strategy plugin
Deployment is deliberately boring, which is a feature. Build the class library to produce your plugin DLL, then drop that DLL into the SabrTrader plugins folder. At load time the host scans the assembly, finds your public non-abstract Strategy subclass with its parameterless constructor, probes its OnInitialize to learn its parameters, and adds it to the catalog. No manifest, no registration call, no restart dance beyond letting the host pick up the assembly. In practice, dropping your plugin DLL into the plugins folder is the entire deployment step.
If you want to inspect the catalog programmatically, for tooling or tests, it's a public surface:
StrategyCatalog cat = StrategyCatalog.BuildDefault(); // scan loaded assemblies
// or: StrategyCatalog.BuildFrom(new[] { typeof(SmaCrossoverStrategy).Assembly });
foreach (StrategyDescriptor d in cat.Strategies)
Console.WriteLine($"{d.DisplayName}: {d.Description}");
StrategyDescriptor? sd = cat.Find("SMA Crossover"); // by id, display name, type name, or full name
Strategy instance = sd!.CreateInstance(); // a fresh, ready-to-run instance
A StrategyDescriptor exposes a stable Id (the type's full name, which survives a display-name change), DisplayName, Description, StrategyType, the declared Parameters, and CreateInstance(). If a display name doesn't show up after deploying, this is the first place to look: enumerate the catalog and confirm the type was discovered.
Backtesting the strategy
You don't write run plumbing inside your plugin. The Strategy Analyzer backtests your strategy over historical bars, and the Control Center Strategies tab launches the same class live against a real broker. Because the strategy only ever talks to the IStrategyContext seam, neither mode needs a code change. The difference is entirely in what feeds the context.
Every run, backtest or live, is described by a StrategyRunConfig. The Analyzer and Control Center build one from their dialog, and the same record is what a host-side test fixture passes to the engine. It's the canonical list of everything a run needs:
new StrategyRunConfig(
instrument: "ESZ24",
account: accountId, // SabrTrader.Pipeline.Trading.AccountId
strategyName: "SMA Crossover",
parameterOverrides: new Dictionary<string, object> { ["FastPeriod"] = 8, ["SlowPeriod"] = 21 },
tickSize: 0.25, pointValue: 50,
instrumentKey: null, instrumentVenue: null, // cross-venue routing (optional)
tradingHoursTemplateName: "CME US Index Futures RTH");
tickSize and pointValue are instrument metadata, not tunable parameters. The host fills them from its instrument catalog and the strategy reads them via TickSize and PointValue (they also feed UseAtm). parameterOverrides is the seam an optimizer drives: each pass is a fresh run with a different override map, which is exactly why parameter-sized indicators rebuild correctly under a sweep.
Reading the result
A backtest returns a BacktestResult carrying the final account and position, the raw Fills and Orders, an EquityCurve, and a full PerformanceReport. Convenience members include NetProfit, RealizedPnL, StartingCash, FinalAccount, FillCount, BarsReplayed, and FinalState. The performance report groups its figures into sub-records rather than a flat bag:
// `result` is a BacktestResult handed back by the Analyzer / a host fixture.
PerformanceReport p = result.Performance;
Console.WriteLine($"Net {p.Pnl.NetProfit} PF {p.Pnl.ProfitFactor} trades {p.Trades.TotalTrades}");
Console.WriteLine($"Win% {p.Trades.WinRatePct:F1} Sharpe {p.RiskAdjusted.SharpeRatio}");
Console.WriteLine($"MaxDD {p.Drawdown.MaxDrawdown} ({p.Drawdown.MaxDrawdownPct:F1}%)");
Note the trade count is Trades.TotalTrades; there's no top-level one. Many ratios are double? and null when undefined (for example ProfitFactor with no losing trades, or the risk-adjusted ratios on a too-short run). For a run with no trades the report is PerformanceReport.Empty(startingCash), so check HasTrades first. The report's blocks are Pnl (a PnLSummary), Trades (a TradeStatistics), Drawdown, RiskAdjusted, Excursion, Exposure, plus the per-round-trip TradeList and the EquityCurve.
Replay resolution and tie-break
A backtest is single-threaded and synchronous. The same code plus the same bars yield byte-identical results, which makes regression tests trivial. The BacktestResolution enum selects how finely the engine replays each primary bar between OnBar calls. It never changes the callback cadence. OnBar still fires once per primary bar at close, exactly as live. What it changes is how many quotes the fill engine sees, which decides how many take-profit / stop-loss fills it catches within a bar:
-
BarOpenOnly/BarClose: one quote per bar. Fastest, for smoke tests and long-horizon trend tests. -
BarOHLC: four quotes (open, extremes, close). The default, catching most intrabar fills. -
BarMagnifier: the sweet spot. Strong accuracy at moderate cost. -
EveryTickGenerated: synthetic ticks, no tick data required. -
EveryTickReal: every recorded tick. The gold standard, slowest, needs tick data.
When a stop and a target both sit inside one bar's range, IntrabarTieBreak decides which fills first. It's the single most impactful accuracy knob: UseBarDirection (the safe default heuristic), LowBeforeHigh (conservative for longs), or HighBeforeLow (conservative for shorts). For a strategy with brackets, always sanity-check your results at a higher resolution before you believe them.
Running it live or in sim
Once the backtest looks reasonable, launch the same class from the Control Center Strategies tab. Pick the instrument, the account (a sim/paper account first, always), your parameter values, and a trading-hours template, and the runner drives the identical lifecycle you backtested. Live runs expose the same State property, and the fire-and-forget order model means fills and position changes flow back through OnOrderUpdate, OnFill and OnPositionUpdate exactly as they did in the harness.
Always sim first. Run on a paper/simulated account until you've watched the strategy trade through a full session and confirmed its entries, exits, and brackets behave the way your backtest predicted. Slippage, partial fills, and live latency are real. The backtest models them but can't guarantee them. Promote to a live account only once you're confident, and start with the smallest size.
Session-aware refinements
Two common live refinements are worth knowing. First, gate entries to trading hours: InSession tells you whether CurrentBar is inside trading hours, and IsFirstBarOfSession is the place to reset per-session state. Second, when you decline an entry for a risk or session reason, call NotifyEntryBlocked(reason) instead of silently returning. The live runtime raises a structured event the Strategies tab can show, so a user can see why a run looks idle:
protected override void OnBar()
{
if (!InSession) { NotifyEntryBlocked("outside RTH"); return; }
// ... crossover logic ...
}
Troubleshooting common issues
-
My strategy doesn't appear in the catalog. Confirm the class is
public, non-abstract, and has a public parameterless constructor. Discovery skips anything it can'tnew. EnumerateStrategyCatalog.BuildDefault().Strategiesto see what was found. -
Parameter overrides seem to be ignored. You're probably reading
.Valuetoo early. Overrides land betweenOnInitializeandOnStart, so read final values fromOnStartonward. A value read inOnInitializeis still the default. Also check the override key exactly matches the declared parameter name; a mismatch is logged and skipped, not faulted. -
Reading
.Instancethrows. You're reading the indicator before it's built. Indicators are constructed betweenOnInitializeandOnStart. Read.InstancefromOnStart/OnBar, never fromOnInitialize. -
The strategy enters on every bar. You're missing the position guard.
EnterLongwithout anIsFlatcheck is a no-op once you're long, but combine it withHasPendingManagedOrderto avoid stacking while an order is in flight. -
Indicator values look wrong early in the run.
IsReadymeans built, not warmed up. Gate onBarCount; an SMA over 30 bars is meaningless at bar 3. -
UseAtmthrows. It can only be called once, and it needs a positive tick size. PasstickSizeexplicitly or make sure the run config supplies instrument metadata. Configure it inOnInitialize/OnStart, never per bar. -
A raw order is rejected with an instrument error. The request's
Instrumentmust equal the run'sInstrument. A strategy trades only the instrument it runs on. -
Backtest fills look too good. Raise the
BacktestResolution(tryBarMagnifieror a tick mode) and set an appropriateIntrabarTieBreak. Optimistic tie-breaking on bracketed trades flatters results.
FAQ
What namespace is the Strategy base class in?
SabrTrader.Pipeline.Strategies. The [StrategyMetadata] attribute and the catalog types are in SabrTrader.Pipeline.Strategies.Catalog, and built-in indicators like Sma live in SabrTrader.Pipeline.Indicators.Builtin.
Do I need to register my strategy anywhere?
No. Discovery is fully reflective. A public, non-abstract Strategy subclass with a parameterless constructor is found automatically when its DLL is loaded. Add [StrategyMetadata] only to control the display name and description.
Can the same code really run both backtest and live?
Yes, that's the core design promise. The strategy talks only to IStrategyContext, so the Strategy Analyzer (historical bars, simulated broker) and the Control Center (live feed, real broker) run the identical class. You never change code to switch modes.
How do I add a protective stop and target?
Two ways. Attach a BracketSpec with absolute stop/target prices to a raw OrderRequest, or declare a full AtmStrategy plan once with UseAtm and let it auto-bracket every position, with scale-outs, auto-breakeven and trailing handled for you.
Which indicator accessor do I use, .Value or Output[0]?
It depends on the indicator; there's no universal accessor. The built-in Sma exposes both a convenience .Value and an ISeries<double> Output. Use Output[1] when you need the previous bar's value, as in strict crossover detection. Check the indicator type for what it offers.
Why is my optimizable parameter not being swept?
Only the numeric helpers with the (name, default, min, max, step) overload are optimizable. The plain (name, default) forms, and BoolParameter / StringParameter / FilePathParameter, are fixed configuration the optimizer won't vary.
Next steps
You've built a complete automated trading strategy end to end: a discovered plugin, tunable parameters, two SMA indicators, edge-triggered managed entries, protective brackets via raw orders and ATM, a backtest read-out, and a live/sim launch path. That's the full arc of how to build a trading strategy in C# on the SabrTrader algorithmic trading platform.
From here, a few natural directions:
- Add a short side. Mirror the logic with
EnterShort/ExitShortso the strategy flips rather than sitting flat. - Size the stop from volatility using an
Atrindicator andSetAtmBracketGeometry. - Sweep
FastPeriodandSlowPeriodin the Strategy Analyzer and study thePerformanceReportacross the grid, but watch out for overfitting. - Gate entries to a session with
InSessionand flatten at the close.
Keep going with the official docs: the full SDK hub, the Your first strategy quick-start, and the companion tutorial on how to build your first SabrTrader indicator. When you're ready to run in production, review the platform features and pricing.
One more time, because it matters. This tutorial is educational. Trading carries real risk of loss, backtest results are simulations and never guarantees, and a strategy that looks good on history can lose money live. Test on a simulated account, understand your worst-case drawdown, and never risk money you can't afford to lose.