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 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, 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 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. 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" — no error handling needed.
  • FlattenAsync — cancel every working order and close every open position on the account, optionally scoped to one instrument. Returns false only 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();
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.