Orders & positions

Trading providers

Orders & positions

Constructing an OrderRequest in every shape the domain models, following an order through its lifecycle, modifying and cancelling it, attaching brackets, and reading the resulting position.

The OrderRequest

OrderRequest is the caller-side specification of an order: an immutable, value-equal record. Once placed it never changes; modifications travel as OrderModification deltas against the resulting order's id. Required fields are positional; everything else is optional with sensible defaults.

OrderRequest.csusing SabrTrader.Pipeline.Venues.Trading;

public sealed record OrderRequest(
    string Instrument,
    OrderSide Side,
    OrderType Type,
    decimal Quantity,
    decimal? LimitPrice = null,
    decimal? StopPrice = null,
    TimeInForce TimeInForce = TimeInForce.Day,
    BracketSpec? Bracket = null,
    string? OcoGroupId = null,
    string? ClientTag = null,
    string? InstrumentKey = null,
    string? InstrumentVenue = null,
    string? OriginId = null);
constructing requests// Market buy 2 contracts.
var mkt = new OrderRequest("MNQU6", OrderSide.Buy, OrderType.Market, Quantity: 2m);

// Limit sell 1 @ 18550, good-till-cancelled.
var lmt = new OrderRequest("MNQU6", OrderSide.Sell, OrderType.Limit,
    Quantity: 1m, LimitPrice: 18_550m, TimeInForce: TimeInForce.Gtc);

// Stop-limit buy: trigger at 18600, rest a limit at 18602.
var stp = new OrderRequest("MNQU6", OrderSide.Buy, OrderType.StopLimit,
    Quantity: 1m, StopPrice: 18_600m, LimitPrice: 18_602m);

// Fill-or-kill limit (all-or-nothing).
var fok = new OrderRequest("BTC-USD", OrderSide.Buy, OrderType.Limit,
    Quantity: 0.5m, LimitPrice: 64_000m, TimeInForce: TimeInForce.Fok);

ClientTag and OcoGroupId let an upstream manager correlate orders it placed and link an OCO pair (OcoGroupKind says what the group's members are to each other, see below). OriginId stamps the component that created the order (see provenance). InstrumentKey and InstrumentVenue support cross-venue routing: when a chart fed by venue A routes to an account on venue B, the execution layer reads the canonical InstrumentKey and rewrites Instrument to the routed venue's native symbol. Both are optional; null sends Instrument as written. Routing itself is keyed solely on AccountId.ProviderKey.

A few further optional init-only properties cover venue-specific order semantics: OcoGroupKind OcoGroupKind (see below), bool? ReduceOnly (the order may only shrink or close a position, never open or flip one; perpetual-futures venues enforce it natively, the built-in simulator caps the fill at the held quantity when the order fills and cancels the rest with its OCO siblings, and it is stamped on every protective leg so a consumer can tell a protective leg from an entry) and bool? PostOnly (a crypto/derivative exchange flag), PositionEffect? PositionEffect (Open / Close, for venues that require it on the order), and NativeInstrumentId / NativeInstrumentType (the venue's own instrument identifiers, carried through for options and other non-symbol-keyed instruments). Each is null by default and ignored by venues that do not use it.

Time in force & session

An order carries two terms about time. TimeInForce says how long it lives: Day, Gtc, Ioc or Fok. Session (an OrderSession) says which trading sessions it may fill in: Regular (the default), PreMarket, PostMarket, or Extended, which is the pre-market, the regular session and the post-market as one order. Session is an init property, so an order built without it is a regular-session order.

Each venue takes its own combinations, and they can differ by what is traded: a venue may take a session on stocks and not on options, or a lifetime on stocks and not on crypto. ITradingService.GetTimeInForceChoices(account, assetClass) answers the list for one account and asset class as TimeInForceChoice values (a lifetime plus a session), the venue's default first. An order screen offers exactly that list. InstrumentCanonicalKey.AssetClassOf reads the asset class from the instrument's canonical key.

offering and placing a choiceusing SabrTrader.Pipeline.Venues;
using SabrTrader.Pipeline.Venues.Trading;

const string key = "stock:AAPL";
VenueAssetClass assetClass = InstrumentCanonicalKey.AssetClassOf(key);

// What this account's venue takes for a stock, its default first.
IReadOnlyList<TimeInForceChoice> choices = service.GetTimeInForceChoices(account, assetClass);

// A day order for the extended session, when the venue offers it.
var extended = new TimeInForceChoice(TimeInForce.Day, OrderSession.Extended);
TimeInForceChoice pick = TimeInForceOffers.Holds(choices, extended) ? extended : choices[0];

var order = new OrderRequest("AAPL", OrderSide.Buy, OrderType.Limit,
    Quantity: 10m, LimitPrice: 190.50m, TimeInForce: pick.TimeInForce, InstrumentKey: key)
{
    Session = pick.Session,
};
OrderId id = await service.PlaceAsync(order, account);

The list instance stays the same while the venue's answer is the same, so a screen that keeps the last list compares by reference and rebuilds only when it differs. A venue that declares no list answers TimeInForceOffers.Standard: the four lifetimes for the regular session.

The trading service sends an order that names a session only when the account's venue declares that session with the order's time in force for the instrument's asset class. Any other session order comes back as one Rejected order with RejectOrigin OrderRejectOrigin.Venue, and nothing is sent. The asset class is read from InstrumentKey, so an order that names a session carries its InstrumentKey. A modification keeps the order's session: OrderModification changes the lifetime, the prices and the quantity.

Structural validation

Validate() enforces the structural invariants that make a request impossible regardless of venue. The service calls it before routing, so only well-formed requests reach a provider. It throws ArgumentException with a pinpoint message. Provider-specific checks (instrument exists, market open, sufficient buying power) layer on top inside your adapter.

OrderRequest.Validate (rules)// Throws ArgumentException when:
//  - Instrument is blank
//  - Quantity <= 0           (use OrderSide.Sell + positive qty to reduce a long)
//  - Limit order has no positive LimitPrice
//  - Stop order has no positive StopPrice
//  - StopLimit lacks a positive StopPrice (trigger) AND LimitPrice (resting limit)
//  - Market order carries a LimitPrice or StopPrice
//  - the attached Bracket fails BracketSpec.Validate(Side)
request.Validate();
Quantity is always positive. Side carries direction. To reduce a long position you place an OrderSide.Sell with a positive quantity.

Brackets & presets

An optional BracketSpec attaches a protective stop-loss and profit-target to an entry, linked OCO so a fill on one cancels the other. Prices are absolute prices; the provider translates the bracket into native orders and an OCO group at submission time. Declare the Brackets capability if you support it.

BracketSpec.csusing SabrTrader.Pipeline.Venues.Trading;

public sealed record BracketSpec(decimal? StopLossPrice, decimal? TakeProfitPrice)
{
    // Validates relative to the entry side: for a Buy, SL < TP; for a Sell, SL > TP.
    // Needs at least one of the two prices; both must be positive when set.
    public void Validate(OrderSide entrySide);
}

// A bracketed long entry: stop at 18500, target at 18650.
var entry = new OrderRequest("MNQU6", OrderSide.Buy, OrderType.Market, Quantity: 1m,
    Bracket: new BracketSpec(StopLossPrice: 18_500m, TakeProfitPrice: 18_650m));

Traders usually think in offsets ("10/20 scalp") rather than absolute prices. The Brackets namespace provides reusable presets for that: a named template of magnitude offsets (in price, ticks or pips) that resolves to absolute SL/TP prices against an entry.

BracketPreset.csusing SabrTrader.Pipeline.Venues.Trading;
using SabrTrader.Pipeline.Venues.Trading.Brackets;

public enum BracketOffsetUnit { Price, Ticks, Pips }

public sealed record BracketPreset
{
    public BracketPreset(string name, BracketOffsetUnit unit,
                         decimal? stopLossOffset, decimal? takeProfitOffset);

    public string Name { get; init; }
    public BracketOffsetUnit Unit { get; init; }
    public decimal? StopLossOffset { get; init; }      // magnitude (positive); direction resolved per side
    public decimal? TakeProfitOffset { get; init; }
}

// "Scalp 10/20" — 10 ticks stop, 20 ticks target.
var scalp = new BracketPreset("Scalp 10/20", BracketOffsetUnit.Ticks,
    stopLossOffset: 10m, takeProfitOffset: 20m);

// Resolve to absolute prices for a long entry at 18600 on a 0.25-tick instrument.
// The trailing pipSize (default 0m) is only consulted for a BracketOffsetUnit.Pips preset.
BracketPriceResult px = BracketPresetMath.ComputeBracketPrices(
    entryPrice: 18_600m, side: OrderSide.Buy, preset: scalp, tickSize: 0.25m, pipSize: 0m);
// px.StopLossPrice == 18597.50, px.TakeProfitPrice == 18605.00

var bracketed = entry with { Bracket = new BracketSpec(px.StopLossPrice, px.TakeProfitPrice) };

The host saves presets on this machine: a missing or corrupt presets file loads as an empty list instead of failing, and a save is atomic.

The Order snapshot & fills

Order is an immutable snapshot of an order at a moment in time. The service keeps the latest per Id and delivers a fresh snapshot through the event stream on every meaningful change. The original request is preserved verbatim; a modification produces a new snapshot whose Request reflects the post-modification fields while OriginalRequest keeps the as-submitted form.

Order.csusing SabrTrader.Pipeline.Venues.Trading;

public sealed record Order(
    OrderId Id,
    AccountId AccountId,
    OrderRequest Request,          // live target (reflects modifications)
    OrderRequest OriginalRequest,  // as-submitted, for audit / undo
    OrderState State,
    decimal FilledQuantity,
    decimal AverageFillPrice,
    string? BrokerOrderId,
    string? RejectReason,
    DateTime CreatedUtc,
    DateTime UpdatedUtc)
{
    public bool IsTerminal { get; }              // Filled / Cancelled / Rejected / Expired
    public decimal RemainingQuantity { get; }    // Request.Quantity − FilledQuantity

    public static Order Pending(OrderId id, AccountId accountId, OrderRequest request);
}

// One execution against an order. Multiple fills per order are possible (partials, slicing).
public sealed record Fill(
    OrderId OrderId,
    decimal Quantity,
    decimal Price,
    decimal Commission,
    DateTime TimestampUtc,
    string? FeeCurrency = null)   // null = account currency (the normal case)
{
    public string? ExecutionId { get; init; }              // the venue's own fill id, when it has one
    public bool CommissionIsAuthoritative { get; init; }   // Commission is the venue's fee, not an estimate
}

The service folds fills into the order's running AverageFillPrice and FilledQuantity, but keeps each raw Fill in a separate stream (FillEvent) for audit. Fill.FeeCurrency is non-null only when a venue charged a fee in an asset other than the account currency and it could not be converted at fill time: the fee is recorded raw in that asset and flagged by the currency. Fill.CommissionIsAuthoritative tells the venue's own fee (a genuine 0 on a commission-free venue included) from a fill the platform priced from the trader's commission settings; see Venue-reported fees.

Four more members say what a consumer may do with an order without parsing RejectReason. RejectOrigin (an OrderRejectOrigin) says who refused a rejected order: OrderRejectOrigin.Venue (the venue refused it, or does not take it by its own declaration, such as a session it does not offer), OrderRejectOrigin.Service (the trading service's own veto, nothing was sent) or NotSent. An order nothing was sent for can be asked again as soon as the cause clears; an order the venue refused will be refused again. EndCause (an OrderEndCause) says why an order ended where the platform knows it: OwnModifyFailed and VenueNoLongerKnowsIt both mean what the order protected may be unprotected. PlacedOutside is true for an order this session of the platform did not place, so nothing automated treats it as its own. RestsAtVenue is true while the venue holds the order on its book. The Trading reference lists every value.

Modifying & cancelling

A modification is a delta: what changes when the user drags an SL line, edits a limit, or amends a working order's quantity. Null fields mean "leave unchanged"; non-null fields replace the current value.

OrderModification.csusing SabrTrader.Pipeline.Venues.Trading;

public sealed record OrderModification(
    decimal? NewQuantity = null,
    decimal? NewLimitPrice = null,
    decimal? NewStopPrice = null,
    TimeInForce? NewTimeInForce = null);

// Re-price a working limit to 18560 (qty + TIF unchanged).
bool ok = await service.ModifyAsync(orderId, new OrderModification(NewLimitPrice: 18_560m));

// Cancel a working order. false = unknown or already terminal (raced a fill).
bool cancelled = await service.CancelAsync(orderId);

Both return false when the order is unknown or already terminal, so you can race a cancel against a fill and treat false as "too late" with no special handling. To unwind everything on an account at once, call FlattenAsync(account, instrument?): it cancels every working order and market-closes every open position, optionally scoped to one instrument.

Splitting a protective leg. The chart's order surfaces split one bracket leg into two independent OCO brackets, and combine them back. The host does this on any venue and only on legs its ATM engine placed; adopted and foreign orders keep their size.

Multi-leg & OCO orders

Beyond the single OrderRequest, two placement families model orders that must be submitted as a unit. A provider gates each on its capability flag and implements the matching verb; the host never offers one whose flag is unset.

A MultiLegOrderRequest is an atomic net-priced combo (options spreads) of 2 to 4 OptionLegs (MinLegs = 2, MaxLegs = 4). Place it with PlaceMultiLegAsync (capability MultiLegOrders) which returns a MultiLegPlacement; PrecheckMultiLegAsync returns a MultiLegPrecheckResult with the estimated cash required before committing.

multi-leg (options combo)using SabrTrader.Pipeline.Venues.Trading;

// A vertical call spread: buy the lower strike, sell the higher, one net debit.
var request = new MultiLegOrderRequest(
    Legs: new[]
    {
        new OptionLeg("SPY 250117C600", NativeInstrumentId: "…", NativeInstrumentType: "OPT",
                      OrderSide.Buy,  Ratio: 1, PositionEffect.Open),
        new OptionLeg("SPY 250117C610", NativeInstrumentId: "…", NativeInstrumentType: "OPT",
                      OrderSide.Sell, Ratio: 1, PositionEffect.Open),
    },
    NetLimitPrice: 3.20m,
    Quantity: 1m)
{
    Direction = NetPriceDirection.Debit,
};
request.Validate();

MultiLegPrecheckResult pre = await service.PrecheckMultiLegAsync(request, account);
if (pre.Ok)
{
    MultiLegPlacement placed = await service.PlaceMultiLegAsync(request, account);
    // placed.Success, placed.OrderIds, placed.GroupId, placed.RejectReason
}

An OcoRequest submits a set of OcoLegs where a fill on one cancels the others. Place it with PlaceOcoAsync (capability NativeOco) which returns an OcoPlacement. For venues without native OCO the host can emulate it client-side via ClientSideOcoCoordinator (see the Trading reference).

Orders placed one by one can form a group too: give each the same OcoGroupId and say what the members are to each other with OcoGroupKind. Alternatives (the default) is a set of which only one is meant to trade, such as an entry either way: a rejected member cancels its siblings, so the group is never half-armed. ProtectiveExits is one position's stop and target: a fill of one still takes the others down, but a rejected member leaves its siblings working, because cancelling the stop when the target was refused would leave the position naked. You re-place the refused leg. The group is enforced only by a provider that declares EnforcesOcoGroups; the ATM marks its protective legs ProtectiveExits.

protective exits placed one by oneusing SabrTrader.Pipeline.Venues.Trading;
// A stop and a target on an existing long, linked by one group id.
string group = Guid.NewGuid().ToString("N");
var stop = new OrderRequest("MNQU6", OrderSide.Sell, OrderType.Stop, Quantity: 1m,
    StopPrice: 18_500m, OcoGroupId: group) { OcoGroupKind = OcoGroupKind.ProtectiveExits };
var target = new OrderRequest("MNQU6", OrderSide.Sell, OrderType.Limit, Quantity: 1m,
    LimitPrice: 18_650m, OcoGroupId: group) { OcoGroupKind = OcoGroupKind.ProtectiveExits };
if (service.GetCapabilities(account).HasFlag(TradingProviderCapabilities.EnforcesOcoGroups))
{
    await service.PlaceAsync(stop, account);
    await service.PlaceAsync(target, account);
}

The third kind, EntryPair, is two entries of which only one may open the position, such as a buy stop above a range and a sell stop below it. The first fill of any size, a partial one included, cancels the other. Two orders that already work on one account and instrument become an entry pair with ITradingService.LinkEntryPairAsync. It never throws for a runtime condition: the OcoLinkOutcome it returns is either linked, or carries an OcoLinkRefusal, and on a refusal both orders keep working as they were. IsInEntryPair says whether an order works as one of a pair, and EntryPairsChanged tells a view to read that again.

linking two working entriesusing SabrTrader.Pipeline.Venues.Trading;
using SabrTrader.Pipeline.Venues.Trading.Oco;

// buyStopId and sellStopId are two working orders on the same account and instrument.
OcoLinkOutcome linked = await service.LinkEntryPairAsync(buyStopId, sellStopId);
if (!linked.IsLinked)
{
    // linked.Refusal says why (OcoLinkRefusal). Both orders keep working, unlinked.
}

bool marked = service.IsInEntryPair(buyStopId);   // what a view marks as OCO
OCO pairusing SabrTrader.Pipeline.Venues.Trading;

// Bracket an existing long: a stop below and a limit above, one-cancels-other.
var oco = new OcoRequest(
    Instrument: "MNQU6",
    Legs: new[]
    {
        new OcoLeg(OrderSide.Sell, OrderType.Stop,  Quantity: 1m, StopPrice: 18_500m),
        new OcoLeg(OrderSide.Sell, OrderType.Limit, Quantity: 1m, LimitPrice: 18_650m),
    });
oco.Validate();

OcoPlacement placed = await service.PlaceOcoAsync(oco, account);
// placed.Success, placed.LegOrderIds, placed.FailureReason, placed.NothingRests
if (!placed.Success && !placed.NothingRests)
{
    // A leg may still rest: look at the working orders before you place the pair again.
}
A failed OCO is not proof that nothing rests. A group can fail after one leg went out (the second leg threw, or the rollback cancel of the first was not confirmed). Only a failure built with OcoPlacement.Vetoed(reason), a refusal before any leg was sent (unsupported, not connected, a pre-trade veto), carries NothingRests = true. A provider that refuses a group up front returns Vetoed; a caller that would otherwise retry treats every other failure as of unknown fate.

The Position model

Position is the net position for one (account, instrument) pair. Quantity is signed: positive long, negative short, zero flat (a zero record lingers briefly after close so consumers see the realized-PnL crystallization before the entry is evicted). PnL is split into a mark-to-market UnrealizedPnL (points × quantity × Multiplier) and a running RealizedPnLSession. PnlCurrency names the currency UnrealizedPnL is in when whoever computed it can prove it (a self-marked figure is in the instrument's quote currency); null means the denomination is unproven, and consumers show the raw figure unlabelled.

Position.csusing SabrTrader.Pipeline.Venues.Trading;

public sealed record Position(
    AccountId AccountId,
    string Instrument,
    decimal Quantity,           // signed: + long, − short, 0 flat
    decimal AverageEntryPrice,
    decimal LastPrice,
    decimal UnrealizedPnL,
    decimal RealizedPnLSession,
    DateTime OpenedUtc,
    DateTime UpdatedUtc,
    decimal Multiplier = 1m)    // currency-per-point (ES = 50, MNQ = 2, CL = 1000)
{
    public bool IsSnapshot { get; init; }
    public PositionMetrics? Metrics { get; init; }   // optional per-position financing / derivative / option detail
    public string? PnlCurrency { get; init; }        // ISO-4217 currency of UnrealizedPnL when proven, else null
    public string? InstrumentKey { get; init; }      // provider-neutral identity ("future:ES:2026-06")
    public string? InstrumentVenue { get; init; }    // which venue that identity was resolved on
    public bool IsLong  { get; }
    public bool IsShort { get; }
    public bool IsFlat  { get; }

    public Position ApplyFill(OrderSide side, decimal fillQty, decimal fillPrice, DateTime fillTimeUtc);
    public Position MarkToMarket(decimal price, DateTime timestampUtc);

    public static Position Empty(AccountId account, string instrument, DateTime nowUtc, decimal multiplier = 1m);
}

ApplyFill and MarkToMarket are pure functions implementing the canonical signed-quantity arithmetic: weighted-average on a same-direction add, realized-PnL crystallization on a reduce, and a close-and-flip on an over-fill. They are useful to both a sim engine and a live PnL reconciler, but a broker provider that already reports authoritative position state simply projects the broker's figures into a Position and emits a PositionUpdateEvent.

InstrumentKey / InstrumentVenue are the provider-neutral identity of the instrument, in the same spelling OrderRequest.InstrumentKey uses (InstrumentCanonicalKey.Format(...)). A venue adapter leaves both null: the platform stamps them at book admission through its own IPositionInstrumentIdentity, which is the only thing that can map a venue's wire symbol to a canonical key. They are what lets a consumer match a position against an order routed on a different venue; null simply means "not resolved", never "different instrument".

applying a fill (sim / reconciler)var flat = Position.Empty(accountId, "MNQU6", DateTime.UtcNow, multiplier: 2m);

// Buy 2 @ 18600, then sell 1 @ 18650 → realises (18650−18600) × 1 × 2 = 100.
var p1 = flat.ApplyFill(OrderSide.Buy,  2m, 18_600m, DateTime.UtcNow);  // long 2
var p2 = p1.ApplyFill(OrderSide.Sell, 1m, 18_650m, DateTime.UtcNow);    // long 1, realised +100

// Re-mark against a fresh tick (no realised change).
var p3 = p2.MarkToMarket(18_640m, DateTime.UtcNow);                     // unrealised = (18640−18600)×1×2 = 80

Account-level realized PnL (Account.RealizedPnL) is the cross-instrument aggregate; RealizedPnLSession on a position is that instrument's contribution.