Live ticks & depth
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.
On this page
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);
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
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.