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 complementsNautilus 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.
1.226.0 API correction (verified 2026-05-30)
This page predates verification against the installed
nautilus_trader==1.226.0 and the live-registration sketch below is
WRONG for that version. A 4-lens adversarial review confirmed
against the installed package:
There is NO LiveNode class in 1.226.0. The
LiveNode.builder().with_data_engine_config().add_data_client(...)
fluent API in the “Live registration” section is fictional. The
real API is config-driven TradingNode:
from nautilus_trader.live.node import TradingNodefrom nautilus_trader.config import TradingNodeConfigfrom nautilus_trader.common import Environmentfrom nautilus_trader.model.identifiers import TraderIdconfig = TradingNodeConfig( trader_id=TraderId("CORTANA-001"), environment=Environment.LIVE, data_clients={"UW": uw_config}, # key == client_id used on subscribe)node = TradingNode(config=config)node.add_data_client_factory("UW", UWLiveDataClientFactory) # the CLASS, not an instancenode.build()node.trader.add_actor(some_actor) # imperative add after buildnode.run()
The factory is registered by CLASS, not instance, and the
factory itself is class XFactory(LiveDataClientFactory) with a
@staticmethod create(loop, name, config, msgbus, cache, clock).
LiveDataClient.__init__ takes
(loop, client_id, venue, msgbus, cache, clock, config);
LiveDataClient takes NO instrument_provider.
Data client hooks take a single command object, not
(data_type, **kwargs): async def _subscribe(self, command: SubscribeData), _unsubscribe(self, command: UnsubscribeData)
(from nautilus_trader.data.messages). Read channel/metadata off
command.data_type.
Adapters emit CustomData, never a bare Data subclass - see
the nautilus-custom-data.md correction. A bare typed object passed
to _handle_data is silently dropped.
The conceptual model below (5-component bundle, data-only adapters,
_handle_data as the ingest hook, factory registration seam) is
correct. Only the exact class names/signatures changed. Verified
evidence in MK3 plan 2026-05-30-codex-handoff-m2s2a-uw-dataclient.md.
Core claim
An adapter is a 5-component bundle that plugs into the 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
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:
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 via on_instrument(instrument)
callbacks as the provider streams them in.
Same provider, two access patterns.
Standalone example (Binance Futures testnet, from the doc)
InstrumentProviderConfig controls what gets loaded on TradingNode start:
# Load every instrument the venue exposesInstrumentProviderConfig(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:
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.
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.
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.rs
classifies responses as Retryable / NonRetryable / Fatal with optional
retry_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_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 LiveNodefrom nautilus_trader.config import LiveDataEngineConfigfrom nautilus_trader.adapters.interactive_brokers.factories import ( InteractiveBrokersLiveDataClientFactory, InteractiveBrokersLiveExecClientFactory,)from cortana_mk3.adapters.unusual_whales.factories import UnusualWhalesLiveDataClientFactorynode = ( 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
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.
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).
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:
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 dataadd_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(), )
DataEngine publishes on the bus topic
(UWFlowAlert, metadata={"underlying": "SPY"}).
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:
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).
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 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).