Nautilus Adapters
Every Nautilus venue or data-provider integration is an adapter - a pluggable bundle of five components:
HttpClient(REST transport),WebSocketClient(streaming transport),InstrumentProvider(parses venue instrument metadata into NautilusInstrumentobjects),DataClient(subscribes/requests market data and emits NautilusDatatypes onto the bus), andExecutionClient(submits/modifies/cancels orders, emits lifecycle events). Adapters are wired into aLiveNode/TradingNodevia factory functions registered asadd_data_client(...)/add_exec_client(...). The framework makes data-only adapters first-class: a venue that has no execution surface (UW, Databento, Tardis) ships only aDataClient+InstrumentProvider. For Cortana MK3, this is the blueprint for the UW WebSocket adapter - the largest piece of new code we would write post-spike. The closest reference is the Databento adapter (data-only, equity/option universe, REST + WebSocket transports). Bybit options is the structural template the spike plan names; Databento is the closer feature-shape match. This page is the canonical Nautilus-adapters reference for the Saturday 2026-05-09 spike.
This page complements Nautilus Integrations (IBKR-focused) - that page covers the shipped IBKR adapter in operational detail (ports, account IDs, config keys, paper-vs-live). This page covers the adapter contract - what a custom adapter must implement to plug into Nautilus, with the UW WebSocket adapter as the worked example.
Core claim
An adapter is a 5-component bundle that plugs into the LiveNode /
TradingNode via factory registration. The framework owns the engine, the
bus, the cache, and the strategy/actor lifecycle; the adapter only owns the
wire-format translation between the venue and Nautilus’s normalized domain
model. Strategies remain venue-agnostic because the adapter normalizes
everything to Nautilus types (built-in or custom) before publishing.
The five components
The concepts/adapters/ page lays out the five components verbatim:
| Component | Purpose |
|---|---|
HttpClient | REST API communication. |
WebSocketClient | Real-time streaming connection. |
InstrumentProvider | Loads and parses instrument definitions from the venue. |
DataClient | Handles market data subscriptions and requests. |
ExecutionClient | Handles order submission, modification, and cancellation. |
Not every adapter ships all five. Data-only providers (UW, Databento,
Tardis) skip ExecutionClient. Some venues that don’t define their own
instruments (UW proxies underlyings; the actual instrument is the IB-listed
SPY option) can ship a near-no-op InstrumentProvider. **The DataClient
ExecutionClientseparation is structural** - they’re distinct registrations against theLiveNodeand the engine routes commands byVenue, so a single venue can split data and execution across two processes if needed (and Cortana MK3 explicitly does: UW for data, IBKR for execution).
Architecture - where adapters fit
The concepts/architecture/ page (covered in
nautilus-architecture.md) describes the kernel
topology. Adapters sit at the edge of that topology:
+-------------------------------------------------------+
| LiveNode kernel |
| |
| Strategy / Actor |
| | |
| v |
| MessageBus <---- Cache <---- DataEngine |
| ^ ^ |
| | | |
| ExecutionEngine ----+ DataClient (adapter) |
| | | ^ |
| v | | |
| ExecutionClient (adapter) | |
| ^ | |
+--------|-----------------------------|----------------+
| |
+--- venue REST/WS ---+ +--- venue REST/WS ---+
IBKR Gateway UW WebSocket / REST
Two routes cross the adapter boundary:
- Data ingress (venue → DataClient → DataEngine → Cache → MessageBus
→ Strategy). Adapter responsibilities: connect, subscribe, parse, emit
_handle_data(...). - Execution egress (Strategy → RiskEngine → ExecutionEngine → ExecutionClient → venue). Adapter responsibilities: translate Nautilus order to wire format, send, await ack, parse fill events back into Nautilus events.
The adapter never touches the cache directly - it emits typed events; the engine writes the cache. The adapter never validates risk - it submits what the engine routes; the engine ran the RiskEngine first. This separation is what makes “venue-swap is a config change” possible.
InstrumentProvider - the metadata loader
Every adapter must expose an InstrumentProvider that translates venue
instrument definitions into Nautilus Instrument objects (or
InstrumentAny variants - Equity, OptionContract, FuturesContract,
CurrencyPair, CryptoPerpetual, etc., per
nautilus-data.md).
Two use cases
The doc lists two distinct contexts the same InstrumentProvider serves:
- Standalone discovery - research / backtesting scripts call
provider.load_all_async()directly. Returns the full universe for that venue. - Runtime loading in a sandbox or live
TradingNode- Actors and Strategies receive instruments viaon_instrument(instrument)callbacks as the provider streams them in.
Same provider, two access patterns.
Standalone example (Binance Futures testnet, from the doc)
import asyncio, os
from nautilus_trader.adapters.binance.common.enums import BinanceAccountType
from nautilus_trader.adapters.binance import get_cached_binance_http_client
from nautilus_trader.adapters.binance.futures.providers import BinanceFuturesInstrumentProvider
from nautilus_trader.common.component import LiveClock
async def main():
clock = LiveClock()
client = get_cached_binance_http_client(
clock=clock,
account_type=BinanceAccountType.USDT_FUTURES,
api_key=os.getenv("BINANCE_FUTURES_TESTNET_API_KEY"),
api_secret=os.getenv("BINANCE_FUTURES_TESTNET_API_SECRET"),
is_testnet=True,
)
provider = BinanceFuturesInstrumentProvider(
client=client,
account_type=BinanceAccountType.USDT_FUTURES,
)
await provider.load_all_async()
instruments = provider.list_all()
print(f"Loaded {len(instruments)} instruments")
asyncio.run(main())Runtime loading config
InstrumentProviderConfig controls what gets loaded on TradingNode start:
# Load every instrument the venue exposes
InstrumentProviderConfig(load_all=True)
# Load only specific instruments (recommended for prod)
InstrumentProviderConfig(
load_ids=["BTCUSDT-PERP.BINANCE", "ETHUSDT-PERP.BINANCE"],
)For IBKR the equivalent is InteractiveBrokersInstrumentProviderConfig
with load_ids (e.g., frozenset(["SPY.ARCA"])) or load_contracts
(e.g., IBContract(secType="STK", symbol="SPY", build_options_chain=True, min_expiry_days=0, max_expiry_days=1) for the 0DTE chain). Detail in
nautilus-integrations.md.
Cortana implication
UW does not define instruments - UW alerts always reference an
IB-listable underlying (SPY, QQQ, AAPL, …). The UWInstrumentProvider
is therefore a near-no-op: it can either return an empty list and
let the IBKR adapter own all instruments, or it can proxy IBKR
instruments by InstrumentId. The simplest implementation: empty
load_all_async() + an find(instrument_id) that delegates to the
shared cache (the IBKR adapter has already loaded it).
DataClient - market data subscriptions and requests
The DataClient is the hot path for data ingress. It connects to the
venue’s streaming + REST surfaces, normalizes wire bytes into Nautilus
Data types (built-in or custom), and emits them through
_handle_data(...) so the DataEngine runs the cache-then-publish
sequence.
Strategy/Actor surface (what the user calls)
Strategies and Actors don’t talk to the DataClient directly. They use
two parallel APIs on every data type:
Request (one-shot historical fetch):
class MyStrategy(Strategy):
def on_start(self) -> None:
self.request_instrument(InstrumentId.from_str("BTCUSDT-PERP.BINANCE"))
self.request_bars(BarType.from_str("BTCUSDT-PERP.BINANCE-1-HOUR-LAST-EXTERNAL"))
def on_instrument(self, instrument: Instrument) -> None:
self.log.info(f"Received instrument: {instrument.id}")
def on_historical_data(self, data) -> None:
self.log.info(f"Received historical data: {data}")Subscribe (continuous live stream):
def on_start(self) -> None:
self.subscribe_trade_ticks(InstrumentId.from_str("BTCUSDT-PERP.BINANCE"))
self.subscribe_bars(BarType.from_str("BTCUSDT-PERP.BINANCE-1-MINUTE-LAST-EXTERNAL"))
def on_trade_tick(self, tick: TradeTick) -> None:
self.log.info(f"Trade: {tick}")
def on_bar(self, bar: Bar) -> None:
self.log.info(f"Bar: {bar}")The concepts/actors/ page enumerates the full request/subscribe surface:
subscribe_quote_ticks, subscribe_trade_ticks, subscribe_bars,
subscribe_order_book_deltas, subscribe_order_book_at_interval,
subscribe_data (custom data type), and a parallel request_* family.
For custom data, the catch-all subscribe_data(DataType(MyType)) /
on_data(data) path applies - see
nautilus-custom-data.md.
DataClient lifecycle methods (what an adapter author implements)
The DataClient is an abstract class. A live adapter subclasses
LiveDataClient (or LiveMarketDataClient for venues exposing standard
market-data primitives - bars, quotes, trades, books). The hooks the
engine calls:
| Method | Called when | Adapter responsibility |
|---|---|---|
_connect() | LiveNode starts | Open WebSocket(s), authenticate, prepare REST client. |
_disconnect() | LiveNode stops | Close WebSocket(s), flush any in-flight requests. |
_subscribe(data_type, **kwargs) | Strategy calls subscribe_* | Send venue WS subscribe message; remember mapping. |
_unsubscribe(data_type, **kwargs) | Strategy calls unsubscribe_* | Send venue WS unsubscribe message; clean up mapping. |
_request(data_type, request_id, **kwargs) | Strategy calls request_* | Issue REST call (or historical WS query); wait for response; emit on response. |
_handle_data(event) | Adapter internal | The escape hatch: pushes a Data instance into the DataEngine. |
_handle_data(event) is the integration point. Once you call it,
the engine’s cache-then-publish sequence runs and your event lands in
strategy on_* handlers without further work. Everything else
(_connect, _subscribe, frame parsing) exists to feed
_handle_data.
Subscribe-method-to-handler map (what gets emitted)
| Subscribe call | Emits | Strategy/Actor handler |
|---|---|---|
subscribe_quote_ticks(...) | QuoteTick | on_quote_tick(tick) |
subscribe_trade_ticks(...) | TradeTick | on_trade_tick(tick) |
subscribe_bars(bar_type) | Bar | on_bar(bar) |
subscribe_order_book_deltas(...) | OrderBookDelta | on_order_book_deltas(deltas) |
subscribe_data(DataType(MyCustom)) | MyCustom | on_data(data) |
The catch-all path for custom types is on_data - type-check inside the
handler. This is the path UW alerts ride.
Error / disconnect handling
The LiveDataClient base class provides:
- Watchdog reconnect - if the WebSocket drops, the client attempts
reconnect with backoff (configurable via the adapter’s config; IBKR
exposes
IB_MAX_CONNECTION_ATTEMPTS). Adapter authors implement_connect()idempotently so reconnect re-runs it cleanly. - State resync on reconnect - the adapter must re-subscribe to every active topic after reconnect. The framework keeps the subscription set; the adapter replays it.
- Dropped messages - if a frame fails to parse, the rule from
nautilus-developer-guide.md is never
panic, hang, or leak - return
Result::Err. Log the bad frame at warn level, drop it, continue.
Rate limiting
REST clients should respect venue rate limits. Two patterns:
- In-adapter token bucket -
crates/adapters/<venue>/src/common/retry.rsclassifies responses asRetryable / NonRetryable / Fatalwith optionalretry_after: Option<Duration>for venues that emit hint headers. - Engine-side throttling -
request_timeout_secs(default 60s on IBKR) caps a single request; the engine spaces out concurrent requests.
UW’s rate limits (~120 REST/min on the standard tier) live well below the WebSocket alert volume, so the REST path is mostly used for chain-snapshot pulls (rare) and option-contract metadata (cached).
ExecutionClient - order management
The ExecutionClient translates Nautilus order commands into venue API
calls and processes execution reports back into Nautilus events. It is
the outbound mirror of the DataClient.
Responsibilities (verbatim from the adapters page)
- Submit, modify, and cancel orders.
- Process fills and execution reports.
- Reconcile order state with the venue.
- Handle account and position updates.
Routing
The ExecutionEngine routes commands to the correct ExecutionClient
based on the order’s Venue. Strategy code is venue-agnostic -
self.submit_order(order) finds the right adapter via the order’s
instrument_id.venue. Detail in nautilus-execution.md.
Lifecycle methods
Same shape as DataClient for connect/disconnect, plus order-side
methods:
| Method | Called when | Adapter responsibility |
|---|---|---|
_connect() | LiveNode start | Open auth’d WS; pull initial AccountState, positions, open orders. |
_disconnect() | LiveNode stop | Close WS; flush pending acks. |
_submit_order(command) | Strategy submit_order | Translate to venue wire format; HTTP/WS call; on ack emit OrderAccepted. |
_submit_order_list(command) | Bracket / OCO submission | Same, batch. |
_modify_order(command) | Strategy modify_order | Translate to venue modify call. |
_cancel_order(command) | Strategy cancel_order | Translate to venue cancel call. |
_cancel_all_orders(command) | Strategy cancel_all_orders | Batch cancel. |
_query_order(command) | Engine reconciliation polling | Return OrderStatusReport. |
_query_account(command) | Engine reconciliation | Return AccountState. |
Reconciliation reports (live mode)
Every LiveExecutionClient must emit one of four reconciliation report
variants on demand (per nautilus-execution.md):
| Variant | Use case |
|---|---|
OrderStatusReport | Standalone order state update. |
FillReport | Standalone execution. |
OrderWithFills | Status + fills bundled atomically. |
PositionStatusReport | Position snapshot from venue (advisory only). |
The engine uses these for startup snapshot + continuous polling to keep
Cache and venue truth in sync. This is non-optional - the spec
acceptance tests (ExecTester) check it.
What the ExecutionClient does not do
- Position tracking (engine owns positions; adapter emits fills).
- Risk validation (RiskEngine owns this - adapter only sees orders that already passed validation).
- Cache writes (engine owns the cache).
- Order ID synthesis when
client_order_idis supplied (engine generates viaOrderFactory); adapter generatesvenue_order_idfrom venue acks.
Factory pattern - DataClientFactory and ExecClientFactory
Adapters expose factory functions that the LiveNode builder calls
to instantiate the live clients from a config. This is the
registration seam.
Live registration (Cortana MK3 sketch)
from nautilus_trader.live.node import LiveNode
from nautilus_trader.config import LiveDataEngineConfig
from nautilus_trader.adapters.interactive_brokers.factories import (
InteractiveBrokersLiveDataClientFactory,
InteractiveBrokersLiveExecClientFactory,
)
from cortana_mk3.adapters.unusual_whales.factories import UnusualWhalesLiveDataClientFactory
node = (
LiveNode.builder("CORTANA-PAPER", TraderId("CORTANA-001"), Environment.LIVE)
.with_data_engine_config(LiveDataEngineConfig(...))
# UW data only
.add_data_client(None, UnusualWhalesLiveDataClientFactory(), uw_config)
# IBKR data + execution
.add_data_client(None, InteractiveBrokersLiveDataClientFactory(), ibkr_data_cfg)
.add_exec_client(None, InteractiveBrokersLiveExecClientFactory(), ibkr_exec_cfg)
.build()
)Factory function shape
A factory is a callable that takes the config + supporting context
(clock, message bus, cache) and returns an instantiated LiveDataClient
or LiveExecutionClient. From
nautilus-developer-guide.md:
“Factory functions live in
nautilus_trader/adapters/<adapter>/factories.pyand instantiate the live clients from the config. The PyO3 bindings exposed fromcrates/adapters/<adapter>/src/python/are what the factory wires through.”
For a Python-only adapter (acceptable for UW v0; the full Rust crate is the v1 path), the factory is pure Python - no PyO3 hop.
Why factories instead of direct instantiation
- Lazy construction - the
LiveNodeinstantiates clients in the right kernel context (post-cache, post-bus initialization). User code can’t accidentally construct a client too early. - Config-driven swap - the user instantiates a config object; the factory turns it into a live client. Swapping testnet for prod is a config flag, not a code change.
- Multiple instances - same factory, different configs = two
clients (e.g., paper IBKR + live IBKR side-by-side via different
ibg_client_id).
Adapter implementation sequence (Phase 1-7)
nautilus-developer-guide.md is explicit that adapter phases are dependency-ordered, not optional:
- Phase 1: Rust core infrastructure. HTTP/WS clients, error types,
PyO3 bindings. Milestone:
cargo testpasses; client authenticates and streams raw bytes. - Phase 2: Instruments. Parse venue instrument definitions into
InstrumentAnyvariants. ImplementInstrumentProvider. Symbol normalization. Milestone:load_all_async()returns valid Nautilus instruments. - Phase 3: Market data. Public WS streams + historical HTTP +
Python
LiveDataClientwiring. Milestone: subscribed data lands on the message bus. - Phase 4: Execution. Private WS + order submit/modify/cancel +
LiveExecutionClient+ reconciliation reports. Milestone: orders submit, fills arrive, state reconciles on connect. - Phase 5: Advanced features. Conditional orders, brackets, batch ops, venue-specific data (funding rates, options chains, liquidations).
- Phase 6: Configuration and factories.
LiveDataClientConfig/LiveExecClientConfigsubclasses, factory functions, env-var credential resolution. - Phase 7: Testing and documentation. Spec acceptance suite
(
DataTester/ExecTestermatrix).
For a data-only adapter like UW, Phase 4 is skipped entirely. The sequence collapses to Phase 1 → 2 (near-no-op for UW) → 3 → 6 → 7. Realistic effort: 2-3 days for a Python-only v0; 1-2 weeks for a full Rust + PyO3 v1.
Reference adapters worth reading
The Nautilus repo ships several adapters that serve as templates. Pick the one closest to your venue’s shape:
| Reference adapter | Path | When to mirror |
|---|---|---|
| Databento | crates/adapters/databento/ + nautilus_trader/adapters/databento/ | Data-only, multi-asset (equities + options + futures), historical REST + live streaming. Closest match for UW - same data-only shape, same equity-option universe focus, same historical-snapshot vs live-stream split. |
| Bybit (options) | crates/adapters/bybit/ (look for options/ subtree) | Crypto-options venue; full data + execution. The spike plan names this as the structural template because it’s a relatively clean modern adapter and the options-chain handling is similar. UW data-side mechanics (subscribe → emit options-flow events) mirror Bybit’s options ticker subscriptions. |
| Interactive Brokers | crates/adapters/ibkr/ (if shipped as Rust crate) + nautilus_trader/adapters/interactive_brokers/ | Multi-asset broker; data + execution. Read the data-side for IBKR’s option-chain handling pattern (build_options_chain, contract discovery). |
| Coinbase (beta) | crates/adapters/coinbase/ | Smaller, less mature - useful as a minimum complete adapter template. |
| Polymarket | crates/adapters/polymarket/ | Event-driven, sub-second alert-style data. Useful for thinking about UW-style alert payload structures. |
| Tardis | nautilus_trader/adapters/tardis/ | Data-only, historical-focused. Less relevant for live UW but useful for the catalog-backed historical path. |
Recommended reading order for the UW adapter: Databento first (data- only shape parity), then Bybit options (options-specific patterns), then IBKR’s data side (option-chain handling).
Cortana MK3 implications - the UW WebSocket adapter blueprint
This is the load-bearing section. The UW adapter is the largest piece of new code MK3 requires. Everything else (Cortana strategy, scoring actor, meta-gate actor) is small relative to the adapter.
Why UW is data-only (no execution)
UW is a flow-data and analytics provider. There is no UW order surface; execution stays on the IBKR adapter. The MK3 wiring per spike Step 4:
# UW provides data only (no execution)
add_data_client(None, UnusualWhalesLiveDataClientFactory(), uw_config)
# IBKR provides execution + supplemental price data
add_data_client(None, InteractiveBrokersLiveDataClientFactory(), ibkr_data_cfg)
add_exec_client(None, InteractiveBrokersLiveExecClientFactory(), ibkr_exec_cfg)Two add_data_client calls (UW + IBKR) plus one add_exec_client
(IBKR). UW never sees an order command.
Component map for UW v0
| Component | Status | Cortana implementation |
|---|---|---|
HttpClient | Required | Thin wrapper around httpx.AsyncClient (Python) for v0. UW REST endpoints: /api/net-flow/expiry, /api/options-volume, /api/option-contract/{ticker}/{expiry}/{strike}/{side}. |
WebSocketClient | Required | Wrapper around websockets (Python) for v0. UW WS endpoint: wss://api.unusualwhales.com/socket?token=.... Subscribes to channels: flow-alerts, option_trades:SPY, etc. |
InstrumentProvider | Near-no-op | UW does not define instruments. Provider returns empty, defers to IBKR’s instrument cache. |
DataClient | The main work. | UWLiveMarketDataClient(LiveDataClient) - connects to WS in _connect, parses each frame to a UWFlowAlert custom data type, emits via _handle_data. Handles _subscribe(DataType(UWFlowAlert)) to register interest in a channel; handles _request(DataType(OptionChainSnapshot)) for REST snapshot pulls. |
ExecutionClient | Skipped. | UW is read-only. |
_subscribe for sub-second flow alerts - sketch
class UWLiveMarketDataClient(LiveDataClient):
async def _subscribe(self, data_type: DataType, **kwargs) -> None:
# Match on the custom data type
if data_type.type == UWFlowAlert:
underlying = data_type.metadata.get("underlying", "ALL")
channel = f"flow-alerts:{underlying}" if underlying != "ALL" else "flow-alerts"
await self._ws.send_json({
"channel": channel,
"msg_type": "join",
})
self._active_channels.add(channel)
else:
self.log.warning(f"UW does not support {data_type}")
async def _on_ws_message(self, frame: dict) -> None:
if frame.get("channel", "").startswith("flow-alerts"):
alert = self._parse_flow_alert(frame)
self._handle_data(alert) # → DataEngine → Cache → MessageBus
def _parse_flow_alert(self, frame: dict) -> UWFlowAlert:
return UWFlowAlert(
instrument_id=InstrumentId.from_str(f"{frame['ticker']}.ARCA"),
strike=float(frame["strike"]),
expiry=frame["expiry"],
option_side=frame["side"].upper(),
aggressor_side=frame["aggressor"].upper(),
premium_usd=float(frame["premium"]),
size_contracts=int(frame["size"]),
is_sweep=bool(frame.get("is_sweep", False)),
is_block=bool(frame.get("is_block", False)),
flow_score=float(frame.get("score", 0.0)),
underlying_price=float(frame.get("underlying", 0.0)),
raw_id=str(frame["alert_id"]),
ts_event=int(frame["ts_ms"]) * 1_000_000, # ms → ns
ts_init=self._clock.timestamp_ns(),
)_request for REST option-chain snapshots - sketch
async def _request(self, data_type: DataType, request_id: UUID4, **kwargs) -> None:
if data_type.type == OptionChainSnapshot:
ticker = kwargs["ticker"]
expiry = kwargs["expiry"]
# REST call
resp = await self._http.get(f"/api/option-contract/{ticker}/{expiry}")
snapshot = OptionChainSnapshot(
instrument_id=InstrumentId.from_str(f"{ticker}.ARCA"),
expiry=expiry,
chain_json=msgspec.json.encode(resp.json()).decode(),
ts_event=self._clock.timestamp_ns(),
ts_init=self._clock.timestamp_ns(),
)
self._handle_data_response(request_id, snapshot)
else:
self.log.warning(f"UW does not support request {data_type}")Publishing UWFlowAlert custom data events on the bus
The flow:
- UW WebSocket frame arrives.
_on_ws_messageparses toUWFlowAlert(a@customdataclass- see nautilus-custom-data.md)._handle_data(alert)is the engine’s ingest hook.DataEnginewrites the alert to the Cache.DataEnginepublishes on the bus topic(UWFlowAlert, metadata={"underlying": "SPY"}).- Every Strategy / Actor that called
subscribe_data(DataType(UWFlowAlert, metadata={"underlying": "SPY"}))receives the alert viaon_data(data).
The cache-then-publish invariant
(nautilus-data.md) holds for custom data the same as
built-in types. A subscriber can do self.cache.add(...) writes safely
during on_data because the engine already wrote the alert to the
cache before dispatching.
How much code? - honest LOC estimate
Per spike Step 7 question 7: “How hard does the UW custom DataClient look?” Estimating against the components:
| Component | Estimate (Python v0) | Estimate (Rust + PyO3 v1) |
|---|---|---|
HttpClient (httpx wrapper, retries, auth) | ~80 LOC | ~250 LOC Rust + 50 LOC bindings |
WebSocketClient (websockets wrapper, reconnect) | ~120 LOC | ~350 LOC Rust + 60 LOC bindings |
UWInstrumentProvider (no-op) | ~30 LOC | ~80 LOC |
UWLiveMarketDataClient (connect, subscribe, parse, emit) | ~200 LOC | ~450 LOC Rust + ~150 LOC Python wiring |
UWFlowAlert + OptionChainSnapshot (custom data classes) | ~80 LOC | ~80 LOC Python + ~150 LOC Rust if @customdataclass_pyo3 |
UWLiveDataClientFactory + UWConfig | ~60 LOC | ~60 LOC |
| Tests (unit + integration + spec subset) | ~300 LOC | ~600 LOC |
| Total | ~870 LOC Python | ~2,280 LOC Rust+Python |
Realistic v0 (Python-only) ship: 2-3 working days. This matches the mk3-roadmap estimate. The Rust v1 path adds ~1.5 weeks but is only warranted if profiling shows the Python WS parse path is a bottleneck - unlikely at UW’s ~10 alerts/sec peak.
The dominant risk is not LOC count but wire-format parsing
correctness. UW’s WebSocket payload schema is undocumented (existing
Cortana code has reverse-engineered it through GH #54 strike-format and
GH #59 timestamp-unit issues). The MK3 adapter inherits these
hard-won parsers - they port almost directly from
cortanaroi/data/uw_ws_parser.py into _parse_flow_alert.
Closest analog - answered
Databento. Reasons:
- Data-only adapter - same shape (no
ExecutionClient). - Equity + options universe - same instrument focus as UW’s SPY/QQQ/etc.
- REST + WebSocket dual-transport - UW has both (chain pulls via REST, alerts via WS); Databento has both (historical via REST, live via WS).
- Standalone-discovery + runtime-loading dual provider modes - exact pattern UW needs.
- Catalog-friendly - Databento’s normalized historical data writes
to the
ParquetDataCatalog; UW historical alerts can do the same.
The spike plan names Bybit options as the structural template, which is a defensible choice because:
- Bybit options is more recently authored - modern crate layout.
- Options-specific patterns (strike, expiry, option_side fields) are already there.
- The full Rust + PyO3 stack is exemplary.
Recommendation: read Databento for shape and Bybit options for structure. The Databento source teaches you “data-only adapter boundary”; the Bybit options source teaches you “options-flow-style event publishing.”
What we already have (port, don’t rewrite)
Cortana MK2 has:
cortanaroi/data/uw_http.py- REST client. Ports to v0HttpClientwith minor adjustments (usenautilus_common::live::get_runtime()if going Rust).cortanaroi/data/uw_ws.py- WebSocket client + reconnect. Ports to v0WebSocketClient.cortanaroi/data/uw_ws_parser.py- Frame → typed dict parser. Drops in as_parse_flow_alert.cortanaroi/data/uw_schema.py- Pydantic schemas. Replaced by@customdataclassdeclarations.
Net new code is glue + factory + config + tests. The hard parts are already written.
Anti-patterns to avoid
- Reimplementing wire-format parsing. Cortana’s UW parsers are battle-tested against UW’s quirks (#54, #59). Port them; don’t rewrite.
- Bypassing
_handle_data. Directly publishing to the bus from inside the adapter (self._msgbus.publish(topic, payload)) skips the engine’s cache-then-publish sequence - subscribers can’t trust the cache mid-handler. Always go through_handle_data. - Putting risk logic in the adapter. The
RiskEngineowns risk; the adapter must not silently scale or veto an order. If meta-prob belongs anywhere, it’s in theRiskEngine(or a pre-submit Actor). - Plain
tokio::spawnfrom a Rust adapter. Per nautilus-developer-guide.md: tasks must spawn vianautilus_common::live::get_runtime().spawn(...), never plaintokio::spawn. Plain spawn panics from Python threads because they lack Tokio thread-local context. Thecheck_tokio_usage.shpre-commit hook enforces this. - Forgetting
ts_eventprecision. UW gives milliseconds. Multiply by 1e6 to get nanoseconds before populatingts_event. Otherwise backtest replay ordering is wrong by 6 orders of magnitude. - One process per adapter. All adapters live in one
LiveNode. Don’t spin up a separate process for UW - register both data clients on the same node. A single sharedMessageBusis the whole point. - Premature Rust port. v0 is Python-only. Reach for the Rust crate only after profiling shows the Python WS parse path is hot.
When this concept applies
- Designing the UW WebSocket adapter for MK3.
- Evaluating whether a new data provider (Tradier, Polygon options, CME data feed) can be pulled into Nautilus.
- Reading existing adapters as templates.
- Splitting a venue’s data and execution responsibilities across multiple adapter registrations (Cortana MK3 splits IBKR exec from UW data).
When it does not apply
- The page does not document the Rust crate skeleton in detail - see nautilus-developer-guide.md Phase 1-7.
- Specific venue quirks (IBKR OCA, IB pacing limits) live in nautilus-integrations.md.
- Custom data type definition ergonomics live in nautilus-custom-data.md.
- Order routing and reconciliation mechanics live in nautilus-execution.md.
See Also
- Nautilus Integrations (IBKR-focused) - Shipped IBKR adapter detail; the execution side of Cortana MK3.
- Nautilus Data Model - Built-in
Datatypes, ts_event/ts_init, ParquetDataCatalog. Adapters emit these types. - Nautilus Execution - How
ExecutionClientfits into the routing pipeline; reconciliation report variants. - Nautilus Custom Data - Defining
UWFlowAlertandOptionChainSnapshotas@customdataclasstypes the adapter emits. - Nautilus Developer Guide (Extension + Contribution) - Phase 1-7 adapter implementation sequence; Cortana → Nautilus translation table.
- Spike plan:
~/conductor/workspaces/cortanaroi-mk2/belo-horizonte/plans/2026-05-09-nautilus-spike.md - MK3 roadmap:
~/conductor/workspaces/cortanaroi-mk2/belo-horizonte/plans/2026-05-09-mk3-roadmap.md - Source: https://nautilustrader.io/docs/latest/concepts/adapters/
Timeline
2026-05-07 | Cody - Filed during pre-spike concept mastery sweep batch 3.