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 — 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 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.

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;
    }
};
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.