Calculation & market data

Indicators

Calculation & market data

Two independent opt-ins control how often your code runs: a calculation mode sets the cadence of OnBarUpdate, and a separate flag delivers raw ticks. Plus depth, alerts and sessions.

Two separate knobs

Calculate (a CalculationMode) and ProcessesMarketData (a bool) are independent. The first chooses how often OnBarUpdate fires; the second opts into raw OnMarketData tick delivery. Pick either, both, or neither — an indicator that opts into nothing pays zero per-tick dispatch overhead.

  • An indicator wanting per-tick OHLC recalculation: OnEachTick + leave ProcessesMarketData = false.
  • An orderflow indicator wanting raw bid/ask trades: OnBarClose + ProcessesMarketData = true.
  • A footprint indicator wanting both: set both.

Set them in your constructor (the setters are protected); the value must be stable for the indicator's lifetime.

Calculation mode

Mode Fires OnBarUpdate
OnBarClose (default) Once per closed bar. Lowest CPU; the right choice for SMA/EMA/RSI and other closed-bar smoothers.
OnPriceChange Whenever the bar's close changes (skips repeat-price ticks).
OnEachTick On every tick affecting OHLC. Highest CPU; for tick-precision VWAP, footprint, volume-reactive logic.

Regardless of mode, the closing OnBarUpdate for a bar always fires exactly once; the intra-bar modes simply add updates leading up to it. Inside the callback, read ctx.IsBarClosed to branch close vs intra-bar logic, ctx.IsFirstTickOfBar to roll your per-bar accumulators, and ctx.IsFirstBarOfSession to reset daily state.

a per-tick indicator[IndicatorInput(IndicatorInputKind.Bars)]
public sealed class MyVwap : IndicatorBase
{
    public MyVwap(IIndicator? parent = null) : base(parent)
    {
        Calculate = CalculationMode.OnEachTick;   // recompute on every tick
        AddPlot(new Plot("VWAP", _vwap, PlotStyle.Line, new ChartColor(255, 193, 7), 1.5));
    }

    public override void OnBarUpdate(IIndicatorContext ctx)
    {
        if (ctx.IsFirstBarOfSession)              // reset session accumulators at the open
            _cumPV = _cumVol = 0;

        Bar b = ctx.Bars(0)[0];
        double typical = (b.High + b.Low + b.Close) / 3.0;
        _cumPV  += typical * b.Volume;
        _cumVol += b.Volume;

        double vwap = _cumVol > 0 ? _cumPV / _cumVol : double.NaN;
        if (ctx.IsFirstTickOfBar) _vwap.Append(vwap);   // new bar -> new slot
        else                      _vwap.UpdateLast(vwap); // same bar -> rewrite the tail
    }
}

If you write a custom gate, CalculationCadence.ShouldFire(mode, isClosed, closeChanged) is the single source of truth the runtime itself uses — reuse it rather than re-deriving the rules.

Raw market data

Set ProcessesMarketData = true to receive OnMarketData(in Tick tick, IIndicatorContext ctx) on every live tick. A Tick (from SabrTrader.Pipeline.Ticks) carries Price, Size, ExchangeTimestampUtc and a Flags bitmask — filter on it, because a tick can be a trade, a bid/ask quote, or an informational print.

handling raw tickspublic MyTape(IIndicator? parent = null) : base(parent)
{
    Calculate = CalculationMode.OnBarClose;
    ProcessesMarketData = true;     // opt into OnMarketData
}

public override void OnMarketData(in Tick tick, IIndicatorContext ctx)
{
    if ((tick.Flags & TickFlags.Trade) == 0) return;   // ignore pure quote updates
    bool buyAggressor = (tick.Flags & TickFlags.AtAsk) != 0;
    _delta += buyAggressor ? tick.Size : -tick.Size;
}
OnMarketData never fires during backfill. Even when the historical load includes tick data, raw ticks are not replayed — orderflow indicators receive the pre-aggregated OrderFlowBar via OnDataLoaded instead (see Orderflow). On the live path this fires at the full tick rate (thousands per second on a busy instrument), so keep it allocation-free and never Print per tick.

Depth & market-by-order

Depth and trades are separate streams. Set ProcessesMarketDepth = true to receive the order-book callbacks: OnMarketDepth(in DepthUpdate update, ctx) (aggregated Level 2), OnMarketByOrder(in MboEvent evt, ctx) (Level 3), OnMarketByOrderLoss(long lostEvents, ctx) and OnOrderBookLifecycle(OrderBookLifecycleEvent evt, ctx). The chart has already applied each event to the shared ctx.OrderBook before your callback runs, so a read of the book inside the callback sees consistent state. All four are default no-ops, so a depth-only indicator implements just the ones it needs. The callbacks are serialised per indicator: no two of them ever run at the same time.

What is delivered, and what is not

The shared book is complete. The callbacks are not. On a live chart the host drains the instrument's queued MBO events once per worker pass and hands your indicator only the newest one; the events it skipped were still applied to the book. Every skipped event is announced through OnMarketByOrderLoss before the event that follows it, with an exact count for the skipped events plus anything the delivery ring itself discarded under load (that part may be attributed one pass early, never twice). Under a quiet market most passes deliver one event and no loss notice; under a busy one you will see loss notices constantly. That is the design, not a fault: read the book (ctx.ReadOrderBook) for state, and treat OnMarketByOrder as the cue to do so.

Two consequences follow. An indicator can support sampled-flow and current-book studies from the callbacks alone. It cannot certify complete order lifecycles or queue persistence from them, and no amount of waiting makes the deltas of the book's MboAddCount / MboModifyCount / MboCancelCount line up with its callback count: those counters record apply outcomes on a book shared by every consumer of the instrument (see IOrderBook). For the complete record use the recorded tape through ctx.RecordedDepth (IRecordedDepthCatalog), which carries every event the recorder received plus the markers that say where a reset, a venue re-image or a capture gap sits.

One more shape to know: an indicator that joins an instrument already streaming first receives the current book replayed as MboAction.Add events stamped with the join time. Those are the orders already resting, not wire events; they are never applied to the book (it already holds them) and they move none of its counters.

Book lifecycle

State you derive from the callbacks is only as good as the book it came from, and the book is cleared and rebuilt during a session: on a feed reset, and whenever the venue re-images the instrument after a recovery. OnOrderBookLifecycle tells you when. Reset means the book was cleared and nothing is promised; RebuildStarted means it was cleared and the venue's fresh image is on its way; RebuildCompleted means the image is applied and the book is consistent again, which is the moment to re-derive from ctx.ReadOrderBook; DepthModeChanged means ctx.OrderBook.DepthMode flipped between PerOrder and Aggregated, and per-order features stop describing the venue's book on the way down. The notice runs on the same serialised path as the event callbacks but is not ordered against events still queued for delivery, so a few pre-transition events may still arrive after RebuildStarted; that is why re-deriving at RebuildCompleted beats tracking the image events yourself.

a DOM indicatorpublic MyDom(IIndicator? parent = null) : base(parent)
{
    ProcessesMarketDepth = true;    // opt into the order-book callbacks
}

public override void OnMarketByOrder(in MboEvent evt, IIndicatorContext ctx)
{
    // The newest event of this pass. The book already has every event applied.
    ctx.ReadOrderBook(book => Sample(book));
}

public override void OnMarketByOrderLoss(long lostEvents, IIndicatorContext ctx)
{
    _skippedSinceOpen += lostEvents;   // honest denominator for anything counted from callbacks
}

public override void OnOrderBookLifecycle(OrderBookLifecycleEvent evt, IIndicatorContext ctx)
{
    switch (evt)
    {
        case OrderBookLifecycleEvent.Reset:
        case OrderBookLifecycleEvent.RebuildStarted:
            _derived.Clear();                          // built from a book that no longer exists
            break;
        case OrderBookLifecycleEvent.RebuildCompleted:
            ctx.ReadOrderBook(book => Rebuild(book));  // the image is in; start again from the book
            break;
        case OrderBookLifecycleEvent.DepthModeChanged:
            _perOrder = ctx.OrderBook?.DepthMode == OrderBookDepthMode.PerOrder;
            break;
    }
}

Alerts

Raise a user-facing alert with ctx.Alert(message) for the common case, or the full overload for control over de-duplication and sound:

alertsctx.Alert("RSI crossed above 70");                    // simple; message is also the rearm id

ctx.Alert(
    id: "rsi-overbought",                             // rearm key — throttles repeats
    message: "RSI overbought",
    soundPath: "Alert2.wav",                          // bare filename resolves to the host sounds folder
    severity: AlertSeverity.Warning,                  // Info / Warning / Critical
    rearmSeconds: 60);                                // don't re-fire this id for 60s
Alerts are live-only by design. The host routes them through its sinks only in a live context, so a historical/backtest pass never replays thousands of alerts — you don't need to gate them on ctx.IsLive yourself, though doing so for other live-only side effects is good practice.

Trading hours & sessions

For session-aware indicators (daily VWAP, opening range, initial balance) the context exposes the instrument's trading calendar. ctx.TradingHours is the bound TradingHoursTemplate (or null = 24/7), and ctx.CreateSessionIterator() returns a stateful ISessionIterator — the equivalent of NinjaTrader's new SessionIterator(Bars). Create it once in OnInit/OnDataLoaded and keep it for the series' life.

session iterationusing SabrTrader.Pipeline.Indicators.TradingHours;

private ISessionIterator? _sessions;

public override void OnInit(IIndicatorContext ctx)
{
    base.OnInit(ctx);
    _sessions = ctx.CreateSessionIterator();   // null on a stub context; production always returns one
}

public override void OnBarUpdate(IIndicatorContext ctx)
{
    var t = ctx.Bars(0)[0].Start;
    if (_sessions is not null && _sessions.IsNewSession(t, includesEndTimestamp: false))
    {
        _sessions.GetNextSession(t, includesEndTimestamp: false);
        ResetDailyAccumulators();
    }
}

ctx.IsFirstBarOfSession is the simpler signal when all you need is "reset at the open". Chart session iterators take Bar.Start / Bar.End (the chart timeline). ctx.DisplayTimeZone is the zone used to stamp that timeline and to convert an external UTC instant via ChartDisplayTime.FromUtc.