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. 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 leaves
Instrument untouched (legacy / same-venue behaviour is byte-for-byte unchanged). Neither
affects routing — routing is keyed solely on AccountId.ProviderKey.
A few further optional init-only properties cover venue-specific order semantics:
bool? ReduceOnly and bool? PostOnly (crypto/derivative exchange flags),
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.
Structural validation
Validate() enforces the structural invariants that make a request impossible regardless of
venue — the service calls it before routing, so a malformed request never reaches 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 — never a negative 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 (not offsets) — 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 rarely type absolute prices, though — they think in offsets ("10/20 scalp"). 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) };
Presets persist through IBracketPresetStore (Load() /
Save(presets)) — a never-throwing load that returns empty on a missing or corrupt file,
and an atomic save. The host ships a JSON-backed implementation; tests can supply an in-memory fake.
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)
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 — it's recorded
raw and flagged rather than mis-stated.
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.
IBracketLegSplitController seam (CanSplit(orderId) /
SplitLeg(orderId, quantity, widenTicks)). It is venue-agnostic (quantity is a plain
decimal) and acts only on engine-owned legs — an adopted or foreign order is never
re-sized.
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–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).
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
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 and a running RealizedPnLSession /* + string? PnlCurrency */; both are reported in account
currency as points × quantity × Multiplier.
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 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.
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.