The trading port

Broker venues

The trading port INTERFACE

Implementing ITradingProvider directly: the four operations, groups and multi-leg, the event stream, and the venue-neutral components that carry the order-lifecycle invariants.

The contract

ITradingProvider (namespace SabrTrader.Pipeline.Venues.Trading) is the trading port every venue publishes directly over its own vendor client. There is no shared broker machine between you and it.

ITradingProvider.cs (abridged)public interface ITradingProvider : IDisposable
{
    string ProviderKey { get; }                        // matches the connection profile
    TradingProviderCapabilities Capabilities { get; }
    bool ReportsCommission => false;                   // do your fills carry an authoritative fee?
    bool IsRunning { get; }

    IReadOnlyList<Account>  Accounts   { get; }        // lock-free snapshots
    IReadOnlyList<Order>    OpenOrders { get; }        // working / partially filled only
    IReadOnlyList<Position> Positions  { get; }        // non-flat only
    IReadOnlyList<AccountBalance> GetBalances(AccountId account);

    event Action<TradingEvent>? EventEmitted;

    Task<bool> StartAsync(CancellationToken ct = default);
    Exception? LastStartError => null;
    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);

    Task<OcoPlacement> PlaceOcoAsync(OcoRequest request, AccountId account, CancellationToken ct = default);
    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);
}

Rules you must honour

  • Thread-safe methods, free-threaded events. Every public method is thread-safe. Events fire on whatever thread your worker decides — subscribers marshal, not you. Do not hold a lock across an event raise.
  • Snapshots are lock-free and always current. Accounts / OpenOrders / Positions return the most-recent committed snapshot; swap the underlying list atomically inside your mutation critical section rather than exposing a live collection.
  • Terminal is terminal. An order that reached a terminal state never re-opens. A cancel racing a fill answers false, never a throw and never a state flip.
  • A venue reject is not an exception. Insufficient buying power, market closed, bad symbol — those surface as an OrderUpdateEvent with state Rejected. Throw only for local validation failures (ArgumentException) and unknown accounts (InvalidOperationException).
  • Unsupported is a value, not a throw. PlaceOcoAsync and the multi-leg members default to a Failed / Unsupported result so callers can substitute any provider freely.
  • Never a leg-by-leg fallback for an atomic order. A venue with no atomic multi-leg endpoint returns MultiLegPlacement.Failed. Submitting the legs sequentially reintroduces exactly the leg risk the atomic order exists to eliminate.

The four operations

Operation Returns Contract
PlaceAsync OrderId The service-side id, immediately. Everything after flows through EventEmitted.
CancelAsync bool true = cancel submitted (the ack still arrives on the stream). false = unknown id OR already terminal — callers treat false as "too late, already happened" and have no error handler.
ModifyAsync bool Same race semantics. A venue without a native modify synthesises it with cancel-replace — and then must track revisions.
FlattenAsync bool Cancel every working order + close every position on the account, optionally scoped to one instrument. false = account unknown.
Emergency flatten has no error handler. A sweep cancels ids it may no longer own, and one throw aborts it — leaving live orders resting on an account the trader believes is flat. That is why CancelAsync of an unknown id must return false rather than throw, and why the contract kit's very first check pins it.

OCO, multi-leg and exercise

PlaceOcoAsync submits a one-cancels-other group — the protective stop and target behind a bracket — in a single call. A venue that enforces OCA/OCO natively submits one broker group and declares TradingProviderCapabilities.NativeOco; a venue that does not enforces the link itself. Either way the returned LegOrderIds align one-to-one with the request legs, so a caller can still trail the stop or drag the target afterwards.

Never route a non-native venue through the native path. The ATM declares its protective pair as a group only when the account's provider publishes NativeOco; without the flag the default PlaceOcoAsync returns Failed and the consumer keeps its client-side path. A venue that claims native OCO it does not have leaves positions unprotected.

Multi-leg placement, precheck and option exercise are venue-specific opt-ins with the same shape: implement the member, declare the flag (MultiLegOrders, ExerciseOptions), and the UI gates on it.

Events & snapshots

EventEmitted is the push stream of state changes; the snapshot properties are how a late subscriber learns current state. The provider does not buffer for late subscribers — a consumer attaching after startup gets only future events and seeds itself from the snapshots.

Event Raised when
AccountSnapshotEvent / AccountRemovedEvent Account discovery and balance/metric updates.
OrderUpdateEvent Any order state change, including venue rejects.
FillEvent Each execution. Carries Commission only when ReportsCommission is true.
PositionUpdateEvent Quantity / average price / live PnL changes.
BalanceSnapshotEvent Per-asset wallet balances (multi-asset venues; declare AssetBalances).
Account.RealizedPnL is nullable and the nullability is load-bearing. Report a figure only when the venue actually gives you today's realized PnL, and declare TradingProviderCapabilities.VenueRealizedPnl when you do. 0m means "flat on the day"; null means "the venue has not reported". Fabricating 0m silently disables every PnL-based risk rule downstream.

The venue-neutral components

All in SabrTrader.Pipeline.Venues.Trading, all pure and venue-agnostic, all carrying the incident that produced them in their own tests. Compose the ones your venue's behaviour needs — they take primitives and delegates, never vendor types.

Component The problem it solves
OrderRevisionTracker A cancel-replace keeps ONE stable OrderId and tells its successive venue orders apart by a :revision suffix on the client reference. The venue streams the superseded revision's cancel while the replacement is still in flight; resolving that echo by the id prefix alone folds the platform order terminal and the moved stop VANISHES from the chart while it rests live at the venue.
StreamGenerationGuard<TKey> One monotonic counter per key, so a REST reconcile can tell whether the stream touched a key DURING its round trip. Without it a stale snapshot stomps fresher stream truth.
AmbiguousPlacementResolver A placement whose transport failed after the request may have reached the venue. Present among open orders → adopt. Absent and still Pending → a Rejected is now truthful. Venue unreachable → stay Pending, because "outcome unknown" is honest and a false Rejected leaves real money unmanaged.
WorkingOrderProbe<TOrder,TUpdate> The missed-fill safety net: periodically ask the venue for each working order's actual state and fold the answer through the normal update path, recovering fills a silently-dead stream never delivered. Single-flight; zero venue calls when nothing is working; a null lookup leaves the order untouched.
PositionSnapshotReconciler Which tracked positions a reconcile snapshot retires. Only licensed by a COMPLETE snapshot; stream-touched keys are kept. Applying only what the snapshot returns leaves ghosts behind — and a ghost position is what makes the ATM rebracket a position that no longer exists.
OrderUpdateFold + VenueOrderReport The fold rules: terminal sealing, local-cancel override, unclassifiable and stale-fill drops, PartiallyFilled derivation, first-write-wins venue id, and a Fill emitted ONLY on cumulative advance — a duplicate equal-quantity snapshot must not re-emit the previous fill.
OrderQuantitySnapper + OrderSizingRules Snap an OrderRequest onto the venue's lot/tick grid. Quantity rounds DOWN (never place more than asked), a limit price rounds in the SAFE direction, a stop/trigger rounds to the nearest tick.
VenueNotConnectedException The one offline condition, shared by the data and trading planes.
Composing the components// Cancel-replace: claim a revision, stamp it, roll back if the venue refused.
int revision = _revisions.Claim(orderId);
string clientRef = OrderRevisionTracker.Stamp(orderId, revision);
if (!await _vendor.ReplaceAsync(venueId, clientRef, mod, ct))
{
    _revisions.Rollback(orderId, revision);
    return false;
}

// …and when a cancel arrives on the stream, ask whether it is the superseded echo:
if (_revisions.IsSupersededRevisionCancel(update.ClientReference))
    return;                       // the replacement is live; folding this terminal would kill it

// Snap before sending. Never place more than the trader asked for.
if (!OrderQuantitySnapper.TrySnap(request, rules, out var snapped, out var error))
    throw new ArgumentException(error, nameof(request));

// The missed-fill net: candidates are ALREADY filtered to non-terminal orders WITH a
// venue id — an order still awaiting its placement ack belongs to the placement resolver.
_probe = new WorkingOrderProbe<Order, VendorOrder>(
    snapshotCandidates: () => OpenOrders.Where(o => o.BrokerOrderId is not null).ToArray(),
    lookup:  o => _vendor.GetOrderAsync(o.BrokerOrderId!),
    fold:    (o, v) => ApplyVenueUpdate(o, v),
    abandoned: () => !IsRunning,
    diagnostic: _log);
_probe.Start();

Optional facets

Optional trading-plane surfaces are probed with AsCapability<T>(), which walks ITradingProviderDecorator chains. Never use a bare is test: a decorator between the consumer and your provider would silently hide the facet.

Trading facets// Probe (consumer side)
var delay = provider.AsCapability<IPositionPropagationDelay>()?.PositionPropagationDelay
            ?? TimeSpan.Zero;

// Declare (venue side): how long this venue's POSITION ROW may lag a fill that moved it.
// Milliseconds on a pushed feed; a full poll period on a REST-rebuilt account state.
public sealed class MyBrokerTradingProvider : ITradingProvider, IPositionPropagationDelay
{
    public TimeSpan PositionPropagationDelay => TimeSpan.FromSeconds(3);
}
Client-side OCO venues must expose IOcoLegGateway. If your provider enforces the OCO link itself, surface the leg gateway so AsCapability<IOcoLegGateway>() finds it. Buried in a private nested adapter it is invisible — and the contract kit's dual-contract pin silently SKIPS instead of gating you. A skip there must be a decision (a native-OCO venue), never an accident.

Its dual contract is the 2026-08-05 lesson: CancelLegAsync returning false means "not accepted — retry", and the coordinator's ONLY terminal short-circuit is TryGetOrder. So a provider that answers false for an order it knows is terminal MUST still report that terminal order through TryGetOrder. False plus untracked is an unbounded retry alarm.

A realistic skeleton

MyBrokerTradingProvider.csinternal sealed class MyBrokerTradingProvider : ITradingProvider, IPositionPropagationDelay
{
    private readonly MyVendorClient _vendor;
    private readonly OrderRevisionTracker _revisions = new();
    private readonly StreamGenerationGuard<string> _generations = new();
    private WorkingOrderProbe<Order, VendorOrder>? _probe;

    private volatile Account[]  _accounts  = Array.Empty<Account>();
    private volatile Order[]    _orders    = Array.Empty<Order>();
    private volatile Position[] _positions = Array.Empty<Position>();

    public string ProviderKey { get; }

    // Declare exactly what works. Modify is synthesised via cancel-replace here —
    // synthesised still counts, and the contract kit proves it works.
    public TradingProviderCapabilities Capabilities =>
        TradingProviderCapabilities.PlaceOrders
        | TradingProviderCapabilities.CancelOrders
        | TradingProviderCapabilities.ModifyOrders
        | TradingProviderCapabilities.FlattenPositions
        | TradingProviderCapabilities.AccountDiscovery
        | TradingProviderCapabilities.PositionStream
        | TradingProviderCapabilities.FillsStream
        | TradingProviderCapabilities.Brackets
        | TradingProviderCapabilities.ShortSelling
        | TradingProviderCapabilities.VenueRealizedPnl;   // this venue DOES report today's realized

    public bool ReportsCommission => true;                // …and its fills carry the fee
    public TimeSpan PositionPropagationDelay => TimeSpan.FromSeconds(3);

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

    public bool IsRunning { get; private set; }
    public Exception? LastStartError { get; private set; }
    public event Action<TradingEvent>? EventEmitted;

    public async Task<bool> StartAsync(CancellationToken ct = default)
    {
        if (IsRunning) return true;
        try
        {
            await _vendor.OpenStreamsAsync(ct).ConfigureAwait(false);
            await ReconcileAsync(ct).ConfigureAwait(false);

            _probe = new WorkingOrderProbe<Order, VendorOrder>(
                snapshotCandidates: () => _orders.Where(o => o.BrokerOrderId is not null).ToArray(),
                lookup: o => _vendor.GetOrderAsync(o.BrokerOrderId!),
                fold: ApplyVenueUpdate,
                abandoned: () => !IsRunning);
            _probe.Start();

            IsRunning = true;
            return true;
        }
        catch (Exception ex)
        {
            // A failed start is `false`, not a throw — but the venue's own explanation
            // must not be lost, or the host is left guessing "check your credentials".
            LastStartError = ex;
            return false;
        }
    }

    public async Task<OrderId> PlaceAsync(OrderRequest request, AccountId account, CancellationToken ct = default)
    {
        if (!_accounts.Any(a => a.Id == account))
            throw new InvalidOperationException($"Account {account} is not known to {ProviderKey}.");

        if (!OrderQuantitySnapper.TrySnap(request, RulesFor(request.Instrument),
                                          out var snapped, out var error))
            throw new ArgumentException(error, nameof(request));

        var orderId = OrderId.New();
        int revision = _revisions.Claim(orderId);
        try
        {
            await _vendor.SubmitAsync(snapped!, account, OrderRevisionTracker.Stamp(orderId, revision), ct)
                         .ConfigureAwait(false);
        }
        catch (TimeoutException)
        {
            // The request MAY have landed. Never reject on a guess — resolve it.
            _ = AmbiguousPlacementResolver.ResolveAsync(
                AmbiguousPlacementResolver.DefaultProbeDelay,
                Task.Delay,
                abandoned: () => !IsRunning,
                fetchOpenOrders: () => _vendor.ListOpenOrdersAsync(account),
                isPendingOrdersTwin: v => v.ClientReference == OrderRevisionTracker.Stamp(orderId, revision),
                adoptVenueTruth: v => ApplyVenueUpdate(Track(orderId), v),
                isStillPending: () => StateOf(orderId) == OrderState.Pending,
                rejectAsNeverLanded: () => Emit(RejectedUpdate(orderId, "never reached the venue")));
        }
        return orderId;
    }

    public async Task<bool> CancelAsync(OrderId orderId, CancellationToken ct = default)
    {
        // Unknown or terminal: false, never a throw. Emergency flatten depends on it.
        // Order.IsTerminal is a property ON the order, not an OrderState extension.
        if (!TryGetTracked(orderId, out var order) || order.IsTerminal) return false;
        return await _vendor.CancelAsync(order.BrokerOrderId!, ct).ConfigureAwait(false);
    }

    public void Dispose() { _probe?.Dispose(); _vendor.Dispose(); }
}