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 TradeStrike.Pipeline.Trading;
public interface ITradingProvider : IDisposable
{
string ProviderKey { get; } // matches AccountId.ProviderKey
TradingProviderCapabilities Capabilities { get; } // what the UI may offer
bool ReportsCommission => false; // true when fills carry an authoritative fee
bool IsRunning { get; }
// 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);
}
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 gets only future events — they read 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.
-
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/ModifyAsync— returnfalsewhen the order is unknown or already terminal. Callers can race a cancel against a fill and treatfalseas "too late, already happened" — no error handling needed. -
FlattenAsync— cancel every working order and close every open position on the account, optionally scoped to one instrument. Returnsfalseonly when the account is unknown; the resulting cancels and market-close orders flow 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;
ReportsCommission. Return true only when your fills already carry an
authoritative Fill.Commission (crypto venues that report fees, or a sim engine charging its
own configured commission). The futures brokers report 0 and leave it false
(the default) so the platform computes the fee. This flag exists so the commission layer never
double-charges a venue that already reported one. See Commissions.
A realistic provider skeleton
The shape below shows how the pieces fit: an atomically-swapped snapshot, an emit helper that all your worker code routes through, 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.Collections.Concurrent;
using TradeStrike.Pipeline.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();
public DemoTradingProvider(string providerKey) => ProviderKey = providerKey;
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;
Emit(new AccountSnapshotEvent(DateTime.UtcNow, acct));
Emit(new ConnectionStateEvent(DateTime.UtcNow, ProviderKey, Connected: true, Reason: null));
return await Task.FromResult(true);
}
public async Task StopAsync(CancellationToken ct = default)
{
// cancel session-only orders + close streams here...
IsRunning = false;
Emit(new ConnectionStateEvent(DateTime.UtcNow, ProviderKey, Connected: false, Reason: "stopped"));
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
_live[id] = order;
Emit(new OrderUpdateEvent(DateTime.UtcNow, order));
// ...route to the venue; broker acks/fills/rejects flow back through Emit later.
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 + market-close positions (scoped to instrument when non-null).
return Task.FromResult(true);
}
private bool KnowsAccount(AccountId id)
{
foreach (var a in _accounts) if (a.Id == id) return true;
return false;
}
// All worker code routes emits through here so ordering + thread choice are consistent.
private void Emit(TradingEvent ev) => EventEmitted?.Invoke(ev);
public void Dispose() { /* tear down session, detach handlers */ }
}
Bringing it online
The provider must be started before it is registered — the service does not start it for you. Construct, start, then add:
wiring (host side)var provider = new DemoTradingProvider("demo");
await provider.StartAsync();
tradingService.AddProvider(provider); // throws if "demo" is already registered
// ...later, to detach (does NOT stop the provider):
tradingService.RemoveProvider("demo");
await provider.StopAsync();
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.