Options: multi-leg orders & exercise
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.
On this page
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:
NativeInstrumentIdandNativeInstrumentTypeboth 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
Quantityinstead (a 1/2/1 butterfly atQuantity: 2works 2/4/2 contracts). - No duplicate (contract, side) pairs: merge the ratios instead.
-
QuantityandNetLimitPricepositive.
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}");
}
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.
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.
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
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.