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;                   // does EVERY fill carry the venue's own fee?
    bool? RealizedPnLIncludesCommissions => null;      // is Account.RealizedPnL net of fees? null = undeclared
    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. 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 an event. 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 Vetoed / 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 core 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. Pin it with a test.

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 OcoPlacement.Vetoed (failed, nothing sent) and the consumer keeps its client-side path. A venue that claims native OCO it does not have leaves positions unprotected.

Say how an OCO failed. Return OcoPlacement.Vetoed(reason) when you refuse the group before any leg is sent (unsupported, not connected, a failed pre-flight check): it sets NothingRests, the caller's proof that no leg works. Return OcoPlacement.Failed(reason) for anything that went wrong after a leg may have reached the venue; a caller treats that as of unknown fate and looks before it places the group again. ClientSideOcoCoordinator.PlaceAsync follows the same rule: its rollback cancels are unconfirmed, so its failures never carry NothingRests.

refusing up frontpublic Task<OcoPlacement> PlaceOcoAsync(OcoRequest request, AccountId account, CancellationToken ct = default)
{
    if (!IsRunning)
        return Task.FromResult(OcoPlacement.Vetoed("Not connected."));   // nothing was sent
    return _oco.PlaceAsync(request, account, ct);                          // Ok, or Failed with unknown fate
}

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.

Individual PlaceAsync calls can form a group as well: each leg carries the same OrderRequest.OcoGroupId, and OrderRequest.OcoGroupKind says what the members are to each other. The ATM places its stop and target this way, marked ProtectiveExits. A provider that enforces such groups itself registers every leg with its ClientSideOcoCoordinator through Register(OrderRequest request, OrderId orderId), the overload that records the kind, feeds it its own order updates, and declares EnforcesOcoGroups. The coordinator then takes the other members down when one fills; when one is REJECTED, an Alternatives group cancels its siblings while a ProtectiveExits group leaves them working, because cancelling a working stop since its target was refused leaves the position naked.

enforcing OcoGroupId on single placementspublic async Task<OrderId> PlaceAsync(OrderRequest request, AccountId account, CancellationToken ct = default)
{
    var orderId = OrderId.New();
    _oco.Register(request, orderId);   // no-op without an OcoGroupId; records OcoGroupKind
    await SubmitAsync(request, account, orderId, ct).ConfigureAwait(false);
    return orderId;
}
// …and every order update of your own goes through the coordinator:
_ = _oco.OnOrderUpdate(order);

Events & snapshots

EventEmitted is the push stream of state changes; the snapshot properties are how a late subscriber learns current state. 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. Put the venue's fee in Commission and set CommissionIsAuthoritative when the venue reported one for this execution; otherwise leave Commission at 0 and the flag false, and the platform prices it (see Venue-reported fees).
PositionUpdateEvent Quantity / average price / live PnL changes.
BalanceSnapshotEvent Per-asset wallet balances (multi-asset venues; declare AssetBalances).
ConnectionStateEvent Your connection went up or down. Every connect starts a replay that only its own BooksSyncedEvent ends.
BooksSyncedEvent Once per connect, after the whole book is published (below).
AccountStateResetEvent An account was reset outside trading; raised before you restate it.
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.

Books-synced & account resets

The trading service writes every order, position and execution you raise into its broker facts before any decorator or subscriber sees it. Those facts hold statements only: nothing is computed from fills, and a position you have not stated is unknown, never flat. So a missing position proves nothing until you say your book is complete. That is what BooksSyncedEvent says.

  • Exactly once per connect. Raise it after the connect's ConnectionStateEvent and after you have published every open order and every open position you hold for your accounts: a streaming venue when its initial replay completes, a polling broker after its first complete poll of orders and positions. Every later connect (a reconnect, a recovered poll) raises its own.
  • Only over a complete read. A failed, truncated, paged-but-changing or interrupted poll must not raise it. If you cannot prove the book complete, do not raise it: a provider that never raises it only leaves its absences unproven, while one raised over a partial book makes a position the venue still holds read flat.
  • Carry the book you published. new BooksSyncedEvent(TimestampUtc, ProviderKey, Positions) takes the open positions you stated before it, captured when you raise it. Never read them later from your live book: a provider being stopped has emptied it.
  • From then on, absence is a statement. A position you do not state is a position the account does not hold, so state a position going flat at least once and never let an older frame restate a position you have moved past.

The service never takes your boundary as proof of a position whose last execution is younger than your settle window (max(IPositionPropagationDelay, BrokerFactsTable.MinimumSettle), at least 10 seconds), because your own position stream may not reflect that fill yet. That rule lives once, in the service: raise the boundary when the book is published, and never hold it back on a timer.

When an account is reset outside trading (a simulator's account reset), raise AccountStateResetEvent(TimestampUtc, AccountId) before you restate the account: every order and position stated for it before belongs to a history that no longer exists.

vouching for the book// After the connect and a complete read of orders and positions.
Emit(new ConnectionStateEvent(DateTime.UtcNow, ProviderKey, Connected: true, Reason: null));
foreach (var order in _orders)    Emit(new OrderUpdateEvent(DateTime.UtcNow, order));
foreach (var held in _positions)  Emit(new PositionUpdateEvent(DateTime.UtcNow, held));
Emit(new BooksSyncedEvent(DateTime.UtcNow, ProviderKey, _positions.ToArray()));

The venue-neutral components

All in SabrTrader.Pipeline.Venues.Trading, all pure and venue-agnostic, all tested against the failure that produced them. 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. A report is (OrderState? State, decimal FilledQuantity, DateTime TimestampUtc, decimal AverageFillPrice, string? VenueOrderId = null, string? RejectReason = null, decimal? LastFillQuantity = null, decimal? LastFillPrice = null, decimal LastFillCommission = 0, bool LastFillCommissionIsAuthoritative = false): fill the LAST-fill fields when your venue reports per-execution detail, and leave them null when it only sends cumulative state. Set LastFillCommissionIsAuthoritative when LastFillCommission is the venue's own fee for that execution; the fold carries it onto the emitted Fill as CommissionIsAuthoritative.
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(orderId, update.ClientReference,
                                           isNonExecutingCancelOrExpiry: update.IsCancelOrExpiry))
    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);
}

IPositionRefresh is the other position facet: Task<Position> RefreshPositionAsync(AccountId account, string instrument, CancellationToken ct = default) asks the venue for one position now instead of waiting for the next pushed or polled statement. Return a flat Position when the venue holds none, and THROW when the venue could not be asked (a failed or cancelled request). An unanswered pull is never a flat position: answering flat on a failure tells a consumer that may cancel a stop that the position is gone.

IPositionRefreshpublic async Task<Position> RefreshPositionAsync(AccountId account, string instrument, CancellationToken ct = default)
{
    // Throws on a transport failure or a non-success answer: never caught and turned into flat.
    VendorPosition? row = await _vendor.GetPositionAsync(account.ProviderAccountId, instrument, ct).ConfigureAwait(false);
    return row is null ? Position.Empty(account, instrument, DateTime.UtcNow) : ToPosition(account, row);
}

ITimeInForceOffer declares the time-in-force list of your venue: IReadOnlyList<TimeInForceChoice> TimeInForceChoices(VenueAssetClass assetClass). A TimeInForceChoice is a lifetime (TimeInForce) plus the sessions it applies to (OrderSession). Order screens offer exactly the list you return, in your order, and pick its first entry as the default. The trading service hands your provider an order that names a session (OrderRequest.Session other than Regular) only when your list holds that session with the order's time in force for the instrument's asset class.

ITimeInForceOffer// One table: what the ticket is offered and what the order body is sent with.
internal static class MyBrokerTimeInForce
{
    private static readonly IReadOnlyList<TimeInForceChoice> Stocks = new TimeInForceChoice[]
    {
        new(TimeInForce.Day),                           // first entry: the default
        new(TimeInForce.Gtc),
        new(TimeInForce.Day, OrderSession.Extended),
        new(TimeInForce.Gtc, OrderSession.Extended),
    };

    private static readonly IReadOnlyList<TimeInForceChoice> RegularOnly = new TimeInForceChoice[]
    {
        new(TimeInForce.Day),
        new(TimeInForce.Gtc),
    };

    public static IReadOnlyList<TimeInForceChoice> For(VenueAssetClass assetClass)
        => assetClass == VenueAssetClass.Stock ? Stocks : RegularOnly;
}

public sealed class MyBrokerTradingProvider : ITradingProvider, ITimeInForceOffer
{
    public IReadOnlyList<TimeInForceChoice> TimeInForceChoices(VenueAssetClass assetClass)
        => MyBrokerTimeInForce.For(assetClass);
}

Declare what your adapter sends: a lifetime it sends as that lifetime, and a session it sends as that session. Return a list you hold for the provider's lifetime, the same instance for the same answer and never empty, so a screen can compare two answers by reference. VenueAssetClass.Unknown asks for the choices that hold whatever is traded; answer it with regular-session entries only. Read the order body's lifetime and session from the same table as the list, and have a test drive your real order mapping for every declared choice, so the ticket and the sender stay in step. Your adapter still refuses what the venue takes in a session for some order types only (a venue that fills limit orders only outside its regular session refuses a market order that names one), with a reason the trader can read. A provider without this facet is offered as TimeInForceOffers.Standard: Day, Gtc, Ioc and Fok for the regular session.

IVenueCommissionRateSource (namespace SabrTrader.Pipeline.Venues.Trading.Commissions) is the facet for a venue that publishes its commission rates per account and product root, such as Rithmic's RMS rates: TryGetVenueCommissionRate(account, productRoot) and VenueCommissionRates(account). The platform prices your unstamped fills from it ahead of the trader's own settings. Venue-reported fees has the precedence and a thread-safe implementation.

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 to the ATM and to your own tests. Only a native-OCO venue leaves it out.

Its dual contract: 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 loop.

A realistic skeleton

MyBrokerTradingProvider.cs (abridged)internal 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 a test against the venue double 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);
            // Connected and the whole book published: vouch for it, once for this connect.
            Emit(new BooksSyncedEvent(DateTime.UtcNow, ProviderKey, _positions));

            _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(); }
}