Order & account updates
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.
On this page
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 = 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).deliverhands one event to your consumers, which normally means raisingEventEmitted.onDeliveryFaultreceives whateverdeliverthrew; 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) beforeCancelAsyncreturnsfalse. It returns once every event up tosequencehas 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, useFlush()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
FlushThroughon the delivering thread therefore returns at once, before those events are delivered. -
Diagnostics.
LastStagedis the sequence of the last event staged.CommandsWaitingis how many commands are inFlushThroughwaiting 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
StopAsyncorDispose. 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. UseFlush()there, neverFlushThrough. A withdrawal still undelivered when the host removes your provider is not lost:ITradingService.RemoveProviderwithdraws 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
FlushThroughcommand from a consumer. A consumer must not block synchronously (for exampleTask.Resulton a UI thread) on a command that usesFlushThroughand 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;
}
};
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.