Options: multi-leg orders & exercise
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.
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/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 leg —
NativeInstrumentIdandNativeInstrumentTypeboth 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
Quantityinstead (a 1/2/1 butterfly atQuantity: 2works 2/4/2 contracts). - No duplicate (contract, side) pairs — the same contract on the same side twice is rejected; 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. 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}");
}
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.
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
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.