The trading port
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.
On this page
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/Positionsreturn 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
OrderUpdateEventwith stateRejected. Throw only for local validation failures (ArgumentException) and unknown accounts (InvalidOperationException). -
Unsupported is a value, not a throw.
PlaceOcoAsyncand the multi-leg members default to aFailed/Unsupportedresult 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. |
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.
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);
}
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(); }
}