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; // 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/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 an event. 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 aVetoed/Failed/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 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. |
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.
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
ConnectionStateEventand 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.
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(); }
}