The trading provider
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,OpenOrdersandPositionsalways 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.
OpenOrdersholds only working / partially-filled orders (terminal ones drop out on the next snapshot);Positionsholds only non-flat positions (a position going flat emits one final snapshot, then drops). -
No replay for late subscribers. Anyone attaching to
EventEmittedafter 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
ProviderEventOutboxand 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
Positionsstays open for every consumer. An account you stop hosting (stopped, failed start, rolled-back start, closed at the venue) is withdrawn withAccountRemovedEvent, 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 oneBooksSyncedEventcarrying 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
BooksSyncedEventfor a connect (its venue's lists can be cut short, or its positions are added up from its own fills) raises oneReplayCompletedEventinstead, 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
BooksSyncedEventis announced with anAccountSnapshotEvent. Once you have published every open order and position it holds, raise oneAccountBookSyncedEventfor it. Without it the account's missing positions prove nothing. -
A broker reject is an event, not an exception.
PlaceAsyncthrows only for local validation failure (ArgumentException) or an unknown account (InvalidOperationException). Insufficient buying power, market closed, etc. surface as anOrderUpdateEventwith stateOrderState.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/ModifyAsyncreturnfalsewhen the order is unknown or already terminal. Callers can race a cancel against a fill and treatfalseas "too late, already happened" without further error handling. -
FlattenAsynccancels 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. Returnstruewhen the request was accepted andfalsewhen it could not be sent (unknown account, venue unreachable, refused before dispatch). A later refusal is stated withFlattenRefusedEvent; 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;
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.
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();
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.