Nautilus Trader - Options
Nautilus models options as first-class instruments (
OptionContract,OptionSpread,CryptoOption,BinaryOption) that flow through the same DataEngine / Cache / Strategy plumbing as any other tradable. It exposes two subscription levels - per-instrument Greeks for single contracts, and option chain slices for whole expiries - both event-driven throughon_option_greeksandon_option_chainhandlers. Strike selection (delta-percentile, ATM-relative, ATM-percent band, fixed list) is a first-classStrikeRangeconfig primitive, not something the strategy hand-rolls. TheOptionChainAggregatorkeeps quotes + Greeks across all active strikes synchronized; anAtmTrackerrebalances the active set as the underlying drifts. Multi-leg structures exist asOptionSpread(exchange-defined, executed as one order, positions per leg) - Cortana V1 single-leg flow does not need them. For Cortana MK3, this gives us a native, tested layer for “give me the SPY 0DTE call closest to delta=0.30” without re-implementing chain plumbing.
Core claim
Nautilus’s options model is rich enough that Cortana V1’s
single-leg-buy-at-delta-X-percentile workflow maps cleanly to a built-in
OptionContract + subscribe_option_chain(StrikeRange.atm_relative(...))
pattern. We do not need to ship a custom chain aggregator, ATM tracker,
or strike-range filter as MK3 deliverables - those are part of the
framework. What we do need: an IBKR options-Greeks path (currently the
adapter ships per-instrument Greeks for Deribit/Bybit/OKX, not IBKR -
see Adapter Support table below) or to back-fill Greeks from UW into a
Nautilus custom-data type.
Option instrument representation
Four concrete instrument types live under nautilus_trader.model.instruments
(Rust core, Python/Cython binding):
| Instrument | Description | Greeks-relevant fields |
|---|---|---|
OptionContract | Exchange-traded single-leg call or put | strike_price, option_kind (CALL/PUT), expiration_utc, underlying, multiplier, price_increment, size_increment, margin_init, margin_maint |
OptionSpread | Exchange-defined multi-leg (vertical, calendar, straddle, etc.); up to 4 legs each with a ratio | underlying, expiration_utc, strategy_type; per-leg strike_price/option_kind live on the leg’s OptionContract. Greeks computed per leg, aggregated. |
CryptoOption | Option on crypto underlying with crypto quote/settlement; supports inverse and quanto styles | Full Greeks inputs plus crypto-settlement fields |
BinaryOption | Fixed payout (settles to 0 or 1 on a binary outcome) | expiration_utc, outcome/description. No strike_price, option_kind, or underlying (correctly - binary options don’t have them) |
All four inherit the universal Instrument contract documented in
nautilus-concepts.md: precision-aware Price/Quantity/Money value
types, instrument.make_price(...) / instrument.make_qty(...) to coerce
raw numbers to the venue’s tick/lot grid, margin_init / margin_maint,
and venue-symbol identification ({symbol.venue}, e.g.
SPY 250509C00580000.SMART in IBKR-raw symbology).
Identification / symbology
InstrumentId is {native_symbol.venue}. For options the native symbol
encodes (per venue conventions): underlying, expiry, kind, strike. IBKR’s
SymbologyMethod.IB_RAW mirrors IB’s localSymbol/conId form;
SymbologyMethod.IB_SIMPLIFIED collapses some fields for readability.
Cortana should pick one and stick with it (see
nautilus-integrations.md IBKR section).
Multi-leg primitives - does Nautilus have them?
Yes. OptionSpread is a first-class instrument representing a
venue-defined multi-leg combo. Critical semantics:
- Order side: spreads are commonly used as the unit-of-execution - the
exchange treats the combo as one tradable, so you submit one order against
the
OptionSpread.id. - Position side: even when you traded the combo as one order, the individual legs appear as positions. The exchange decomposes the fill back into per-contract position changes, so portfolio Greeks aggregate leg-by-leg rather than treating the spread opaquely.
- Greeks: computed per leg via the leg’s
OptionContract, then aggregated. There is no “spread Greeks” black box. - Leg count: up to 4 legs, each with a ratio (signed integer
contract-multiplier, e.g.
+1, -2, +1for a butterfly).
Cortana V1 is single-leg only (BULL CALL / BEAR PUT). MK3 V1 should not
adopt OptionSpread - the V1 thesis is “buy the right strike at the right
moment,” not structural plays. Spreads become relevant in V2+ if/when we
introduce defined-risk plays (verticals) or vol structures (straddles
around event windows). The fact that Nautilus has spreads ready means
adopting them later does not require a framework change - only a new
strategy module.
Option chain model
Two subscription tiers expose the chain to a strategy:
Per-instrument Greeks (single contract)
from nautilus_trader.model.identifiers import ClientId
client_id = ClientId("DERIBIT") # or BYBIT, OKX
self.subscribe_option_greeks(instrument_id, client_id=client_id)Handler: on_option_greeks(self, greeks) receives an OptionGreeks object
(see schema below). Unsubscribe via unsubscribe_option_greeks(...).
This is the lightest-weight path: the strategy already knows which contract it cares about (e.g., a specific 0DTE call it just opened) and wants Greeks updates on that contract only.
Option chain (whole series)
from nautilus_trader.core import nautilus_pyo3
series_id = nautilus_pyo3.OptionSeriesId(...) # (venue, underlying, expiry)
strike_range = nautilus_pyo3.StrikeRange.atm_relative(
strikes_above=5,
strikes_below=5,
)
self.subscribe_option_chain(
series_id,
strike_range=strike_range,
snapshot_interval_ms=1000,
)Handler: on_option_chain(self, chain) receives an OptionChainSlice -
a point-in-time snapshot containing both calls and puts across the active
strike set. Methods on the slice:
chain.strikes()- all unique strike priceschain.strike_count(),chain.call_count(),chain.put_count()chain.get_call(strike)/chain.get_put(strike)- fullOptionStrikeData(quote + Greeks)chain.get_call_greeks(strike)/chain.get_put_greeks(strike)chain.get_call_quote(strike)/chain.get_put_quote(strike)chain.atm_strike- current ATM (may beNoneuntil bootstrap)chain.is_empty()
StrikeRange variants - the strike-selection primitive
| Variant | Description | Usage |
|---|---|---|
Fixed | Explicit list of strikes | StrikeRange.fixed([580.0, 581.0, 582.0]) |
AtmRelative | N strikes above and N below the live ATM | StrikeRange.atm_relative(5, 5) |
AtmPercent | All strikes within a % band around ATM | StrikeRange.atm_percent(0.10) (±10%) |
ATM-based variants defer subscription until the ATM price is
determined. The AtmTracker derives ATM from the underlying_price field
inside venue-provided OptionGreeks updates (the venue’s forward price
for that expiry). It can be pre-seeded from an HTTP forward-price call for
instant bootstrap. As the underlying drifts the active strike set
rebalances automatically with hysteresis + cooldown to prevent
thrashing near a strike boundary.
Option chain architecture (component breakdown)
This is the engine-internal decomposition - useful for MK3 design because it shows how Nautilus wants the chain plumbing structured. We do not write these components; they exist.
| Component | Responsibility |
|---|---|
DataEngine | Owns one OptionChainManager per active OptionSeriesId. On SubscribeOptionChain: resolves instruments from cache, creates manager, subscribes wire-level feeds, sets up snapshot timer. On each timer tick: calls manager.check_rebalance() and manager.snapshot(), forwards subscription deltas to the data client. On UnsubscribeOptionChain or full expiry: tears down manager, cancels timer, unsubscribes feeds. |
OptionChainManager (PyO3) | Thin wrapper around OptionChainAggregator + AtmTracker. Does not interact with message bus, clock, or data clients - pure aggregation state. Fed market data via handle_quote() / handle_greeks(); emits via snapshot(). The handle_* methods return a boolean flagging “ATM bootstrap just occurred,” which the engine uses to trigger initial subscription of the active set. |
OptionChainAggregator | Accumulates quotes + Greeks per instrument with keep-latest semantics. Instruments that did not update since the last snapshot are still included (the chain stays “complete”). Greeks arriving before any quote are held in a pending_greeks buffer and attached when the first quote shows up. Each snapshot() produces an immutable OptionChainSlice. |
AtmTracker | Reactive ATM derivation from OptionGreeks.underlying_price. Optional HTTP pre-seed for instant bootstrap without waiting for WebSocket ticks. Rebalance hysteresis + cooldown built in. |
Two bootstrap paths - both worth understanding because they explain how fast a fresh subscription becomes useful:
- Instant bootstrap - HTTP forward-price call returns a price; engine creates manager with ATM pre-seeded; active set computed at construction; wire-subscriptions fire immediately.
- Deferred bootstrap - no HTTP forward price available. Manager is
created with empty active set. The engine waits for the first
OptionGreeksevent (typically arriving from an unrelatedsubscribe_option_greeks(...)already in flight) carryingunderlying_price; that triggershandle_greeks()to returnTrueand the engine subscribes the now-active set.
Snapshot vs. raw mode
snapshot_interval_ms controls publishing cadence:
- Snapshot mode (
snapshot_interval_ms=1000): quotes + Greeks accumulate in a buffer; oneOptionChainSlicepublished per timer tick. Use for periodic rebalancing / portfolio Greeks / UI. - Raw mode (
snapshot_interval_ms=None): each individual quote or Greeks update produces a slice immediately. Use for latency-sensitive strategies that react to single-strike updates.
Cortana MK3 likely wants raw mode for the entry decision (we want the freshest delta at strike-selection time) and a separate periodic snapshot subscription for portfolio-level Greeks if that becomes a feature.
OptionGreeks data type
Venue-provided sensitivities for one option contract:
| Field | Type | Description |
|---|---|---|
instrument_id | InstrumentId | The option contract |
delta | float | dV / dS |
gamma | float | dDelta / dS |
vega | float | dV per 1% IV change (scaled by 0.01) |
theta | float | Daily decay (dV / dt scaled by 1/365.25) |
rho | float | dV per unit interest-rate change |
mark_iv | float | None | Mark implied vol |
bid_iv | float | None | Bid IV |
ask_iv | float | None | Ask IV |
underlying_price | float | None | Underlying at calc time (forward price) |
open_interest | float | None | OI |
ts_event | int (ns) | Event timestamp at venue |
ts_init | int (ns) | When Nautilus created the object |
Both ts_event and ts_init follow the same convention as every other
Nautilus data type - their delta is your end-to-end latency for Greeks
delivery, not just the Cortana-side processing window.
For the local-Black-Scholes path (when the venue does not stream Greeks
or you need shocks/scenarios), see the parallel
nautilus-greeks.md page covering the
GreeksCalculator Cython class and the underlying Rust/PyO3
black_scholes_greeks / imply_vol_and_greeks /
refine_vol_and_greeks functions.
OptionChainSlice data type
Point-in-time snapshot of a whole series:
| Property | Type | Description |
|---|---|---|
series_id | OptionSeriesId | (venue, underlying, expiry) |
atm_strike | Price | None | Current ATM (may be None pre-bootstrap) |
ts_event | int (ns) | Snapshot event time |
ts_init | int (ns) | Snapshot init time |
Calls and puts are accessed via methods, not as direct properties (so
you never accidentally hold a mutable reference to the internal
buffers). Each OptionStrikeData wraps a QuoteTick plus an optional
OptionGreeks.
Expiry, DTE math, and 0DTE specifics
Nautilus stores expiration_utc on every option-bearing instrument as
UTC. Day-count and DTE math is not baked into the instrument - there
is no instrument.dte() helper documented. Strategies are expected to
compute DTE off clock.utc_now() minus expiration_utc. This is fine for
Cortana since:
- 0DTE means same calendar day in NY-time → DTE = 0 on entry
- Theta in
OptionGreeksis already the daily decay (scaled 1/365.25) - The forward / ATM tracking does not need DTE - it uses
underlying_pricefrom the live Greeks stream
0DTE pricing weirdness the framework does not solve for you:
- In the last 30-60 minutes the bid/ask widens dramatically;
mark_ivbecomes unstable; theta accelerates non-linearly. The framework streams whatever the venue publishes - it does not smooth, dampen, or warn. A Cortana strategy entering inside the power-hour window (project_eod_power_hour.md) needs to read live Greeks honestly: a delta-0.30 strike at 14:55 ET is genuinely a different beast from delta-0.30 at 11:00 ET, and the strike-selection layer should not pretend otherwise. - Last-hour BSM Greeks are “model says X, reality says X·1.5” for theta -
if MK3 backtest replays use the local
black_scholes_greeks, the theta curve will under-estimate decay near close. Either (a) trust the venue-providedOptionGreeks(UW or IBKR Greeks if/when implemented), or (b) accept that backtests near 15:30-16:00 ET will overstate winner P&L by a small margin and bias accordingly. - “Pinning to a strike” near the close changes delta dynamics (gamma
spike around ATM). Nautilus’s
OptionChainAggregatorre-streams Greeks honestly through the close; the strategy must just consume them.
Exercise / assignment
The IBKR adapter has explicit support: opt-in
track_option_exercise_from_position_update=True on the execution
client config detects when a position changes due to exercise/assignment
rather than a fill (see nautilus-integrations.md - IBKR adapter,
“Account / position data” sub-section).
Cortana’s V1 mandate is never hold past close - every position is flat by 15:55 ET. So:
- Long calls/puts: zero exercise risk if we close before close.
- Edge case: a fill on the closing leg fails or is partial → leftover contracts go to expiry. ITM contracts auto-exercise (broker-side); OTM expire worthless. The PM exit invariant (project_pm_ibkr_exit_invariant.md) already covers this for MK2 - MK3 must preserve it.
- We never short options in V1, so we never face assignment.
If/when V2 introduces short-premium structures, the
exercise-tracking flag should be flipped on. Until then it is harmless to
leave at default (False).
Multipliers and lot sizing
OptionContract carries an explicit multiplier field (100 for standard
SPY equity options, 1 for some index minis). Every dollar arithmetic
(P&L, margin, premium-paid) goes through instrument.make_price() /
make_qty() and the multiplier is honored automatically - strategies do
not multiply by 100 by hand. This is one of the framework’s main
arithmetic-safety guarantees: no float drift, no silent
“forgot-to-multiply-by-multiplier” bug.
size_increment (lot size) is enforced at three layers (instrument
creation, RiskEngine pre-trade, matching engine). Equity options are
typically size_increment = 1 (one contract); some products use 10 or
100 lots.
Premium / mark / settlement
Same Cache abstraction as any other instrument:
self.cache.price(instrument_id, PriceType.MID) # bid/ask mid
self.cache.price(instrument_id, PriceType.LAST) # last trade
self.cache.price(instrument_id, PriceType.MARK) # venue markFor options, MARK is usually the right choice for unrealized P&L -
it’s the venue’s official theoretical fair value, smoother than LAST
(which can be stale or one-sided in thin contracts) and tighter than
MID (which is wide near close). The Portfolio uses a fallback chain
(see nautilus-concepts.md Portfolio section): cached marks → side-
appropriate quotes → last → recent bar close. Affected positions are
flagged unpriceable rather than silently zero-valued - important for
0DTE where wide spreads can leave a one-sided book briefly.
Settlement: SPY options are physically settled (assignment delivers SPY
shares); SPX is cash-settled. Nautilus does not encode this distinction
on OptionContract directly - it shows up via the IBKR adapter’s
contract metadata and via the assignment events.
IBKR options-data specifics
The full IBKR adapter coverage is in nautilus-integrations.md. Options-
specific points:
- Options chain materialization:
IBContract(secType="STK", symbol="SPY", ..., build_options_chain=True, min_expiry_days=0, max_expiry_days=1)loads the 0DTE chain natively at startup. This is the Cortana-MK3-shaped pattern. - Contract specs include strikes, expiries, multipliers, primary/listing
exchanges per the IB API - Nautilus translates these into
OptionContractinstances in the cache. - Quote ticks, trade ticks, and OHLCV bars work normally for options (subject to IB market-data subscription).
- Caveat - Adapter Support table: per the Options doc, native
subscribe_option_greeks/subscribe_option_chainis only listed for Deribit (✓ both), Bybit (✓ both), and OKX (✓ per-instrument only). IBKR is not in that table. That means MK3 likely cannot rely on native Nautilus Greeks streaming over IBKR. Two paths:- Run the local
GreeksCalculatorover IBKR quotes - it implies vol from market price and computes Greeks via Black-Scholes (seenautilus-greeks.md). Works for entry-time strike selection; less ideal for last-hour where BSM mis-prices theta. - Plug UW Greeks into a custom
Datasubclass via the UW-as-data-only adapter described innautilus-integrations.md. Ports Cortana’s existing UW Greeks pipeline directly. Recommended.
- Run the local
- Data permissions / pacing limits / TWS-UTC requirements all apply
exactly as in the equity case (see
nautilus-integrations.md).
Backtest data sources for options
Same ParquetDataCatalog that handles bars/ticks for stocks handles
options data - OptionContract instances are registered as instruments,
and per-strike QuoteTick/TradeTick/OptionGreeks events flow
through. Sources:
- Databento has options coverage (OPRA feed) and is the docs-blessed
vendor for replay; the Nautilus Databento adapter handles MBO/MBP/CMBP
schemas. See the Databento data-catalog tutorial in
nautilus-tutorials.md. - Tardis covers crypto options (Deribit primarily), not relevant to Cortana’s SPY 0DTE.
- Custom CSV/Parquet ingest via the “Loading External Data” tutorial pattern - Cortana’s existing UW Greeks history + IBKR option-tick history can be replayed this way.
- Bring-your-own: the
@customdataclassmechanism lets us shovel UW flow / GEX / charm into the engine alongside Greeks for backtest scoring research.
For Cortana MK3 the recommended replay shape: ParquetDataCatalog
holding (a) IBKR SPY quote ticks (underlying), (b) IBKR option quote
ticks for the 0DTE chain, (c) UW Greeks attached as either
OptionGreeks events or a custom data type, (d) UW flow/GEX as custom
data types. All four flow through one DataEngine; the strategy’s
on_quote_tick / on_option_greeks / on_data handlers consume
whichever it needs.
Cortana MK3 implications
V1 single-leg flow maps natively
Cortana V1 BULL CALL pattern in Nautilus terms:
on_start()-subscribe_option_chain(spy_0dte_series_id, StrikeRange.atm_relative(8, 8), snapshot_interval_ms=None)to get raw per-update Greeks across ±8 strikes around ATM.on_option_chain(chain)- strike selection logic runs here (or inon_option_greeksif we go per-instrument).- Score / conviction / gates fire from the existing scoring engine
(ported into Nautilus actors / strategies - see
nautilus-concepts.mdStrategy section). - On entry signal:
order_factory.market(instrument_id=chosen_call_id, order_side=OrderSide.BUY, quantity=instrument.make_qty(1))then submit. Bracket TP/SL via OCA group (IBOrderTags- see IBKR caveat innautilus-integrations.md). on_position_opened- register the contract for tighter per-instrument Greeks if needed.on_quote_tick- TP/SL software fallback evaluates against MARK or MID perfeedback_dual_tp_defense_in_depth.md.
No custom chain plumbing, no custom ATM tracker, no custom strike filter. The framework owns those.
Strike selection by delta-percentile - the cleanest pattern
Cortana’s existing rule (per
plans/2026-05-06-contract-selection-plan.md): “buy the call closest to
delta = 0.30 (BULL CALL pattern), or the put closest to delta = -0.30
(BEAR PUT).”
In Nautilus this becomes a one-pass scan over the latest
OptionChainSlice:
def on_option_chain(self, chain) -> None:
target_delta = 0.30
best_strike = None
best_diff = float("inf")
for strike in chain.strikes():
call = chain.get_call(strike)
if call is None or call.greeks is None:
continue
diff = abs(call.greeks.delta - target_delta)
if diff < best_diff:
best_diff = diff
best_strike = strike
if best_strike is not None:
chosen_call = chain.get_call(best_strike)
self._chosen_call_id = chosen_call.quote.instrument_idThat is the entire strike-selection layer. Compare to MK2’s hand-rolled chain query, expiry filter, delta-percentile sort, and stale-cache guard
- the framework’s
OptionChainSlicecollapses all of that to one loop. For Cortana’s BEAR PUT analog: identical loop overchain.get_put(strike)withtarget_delta = -0.30.
Two refinements MK3 should layer on:
- Liquidity gate - reject strikes where
call.quote.bid_size < min_sizeorcall.quote.ask_price - call.quote.bid_price > max_spread_pct. Prevents the “found a 0.30-delta strike but it has a $0.40-wide spread” failure mode. - Open-interest gate -
call.greeks.open_interest > min_oi. Same reasoning. UseOptionGreeks.open_interestdirectly.
Open questions / 0DTE gotchas to test on the spike
- IBKR Greeks: confirm whether
on_option_greeksactually fires from the IBKR adapter, or whether we must route throughGreeksCalculatoror UW. The Adapter Support table suggests we must - but adapter capabilities have shifted between versions, so verify on the spike. - ATM bootstrap latency: how long does the
AtmTrackertake to bootstrap on a fresh subscribe? If >5s, every Cortana cold-start window loses the first window’s signal. Pre-seed via HTTP is the documented mitigation. - Power-hour theta: backtest a known winning power-hour trade (15:35-15:55 ET fill window) using local BSM theta vs UW-supplied theta. Quantify the P&L delta. If material, lock in UW Greeks for the last hour.
- Strike-rebalance hysteresis: confirm the
AtmTrackerdoesn’t churn the active set during the morning open volatility (9:30-9:35) and miss early signals. The cooldown is configurable but not documented in the options page - chase it down in source. - Dual-TP via
OptionSpread: not relevant to V1, but if V2 adds defined-risk plays we need to confirm bracket-on-spread orders behave correctly with the IBKR adapter’sIBOrderTags(ocaGroup=...)workaround.
See Also
- Nautilus Greeks - local Black-Scholes calculator, shock scenarios, beta-weighted portfolio Greeks (filed in parallel)
- Nautilus Integrations - IBKR adapter specifics, options-chain config, UW-as-custom-data adapter sketch
- Nautilus Concepts - DataEngine, Strategy / Actor lifecycle, Cache contract, OMS / position semantics, value types
- Nautilus Tutorials - Bybit options Greeks + delta-neutral options strategy walkthroughs (Rust, but conceptually applicable)
- project_eod_power_hour.md - why last-hour Greeks need to be honest, not smoothed
- project_pm_ibkr_exit_invariant.md
- exit-by-close invariant that keeps assignment risk = 0 in V1
- feedback_dual_tp_defense_in_depth.md
- software TP/SL fallback consumes MARK/MID from the option chain
~/conductor/workspaces/cortanaroi-mk2/belo-horizonte/plans/2026-05-06-contract-selection-plan.md- current Cortana strike-selection plan that this Nautilus model would replace
Timeline
- 2026-05-07 | Cody - Filed during pre-spike concept mastery sweep.