The trading provider

Trading providers

The trading provider

Implementing ITradingProvider end to end: connect and disconnect, submit / modify / cancel orders, expose accounts and positions, and declare exactly what you support.

The contract

A trading provider is one source of accounts, orders and positions under a single ProviderKey, plus a push stream of state changes. It is built around snapshots plus events: at any moment you can read the current accounts, open orders and positions, and between reads the EventEmitted stream tells consumers what changed.

ITradingProvider.csusing SabrTrader.Pipeline.Venues.Trading;

public interface ITradingProvider : IDisposable
{
    string ProviderKey { get; }                       // matches AccountId.ProviderKey
    TradingProviderCapabilities Capabilities { get; } // what the UI may offer
    bool ReportsCommission => false;                  // true when EVERY fill carries the venue's own fee
    bool? RealizedPnLIncludesCommissions => null;     // is Account.RealizedPnL net of fees? null = undeclared
    bool IsRunning { get; }
    Exception? LastStartError => null;               // why the last StartAsync returned false

    // Live snapshots — always the most recent committed state.
    IReadOnlyList<Account>  Accounts   { get; }
    IReadOnlyList<Order>    OpenOrders { get; }        // working / partially-filled only
    IReadOnlyList<Position> Positions  { get; }        // non-flat only

    event Action<TradingEvent>? EventEmitted;

    Task<bool> StartAsync(CancellationToken ct = default);
    Task       StopAsync(CancellationToken ct = default);

    Task<OrderId> PlaceAsync(OrderRequest request, AccountId account, CancellationToken ct = default);
    Task<bool>    CancelAsync(OrderId orderId, CancellationToken ct = default);
    Task<bool>    ModifyAsync(OrderId orderId, OrderModification mod, CancellationToken ct = default);
    Task<bool>    FlattenAsync(AccountId account, string? instrument = null, CancellationToken ct = default);

    // Optional venue verbs. Each has a default implementation that refuses, so implement only what
    // your venue supports and declare the matching capability flag.
    IReadOnlyList<AccountBalance> GetBalances(AccountId account);                                  // AssetBalances
    Task<OcoPlacement> PlaceOcoAsync(OcoRequest request, AccountId account, CancellationToken ct = default);  // NativeOco
    Task<MultiLegPlacement> PlaceMultiLegAsync(MultiLegOrderRequest request, AccountId account, CancellationToken ct = default);
    Task<MultiLegPrecheckResult> PrecheckMultiLegAsync(MultiLegOrderRequest request, AccountId account, CancellationToken ct = default);
    Task<bool> ExerciseOptionAsync(AccountId account, string instrument, decimal quantity, CancellationToken ct = default);
}

The contract's rules you must honour

  • Snapshots are committed state. Accounts, OpenOrders and Positions always return the latest snapshot. Reads should be lock-free: swap the underlying list atomically inside your mutation critical section so a reader never sees a torn list.
  • Open lists are filtered. OpenOrders holds only working / partially-filled orders (terminal ones drop out on the next snapshot); Positions holds only non-flat positions (a position going flat emits one final snapshot, then drops).
  • No replay for late subscribers. Anyone attaching to EventEmitted after startup receives only the events raised after it subscribed, and reads the snapshot properties to learn current state.
  • Events fire on your worker thread. Do not marshal to a UI dispatcher; that is the consumer's job. Just emit promptly and consistently.
  • Raise events in book order. Consumers hear statements in the order you raise them and act on the last one, so raise them in exactly the order your book changed: either while holding the gate that orders your book's changes, or staged under the book's lock with ProviderEventOutbox and flushed after the lock is released. Never update the book under a lock and raise after it from several threads. See Event ordering & the outbox.
  • A closed position is stated flat once; a withdrawn account is removed, not flattened. A position that closes on any path (a stream close, a poll that no longer lists it, a flatten, an expiry, a reconnect) is stated flat once; one that silently drops from Positions stays open for every consumer. An account you stop hosting (stopped, failed start, rolled-back start, closed at the venue) is withdrawn with AccountRemovedEvent, never with made-up flats: a flat makes protective logic cancel stops for a position the broker may still hold.
  • Vouch for your book once per connect. After a connect (the first one, a reconnect, a recovered poll) and its ConnectionStateEvent, publish every open order and position, then raise one BooksSyncedEvent carrying the positions you published. Never raise it over a failed, truncated or interrupted read: from that event on, a position you do not state is one the account does not hold. See Books-synced.
  • When you cannot vouch, say the replay is over. A provider that cannot raise BooksSyncedEvent for a connect (its venue's lists can be cut short, or its positions are added up from its own fills) raises one ReplayCompletedEvent instead, after it has stated every order and position it could read. Never raise both for one connect. Consumers that wait for a current venue then go on, and nothing is retired on your word.
  • Vouch for an account that comes up later. An account created or enabled after the connect's BooksSyncedEvent is announced with an AccountSnapshotEvent. Once you have published every open order and position it holds, raise one AccountBookSyncedEvent for it. Without it the account's missing positions prove nothing.
  • A broker reject is an event, not an exception. PlaceAsync throws only for local validation failure (ArgumentException) or an unknown account (InvalidOperationException). Insufficient buying power, market closed, etc. surface as an OrderUpdateEvent with state OrderState.Rejected.

The four operations

Every operation is async and returns quickly. PlaceAsync returns an OrderId the instant the request is accepted locally; the real outcome (working, fill, reject) arrives on the event stream. CancelAsync, ModifyAsync and FlattenAsync return bool:

  • CancelAsync / ModifyAsync return false when the order is unknown or already terminal. Callers can race a cancel against a fill and treat false as "too late, already happened" without further error handling.
  • FlattenAsync cancels every working order and closes every open position on the account, optionally scoped to one instrument. The close takes out the position the venue holds when it executes (the venue's own close or liquidate call, or an order the venue binds to the position), never a market order sized from a position you read earlier. Returns true when the request was accepted and false when it could not be sent (unknown account, venue unreachable, refused before dispatch). A later refusal is stated with FlattenRefusedEvent; the outcome flows through the event stream.

Declaring capabilities

Declare exactly what your provider supports today. A read-only "view your live broker positions" phase advertises AccountDiscovery | PositionStream and nothing else; the host then never offers order placement on those accounts. Promote the flags as you implement more.

capabilities// Phase one: discovery + live positions only.
public TradingProviderCapabilities Capabilities =>
    TradingProviderCapabilities.AccountDiscovery |
    TradingProviderCapabilities.PositionStream;

// Fully wired later:
// public TradingProviderCapabilities Capabilities => TradingProviderCapabilities.Full;
Commission facts. Stamp Fill.CommissionIsAuthoritative = true on every fill whose Commission is the venue's own fee (a genuine 0 on a commission-free venue included), and leave it false with Commission = 0 when the venue reported nothing, so the platform prices that fill. ReportsCommission is the coarse per-venue shortcut: return true only when every fill carries the venue's fee (crypto venues that report fees, or a sim engine charging its own configured commission). RealizedPnLIncludesCommissions says whether your Account.RealizedPnL is net of fees (true) or gross (false); leave it null until you have proven which. See Venue-reported fees.
Beyond the four verbs. ITradingProvider also exposes optional venue verbs the host gates behind their capability flag: GetBalances(AccountId) (with AssetBalances), ExerciseOptionAsync(AccountId, string instrument, decimal quantity, ct) (with ExerciseOptions), PlaceMultiLegAsync / PrecheckMultiLegAsync (with MultiLegOrders) and PlaceOcoAsync (with NativeOco). Their default implementations refuse: an empty balance list, OcoPlacement.Vetoed, MultiLegPlacement.Failed, MultiLegPrecheckResult.Unsupported and false. The opt-in flags outside Full are NativeOco, MultiLegOrders, ExerciseOptions, Leverage, VenueRealizedPnl, EnforcesOcoGroups, ProtectionIsPositionLevel, DurableOrderIds, BrokerIdChangesOnModify, SupportsAtm, RestatesOrdersAcrossSessions and BookIsOpenTrades (declare it only when everything the venue holds is a futures, forex or crypto perpetual trade, never a holding); see Declaring honestly for when each is true. Keep the default for every verb you do not support and leave its flag unset; the host offers only declared verbs. See the Trading reference and Orders & positions.

A realistic provider skeleton

The shape below shows how the pieces fit: an atomically-swapped snapshot, a ProviderEventOutbox that every book write stages its statement in (under the same lock) and flushes after releasing it, and the four operations validating then translating to your venue. The translation to a real broker session is yours; the contract obligations are what matters here.

DemoTradingProvider.csusing System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using SabrTrader.Pipeline.Venues.Trading;

public sealed class DemoTradingProvider : ITradingProvider
{
    private readonly object _gate = new();
    private volatile IReadOnlyList<Account>  _accounts  = Array.Empty<Account>();
    private volatile IReadOnlyList<Order>    _orders    = Array.Empty<Order>();
    private volatile IReadOnlyList<Position> _positions = Array.Empty<Position>();
    private readonly ConcurrentDictionary<OrderId, Order> _live = new();
    private readonly ProviderEventOutbox _outbox;

    public DemoTradingProvider(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 bool IsRunning { get; private set; }

    public TradingProviderCapabilities Capabilities =>
        TradingProviderCapabilities.PlaceOrders |
        TradingProviderCapabilities.CancelOrders |
        TradingProviderCapabilities.ModifyOrders |
        TradingProviderCapabilities.FlattenPositions |
        TradingProviderCapabilities.AccountDiscovery |
        TradingProviderCapabilities.PositionStream |
        TradingProviderCapabilities.FillsStream |
        TradingProviderCapabilities.Brackets;

    public IReadOnlyList<Account>  Accounts   => _accounts;
    public IReadOnlyList<Order>    OpenOrders => _orders;
    public IReadOnlyList<Position> Positions  => _positions;

    public event Action<TradingEvent>? EventEmitted;

    public async Task<bool> StartAsync(CancellationToken ct = default)
    {
        if (IsRunning) return true;            // idempotent
        // open session + subscribe to account/position/fill streams here...
        var acct = Account.NewSim(
            new AccountId(ProviderKey, "DEMO-1", AccountMode.Sim),
            "Demo account", "USD", startingCash: 50_000m);
        lock (_gate)
        {
            _accounts = new[] { acct };
            IsRunning = true;
            _outbox.Stage(new ConnectionStateEvent(DateTime.UtcNow, ProviderKey, Connected: true, Reason: null));
            _outbox.Stage(new AccountSnapshotEvent(DateTime.UtcNow, acct));
            // ...stage every open order and position you read from the venue, then vouch for the book:
            _outbox.Stage(new BooksSyncedEvent(DateTime.UtcNow, ProviderKey, _positions));
        }
        _outbox.Flush();                       // deliver after the lock is released
        return await Task.FromResult(true);
    }

    public async Task StopAsync(CancellationToken ct = default)
    {
        // cancel session-only orders + close streams here...
        lock (_gate)
        {
            IsRunning = false;
            _outbox.Stage(new ConnectionStateEvent(DateTime.UtcNow, ProviderKey, Connected: false, Reason: "stopped"));
            // Withdraw every account you announced. Never state its positions flat: the broker may still hold them.
            foreach (var a in _accounts)
                _outbox.Stage(new AccountRemovedEvent(DateTime.UtcNow, a.Id));
            _accounts  = Array.Empty<Account>();
            _orders    = Array.Empty<Order>();
            _positions = Array.Empty<Position>();
        }
        _outbox.Flush();                       // never FlushThrough here: Stop must not wait for delivery
        await Task.CompletedTask;
    }

    public Task<OrderId> PlaceAsync(OrderRequest request, AccountId account, CancellationToken ct = default)
    {
        request.Validate();                                  // structural invariants — throws on bad input
        if (!KnowsAccount(account))
            throw new InvalidOperationException($"Unknown account {account}.");

        var id = OrderId.New();
        var order = Order.Pending(id, account, request);     // OrderState.Pending
        lock (_gate)
        {
            _live[id] = order;
            _outbox.Stage(new OrderUpdateEvent(DateTime.UtcNow, order));
        }
        _outbox.Flush();
        // ...route to the venue; broker acks/fills/rejects are staged the same way when they arrive.
        return Task.FromResult(id);
    }

    public Task<bool> CancelAsync(OrderId orderId, CancellationToken ct = default)
    {
        if (!_live.TryGetValue(orderId, out var o) || o.IsTerminal)
            return Task.FromResult(false);                   // unknown or already terminal
        // ...submit the cancel; the ack arrives as an OrderUpdateEvent.
        return Task.FromResult(true);
    }

    public Task<bool> ModifyAsync(OrderId orderId, OrderModification mod, CancellationToken ct = default)
    {
        if (!_live.TryGetValue(orderId, out var o) || o.IsTerminal)
            return Task.FromResult(false);
        // ...apply NewQuantity / NewLimitPrice / NewStopPrice / NewTimeInForce; emit the new snapshot.
        return Task.FromResult(true);
    }

    public Task<bool> FlattenAsync(AccountId account, string? instrument = null, CancellationToken ct = default)
    {
        if (!KnowsAccount(account)) return Task.FromResult(false);
        // ...cancel working orders + close the positions with the venue's own close call (scoped to instrument
        // when non-null); a later refusal is stated with FlattenRefusedEvent.
        return Task.FromResult(true);
    }

    private bool KnowsAccount(AccountId id)
    {
        foreach (var a in _accounts) if (a.Id == id) return true;
        return false;
    }

    public void Dispose() { /* tear down session, detach handlers */ }
}

Bringing it online

The host's connection layer brings your provider online; a venue plugin publishes it as its session's Trading port and does not wire it itself. The host registers the provider first, so the service is subscribed before the first account snapshot, then starts it. The service refuses a provider that is already running: it never reads your book to learn what you hold, it only hears what you state, so hand it a provider that has not been started. Teardown marks the provider detaching, stops it, removes it and disposes it. Neither StopAsync nor Dispose waits for its events to be delivered; any account you stated and never withdrew is withdrawn by RemoveProvider, after everything you already queued:

wiring (host side)var provider = new DemoTradingProvider("demo");
tradingService.AddProvider(provider);   // throws if "demo" is already registered, or if the provider is running
await provider.StartAsync();

// ...later, to take it down:
tradingService.BeginDetach("demo");     // its statements now describe a session that is ending
await provider.StopAsync();
tradingService.RemoveProvider("demo");  // detaches its event stream, withdraws accounts it never withdrew
provider.Dispose();
Provider keys are persisted. Keep ProviderKey deterministic across restarts: persisted AccountIds (trade history, copier groups) embed it. Use the "<venue>:<qualifier>" form (via TradingProviderKeys.Compose) when one venue runs multiple concurrent sessions with different credentials.