Options: multi-leg orders & exercise

Options data & trading

Multi-leg orders & exercise

Place verticals, straddles, butterflies and iron condors as one net-limit order from a strategy: legs built from chain data, validated before they reach the network, with explicit result semantics when something goes 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 or margin required) without placing. Advisory only.
PlaceMultiLegOrderAsync(request)
PlaceMultiLegOrderAsync(request, contracts, name)
Places all legs as one net-limit order that the venue fills as a unit, so there is no leg risk. The second overload also records the legs as a named strategy (see Recording it as a strategy).
ExerciseOptionAsync(instrument, quantity) Exercises an option position held on the run's account.

PlaceOrder is guarded to the run's chart instrument; the multi-leg methods are not, because option contracts are always different instruments from the chart symbol. A host or venue without the capability answers through the result (Failed / Unsupported / false) rather than an exception, backtest hosts included, so your strategy compiles and runs everywhere. Venues declare support through the MultiLegOrders and ExerciseOptions capability flags.

Building legs from chain data

Multi-leg orders are native-only: every leg carries 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 pass them through unchanged:

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,                       // always a positive magnitude
    Quantity: 1m)                               // strategy count: each leg works Ratio × Quantity
{
    Direction = NetPriceDirection.Debit,        // pinned at the moment of intent
};

ClientTag (optional, last constructor argument) is your own label for the order group.

PositionEffect is explicit per leg. Venues with open/close netting reject ambiguity server-side, and a wrong value 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 specific 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 hold for every venue:

  • 2 to 4 legs (MinLegs/MaxLegs), from a vertical up to an iron condor.
  • Native ids on every leg: NativeInstrumentId and NativeInstrumentType both non-empty.
  • Ratios ≥ 1, normalised so gcd(ratios) = 1: a 1/2/1 butterfly, not 2/4/2, because venues require 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: 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. Some venues derive debit or credit from the legs and reject signed prices, others need it stated. Direction (Debit = you pay, Credit = you receive) covers both. Set it when you compute the price, at the moment of intent, and keep it: a near-even spread must not flip sign between decision and placement because a live mid moved. Leave it null only when you cannot know; venues that require a direction then reject the order with a reason.

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 checks again at placement and has the final word. A venue without the capability answers with Ok == false and an "unsupported" reason (MultiLegPrecheckResult.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, because venues cancel a strategy order as a unit. Track the legs by their OrderIds; state lands per leg through the normal order-update stream.

Recording it as a strategy

To the order book, the legs you placed are separate option orders. Pass the chain contracts alongside the request and the run also records them as ONE named strategy: the row the trader sees in the account tab, with its own net entry price, live P&L and close action.

Place and record// The OptionContract objects the chain view handed you, one per leg,
// positionally aligned with request.Legs.
MultiLegPlacement result = await PlaceMultiLegOrderAsync(request, contracts);

// Or choose the name yourself instead of the classifier's:
MultiLegPlacement preset = await PlaceMultiLegOrderAsync(request, contracts, name: "Weekly condor");

The host names the shape by matching sides, strikes, rights, expiries and ratios against the known shapes (vertical, straddle, strangle, butterfly, iron condor, calendar, diagonal, iron butterfly, risk reversal, ratio spread, backspread, condor) and answers "Custom (N legs)" for anything else. It is the platform's single shape rule, so a butterfly is a butterfly in the options builder, in the account tab and in your run.

Only OPENING legs are recorded. A leg that closes an existing contract counts as a close of the strategy that holds that contract. A set with no opening leg (closing a strategy, the close half of a roll) records nothing and just places. Each contract must match its leg's NativeInstrumentId, and the opening legs must share one underlying; otherwise the call is rejected. Both overloads return the same MultiLegPlacement.

The platform tracks leg fills, closes and the derived status (Working → Partial → Open → Closed, or Cancelled) from the order stream and shows the strategy in the account tab.

When the call itself fails

The task completes normally for every failure except your own cancellation: the failure lands in RejectReason, so a fire-and-forget call cannot lose an order fault. Read the reason before acting on it, because two very different outcomes end up there:

  • Definitive rejects: validation failures and venue refusals that happened before or at the order gate. Nothing is working. Safe to correct and place again.
  • 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 place again on fate-unknown. A spread that reached 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. Place again only once you have confirmed nothing is.

Threading

Code after the await runs OFF the strategy thread. The strategy's callbacks (OnBar, OnOrderUpdate, ...) are serialised for you; a continuation of PlaceMultiLegOrderAsync resumes on a thread-pool thread. Leave bars, managed-order state and other context members alone 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 rather than building 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 does not support exercise"; as with every options method, unsupported is a result. Whether early exercise is possible depends on the venue and the contract's ExerciseStyle.