Options: multi-leg orders & exercise

Options data & trading

Multi-leg orders & exercise

Place verticals, straddles, butterflies and iron condors as one atomic net-limit order from a strategy — legs built from chain data, validated before they touch the network, with honest result semantics when things go wrong.

The strategy surface

Three protected methods on Strategy (mirrored on IStrategyContext) cover options execution, all on this run's account:

Member Does
PrecheckMultiLegOrderAsync(request) Venue pre-check — estimated cash/margin required — without placing. Advisory only.
PlaceMultiLegOrderAsync(request) Places all legs as one atomic net-limit order. The venue fills legs symmetrically — no leg risk.
ExerciseOptionAsync(instrument, quantity) Exercises an option position held on the run's account.

Unlike PlaceOrder, the multi-leg verbs are not instrument-guarded to the chart symbol — option contracts are distinct instruments from the run's chart symbol by construction. A host or venue without the capability answers through the result (Failed / Unsupported), never with an exception — backtest hosts included, so your strategy compiles and runs everywhere. Venue-side, support is declared through the MultiLegOrders and ExerciseOptions capability flags.

Building legs from chain data

Multi-leg is native-only: every leg must carry the venue-native instrument id and classification, because option contracts are not resolvable through keyword symbol search. Both come straight off the chain contract — take ProviderInstrumentId and NativeInstrumentType from contracts of a chain you opened through OptionChains, and never hand-build them:

Bull call verticalusing SabrTrader.Pipeline.Options;
using SabrTrader.Pipeline.Venues.Trading;

static OptionLeg Leg(OptionContract c, OrderSide side, decimal? mid = null) => new(
    Instrument:           c.DisplaySymbol,
    NativeInstrumentId:   c.ProviderInstrumentId,
    NativeInstrumentType: c.NativeInstrumentType,
    Side:                 side,
    Ratio:                1,
    PositionEffect:       PositionEffect.Open)
{ ReferencePrice = mid };                       // live mid, for realistic pre-trade risk checks

var request = new MultiLegOrderRequest(
    Legs: new[]
    {
        Leg(longCall,  OrderSide.Buy,  longMid),   // buy the lower strike…
        Leg(shortCall, OrderSide.Sell, shortMid),  // …sell the higher strike
    },
    NetLimitPrice: 1.25m,                       // positive magnitude, always
    Quantity: 1m)                               // strategy count — each leg works Ratio × Quantity
{
    Direction = NetPriceDirection.Debit,        // pinned at the moment of intent
};
PositionEffect is explicit per leg — never derived. Venues with open/close netting reject ambiguity server-side, and a wrong guess silently opens a naked short instead of closing a position. Opening a new spread: Open on every leg. Closing one you hold: Close on every leg, sides reversed.

The validation rules

request.Validate() throws ArgumentException with a pinpoint message so a bad shape never reaches the network. The placement path runs it for you; calling it yourself first gives you the error at build time. The invariants, which hold for every venue:

  • 2 to 4 legs (MinLegs/MaxLegs) — from a vertical up to an iron condor. The cap is deliberate: every supported shape is probe-proven against real venues.
  • Native ids on every legNativeInstrumentId and NativeInstrumentType both non-empty.
  • Ratios ≥ 1, and normalised so gcd(ratios) = 1 — a 1/2/1 butterfly, not 2/4/2; the venue requires leg amounts to be mutually divisible. Scale size with Quantity instead (a 1/2/1 butterfly at Quantity: 2 works 2/4/2 contracts).
  • No duplicate (contract, side) pairs — the same contract on the same side twice is rejected; merge the ratios instead.
  • Quantity and NetLimitPrice positive.

Net price & direction

NetLimitPrice is always a positive magnitude — whether you pay it or receive it is a separate field. Venues differ: some derive debit/credit from the legs themselves and reject signed prices outright; others need it stated explicitly. Direction (Debit = you pay, Credit = you receive) covers both — pin it when you compute the price, at the moment of user/strategy intent, and never re-derive it from a live mid afterwards: a near-even spread must not sign-flip between decision and placement. Leave it null only when you genuinely cannot know; venues that require a direction then refuse honestly.

Pre-checking

PrecheckMultiLegPrecheckResult check = await PrecheckMultiLegOrderAsync(request);
if (check.Ok)
    Log($"Estimated requirement: {check.EstimatedCashRequired} {check.Currency}");
else
    Log($"Precheck: {check.Reason}");           // unsupported venue, or a venue-side refusal

The estimate is advisory — the venue re-checks at placement and remains the final arbiter. A provider without the capability answers Unsupported; treat that as "don't place", not as an error.

Placing & the result

PlaceMultiLegPlacement result = await PlaceMultiLegOrderAsync(request);

if (result.Success)
{
    // One platform OrderId per leg, POSITIONALLY aligned with request.Legs.
    // Fills and later state changes arrive through OnOrderUpdate / OnFill as usual.
    _spreadOrderIds = result.OrderIds;
    _spreadGroupId  = result.GroupId;           // the venue's strategy-order id
}
else
{
    Log($"Spread rejected: {result.RejectReason}");
}
The group is one order at the venue. Cancelling any leg cancels the whole group — venues refuse per-leg cancels of a strategy order. Track the legs by their OrderIds; state lands per leg through the normal order-update stream.

When the call itself fails

The task never faults (short of your own cancellation) — every failure lands in RejectReason, so even a fire-and-forget call cannot silently lose an order fault. But read the reason before acting on it, because two very different things end up there:

  • Definitive rejects — validation failures and venue refusals that provably happened before or at the order gate. Nothing is working. Safe to correct and re-place.
  • ORDER FATE UNKNOWN — the placement call faulted mid-flight (transport error, timeout, service fault). The reason string starts with exactly that marker. The order may have reached the venue and may be working right now.
Never re-place on fate-unknown. A spread that actually made it to the venue plus your "retry" is a doubled position. On an ORDER FATE UNKNOWN reason: check WorkingOrders and wait for OnOrderUpdate to tell you whether legs are working — only re-place once you've confirmed nothing is.

Threading

Code after the await runs OFF the strategy thread. The strategy's callbacks (OnBar, OnOrderUpdate, …) are serialized for you; a continuation of PlaceMultiLegOrderAsync is not — it resumes on a thread-pool thread. Don't touch bars, managed-order state or any other context member there. Store the result in a field and act on it from the next OnBar/OnOrderUpdate — which is where the leg fills land anyway.

Exercising a position

ExerciseOptionAsync exercises an option position held on the run's account. Pass the position's instrument string as the platform reports it — take Position.Instrument from your position updates, don't reconstruct it — and the number of contracts:

Exercisebool ok = await ExerciseOptionAsync(optionPosition.Instrument, quantity: 1m);
if (!ok) Log("Exercise refused (venue refusal, or host without exercise support).");

false covers both "the venue refused" and "this host doesn't support exercise" — like every options verb, unsupported is a result, not an exception. Whether early exercise is even possible is venue- and style-dependent; American-style contracts report it through position metrics where the venue exposes that.