Nautilus Synthetic Instruments
A
SyntheticInstrumentis 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 codeSYNTH, so Actors and Strategies cansubscribe_quote_ticks(synthetic_id), build bars off it, or use it as thetrigger_instrument_idfor 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 fromOptionSpread, 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:
| Field | Type | Notes |
|---|---|---|
symbol | Symbol | The bare symbol part (e.g., Symbol("BTC-ETH:BINANCE")) |
price_precision | int | Decimal precision for the derived Price |
components | list[InstrumentId] | The real instruments referenced in the formula |
formula | str | The derivation expression (see Formula language) |
ts_event / ts_init | int | UNIX-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
| Construct | Example | Notes |
|---|---|---|
| Component reference | BTCUSDT.BINANCE | Uses raw InstrumentId text. IDs containing / and - are valid (AUD/USD.SIM, ETH-USDT-SWAP.OKX). |
| Numeric literal | 1, 0.5, 1.2e-3 | Evaluated with f64 semantics |
| Boolean literal | true, false | For conditions and logical expressions |
| Parentheses | (a + b) / 2 | Override precedence |
| Unary operators | -x, !flag | Negation (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 assignment | spread = a - b; spread / 2 | Statements run left-to-right; the formula must end with a value |
| Comments | // line, /* block */ | Ignored |
Operator precedence (high → low)
^(right-assoc)- Unary
-,! *,/,%+,-<,<=,>,>===,!=&&,||
-2 ^ 2 evaluates as -(2 ^ 2) = -4. Use parens to disambiguate.
Built-in functions
| Function | Signature | Notes |
|---|---|---|
abs | abs(x) | Absolute value |
ceil | ceil(x) | Ceiling |
floor | floor(x) | Floor |
round | round(x) | Rust f64 round-to-nearest |
min | min(x1, x2, ...) | One or more numeric args |
max | max(x1, x2, ...) | One or more numeric args |
if | if(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
| Limit | Value |
|---|---|
| Stack depth | 32 |
| Local variables | 16 |
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 pattern | Evaluation time |
|---|---|
(A + B) / 2 | 12 ns |
A * 0.4 + B * 0.3 + C * 0.2 + D * 0.1 | 18 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:
- Component instrument’s
QuoteTickarrives inDataEngine. - Cache is updated (cache-then-publish invariant - see nautilus-cache.md).
- Any synthetics referencing this component are re-evaluated.
- Each affected synthetic publishes its own
QuoteTickon the bus undersynthetic_id. - 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
| Limitation | Impact |
|---|---|
| Cannot be traded directly | No 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 stream | The catalog stores component ticks; the synthetic is rebuilt at run time. Formulas are reproducible but not historical artifacts. |
| Formula must end numeric | An expression returning boolean or ending in an assignment is rejected at construction. |
| Stack depth 32, locals 16 | Non-issue for any realistic pricing formula. |
| No vendor-side vs locally-derived distinction at the consumer | Subscribers 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 evaluation | Non-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:
| Primitive | What it is | Tradable? | Lives where |
|---|---|---|---|
SyntheticInstrument | Derived-price instrument computed locally via formula over real instruments. | No. Read-only. | nautilus_trader.model.instruments.SyntheticInstrument |
OptionSpread | Exchange-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 subclass | Arbitrary 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:
-
“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
RegimeDetectorActorthat publishesRegimeUpdate(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. -
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.
-
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:
-
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. -
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 customDatatype. Once you’ve already written the Actor, the synthetic adds no value. -
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
Datasubclasses 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-legOptionContract, option chain plumbing - Nautilus Data Model - built-in types, custom
Datasubclasses (the right tool for non-price Cortana events), instrument taxonomy table includingSyntheticInstrumentrow - 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.