Nautilus Synthetic Instruments

A SyntheticInstrument is a locally-defined, derived-price instrument whose price is computed by a compiled numeric formula over the live prices of other (real) instruments in the cache. It exposes the result as a standard Nautilus instrument under the synthetic venue code SYNTH, so Actors and Strategies can subscribe_quote_ticks(synthetic_id), build bars off it, or use it as the trigger_instrument_id for emulated orders. Synthetics cannot be traded directly - there is no venue, no order routing, no fills. They are pure analytical primitives. The formula language is small and well-defined: numeric/boolean expressions with arithmetic, comparison, logical, if(...), min/max/abs/floor/ceil/round, and local assignments. Compile-once, evaluate-many, ~12-28ns per tick. Distinct from OptionSpread, which is a real venue-defined multi-leg combo that executes as one order. Synthetics are read-only signal-shaped objects; Spreads are tradable. For Cortana, synthetics are MK5+ territory at most (regime-indicator composites); V1-V3 do not need them.

Core claim

SyntheticInstrument is the framework’s answer to “I want to derive a price stream from N other price streams and treat the result as a first-class instrument.” It solves a real problem (cross-venue spreads, basket indices, bespoke regime composites) but it is not a problem Cortana V1 has. The Cortana entry path trades a single SPY 0DTE option contract; nothing about that decision is improved by routing through a synthetic. The page exists in the concept sweep so that future Cody (MK5+ regime work) does not re-derive the wisdom from scratch.

Definition

A SyntheticInstrument is constructed locally by an Actor or Strategy and registered into the cache via self.add_synthetic(synthetic). Constructor fields:

FieldTypeNotes
symbolSymbolThe bare symbol part (e.g., Symbol("BTC-ETH:BINANCE"))
price_precisionintDecimal precision for the derived Price
componentslist[InstrumentId]The real instruments referenced in the formula
formulastrThe derivation expression (see Formula language)
ts_event / ts_initintUNIX-ns timestamps

The resulting instrument_id is {symbol}.SYNTH, e.g. BTC-ETH:BINANCE.SYNTH. Component instruments must already exist in the cache - synthetic construction will fail otherwise.

Once registered, the synthetic emits QuoteTick (and optionally TradeTick) on the bus exactly like a real instrument. Strategies subscribe to it via subscribe_quote_ticks(synthetic_id) and consume it through the normal on_quote_tick handler.

Formula language

A small built-in numeric expression DSL. Compiled once at construction; evaluated on every component tick.

Supported syntax

ConstructExampleNotes
Component referenceBTCUSDT.BINANCEUses raw InstrumentId text. IDs containing / and - are valid (AUD/USD.SIM, ETH-USDT-SWAP.OKX).
Numeric literal1, 0.5, 1.2e-3Evaluated with f64 semantics
Boolean literaltrue, falseFor conditions and logical expressions
Parentheses(a + b) / 2Override precedence
Unary operators-x, !flagNegation (numeric / boolean)
Binary arithmetic+ - * / % ^^ is exponentiation, right-associative
Comparison== != < <= > >=Numeric comparison; ==/!= allow matching types (both numeric or both boolean)
Logical&& ||Boolean operands; short-circuit evaluation
Local assignmentspread = a - b; spread / 2Statements run left-to-right; the formula must end with a value
Comments// line, /* block */Ignored

Operator precedence (high → low)

  1. ^ (right-assoc)
  2. Unary -, !
  3. *, /, %
  4. +, -
  5. <, <=, >, >=
  6. ==, !=
  7. &&, ||

-2 ^ 2 evaluates as -(2 ^ 2) = -4. Use parens to disambiguate.

Built-in functions

FunctionSignatureNotes
absabs(x)Absolute value
ceilceil(x)Ceiling
floorfloor(x)Floor
roundround(x)Rust f64 round-to-nearest
minmin(x1, x2, ...)One or more numeric args
maxmax(x1, x2, ...)One or more numeric args
ifif(condition, when_true, when_false)Condition is boolean; only the selected branch evaluates

Type rules

  • Component inputs are numeric.
  • Arithmetic returns numeric; comparison returns boolean.
  • == / != accept matching types.
  • &&, ||, unary ! require boolean operands; &&/|| short-circuit.
  • Local variables: must be assigned before use; names start with letter or _, then letters/digits/_.
  • The final result must be numeric. A formula that ends with an assignment or a boolean is rejected at construction.

Compile-time limits

LimitValue
Stack depth32
Local variables16

A weighted sum of 8 components uses peak stack depth 3 and zero locals - limits are generous for any realistic pricing formula.

Examples

# Simple spread
formula = "BTCUSDT.BINANCE - ETHUSDT.BINANCE"
 
# Average of two FX pairs
formula = "(AUD/USD.SIM + NZD/USD.SIM) / 2"
 
# Reuse intermediate value
formula = "spread = BTCUSDT.BINANCE - ETHUSDT.BINANCE; spread / 2"
 
# Conditional output
formula = "if(BTCUSDT.BINANCE > ETHUSDT.BINANCE, BTCUSDT.BINANCE, ETHUSDT.BINANCE)"

Price update semantics

Compile-once / evaluate-many. The formula is parsed and compiled to a zero-allocation f64 expression stack at construction. Every incoming component price tick triggers re-evaluation - the synthetic does not poll; it is reactive to its inputs.

Performance (Apple M4 Pro, rustc 1.94.1, release opt-level 3):

Formula patternEvaluation time
(A + B) / 212 ns
A * 0.4 + B * 0.3 + C * 0.2 + D * 0.118 ns
if(A > B, A - B, B - A)12 ns
spread = A - B; mid = ...; mid + ...19 ns
max(min(A, B * 20), abs(A - B))15 ns

Scaling for weighted sum: 2 components → 14 ns, 4 → 18 ns, 8 → 28 ns.

Compilation (cold path, one-time): 675 ns - 1.4 μs depending on complexity.

Implication: evaluation overhead is negligible. Latency budget for Cortana-shaped scoring (<1 ms inference) is not threatened by adding a few synthetics. The constraint is upstream: the synthetic only re-prices when one of its components ticks. If you compose SPY.ARCA + VIX.CBOE, the synthetic re-prices on either feed update.

Event flow

The synthetic publishes on the bus as a standard instrument:

  1. Component instrument’s QuoteTick arrives in DataEngine.
  2. Cache is updated (cache-then-publish invariant - see nautilus-cache.md).
  3. Any synthetics referencing this component are re-evaluated.
  4. Each affected synthetic publishes its own QuoteTick on the bus under synthetic_id.
  5. Subscribers receive it via on_quote_tick(tick) exactly like a real instrument.

This is the load-bearing property: the synthetic is indistinguishable from a real instrument at the subscription surface. Bars built from the synthetic, indicators registered against the synthetic, and emulated orders triggered off the synthetic all “just work.”

Updating a formula at runtime

synthetic = self.cache.synthetic(self._synthetic_id)
new_formula = "(BTCUSDT.BINANCE + ETHUSDT.BINANCE) / 2"
synthetic.change_formula(new_formula)
self.update_synthetic(synthetic)

Recompiles the formula. The synthetic identity (InstrumentId) is preserved.

Trigger instrument IDs (emulated orders)

A synthetic price can drive an emulated order on a real instrument:

order = self.strategy.order_factory.limit(
    instrument_id=ETHUSDT_BINANCE.id,            # real instrument; the order routes here
    order_side=OrderSide.BUY,
    quantity=Quantity.from_str("1.5"),
    price=Price.from_str("30000.00000000"),
    emulation_trigger=TriggerType.DEFAULT,
    trigger_instrument_id=self._synthetic_id,    # synthetic provides the trigger price
)
self.strategy.submit_order(order)

The order is held in the local OrderEmulator until the synthetic’s price crosses the trigger condition; only then does the basic order release to the real venue. This is the one place synthetics indirectly participate in execution - but the order itself still routes to a real instrument’s venue.

Backtest support

Synthetics work identically in backtest and live. The BacktestEngine feeds component ticks through the DataEngine; synthetic re-pricing falls out automatically. There is no separate “backtest synthetic” code path because the synthetic is engine-internal - it never crosses an adapter boundary.

This also means: synthetics are not persisted to the ParquetDataCatalog as their own data type. Replay reconstructs them from their components on the fly - which is the correct design (formulas might change between runs; persisting the derived stream would freeze old logic into the artifact).

Limitations

LimitationImpact
Cannot be traded directlyNo order routing. Synthetic instruments live “locally within the platform and serve as analytical tools.” A future framework version may support trading-component instruments based on synthetic behavior, but this is not in scope today.
No persistence as a derived streamThe catalog stores component ticks; the synthetic is rebuilt at run time. Formulas are reproducible but not historical artifacts.
Formula must end numericAn expression returning boolean or ending in an assignment is rejected at construction.
Stack depth 32, locals 16Non-issue for any realistic pricing formula.
No vendor-side vs locally-derived distinction at the consumerSubscribers can’t tell a synthetic QuoteTick from a real one without checking the venue suffix. Usually fine; occasionally a footgun (e.g., a “send to dashboard” sink that didn’t expect SYNTH ticks).
NaN / Infinity rejected at evaluationNon-finite prices in components are rejected before reaching the formula - the synthetic stays “stuck” rather than emitting garbage.

Synthetic vs OptionSpread vs custom Data - the distinction

This is the most-confused area of the framework for someone coming in cold. Three primitives that sound similar but address completely different problems:

PrimitiveWhat it isTradable?Lives where
SyntheticInstrumentDerived-price instrument computed locally via formula over real instruments.No. Read-only.nautilus_trader.model.instruments.SyntheticInstrument
OptionSpreadExchange-defined multi-leg combo (vertical, calendar, butterfly, etc.) treated by the venue as one tradable. Up to 4 legs with ratios. Submits as one order; fills decompose into per-leg positions.Yes. Real venue order.nautilus_trader.model.instruments.OptionSpread (see nautilus-options.md)
Custom Data subclassArbitrary timestamped event published on the bus. Not an instrument; doesn’t have a price; can carry any payload. Used for UWFlowAlert, ScoringEvent, etc.No. Not even a price-bearing object.nautilus_trader.core.Data (see nautilus-data.md)

Mental model:

  • If the venue has a tradable combo product → OptionSpread.
  • If you need a derived price stream that flows through the same plumbing as a real instrument → SyntheticInstrument.
  • If you need a non-price event to ride the bus → custom Data.

The three are orthogonal. They compose: a strategy could subscribe to a real OptionContract, a SyntheticInstrument (e.g., SPY.ARCA + VIX.CBOE weighted), and a custom UWFlowAlert simultaneously, and gate entry on all three.

Cortana MK3 implications

V1 single-leg flow does not use synthetics

Cortana V1 trades real SPY 0DTE option contracts via the IBKR adapter. Entry, exit, sizing, and scoring all operate on real instruments. No part of the V1 decision path benefits from a SyntheticInstrument. The page exists for future-proofing, not present need.

MK4+ multi-leg representation is OptionSpread, not synthetic

When V2+ adds vertical/butterfly plays, the right primitive is OptionSpread (per nautilus-options.md) - it’s a real venue-routed combo, with per-leg positions, and the IBKR adapter already supports it. Do not reach for SyntheticInstrument for multi-leg structures. The synthetic would give you a derived-price view of the spread but not a way to trade it.

MK5+ speculative use cases (regime indicators)

Where synthetics could earn their keep, deferred to the regime-engine horizon:

  1. “Panic synthetic” - a composite of VIX + put/call ratio + skew:

    formula = "VIX.CBOE * 0.5 + PUTCALL_RATIO.SYNTH * 30 + SKEW.CBOE * 0.2"

    Subscribed by a RegimeDetectorActor that publishes RegimeUpdate(regime="PANIC") when the synthetic crosses a threshold. Bars built off the synthetic could feed a moving-average-based regime classifier without the actor doing manual arithmetic.

  2. SPY-vs-VXX divergence (V3+ pair signal) -

    formula = "SPY.ARCA / VXX.ARCA"

    Bars off this ratio capture the cross-asset divergence pattern; a separate scoring actor consumes the synthetic’s bars.

  3. Beta-weighted “implied SPY” - a basket of liquid sector ETFs weighted to SPY’s composition, used as a leading-indicator crosscheck when SPY itself is mid-microstructure-anomaly.

In all three cases, the synthetic is a scoring input, not an execution target. The Strategy would never order against PANIC.SYNTH; it would order SPY 0DTE based on what PANIC.SYNTH told it.

Honest assessment - is this useful for Cortana?

Probably not. Three reasons:

  1. The cost of a Python-level computed field is also ~12-28 ns. Cortana’s scoring engine already computes 78 features in pure Python without complaint. The framework benefit of SyntheticInstrument (cache integration, bar building, indicator registration) is real but small compared to the friction of expressing complex feature logic in a tiny DSL with stack-depth 32 and 16 locals. Once the formula needs to call into a Python function (it can’t), you’re back to an Actor.

  2. The DSL is intentionally small. No control flow beyond if(...). No loops. No state. No memory of previous values. This is a formula language, not a programming language. Anything Cortana-shaped that needs EMA smoothing, rolling windows, or stateful regime tracking cannot live in a synthetic - it has to live in an Actor that publishes a custom Data type. Once you’ve already written the Actor, the synthetic adds no value.

  3. It solves a problem we don’t have. Cortana’s scoring inputs are features (UW flow, IBKR microstructure, scoring outputs), not derived price streams that need to behave like instruments. The synthetic’s raison d’être is “I want this derived value to pretend to be an instrument so I can subscribe / build bars / trigger orders off it.” We don’t have that need. We have the inverse need (publish events that are explicitly not instruments), which is what custom Data subclasses solve.

Verdict: MK5+ at the earliest, and even then most regime-indicator work probably belongs in an Actor with a custom RegimeUpdate event rather than a synthetic. The one place synthetics might genuinely shine: if MK5 builds a multi-asset / multi-tenant regime engine where “expose this composite to N strategies as if it were a tradable” is a real ask, the synthetic gives you that for free. Until then, it is an answer to a question we don’t have.

One edge case where synthetics could earn their keep early

The trigger_instrument_id pattern (emulated order on a real instrument triggered by a synthetic price) is interesting for one scenario: a “buy SPY 0DTE call when the panic-fade synthetic crosses X” entry, where the synthetic encodes “VIX is dropping and put/call ratio is normalizing” as a single number. This is V3+ territory but it would be a cleaner expression than scoring-actor → strategy-gate → order pipeline if the trigger is genuinely price-shaped (a level-crossing, not a discrete event).

For V1-V3 the answer is still no.

See Also

  • Nautilus Options - OptionSpread (the real multi-leg primitive; not a synthetic), single-leg OptionContract, option chain plumbing
  • Nautilus Data Model - built-in types, custom Data subclasses (the right tool for non-price Cortana events), instrument taxonomy table including SyntheticInstrument row
  • Nautilus Strategies - emulated orders, trigger instrument IDs, OrderFactory.limit(emulation_trigger=..., trigger_instrument_id=...)
  • Nautilus Cache - cache-then-publish invariant that guarantees synthetic re-prices land cleanly
  • Nautilus Execution - emulated-order release semantics
  • Spike plan: ~/conductor/workspaces/cortanaroi-mk2/belo-horizonte/plans/2026-05-09-nautilus-spike.md

Timeline

  • 2026-05-07 | Cody - Filed during pre-spike concept mastery sweep batch 4.