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 Nautilus Instrument objects), DataClient (subscribes/requests market data and emits Nautilus Data types onto the bus), and ExecutionClient (submits/modifies/cancels orders, emits lifecycle events). Adapters are wired into a LiveNode / TradingNode via factory functions registered as add_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 a DataClient + 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:

ComponentPurpose
HttpClientREST API communication.
WebSocketClientReal-time streaming connection.
InstrumentProviderLoads and parses instrument definitions from the venue.
DataClientHandles market data subscriptions and requests.
ExecutionClientHandles 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

  • ExecutionClient separation is structural** - they’re distinct registrations against the LiveNode and the engine routes commands by Venue, 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:

  1. Data ingress (venue → DataClient → DataEngine → Cache → MessageBus → Strategy). Adapter responsibilities: connect, subscribe, parse, emit _handle_data(...).
  2. 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:

  1. Standalone discovery - research / backtesting scripts call provider.load_all_async() directly. Returns the full universe for that venue.
  2. Runtime loading in a sandbox or live TradingNode - Actors and Strategies receive instruments via on_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:

MethodCalled whenAdapter responsibility
_connect()LiveNode startsOpen WebSocket(s), authenticate, prepare REST client.
_disconnect()LiveNode stopsClose 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 internalThe 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 callEmitsStrategy/Actor handler
subscribe_quote_ticks(...)QuoteTickon_quote_tick(tick)
subscribe_trade_ticks(...)TradeTickon_trade_tick(tick)
subscribe_bars(bar_type)Baron_bar(bar)
subscribe_order_book_deltas(...)OrderBookDeltaon_order_book_deltas(deltas)
subscribe_data(DataType(MyCustom))MyCustomon_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:

  1. In-adapter token bucket - crates/adapters/<venue>/src/common/retry.rs classifies responses as Retryable / NonRetryable / Fatal with optional retry_after: Option<Duration> for venues that emit hint headers.
  2. 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:

MethodCalled whenAdapter responsibility
_connect()LiveNode startOpen auth’d WS; pull initial AccountState, positions, open orders.
_disconnect()LiveNode stopClose WS; flush pending acks.
_submit_order(command)Strategy submit_orderTranslate to venue wire format; HTTP/WS call; on ack emit OrderAccepted.
_submit_order_list(command)Bracket / OCO submissionSame, batch.
_modify_order(command)Strategy modify_orderTranslate to venue modify call.
_cancel_order(command)Strategy cancel_orderTranslate to venue cancel call.
_cancel_all_orders(command)Strategy cancel_all_ordersBatch cancel.
_query_order(command)Engine reconciliation pollingReturn OrderStatusReport.
_query_account(command)Engine reconciliationReturn AccountState.

Reconciliation reports (live mode)

Every LiveExecutionClient must emit one of four reconciliation report variants on demand (per nautilus-execution.md):

VariantUse case
OrderStatusReportStandalone order state update.
FillReportStandalone execution.
OrderWithFillsStatus + fills bundled atomically.
PositionStatusReportPosition 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_id is supplied (engine generates via OrderFactory); adapter generates venue_order_id from 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.py and instantiate the live clients from the config. The PyO3 bindings exposed from crates/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

  1. Lazy construction - the LiveNode instantiates clients in the right kernel context (post-cache, post-bus initialization). User code can’t accidentally construct a client too early.
  2. 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.
  3. 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:

  1. Phase 1: Rust core infrastructure. HTTP/WS clients, error types, PyO3 bindings. Milestone: cargo test passes; client authenticates and streams raw bytes.
  2. Phase 2: Instruments. Parse venue instrument definitions into InstrumentAny variants. Implement InstrumentProvider. Symbol normalization. Milestone: load_all_async() returns valid Nautilus instruments.
  3. Phase 3: Market data. Public WS streams + historical HTTP + Python LiveDataClient wiring. Milestone: subscribed data lands on the message bus.
  4. Phase 4: Execution. Private WS + order submit/modify/cancel + LiveExecutionClient + reconciliation reports. Milestone: orders submit, fills arrive, state reconciles on connect.
  5. Phase 5: Advanced features. Conditional orders, brackets, batch ops, venue-specific data (funding rates, options chains, liquidations).
  6. Phase 6: Configuration and factories. LiveDataClientConfig / LiveExecClientConfig subclasses, factory functions, env-var credential resolution.
  7. Phase 7: Testing and documentation. Spec acceptance suite (DataTester / ExecTester matrix).

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 adapterPathWhen to mirror
Databentocrates/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 Brokerscrates/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.
Polymarketcrates/adapters/polymarket/Event-driven, sub-second alert-style data. Useful for thinking about UW-style alert payload structures.
Tardisnautilus_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

ComponentStatusCortana implementation
HttpClientRequiredThin wrapper around httpx.AsyncClient (Python) for v0. UW REST endpoints: /api/net-flow/expiry, /api/options-volume, /api/option-contract/{ticker}/{expiry}/{strike}/{side}.
WebSocketClientRequiredWrapper around websockets (Python) for v0. UW WS endpoint: wss://api.unusualwhales.com/socket?token=.... Subscribes to channels: flow-alerts, option_trades:SPY, etc.
InstrumentProviderNear-no-opUW does not define instruments. Provider returns empty, defers to IBKR’s instrument cache.
DataClientThe 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.
ExecutionClientSkipped.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:

  1. UW WebSocket frame arrives.
  2. _on_ws_message parses to UWFlowAlert (a @customdataclass - see nautilus-custom-data.md).
  3. _handle_data(alert) is the engine’s ingest hook.
  4. DataEngine writes the alert to the Cache.
  5. DataEngine publishes on the bus topic (UWFlowAlert, metadata={"underlying": "SPY"}).
  6. Every Strategy / Actor that called subscribe_data(DataType(UWFlowAlert, metadata={"underlying": "SPY"})) receives the alert via on_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:

ComponentEstimate (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:

  1. Data-only adapter - same shape (no ExecutionClient).
  2. Equity + options universe - same instrument focus as UW’s SPY/QQQ/etc.
  3. REST + WebSocket dual-transport - UW has both (chain pulls via REST, alerts via WS); Databento has both (historical via REST, live via WS).
  4. Standalone-discovery + runtime-loading dual provider modes - exact pattern UW needs.
  5. 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:

  1. Bybit options is more recently authored - modern crate layout.
  2. Options-specific patterns (strike, expiry, option_side fields) are already there.
  3. 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 v0 HttpClient with minor adjustments (use nautilus_common::live::get_runtime() if going Rust).
  • cortanaroi/data/uw_ws.py - WebSocket client + reconnect. Ports to v0 WebSocketClient.
  • 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 @customdataclass declarations.

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 RiskEngine owns risk; the adapter must not silently scale or veto an order. If meta-prob belongs anywhere, it’s in the RiskEngine (or a pre-submit Actor).
  • Plain tokio::spawn from a Rust adapter. Per nautilus-developer-guide.md: tasks must spawn via nautilus_common::live::get_runtime().spawn(...), never plain tokio::spawn. Plain spawn panics from Python threads because they lack Tokio thread-local context. The check_tokio_usage.sh pre-commit hook enforces this.
  • Forgetting ts_event precision. UW gives milliseconds. Multiply by 1e6 to get nanoseconds before populating ts_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 shared MessageBus is 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

See Also


Timeline

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