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 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();
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.
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.