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 — no enum-tag plus
switch. 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 TradeStrike.Pipeline.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; }
}
Sequence = 0; the
ITradingService overwrites it as it fans out, per-stream. Do not set it in a provider.
Event kinds
| Event | Payload | Meaning | Gated by capability |
|---|---|---|---|
AccountSnapshotEvent |
Account |
Account financial state changed; UI repaints the row. | AccountDiscovery |
AccountRemovedEvent |
AccountId |
Account closed/deleted; consumers cascade-drop its rows. 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. | — |
FillEvent |
AccountId, Fill
|
Raw execution. An OrderUpdateEvent fires alongside with the rolled-up state. |
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. | — |
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.
Order states & transitions
The state machine is deliberately one-way and linear from Pending. A terminal state
(Filled, Cancelled, Rejected, Expired) is final —
a provider cannot resurrect a cancelled order back into Working. 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.
OrderEnums.csusing TradeStrike.Pipeline.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.
}
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;
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 — they translate at the boundary and never leak into the domain.
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
}
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) but is not a broker field — a reconciled order seen after a
restart has none, which is correct (a reconciled position is adopted, never freshly bracketed).
null means unattributed; an engine never claims it.
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 TradeStrike.Pipeline.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.