Live ticks & depth

Market data connections

Live ticks & depth

Live data is modelled as per-instrument subscriptions that fan events out to callbacks. Lighting up more capability flags adds trades, order-book depth (aggregated or market-by-order), and the other live feeds — all on the same subscribe-and-dispose shape.

Live ticks

A live feed is a subscription, not a stream you poll. The host calls Subscribe with an instrument id and a callback, and you return an IDisposable whose disposal cancels the subscription. That keeps lifetime management simple: the host disposes the token when a chart closes, and you tear down the upstream wire subscription in response when the last subscriber for an instrument drops.

ITickSource.csusing System;
using TradeStrike.Pipeline.Ticks;

namespace TradeStrike.Pipeline.Runtime;

public interface ITickSource : IDisposable
{
    // onTick fires once per tick. Multiple subscribers for the same instrument get
    // the SAME tick instances (broadcast). Dispose the token to unsubscribe.
    IDisposable Subscribe(string instrumentId, Action<Tick> onTick);
}

Ticks may arrive on any thread the source chooses — the host's global sequencer is the single point that re-orders them — so never assume a specific callback thread. Emit SequenceNumber = 0; the sequencer stamps the monotonic number during fan-out. Set ExchangeTimestampUtc to the exchange's authoritative time where you have it, and document the fact if you only have a local receive clock. The flags bitmask classifies each tick.

Tick.csusing System;

namespace TradeStrike.Pipeline.Ticks;

public readonly record struct Tick(
    long SequenceNumber,            // 0 from the source; the sequencer stamps it
    DateTime ExchangeTimestampUtc,
    string InstrumentId,
    double Price,
    long Size,
    TickFlags Flags);

[Flags]
public enum TickFlags : byte
{
    None  = 0,
    Bid   = 1 << 0,   // quote at best bid
    Ask   = 1 << 1,   // quote at best ask
    Trade = 1 << 2,   // actual print
    AtAsk = 1 << 3,   // buyer aggressor (lifted the ask)
    AtBid = 1 << 4,   // seller aggressor (hit the bid)
    PreviousClose = 1 << 5,   // venue-reported reference price (futures: prior settlement)
    Synthetic = 1 << 7,       // pipeline-injected
}

Wiring live feeds into the provider

To go beyond backfill, widen the Capabilities bitmask and return real implementations from the corresponding properties. A tidy pattern is to keep a private nested feed the provider owns and exposes through its property; depth works the same way.

MyDataProvider.cs (excerpt)public ProviderCapabilities Capabilities =>
    ProviderCapabilities.Backfill |
    ProviderCapabilities.LiveTicks |
    ProviderCapabilities.OrderbookDepth;

public ITickSource?      LiveTicks => _ticks;   // your ITickSource impl
public IMarketDepthFeed? Depth     => _depth;   // your IMarketDepthFeed impl

// A minimal broadcast tick source:
public sealed class MyTickSource : ITickSource
{
    private readonly ConcurrentDictionary<string, Action<Tick>> _subs = new();

    public IDisposable Subscribe(string instrumentId, Action<Tick> onTick)
    {
        _subs[instrumentId] = onTick;
        EnsureUpstreamSubscribed(instrumentId);
        return new Unsubscriber(() => _subs.TryRemove(instrumentId, out _));
    }

    // When the wire delivers a trade, fan out (SequenceNumber: 0 — sequencer stamps it):
    private void OnWireTrade(string id, double price, long size, DateTime tsUtc)
    {
        if (_subs.TryGetValue(id, out var cb))
            cb(new Tick(0, tsUtc, id, price, size, TickFlags.Trade | TickFlags.AtAsk));
    }

    public void Dispose() { /* tear down transport */ }
}

Depth: L2 vs market-by-order

Order-book depth is exposed through two sibling contracts in TradeStrike.Pipeline.MarketDepth, parallel to ITickSource but emitting depth events instead of trades. A provider implements whichever its venue supports — only L2, only MBO, or both — and points IDataProvider.Depth / .Mbo at them, leaving the other null. Both are subscribe-and-dispose, reference-counted per instrument, with a SubscriptionReset event.

Feed Emits Carries
IMarketDepthFeed (L2) DepthUpdate one aggregated price level's new total size. DepthAction.Set creates/replaces; Delete (or size 0) removes the level.
IMarketByOrderFeed (L3) MboEvent one resting order's lifecycle: Add / Modify / Cancel, keyed by the venue's stable OrderId, with optional QueuePriority and (on a price move) PrevPrice.
depth feedsnamespace TradeStrike.Pipeline.MarketDepth;

public interface IMarketDepthFeed : IDisposable        // aggregated Level-2
{
    IDisposable Subscribe(string instrumentId, Action<DepthUpdate> onUpdate);
    event Action? SubscriptionReset;   // fired when consumers must drop cached book state
}

public interface IMarketByOrderFeed : IDisposable      // Level-3 market-by-order
{
    IDisposable Subscribe(string instrumentId, Action<MboEvent> onEvent);
    event Action? SubscriptionReset;
}

public readonly record struct DepthUpdate(
    long SequenceNumber, DateTime ExchangeTimestampUtc, string InstrumentId,
    Side Side, DepthAction Action, double Price, long Size);   // Side: Bid | Ask

public readonly record struct MboEvent(
    long SequenceNumber, DateTime ExchangeTimestampUtc, string InstrumentId,
    string OrderId, Side Side, MboAction Action,               // MboAction: Add | Modify | Cancel
    double Price, long Size, ulong QueuePriority, double PrevPrice);
Raise SubscriptionReset on a drop. This is the one piece easy to forget and important to get right. When the upstream connection drops or the venue sends a book-clear, raise it — that tells every consumer to discard the depth state it cached and rebuild from the next snapshot. Without it, post-reconnect Modify/Cancel events reference orders the consumer still thinks exist but the venue does not. Like ticks, depth events carry SequenceNumber = 0 from the feed.

The shared order book

The chart routes whichever depth feed it gets — L2, MBO, or both — into the same OrderBook, a live mutable book per instrument that accepts both ApplyDepth(DepthUpdate) and ApplyMbo(MboEvent). Indicators read it through the read-only IOrderBook interface (best bid/ask, spread, level counts, per-side enumeration, MBO diagnostics counters). The book is not internally synchronised: feed code holds a lock around each Apply; render threads take an immutable copy via OrderBookSnapshot.Capture.

IOrderBook.cs (read surface)using System.Collections.Generic;

namespace TradeStrike.Pipeline.MarketDepth;

public interface IOrderBook
{
    string InstrumentId { get; }
    double BestBidPrice { get; }   // NaN when the side is empty
    double BestAskPrice { get; }
    double Spread { get; }         // ask - bid, NaN when either side empty
    int OrderCount { get; }        // per-order total; 0 in L2-only mode
    int BidLevelCount { get; }
    int AskLevelCount { get; }
    IEnumerable<PriceLevel> EnumerateBids();   // top of book first
    IEnumerable<PriceLevel> EnumerateAsks();
    PriceLevel? GetLevel(Side side, double price);
    // ... plus monotonic MBO diagnostics counters (MboAddCount, MboModifyMissCount, ...)
}

A PriceLevel fed only by L2 reports OrderCount == 0 (aggregated Size only); a level fed by MBO additionally exposes its per-order queue via EnumerateOrders() of OrderBookEntry. The same indicator can therefore render an aggregated histogram from either feed and additionally draw the per-order stack when it is available — no separate code paths. OrderBookSnapshot flattens the book into top-of-book-first Bids/Asks row arrays (with an optional maxLevelsPerSide cap) for lock-free reads on a render thread.

Seeding late subscribers

An MBO feed delivers each instrument's resting-order book once — as a snapshot to whoever is subscribed at the moment it arrives — and thereafter only incremental deltas. A second chart or a SuperDOM opened later would receive only post-join deltas and never learn the orders already resting, leaving its book empty. This is inherent to the snapshot-once shape of every IMarketByOrderFeed, so the SDK ships a venue- and consumer-agnostic decorator that fixes it for any feed: SeedingMarketByOrderFeed.

SeedingMarketByOrderFeed.csnamespace TradeStrike.Pipeline.MarketDepth;

// Multiplexes every consumer of an instrument onto ONE inner subscription, maintains
// a per-symbol OrderBook from that stream, and seeds each late consumer by replaying
// the current book as a burst of MboAction.Add events before it sees live deltas.
public sealed class SeedingMarketByOrderFeed : IMarketByOrderFeed
{
    public SeedingMarketByOrderFeed(IMarketByOrderFeed inner);
    public IDisposable Subscribe(string instrumentId, Action<MboEvent> onEvent);
    public event Action? SubscriptionReset;
    public void Dispose();   // releases only the inner subscriptions it opened
}

Wrap your raw MBO feed in it and expose the wrapper as IDataProvider.Mbo. The decorator does not own the inner feed's lifecycle — whoever created the inner feed disposes it. On the inner feed's SubscriptionReset it clears every cached book (so a consumer subscribing during the reconnect gap is not seeded with stale orders) and forwards the reset to its own subscribers.

Connection hygiene

Lifecycle rules. ConnectAsync must be idempotent and throw only on a real credential / transport failure; DisconnectAsync must never throw, even when already disconnected. Raise ConnectionStatusChanged on every transition so the host's status banner stays accurate. Tear down an instrument's upstream wire subscription only when its last subscriber disposes.