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 TradeStrike.Pipeline.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.

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 TradeStrike.Pipeline.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 or ticks) that resolves to absolute SL/TP prices against an entry.

BracketPreset.csusing TradeStrike.Pipeline.Trading;
using TradeStrike.Pipeline.Trading.Brackets;

public enum BracketOffsetUnit { Price, Ticks }

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.
BracketPriceResult px = BracketPresetMath.ComputeBracketPrices(
    entryPrice: 18_600m, side: OrderSide.Buy, preset: scalp, tickSize: 0.25m);
// 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 TradeStrike.Pipeline.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 TradeStrike.Pipeline.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.

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; both are reported in account currency as points × quantity × Multiplier.

Position.csusing TradeStrike.Pipeline.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 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.