Order & account updates

Trading providers

Order & account updates

Every state change a provider observes (an ack, a fill, a position re-mark, a balance change, a disconnect) travels as a TradingEvent. This page is the event model and the order state machine.

The TradingEvent base

TradingEvent is an abstract record; each concrete event is a sealed record subtype. That lets consumers pattern-match on the concrete type and pull out the fields they want, with no enum tag to switch on. Every event carries a UTC TimestampUtc stamped at the moment the provider observed the change (not when it reached the UI), and a Sequence.

TradingEvent.csusing SabrTrader.Pipeline.Venues.Trading;

public abstract record TradingEvent(DateTime TimestampUtc)
{
    // Monotonic, gap-free position on the service's outbound stream, from 1.
    // 0 = "not yet stamped" — providers leave it 0; the service overwrites it as it fans out.
    // A consumer detects a missed/duplicated event by watching for a step other than +1.
    public long Sequence { get; init; }

    // The key of the trading provider this event is about. Null on the base;
    // every concrete event overrides it with its account's provider or the provider it names.
    public virtual string? OriginProviderKey => null;
}
Sequence is assigned by the service. A provider always leaves Sequence = 0; the ITradingService overwrites it as it fans out, per-stream. Do not set it in a provider.

OriginProviderKey tells a consumer which provider an event belongs to without switching on the concrete type: an account event answers its account's provider key, and a provider event such as ConnectionStateEvent answers the key it names.

Event kinds

Event Payload Meaning Gated by capability
AccountSnapshotEvent Account Account financial state changed; UI repaints the row. AccountDiscovery
AccountRemovedEvent AccountId The provider no longer hosts the account: closed at the venue, or withdrawn because the provider stopped, failed or rolled back its start. Consumers cascade-drop its rows. It never states a position flat: the broker may still hold it. No further events for that id. AccountDiscovery
OrderUpdateEvent Order Order lifecycle change (accepted, working, modified, partial fill summary, terminal). Carries the full post-change order. none
OrderCancelRejectedEvent AccountId, OrderId, string Reason A cancel was rejected by the venue or the risk layer. The order is still working. none
OrderModificationRejectedEvent AccountId, OrderId, OrderModification, string Reason A modify was rejected. The order keeps its PREVIOUS price and quantity; the event carries what was attempted, so a surface can snap its chip back to where the order actually rests. none
FlattenRefusedEvent AccountId, string? Instrument, string Reason A flatten was refused and the position is still open, in the venue's own words. Null instrument = the whole account. Say so and change nothing: never treat a refusal as a close. none
RiskRejectionEvent AccountId, OrderRequest, RiskRejection The platform's own risk layer refused a placement before it reached the venue. Carries the typed rejection and the request it applied to. none
FillEvent AccountId, Fill Raw execution. An OrderUpdateEvent fires alongside with the rolled-up state. On the service's stream Fill.Commission is the venue's own fee when Fill.CommissionIsAuthoritative is true, and otherwise the platform's price from the venue's published rate or the trader's commission settings. FillsStream
PositionUpdateEvent Position Position net-quantity / PnL changed. PositionStream
BalanceSnapshotEvent AccountId, IReadOnlyList<AccountBalance> Per-asset wallet balances changed (crypto-spot). Carries the full non-zero set. AssetBalances
ConnectionStateEvent ProviderKey, Connected, Reason? Provider connectivity changed. On reconnect, the provider re-emits account + order snapshots so consumers reconcile. none
BooksSyncedEvent ProviderKey, IReadOnlyList<Position> Positions Raised by the provider exactly once per connect, after its ConnectionStateEvent and after it has published every open order and position it holds. Positions is that book, captured when the event was raised: the provider's full-book statement. From here on a position the provider does not state is a position it does not hold; before it, a missing position proves nothing. none
AccountBookSyncedEvent AccountId Raised by the provider for an account that came up after this connect's BooksSyncedEvent (created or enabled while connected), once it has announced the account with an AccountSnapshotEvent and published every open order and position the account holds. From here on a position the provider does not state on this account is a position it does not hold. Do not raise it when the account's whole book could not be read. none
ReplayCompletedEvent ProviderKey Raised by a provider that cannot raise BooksSyncedEvent for a connect: it has stated every order and position it could read, and it does not vouch for what it did not state. At most once per connect, after its ConnectionStateEvent, never together with a books-synced for the same connect. The provider reads Current from here, so entries and trade management go on, but nothing is retired on its word: proof of a flat position still needs the provider's own flat statement. none
AccountStateResetEvent AccountId The account was reset outside trading (the simulator's account reset). Every order and position stated for it before belongs to a history that no longer exists; the provider restates the account after it. none
ProviderLifecycleEvent ProviderKey, ProviderFactsStatus Status Raised by the trading service, not by a provider: how far a provider's statements can be trusted changed (Replaying when attached or reconnected, Current at its books-synced, Disconnected, Detaching, Absent once removed). See Broker facts. none

OrderUpdateEvent and FillEvent are complementary: a fill emits the raw execution and the rolled-up order state. The service folds fills into the order's running average and filled quantity, but keeps the raw fills in a separate stream for audit.

Event ordering & the outbox

Consumers know your book only from what EventEmitted states, and every one of them (the ATM, the copier, a strategy) acts on the last statement it hears about a position or an order. So the rule is simple: raise events in exactly the order your book changed.

A provider's book is usually written by several threads: a stream's receive loop, a poll loop, a probe timer, a reconnect, the caller of a command. If each thread updates the book under its lock and raises the event after releasing it, two threads can deliver two statements about the same position or order in the reverse of the order the book took them. A closed position re-opens, a Filled order goes back to Working, and every consumer acts on the wrong one. There are two ways to meet the rule: raise while holding the gate that orders your book's changes, or stage the event under the lock that wrote the state and deliver it after releasing the lock. ProviderEventOutbox does the second for you.

Using ProviderEventOutbox

  • Create one per provider. new ProviderEventOutbox(deliver, onDeliveryFault). deliver hands one event to your consumers, which normally means raising EventEmitted. onDeliveryFault receives whatever deliver threw; delivery then continues with the next event, so one failing consumer never strands the statements behind it. It must not throw itself: if it does, the remaining events wait for the next flush.
  • Stage(evt) under your lock. Call it while you still hold the lock under which you wrote the state the event describes. It queues the event behind everything staged before it, runs no consumer code, and returns the event's sequence, so it is safe under any lock. The staged order is your book's order.
  • Flush() after the lock is released. Call it holding no provider lock. It delivers everything staged, one event at a time and in staged order. When no other thread is delivering, it delivers inline on your own thread, with no hop and no added latency. When another thread is already delivering, it leaves your events to that thread and returns at once, so a venue thread is never held up by a slow consumer.
  • FlushThrough(sequence) only for a command's answer. A command on the caller's thread uses it when the caller must read the command's answer together with what the provider said during it, for example a cancel refusal (OrderCancelRejectedEvent) before CancelAsync returns false. It returns once every event up to sequence has been delivered, delivering them itself when nobody else is. Call it holding no provider lock and never from a venue thread. If your callers never need this, use Flush() everywhere.
  • Calls from inside a delivery are never nested. A consumer that calls back into your provider while handling an event (a strategy placing its bracket on a fill) gets its new events delivered by the drain in progress, after the current event, to every subscriber alike. A FlushThrough on the delivering thread therefore returns at once, before those events are delivered.
  • Diagnostics. LastStaged is the sequence of the last event staged. CommandsWaiting is how many commands are in FlushThrough waiting for another thread to deliver their events. It lets your provider's tests know a command is held there without timing it.
MyVenueProvider.cs (excerpt)using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using SabrTrader.Pipeline.Venues.Trading;

// The rest of ITradingProvider, SendCancelToVenueAsync and CloseVenueSessionAsync live in the other part.
public sealed partial class MyVenueProvider : ITradingProvider
{
    private readonly object _book = new();
    private readonly Dictionary<string, Position> _positions = new();
    private readonly Dictionary<OrderId, Order> _orders = new();
    private readonly List<Account> _accounts = new();
    private readonly ProviderEventOutbox _outbox;

    public MyVenueProvider(string providerKey)
    {
        ProviderKey = providerKey;
        _outbox = new ProviderEventOutbox(
            deliver: e => EventEmitted?.Invoke(e),
            onDeliveryFault: ex => Trace.TraceError($"{providerKey}: a trading event consumer threw: {ex}"));
    }

    public string ProviderKey { get; }
    public event Action<TradingEvent>? EventEmitted;

    // Venue thread (the socket's receive loop): write the book and stage the statement in one step.
    private void OnVenuePosition(Position position)
    {
        lock (_book)
        {
            _positions[position.Instrument] = position;
            _outbox.Stage(new PositionUpdateEvent(DateTime.UtcNow, position));
        }
        _outbox.Flush();   // after the lock; never waits for a consumer on another thread
    }

    // Command on the caller's thread: the caller reads the refusal together with the false it gets back.
    public async Task<bool> CancelAsync(OrderId orderId, CancellationToken ct = default)
    {
        Order? order;
        lock (_book) _orders.TryGetValue(orderId, out order);
        if (order is null || order.IsTerminal) return false;

        string? refusal = await SendCancelToVenueAsync(order, ct);   // your venue client; null = accepted
        if (refusal is null) return true;                           // the ack arrives on the stream

        long said;
        lock (_book)
            said = _outbox.Stage(new OrderCancelRejectedEvent(DateTime.UtcNow, order.AccountId, orderId, refusal));
        _outbox.FlushThrough(said);
        return false;
    }

    // Stop: withdraw what you announced, and never wait for delivery.
    public async Task StopAsync(CancellationToken ct = default)
    {
        await CloseVenueSessionAsync(ct);
        lock (_book)
        {
            foreach (var account in _accounts)
                _outbox.Stage(new AccountRemovedEvent(DateTime.UtcNow, account.Id));
            _accounts.Clear();
            _orders.Clear();
            _positions.Clear();
        }
        _outbox.Flush();
    }
}

Common mistakes

  • Raising after releasing the lock. lock (_book) { Update(); } EventEmitted?.Invoke(evt); looks harmless but lets two threads deliver their statements in the opposite order to the book. Stage under the lock, or raise under the gate that orders the book.
  • Waiting for delivery in StopAsync or Dispose. Teardown can run on the UI thread while a consumer on the delivering thread waits for that UI thread, so waiting deadlocks the app's exit. Use Flush() there, never FlushThrough. A withdrawal still undelivered when the host removes your provider is not lost: ITradingService.RemoveProvider withdraws every account you stated and never withdrew.
  • Stating a flat on disconnect or stop. An account you stop hosting (stopped, failed start, rolled-back start, closed at the venue) is withdrawn with AccountRemovedEvent, never with made-up flat positions. A flat makes protective logic cancel stops for a position the broker may still hold. A position that really closes, on any path, is stated flat once.
  • Blocking on a FlushThrough command from a consumer. A consumer must not block synchronously (for example Task.Result on a UI thread) on a command that uses FlushThrough and runs on another thread while the delivering thread marshals back to it. The command waits for its events, and they queue behind the blocked consumer.

Order states & transitions

An order moves forward from Pending to a terminal state. A terminal state (Filled, Cancelled, Rejected, Expired) is final: once a provider reports it, the order stays in it. Three states are not final and can return to an earlier one. Stale is a soft-terminal: when the connection drops while an order is open its real broker-side state is unknown, and reconciliation on reconnect flips it back to Working or to a real terminal. PendingCancel and PendingModify cover the time between a request and the venue's answer, and are described below the enum.

OrderEnums.csusing SabrTrader.Pipeline.Venues.Trading;

public enum OrderState
{
    Pending,          // accepted by the service, not yet acked by the provider
    Working,          // acked by the broker, resting on the book
    PartiallyFilled,  // some quantity filled, more still on the book
    Filled,           // fully filled. Terminal.
    Cancelled,        // cancelled by user or OCO sibling. Terminal.
    Rejected,         // broker rejected on submit/modify. Terminal.
    Expired,          // TIF elapsed before fill. Terminal.
    Stale,            // connection lost while open; state unknown until reconciled.
    PendingCancel,    // a cancel was sent, the venue has not answered; still rests, can still fill.
    PendingModify,    // a change was sent, the venue has not answered; rests on its earlier terms.
}

PendingCancel and PendingModify are set by the trading service, never by a provider: a provider keeps stating the order as the venue states it. The service shows one of them from the moment a cancel or a change goes to the venue until the venue answers. Both are open states: the order still rests at the venue and can still fill. A PendingCancel order ends as Cancelled, or as Filled when a fill beat the cancel, and goes back to the state it had when the venue refuses the cancel. A PendingModify order goes back to the state it had once the venue states the change or refuses it. A consumer that asks "does this order still work at the venue" reads Order.RestsAtVenue, which is true for Working, PartiallyFilled and both pending states.

graph TD;
      P[Pending]-->W[Working];
      P-->R[Rejected];
      W-->PF[PartiallyFilled];
      W-->F[Filled];
      W-->C[Cancelled];
      W-->E[Expired];
      PF-->F;
      PF-->C;
      W-->S[Stale];
      S-->W;
      S-->C;
      W-->PC[PendingCancel];
      PF-->PC;
      PC-->C;
      PC-->F;
      PC-->W;
      PC-->PF;
      W-->PM[PendingModify];
      PF-->PM;
      PM-->W;
      PM-->PF;

The Order record exposes IsTerminal (true for the four terminal states), so you rarely match the enum by hand:

Order.cs// True iff State is Filled / Cancelled / Rejected / Expired.
public bool IsTerminal { get; }

// Quantity still working on the book (requested − filled).
public decimal RemainingQuantity { get; }

Sides, types & TIF

The domain models four order types, two sides and four time-in-force policies. Broker-specific shapes (trailing stop, order-activated, …) live in provider adapters, which translate them at the boundary into these four types.

OrderEnums.cspublic enum OrderSide { Buy, Sell }

public enum OrderType
{
    Market,     // fill now at the prevailing opposing-side price
    Limit,      // fill only at LimitPrice or better
    Stop,       // trigger a market order when last trade crosses StopPrice
    StopLimit,  // trigger a LIMIT at LimitPrice when last crosses StopPrice
}

public enum TimeInForce
{
    Day,   // cancels at session close (default)
    Gtc,   // good till cancelled
    Ioc,   // immediate or cancel — fill what you can now, cancel the rest
    Fok,   // fill or kill — all or nothing, no partial
}

public enum OrderSession
{
    Regular,     // the venue's regular session only (default)
    PreMarket,   // the session before the regular one only
    PostMarket,  // the session after the regular one only
    Extended,    // pre-market, regular session and post-market as one order
}

OrderId & OriginId provenance

Every order has a service-assigned OrderId: a GUID-backed, broker-independent handle the UI, workspace persistence and log lines refer to. It is distinct from the broker's own id (which the provider maps internally and exposes as Order.BrokerOrderId).

OrderId.cspublic readonly record struct OrderId(Guid Value)
{
    public static OrderId New();                                  // fresh id for a new order
    public override string ToString();                            // 32 hex chars, no dashes ("N")
    public static bool TryParse(string? text, out OrderId id);
}

The OrderRequest.OriginId is a different kind of identity: it names the component that created the order: a chart trader instance, the trade copier, a strategy. An auto-management engine compares the opening order's OriginId against its own owner id so it brackets only positions it opened, never a foreign one. It travels on the in-process Order.Request (so order events carry it for same-session correlation). The broker does not store it, so an order reconciled after a restart arrives without one; on a provider that declares DurableOrderIds, the host recovers it from the claim it recorded against the broker order id. null means unattributed; an engine never claims it.

The spelling of an origin id is not yours to invent. OrderOrigins is the one place that forms and reads them, and a consumer deciding whether a resting order is someone else's to manage must go through it: two copies of the convention drift, and the copier's legs on a slave account are exactly the orders a chart engine must never adopt, resize or cancel.

OrderOrigins.csusing SabrTrader.Pipeline.Venues.Trading;

// Forming one (the prefixes are public constants: "chart:", "strategy:", "copier:")
string origin = OrderOrigins.Strategy("MyBreakout");

// Reading one
if (OrderOrigins.IsCopier(order.Request.OriginId)) return;   // not mine to touch

OrderProvenance who = OrderOrigins.Parse(order.Request.OriginId);
// who.Kind: Unknown | ChartTrader | Strategy | Copier
// who.Name: the copier group, or the strategy run's display name (the saved strategy's name);
//           empty for a Chart Trader instance.

Consuming the stream

Subscribe once, switch on the concrete record type, and marshal to your dispatcher yourself. Reading Order.Request gives you the live (post-modification) target; Order.OriginalRequest keeps the as-submitted form for audit and undo.

consumerusing SabrTrader.Pipeline.Venues.Trading;

service.EventEmitted += ev =>
{
    switch (ev)
    {
        case OrderUpdateEvent ou:
            var o = ou.Order;
            if (o.State == OrderState.Rejected)
                Log($"REJECT {o.Id}: {o.RejectReason}");
            else if (o.State == OrderState.Filled)
                Log($"FILLED {o.Id} {o.FilledQuantity} @ {o.AverageFillPrice}");
            break;

        case FillEvent fe:
            Log($"fill {fe.Fill.Quantity} @ {fe.Fill.Price} fee {fe.Fill.Commission}");
            break;

        case PositionUpdateEvent pe when pe.Position.IsFlat:
            Log($"flat {pe.Position.Instrument} realised {pe.Position.RealizedPnLSession}");
            break;

        case AccountSnapshotEvent ae:
            Repaint(ae.Account);
            break;

        case ConnectionStateEvent ce when !ce.Connected:
            Warn($"{ce.ProviderKey} disconnected: {ce.Reason}");
            break;
    }
};
Watch the sequence. The service stamps Sequence strictly increasing from 1 per stream. If you depend on gap-free delivery (a reconciler, an audit log), track the last sequence and treat any step other than +1 as a missed or duplicated event.