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. 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();
Quantity is always positive. Side carries direction. To reduce a long position you place an 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.

Splitting a protective leg. A UI surface can split one bracket leg into two independent OCO brackets through the 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.