Orders & positions
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.
On this page
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();
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.
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.
}
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.