Backtest & live

Strategies

Backtest & live

The same class backtests in the Strategy Analyzer and runs live from the Control Center, with deterministic backtest results.

Backtest & live

Your plugin contains no run plumbing. The Strategy Analyzer backtests a strategy over historical bars, and the Control Center Strategies tab runs the same class live on a broker account. The strategy only talks to the IStrategyContext seam, so both modes use the same code; only what stands behind the context differs.

The host runs the backtest. The backtest engine (feeds, simulated broker, analytics) ships with the host application, and the SDK packages contain the contracts only. A plugin ships a discoverable Strategy, and the Strategy Analyzer runs it. The SDK contains the PerformanceReport family that describes a run's figures.

The run configuration

The host describes every run, backtest or live, from the Analyzer or Control Center dialog: the instrument, the account its orders route to, a run name, the parameter values for this run, the trading-hours template, and the instrument metadata from the host's instrument catalog.

The strategy reads that metadata as TickSize, PointValue, QuantityStep and MinQuantity, and UseAtm uses the first two. How the venue counts the instrument sets WireUnitsPerQuantity. The parameter values are what an optimizer varies: each pass is a fresh run with different values, applied after OnInitialize declares the parameters and before OnStart.

A live run carries two names. StrategyName is the strategy's own name: its [StrategyMetadata] display name, or its type name made readable. The strategy reads it, and may look its own setup up by it, so it stays the same however the trader names the run. The run's display name is the name the trader gave the saved strategy in the Strategies tab. The host stamps it on every order the run places as OriginId = "strategy:" + display name (see OrderId & OriginId provenance), unless the order already carries an origin. The Trade Journal shows that name as the trade's source. A run without a display name stamps its StrategyName.

Reading the result

When a backtest finishes, the host reports the final account and position, the fills and orders, the account equity after each replayed bar, whether the run was cancelled part-way (the figures then cover the bars replayed until then), how many decision asks went unanswered (see Decision models) and how the run ended (a StrategyState). The figures themselves are a PerformanceReport.

C#// p: the PerformanceReport of a finished backtest
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}%)");

PerformanceReport groups its figures into blocks; the trade count, for example, is Trades.TotalTrades. Ratios that can be undefined are double? and null in that case (ProfitFactor with no losing trades, the risk-adjusted ratios on a very short run). A run with no trades reports PerformanceReport.Empty(startingCash); check HasTrades first.

Block Type Key members
Pnl PnLSummary NetProfit, GrossProfit, GrossLoss, TotalCommission, ProfitFactor?, ReturnPct, StartingCash, EndingEquity
Trades TradeStatistics TotalTrades, WinningTrades, WinRatePct, AverageTrade, AverageWinner, AverageLoser, WinLossRatio?, MaxConsecutiveWinners, MaxConsecutiveLosers, Sqn?
Drawdown DrawdownMetrics MaxDrawdown, MaxDrawdownPct, AverageDrawdown, LongestDrawdownDuration, MaxRunup, UlcerIndex, RecoveryFactor?
RiskAdjusted RiskAdjustedMetrics SharpeRatio?, SortinoRatio?, CalmarRatio?, AnnualReturnPct, DailyReturnStdDevPct?
Excursion ExcursionMetrics AverageMae, AverageMfe, WorstMae, BestMfe, AverageEndTradeDrawdown, AverageTradeEfficiencyPct?
Exposure ExposureMetrics TimeInMarketPct, TotalContractsTraded, AveragePositionSize
TradeList IReadOnlyList<Trade> Per round trip: Direction, EntryPrice, ExitPrice, NetPnL, IsWinner, MaeCurrency, MfeCurrency, Duration, ExitTag, IsOpen
EquityCurve IReadOnlyList<EquityPoint> TimeUtc, Equity per sample

Every member of each type is listed on the Strategies reference.

Replay resolution & tie-break

A backtest is single-threaded and synchronous: the same code on the same bars gives byte-identical results, which makes regression tests straightforward. How finely the engine replays each bar is a setting of the run, chosen in the Strategy Analyzer, not something the strategy sets. It decides how your orders fill, so read a result together with the resolution it came from. The resolution leaves the callback cadence unchanged (OnBar fires as it would live); it sets how many prices the fill engine sees inside each bar, and so how many stop and target fills it catches.

Resolution Prices per bar Use
BarOpenOnly 1 (open) Fastest; long-horizon trend tests.
BarClose 1 (close) Smoke tests, quick sweeps.
BarOHLC 4 (open, extremes, close) Default; catches most intra-bar fills.
BarMagnifier 4 × N Good accuracy at moderate cost.
EveryTickGenerated About N synthetic Tick-like replay without tick data.
EveryTickReal Every recorded tick Most accurate and slowest; needs tick data. With a recorded L2 or MBO tape it also streams the order book into the strategy (recorded order book).

When a stop and a target both lie inside one bar's range, the run's intra-bar tie-break decides which fills first. It has the largest effect on accuracy of any setting: UseBarDirection (the default heuristic), LowBeforeHigh (conservative for longs) or HighBeforeLow (conservative for shorts).

Run state. A run moves through StrategyState: Created, Initialized, Running, then Stopped, or Faulted if a callback throws. A backtest reports where it ended; a live run exposes the same value as State.

Fills and the stated position

On a live account a venue can report a fill before it states the position that includes it, so Position can lag a fill for a moment. CatchUp tells you which case you are in. CatchUp.Kind is CaughtUp when the position accounts for every fill heard, NotCaughtUp while a fill is still waiting for its position statement, and SettleTimePassed when the venue has not stated the position within its settle time. ImpliedQuantity is the signed quantity the fills imply; it is the run's own count, not the venue's position. The managed helpers already wait for this, so read it only in code that sizes raw orders from Position.

C#protected override void OnBar()
{
    // Position does not show the last fill yet: do not size an order from it.
    if (CatchUp.Kind == RunCatchUpKind.NotCaughtUp) return;
    // ...
}

ADVANCED Full example: breakout + ATM + logging

This example pulls the chapter together: an N-bar breakout entry whose exits belong entirely to an ATM plan. It has optimizable lookback and quantity parameters, a one-target bracket with auto-breakeven and one trailing step, an edge-triggered entry guarded by IsFlat and HasPendingManagedOrder, and logging from OnBar and OnPositionUpdate. Once ATM owns the brackets, little trading code remains.

BreakoutAtmStrategy.csusing System;
using SabrTrader.Pipeline.Indicators;
using SabrTrader.Pipeline.Strategies;
using SabrTrader.Pipeline.Strategies.Catalog;
using SabrTrader.Pipeline.Venues.Trading;
using SabrTrader.Pipeline.Venues.Trading.Atm;

[StrategyMetadata(DisplayName = "Breakout + ATM", Description = "N-bar breakout, managed by ATM brackets.")]
public sealed class BreakoutAtmStrategy : Strategy
{
    private StrategyParameter<int> _lookback = null!, _qty = null!;

    protected override void OnInitialize()
    {
        _lookback = IntParameter("Lookback", 20, 2, 200, 1);
        _qty      = IntParameter("Quantity",  2, 1,  50, 1);
        Calculate = CalculationMode.OnBarClose;
    }

    protected override void OnStart()
    {
        UseAtm(new AtmStrategy("BO", AtmParameterUnit.Ticks,
            new[] { new AtmBracket(1.0m, StopLoss: 12m, ProfitTarget: 36m) },
            new AtmStopStrategy(AtmStopOrderType.StopMarket, 0m,
                AutoBreakeven: new AtmAutoBreakeven(18m, 4m),
                AutoTrail: new AtmAutoTrail(new[] { new AtmAutoTrailStep(24m, 10m, 2m) }))));
    }

    protected override void OnBar()
    {
        int lb = _lookback.Value;
        if (BarCount < lb + 1 || !IsFlat || HasPendingManagedOrder) return;

        double hi = double.MinValue;
        for (int i = 1; i <= lb; i++) hi = Math.Max(hi, GetBar(i).High);

        if (CurrentBar.Close > hi)
        {
            EnterLong(_qty.Value);          // ATM brackets the fill
            Log($"Breakout long {_qty.Value} @ {CurrentBar.Close}");
        }
    }

    protected override void OnPositionUpdate(Position pos)
        => Log($"Qty={pos.Quantity} entry={pos.AverageEntryPrice} uPnL={pos.UnrealizedPnL}");
}