Nautilus Developer Guide - Adapters (The Adapter Authoring Bible)

The developer_guide/adapters/ page is the canonical specification for authoring a Nautilus integration adapter end-to-end. Where the concept page nautilus-adapters.md tells you what an adapter is (5-component bundle, factory-registered into LiveNode), this page tells you how to build it: directory layout (crates/adapters/<venue>/ Rust core + nautilus_trader/adapters/<venue>/ Python wiring), file taxonomy (config.rs/py, factories.rs/py, data.rs/py, execution.rs/py, providers.py, parsing.py, __init__.py), the seven-phase dependency-driven implementation sequence, the LiveDataClient / LiveMarketDataClient / LiveExecutionClient / InstrumentProvider Python contract (every overridable abstract method enumerated), the WebSocket two-layer client/handler architecture ({Venue}WebSocketClient outer + {Venue}WsFeedHandler inner with Arc<ArcSwap<AtomicU8>> connection state, SubscriptionState shared via Arc<DashMap>, cmd_tx / out_rx channel discipline, RECONNECTED sentinel + replay logic), error/retry classification (Retryable / NonRetryable / Fatal with retry_after), credential resolution patterns (credential_env_vars() + Credential::resolve()), rate-limit quotas ({VENUE}_REST_QUOTA, {VENUE}_WS_*_QUOTA), order book delta F_LAST / F_SNAPSHOT flag invariants, and four reconciliation report variants (OrderStatusReport, FillReport, OrderWithFills, PositionStatusReport). For Cortana MK3, this is The Adapter Authoring Bible - the page the UW WebSocket adapter blueprint will be built against. The concept-page LOC estimate of ~870 Python LOC for v0 is preserved; this page adds the file-by-file blueprint that makes that estimate executable.

This page complements Nautilus Adapters (concept)

  • that page is the conceptual contract; this page is the implementation contract. It also pairs with Nautilus Developer Guide (extension+contribution) (the higher-level “how does this all fit into the project” view) and with the parallel testing-spec pages Data Testing Spec and the execution-testing equivalent. Read this page when authoring or reviewing any new adapter; cite the concept page when reasoning about adapter boundaries.

Core claim

A Nautilus adapter is a two-tree, seven-phase, file-templated authoring problem. The Rust tree at crates/adapters/<venue>/ owns wire I/O, parsing, retry, and PyO3 bindings. The Python tree at nautilus_trader/adapters/<venue>/ owns the engine-facing LiveDataClient / LiveMarketDataClient / LiveExecutionClient / InstrumentProvider subclasses plus user-facing LiveDataClientConfig and LiveExecClientConfig and the factory functions that wire them. The seven-phase sequence (Rust core → instruments → market data → execution → advanced features → config/factories → testing+docs) is dependency-ordered, not optional - each phase requires the previous. For a data-only adapter (UW, Databento, Tardis), Phase 4 collapses to skipped; Phase 2 collapses to a near-no-op when the venue does not own its own instrument universe.

Two-tree directory layout

The page is explicit that adapters split across two trees. The Rust tree is the production path; the Python tree is the engine-facing seam. Cortana’s UW adapter v0 can ship Python-only by collapsing the Rust tree into a thin Python wrapper around httpx + websockets, but the structure of the Python tree is the same.

Rust core (crates/adapters/<venue>/)

crates/adapters/your_adapter/
├── src/
│   ├── common/              # Shared types and utilities
│   │   ├── consts.rs        # Venue constants / broker IDs
│   │   ├── credential.rs    # API key storage and signing helpers
│   │   ├── enums.rs         # Venue enums mirrored in REST/WS payloads
│   │   ├── error.rs         # Adapter-level error aggregation (when applicable)
│   │   ├── models.rs        # Shared model types
│   │   ├── parse.rs         # Shared parsing helpers
│   │   ├── retry.rs         # Retry classification (when applicable)
│   │   ├── urls.rs          # Environment & product aware base-url resolvers
│   │   └── testing.rs       # Fixtures reused across unit tests
│   ├── http/                # HTTP client implementation
│   │   ├── client.rs        # HTTP client with authentication
│   │   ├── error.rs         # HTTP-specific error types
│   │   ├── models.rs        # Structs for REST payloads
│   │   ├── parse.rs         # Response parsing functions
│   │   └── query.rs         # Request and query builders
│   ├── websocket/           # WebSocket implementation
│   │   ├── client.rs        # WebSocket client (outer)
│   │   ├── dispatch.rs      # Execution event dispatch and order routing
│   │   ├── enums.rs         # WebSocket-specific enums
│   │   ├── error.rs         # WebSocket-specific error types
│   │   ├── handler.rs       # Feed handler (I/O boundary)
│   │   ├── messages.rs      # Frame and message enums
│   │   ├── parse.rs         # Message parsing functions
│   │   └── subscription.rs  # Subscription topic helpers (optional)
│   ├── python/              # PyO3 Python bindings
│   │   ├── enums.rs         # Python-exposed enums
│   │   ├── http.rs          # Python HTTP client bindings
│   │   ├── urls.rs          # Python URL helpers
│   │   ├── websocket.rs     # Python WebSocket client bindings
│   │   └── mod.rs           # Module exports
│   ├── config.rs            # Configuration structures
│   ├── data.rs              # Data client implementation
│   ├── execution.rs         # Execution client implementation
│   ├── factories.rs         # Factory functions
│   └── lib.rs               # Library entry point
├── tests/                   # Integration tests with mock servers
│   ├── data_client.rs       # Data client integration tests
│   ├── exec_client.rs       # Execution client integration tests
│   ├── http.rs              # HTTP client integration tests
│   └── websocket.rs         # WebSocket client integration tests
└── test_data/               # Canonical venue payloads (frozen JSON)

Python layer (nautilus_trader/adapters/<venue>/)

nautilus_trader/adapters/your_adapter/
├── config.py        # Configuration classes (LiveDataClientConfig / LiveExecClientConfig subclasses)
├── constants.py     # Adapter constants
├── data.py          # LiveDataClient or LiveMarketDataClient
├── execution.py     # LiveExecutionClient
├── factories.py     # Factory functions + venue-payload→Nautilus-domain converters
├── providers.py     # InstrumentProvider
└── __init__.py      # Package initialization

The Python tree is flat by design - there are no subdirectories. Six files do the entire job. Cortana’s UW v0 collapses to the Python tree only.

File taxonomy - what each file owns

This is the load-bearing reference table. Every adapter author needs this map memorized.

FileLayerOwnsCortana UW v0 size
crates/.../src/common/credential.rsRustAPI key storage (Box<[u8]> zeroized), HMAC signing, credential_env_vars() + Credential::resolve() for env-var resolutionn/a (Python: ~30 LOC inline in config.py)
crates/.../src/common/error.rsRustTop-level error enum aggregating VenueHttpError, VenueWsError, VenueBuildError via #[from]n/a (Python uses exceptions directly)
crates/.../src/common/retry.rsRustRetryable / NonRetryable / Fatal enum + retry_after: Option<Duration> from rate-limit headersn/a
crates/.../src/common/urls.rsRustconst fn get_ws_base_url(testnet: bool) resolvers; config overridesn/a (Python: in constants.py)
crates/.../src/http/client.rsRustWraps nautilus_network::http::HttpClient; raw + domain layer; Arc-backed for PyO3 cloning~100 LOC (Python httpx async wrapper)
crates/.../src/http/parse.rsRustVenue-response → Nautilus-domain conversion functionsinlined in parsing module
crates/.../src/websocket/client.rsRustOuter {Venue}WebSocketClient: connection-mode Arc<ArcSwap<AtomicU8>>, cmd_tx, out_rx, task_handle, auth_tracker, subscription_state, subscription_args~150 LOC (Python websockets wrapper + reconnect)
crates/.../src/websocket/handler.rsRustInner {Venue}WsFeedHandler: I/O loop, owns WebSocketClient exclusively, pending_requests: AHashMap, pending_messages: VecDeque, RECONNECTED sentinelmerged with client.rs in Python v0
crates/.../src/websocket/messages.rsRustTwo enums: {Venue}WsFrame (wire shapes, handler-internal) + {Venue}WsMessage (handler→client output)n/a (Python: msgspec types in parsing.py)
crates/.../src/websocket/dispatch.rsRustExecution dispatch state (WsDispatchState with order_identities, emitted_accepted, triggered_orders, filled_orders, clearing); cross-source fill BoundedDedup<T>n/a (UW is data-only)
crates/.../src/python/mod.rsRustPyO3 re-exports: m.add_class::<>() for every Python-facing struct/enumn/a
crates/.../src/config.rsRustbon::Builder + Default config struct; T for sensible defaults, Option<T> only when None is semanticmerged into config.py
crates/.../src/data.rsRustDomain-side data client; emits DataEvent::Instrument, ::InstrumentStatus, ::Data, ::Response, ::FundingRatemerged into data.py
crates/.../src/execution.rsRustDomain-side execution client; reconciliation report generation; account-state emittern/a (UW is data-only)
crates/.../src/factories.rsRustFactories that take config + supporting context, return live clientsmerged into factories.py
nautilus_trader/adapters/<venue>/config.pyPythonLiveDataClientConfig / LiveExecClientConfig subclasses with api_key, api_secret, base_url plus venue-specific knobs~60 LOC
nautilus_trader/adapters/<venue>/data.pyPythonLiveMarketDataClient (or LiveDataClient for venues without market-data primitives) subclass; method ordering convention (connect → subscribe → unsubscribe → request)~200 LOC (heart of UW adapter)
nautilus_trader/adapters/<venue>/execution.pyPythonLiveExecutionClient subclass; reconciliation report generatorsn/a (skipped)
nautilus_trader/adapters/<venue>/factories.pyPythonFactory function: def venue_data_client_factory(loop, name, config, msgbus, cache, clock) -> LiveDataClient~60 LOC
nautilus_trader/adapters/<venue>/providers.pyPythonInstrumentProvider subclass with load_all_async, load_ids_async, load_async~30 LOC (near-no-op)
nautilus_trader/adapters/<venue>/parsing.pyPython (extension)Frame → custom-data type conversion~80 LOC
nautilus_trader/adapters/<venue>/__init__.pyPython__all__ exports + the VENUE ClientId constant~15 LOC
nautilus_trader/adapters/<venue>/constants.pyPythonURL constants, channel-name constants, ENV var keys~30 LOC

The seven-phase implementation sequence

The page is explicit: each phase depends on the previous one. No shortcuts. For a data-only adapter (UW), Phase 4 collapses to “skipped”; Phase 2 collapses to a near-no-op when the venue proxies instrument identity (UW references IB-listed underlyings, doesn’t define its own).

Phase 1 - Rust core infrastructure

StepComponentDescription
1.1HTTP error typesDefine HTTP-specific error enum with retryable/non-retryable variants (http/error.rs)
1.2HTTP clientImplement credentials, request signing, rate limiting, retry logic
1.3HTTP API modelsDefine request/response structs for REST endpoints (http/models.rs, http/query.rs)
1.4HTTP parsingConvert venue responses to Nautilus domain models (http/parse.rs, common/parse.rs)
1.5WebSocket error typesDefine WebSocket-specific error enum (websocket/error.rs)
1.6WebSocket clientImplement connection lifecycle, authentication, heartbeat, reconnection
1.7WebSocket messagesDefine streaming payload types (websocket/messages.rs)
1.8WebSocket parsingConvert stream messages to Nautilus domain models (websocket/parse.rs)
1.9Python bindingsExpose Rust functionality via PyO3 (python/mod.rs)

Milestone: Rust crate compiles, unit tests pass, HTTP/WebSocket clients can authenticate and stream/request raw data.

For Cortana UW v0 (Python-only): collapse to a single data.py that bundles HTTP + WS + parsing + dispatch.

Phase 2 - Instrument definitions

StepComponentDescription
2.1Instrument parsingParse venue instrument definitions into InstrumentAny variants (spot, perpetual, future, option)
2.2Instrument providerImplement InstrumentProvider to load, filter, and cache instruments
2.3Symbol mappingHandle venue-specific symbol formats and Nautilus InstrumentId conversion

Milestone: InstrumentProvider.load_all_async() returns valid Nautilus instruments.

For UW: near-no-op. UW does not define instruments; the IBKR adapter already loaded them. UW’s load_all_async returns empty list; or if we want to proxy, query the shared Cache populated by the IBKR adapter and return what’s already there.

Phase 3 - Market data

StepComponentDescription
3.1Public WebSocket streamsSubscribe to order books, trades, tickers, public channels
3.2Historical data requestsFetch historical bars/trades/snapshots via HTTP
3.3Data client (Python)Implement LiveDataClient or LiveMarketDataClient wiring Rust clients to data engine

Milestone: Data client connects, subscribes to instruments, emits market data to the platform.

For UW: this is the core work. The UWLiveDataClient’s _subscribe(SubscribeData) translates a DataType(UWFlowAlert) subscription into a UW WS channel join; _on_ws_message parses each frame into a UWFlowAlert(@customdataclass) and pushes through self._handle_data(alert).

Phase 4 - Order execution

StepComponentDescription
4.1Private WebSocket streamsSubscribe to order/fill/position/balance changes
4.2Basic order submissionImplement market and limit orders via HTTP or WebSocket
4.3Order modification/cancelImplement amendment and cancellation
4.4Execution client (Python)Implement LiveExecutionClient wiring Rust clients to execution engine
4.5Execution reconciliationGenerate order, fill, and position status reports for startup reconciliation

Milestone: Execution client submits orders, receives fills, reconciles state on connect.

For UW: skipped entirely. UW is data-only; execution stays on IBKR. The MK3 wiring registers UW data + IBKR data + IBKR exec on the same LiveNode.

Phase 5 - Advanced features

StepComponentDescription
5.1Advanced order typesConditional orders, stop-loss, take-profit, trailing stops, iceberg
5.2Batch operationsBatch order submission, batch cancellation, mass cancel
5.3Venue-specific featuresOptions chains, funding rates, liquidations, venue-specific data types

For UW: this is where the REST option-chain snapshot path lives - a _request(RequestData) for OptionChainSnapshot custom data type.

Phase 6 - Configuration and factories

StepComponentDescription
6.1Configuration classesLiveDataClientConfig / LiveExecClientConfig subclasses
6.2Factory functionsImplement factory functions to instantiate clients from config
6.3Environment variablesSupport credential resolution from env vars

For UW: standard. UWLiveDataClientFactory plus UWConfig with api_key: str | None, api_secret: str | None (env-fallback), channels: list[str], optional historical_lookback_secs.

Phase 7 - Testing and documentation

StepComponentDescription
7.1Rust unit testsTest parsers, signing helpers, business logic in #[cfg(test)] blocks
7.2Rust integration testsTest HTTP/WebSocket clients against mock Axum servers in tests/
7.3Python integration testsTest data/execution clients in tests/integration_tests/adapters/<adapter>/
7.4Example scriptsProvide runnable examples demonstrating data subscription and order execution

For UW: pytest under tests/integration_tests/adapters/uw/ with mock-WS fixtures replaying canonical UW frames; spec-acceptance subset (the DataTester matrix from Data Testing Spec) for any data type the adapter claims to support.

DataClient / LiveDataClient - Python contract

The page enumerates the complete abstract method set for both LiveDataClient (non-market data; news, custom streams) and LiveMarketDataClient (market data; books, ticks, bars, instrument status, option greeks).

LiveDataClient minimal surface

from nautilus_trader.live.data_client import LiveDataClient
from nautilus_trader.data.messages import RequestData, SubscribeData, UnsubscribeData
 
class TemplateLiveDataClient(LiveDataClient):
    async def _connect(self) -> None: ...
    async def _disconnect(self) -> None: ...
    async def _subscribe(self, command: SubscribeData) -> None: ...
    async def _unsubscribe(self, command: UnsubscribeData) -> None: ...
    async def _request(self, request: RequestData) -> None: ...

Five hooks. This is the surface UW v0 implements.

LiveMarketDataClient full surface

For venues that expose standard market-data primitives (quotes, trades, books, bars, mark/index prices, funding rates, option Greeks, instrument status). The page lists 30+ abstract methods covering subscribe/unsubscribe/request triplets per data type. The full set:

  • Connection: _connect, _disconnect
  • Generic subscribe/unsubscribe/request: _subscribe, _unsubscribe, _request
  • Instruments: _subscribe_instruments, _subscribe_instrument, _unsubscribe_instruments, _unsubscribe_instrument, _request_instrument, _request_instruments
  • Order book: _subscribe_order_book_deltas, _subscribe_order_book_depth, _unsubscribe_order_book_deltas, _unsubscribe_order_book_depth, _request_order_book_deltas, _request_order_book_depth, _request_order_book_snapshot
  • Quotes: _subscribe_quote_ticks, _unsubscribe_quote_ticks, _request_quote_ticks
  • Trades: _subscribe_trade_ticks, _unsubscribe_trade_ticks, _request_trade_ticks
  • Bars: _subscribe_bars, _unsubscribe_bars, _request_bars
  • Mark/Index/Funding: _subscribe_mark_prices, _subscribe_index_prices, _subscribe_funding_rates (+ unsubscribe + request)
  • Instrument status/close: _subscribe_instrument_status, _subscribe_instrument_close (+ unsubscribe)
  • Option greeks: _subscribe_option_greeks, _unsubscribe_option_greeks

Cortana UW v0 implements only the generic triplet because UW publishes custom data (UWFlowAlert), not standard market-data primitives. This is the right choice - LiveDataClient (non-market data superclass) is sufficient.

Method ordering convention

The page mandates this group order in adapter classes:

  1. Connection handlers: _connect, _disconnect
  2. Subscribe handlers: _subscribe, _subscribe_*
  3. Unsubscribe handlers: _unsubscribe, _unsubscribe_*
  4. Request handlers: _request, _request_*

Keeps related functionality together rather than interleaving subscribe/unsubscribe pairs. This is a doc-blessed style rule, not a personal preference.

ExecutionClient - Python contract

For data-only adapters this section is for reference; UW will not implement it. The full surface, verbatim:

class TemplateLiveExecutionClient(LiveExecutionClient):
    async def _connect(self) -> None: ...
    async def _disconnect(self) -> None: ...
 
    # Reconciliation reports - non-optional in live mode
    async def generate_order_status_report(self, command: GenerateOrderStatusReport) -> OrderStatusReport | None: ...
    async def generate_order_status_reports(self, command: GenerateOrderStatusReports) -> list[OrderStatusReport]: ...
    async def generate_fill_reports(self, command: GenerateFillReports) -> list[FillReport]: ...
    async def generate_position_status_reports(self, command: GeneratePositionStatusReports) -> list[PositionStatusReport]: ...
    async def generate_mass_status(self, lookback_mins: int | None = None) -> ExecutionMassStatus | None: ...
 
    # Order command handlers
    async def _submit_order(self, command: SubmitOrder) -> None: ...
    async def _submit_order_list(self, command: SubmitOrderList) -> None: ...
    async def _modify_order(self, command: ModifyOrder) -> None: ...
    async def _cancel_order(self, command: CancelOrder) -> None: ...
    async def _cancel_all_orders(self, command: CancelAllOrders) -> None: ...
    async def _batch_cancel_orders(self, command: BatchCancelOrders) -> None: ...

The five generate_* methods are the four reconciliation report variants + an aggregate generate_mass_status. Required for live adapters; the spec acceptance suite (ExecTester) checks them.

InstrumentProvider - Python contract

from nautilus_trader.common.providers import InstrumentProvider
from nautilus_trader.model import InstrumentId
 
class TemplateInstrumentProvider(InstrumentProvider):
    async def load_all_async(self, filters: dict | None = None) -> None: ...
    async def load_ids_async(self, instrument_ids: list[InstrumentId], filters: dict | None = None) -> None: ...
    async def load_async(self, instrument_id: InstrumentId, filters: dict | None = None) -> None: ...

Three abstract methods. For UW these are near-no-ops; for IBKR these are the venue-walk for the full SPY 0DTE chain.

Lifecycle hooks - beyond what the concept page covers

The concept page describes the connect/disconnect/subscribe/request hooks. The dev-guide page goes deeper on what each hook must do internally.

_connect() - strict initialization order

The page is explicit: the platform waits for all clients to signal connected before running reconciliation or starting strategies, so all initialization must complete within _connect(). Don’t defer work to on_start() - by then reconciliation has already run.

For data clients, the canonical sequence:

// Pseudo-Rust from the doc; Python equivalent is the same idea
async fn connect(&mut self) -> Result<()> {
    let instruments = self.bootstrap_instruments().await?;
    ws.cache_instruments(instruments);
    ws.connect().await?;
    ws.wait_until_active(10.0).await?;
    Ok(())
}

For execution clients, more involved:

async fn connect(&mut self) -> Result<()> {
    self.ensure_instruments_initialized_async().await?;
 
    self.ws_client.connect().await?;
    self.ws_client.wait_until_active(10.0).await?;
    // ... subscribe channels, start stream ...
 
    self.refresh_account_state().await?;
    self.await_account_registered(30.0).await?;
 
    self.core.set_connected();
    Ok(())
}

The await_account_registered(30.0) step is critical: it polls the cache at 10ms intervals until the account appears or the timeout expires, blocking connect so the portfolio can process orders during reconciliation. Skipping this step causes orders to be denied during early reconciliation because the portfolio doesn’t yet know about the account.

Watchdog reconnect - the RECONNECTED sentinel pattern

The underlying WebSocketClient from nautilus_network sends a RECONNECTED sentinel message when reconnection completes, triggering resubscription logic in the handler. The handler:

  1. Receives {Venue}WsMessage::Reconnected from the inner.
  2. If authenticated: re-authenticates and waits for confirmation via AuthTracker::begin()succeed().
  3. Restores all tracked subscriptions via handler commands by replaying the preserved subscription arguments (not the topic strings - store the original args in a separate Arc<DashMap<String, SubscriptionArgs>>).

Why preserve original args, not just topic strings: parsing topics back into args is brittle (multiple parameter types, nested encoding, optional fields). Preserve once, replay verbatim.

Disconnection - the three-step shutdown sequence

async fn close(&mut self) -> Result<()> {
    // 1. Send Disconnect command so handler cleans up gracefully
    self.cmd_tx.read().await.send(HandlerCommand::Disconnect)?;
 
    // 2. Set stop signal so handler loop exits after processing disconnect
    self.signal.store(true, Ordering::Release);
 
    // 3. Await task handle with timeout, abort if stuck
    if let Some(task_handle) = self.task_handle.take() {
        match Arc::try_unwrap(task_handle) {
            Ok(handle) => {
                let abort_handle = handle.abort_handle();
                match tokio::time::timeout(Duration::from_secs(2), handle).await {
                    Ok(Ok(())) => {/* completed */}
                    Ok(Err(e)) => tracing::error!("Handler task error: {e:?}"),
                    Err(_) => {
                        tracing::warn!("Timeout waiting for handler task, aborting");
                        abort_handle.abort();
                    }
                }
            }
            Err(arc_handle) => arc_handle.abort(),
        }
    }
    Ok(())
}

Send Disconnect before setting the stop signal so the handler processes it before exiting. Use Ordering::Release so the handler sees the write. Extract abort_handle before awaiting so it remains available after timeout.

Backoff - the RetryManager pattern

The page mandates nautilus_network::RetryManager for both HTTP and WebSocket retry. WebSocket sends use send_with_retry:

async fn send_with_retry(&self, payload: String, rate_limit_keys: Option<Vec<String>>) -> Result<(), MyWsError> {
    self.retry_manager.execute_with_retry(
        "websocket_send",
        || async {
            client.send_text(payload.clone(), rate_limit_keys.clone()).await
                .map_err(|e| MyWsError::ClientError(format!("Send failed: {e}")))
        },
        should_retry_error,
        create_timeout_error,
    ).await
}
 
fn should_retry_error(error: &MyWsError) -> bool {
    match error {
        MyWsError::NetworkError(_) | MyWsError::Timeout(_) => true,
        MyWsError::AuthenticationError(_) | MyWsError::ParseError(_) => false,
    }
}

Send failures (after retries exhausted) emit SendFailed so the exec client dispatch can produce OrderRejected. Never panic on a send failure; emit a typed event.

Error handling - the load-bearing taxonomy

Adapter-level error enum (common/error.rs)

#[derive(Debug, thiserror::Error)]
pub enum VenueError {
    #[error("HTTP error: {0}")]
    Http(#[from] VenueHttpError),
 
    #[error("WebSocket error: {0}")]
    WebSocket(#[from] VenueWsError),
 
    #[error("Build error: {0}")]
    Build(#[from] VenueBuildError),
}

Top-level enum aggregates component-level errors via #[from] so ? propagates cleanly. Component errors stay specific for debugging; the top-level type provides unified handling at the adapter boundary.

Retry classification (common/retry.rs)

#[derive(Debug, thiserror::Error)]
pub enum VenueError {
    #[error("Retryable error: {source}")]
    Retryable {
        #[source]
        source: VenueRetryableError,
        retry_after: Option<Duration>,
    },
 
    #[error("Non-retryable error: {source}")]
    NonRetryable {
        #[source]
        source: VenueNonRetryableError,
    },
 
    #[error("Fatal error: {source}")]
    Fatal {
        #[source]
        source: VenueFatalError,
    },
}

Three categories. Retryable carries retry_after: Option<Duration> for venues that emit hint headers (e.g., Retry-After: 60). Helpers from_http_status(), from_rate_limit_headers(), is_retryable(), is_fatal(), retry_after() standardize classification. BitMEX and Bybit adapters are the reference implementations.

Client-side vs handler-side propagation

BoundaryFailure typeBehavior
Client → handler (channel)cmd_tx.send(...) failurePropagate immediately as Result<(), Error>. Handler unavailable = caller’s problem.
Handler → network (WebSocket)Transient send failureRetry via RetryManager with backoff.
Handler → network (after retries exhausted)Permanent send failureEmit SendFailed event on out_tx; exec dispatch converts to OrderRejected.

Never silently swallow. Every failure produces either a propagated Result::Err or a typed event.

Rate limiting - the quota pattern

The page is explicit about naming conventions:

  • REST quotas: {VENUE}_REST_QUOTA (e.g., OKX_REST_QUOTA)
  • WebSocket quotas: {VENUE}_WS_{OPERATION}_QUOTA (e.g., OKX_WS_CONNECTION_QUOTA, OKX_WS_ORDER_QUOTA)
  • Rate limit keys: {VENUE}_RATE_LIMIT_KEY_{OPERATION} (e.g., OKX_RATE_LIMIT_KEY_SUBSCRIPTION)

Standard WebSocket keys:

KeyOperations
*_RATE_LIMIT_KEY_SUBSCRIPTIONSubscribe, unsubscribe, login
*_RATE_LIMIT_KEY_ORDERPlace orders (regular and algo)
*_RATE_LIMIT_KEY_CANCELCancel orders, mass cancel
*_RATE_LIMIT_KEY_AMENDAmend/modify orders

Defined as LazyLock<Quota> static variables:

pub static OKX_REST_QUOTA: LazyLock<Quota> =
    LazyLock::new(|| Quota::per_second(NonZeroU32::new(250).unwrap()));
 
pub static OKX_WS_SUBSCRIPTION_QUOTA: LazyLock<Quota> =
    LazyLock::new(|| Quota::per_hour(NonZeroU32::new(480).unwrap()));
 
pub const OKX_RATE_LIMIT_KEY_ORDER: &str = "order";

Pass keys when sending: self.send_with_retry(payload, Some(vec![OKX_RATE_LIMIT_KEY_ORDER.to_string()])).await.

For UW: REST is rare (chain snapshot pulls); WS has no operation quotas because UW doesn’t expose order operations. UW v0 needs only UW_REST_QUOTA: ~120/min matching UW’s standard tier.

Credential resolution - the canonical pattern

credential_env_vars() + Credential::resolve()

Each adapter’s common/credential.rs provides:

use nautilus_core::env::resolve_env_var_pair;
 
pub fn credential_env_vars(is_testnet: bool) -> (&'static str, &'static str) {
    if is_testnet {
        ("{VENUE}_TESTNET_API_KEY", "{VENUE}_TESTNET_API_SECRET")
    } else {
        ("{VENUE}_API_KEY", "{VENUE}_API_SECRET")
    }
}
 
impl Credential {
    pub fn resolve(
        api_key: Option<String>,
        api_secret: Option<String>,
        is_testnet: bool,
    ) -> Option<Self> {
        let (key_var, secret_var) = credential_env_vars(is_testnet);
        let (k, s) = resolve_env_var_pair(api_key, api_secret, key_var, secret_var)?;
        Some(Self::new(k, s))
    }
}

Naming conventions

EnvironmentAPI Key VariableAPI Secret Variable
Mainnet/Live{VENUE}_API_KEY{VENUE}_API_SECRET
Testnet{VENUE}_TESTNET_API_KEY{VENUE}_TESTNET_API_SECRET
Demo{VENUE}_DEMO_API_KEY{VENUE}_DEMO_API_SECRET

Some venues require additional credentials (OKX uses OKX_API_PASSPHRASE).

Key principles

  • Environment variable names centralized in credential_env_vars(), never duplicated as string literals.
  • Resolution happens in core Rust code, not Python bindings.
  • get_or_env_var_opt for optional creds (returns None if missing).
  • get_or_env_var when required (returns error if missing).
  • Invalid credentials must fail fast with an error, never silently degrade to unauthenticated mode.

For UW Python v0: config dataclass with api_key: str | None = None falling back to os.environ["UW_API_KEY"] in __post_init__/factory.

WebSocket two-layer architecture - outer client + inner handler

This is the most architecturally distinctive part of the page and the hardest to internalize. The key insight: the handler exclusively owns the WebSocketClient (no RwLock); the outer client sends commands via lock-free mpsc channel and receives events via another mpsc channel.

Outer client ({Venue}WebSocketClient)

pub struct MyWebSocketClient {
    cmd_tx: Arc<tokio::sync::RwLock<UnboundedSender<HandlerCommand>>>,
    out_rx: Option<Arc<UnboundedReceiver<MyWsMessage>>>,
    task_handle: Option<Arc<JoinHandle<()>>>,
    connection_mode: Arc<ArcSwap<AtomicU8>>,  // Lock-free connection state
    signal: Arc<AtomicBool>,                   // Cancellation signal
    auth_tracker: AuthTracker,                 // From nautilus_network
    subscription_state: Arc<SubscriptionState>,
    subscription_args: Arc<DashMap<String, SubscriptionArgs>>,
}

Responsibilities:

  • Orchestrates connection lifecycle, authentication, subscriptions.
  • Maintains state Python might query via Arc<DashMap>.
  • Tracks subscription state for reconnection logic.
  • Stores instruments cache for replay on reconnect.
  • Sends commands to handler via cmd_tx channel.
  • Receives venue events via out_rx channel.

Inner handler ({Venue}WsFeedHandler)

pub(super) struct MyWsFeedHandler {
    inner: Option<WebSocketClient>,                    // Exclusively owned - no RwLock
    cmd_rx: UnboundedReceiver<HandlerCommand>,
    raw_rx: UnboundedReceiver<Message>,
    out_tx: UnboundedSender<MyWsMessage>,
    pending_requests: AHashMap<String, RequestData>,    // Single-threaded - no locks
    pending_messages: VecDeque<MyWsMessage>,            // Multi-message buffer
    retry_manager: RetryManager<MyWsError>,
}

Responsibilities:

  • Runs in dedicated Tokio task as stateless I/O boundary.
  • Owns WebSocketClient exclusively (no RwLock needed).
  • Processes commands from cmd_rx → serializes to JSON → sends via WebSocket.
  • Receives raw frames → deserializes into {Venue}WsFrame → converts to {Venue}WsMessage → emits via out_tx.
  • Owns pending request state using AHashMap<K, V> (single-threaded, no locking).
  • Uses VecDeque<{Venue}WsMessage> to buffer multi-message yields from a single frame parse.

Key principles

  • No shared locks on hot path: handler owns WebSocketClient; client sends commands via lock-free mpsc channel.
  • Command pattern for all sends: subscriptions, orders, cancels all route through HandlerCommand enum.
  • Event pattern for state: handler emits {Venue}WsMessage events (including Authenticated); client maintains state from events.
  • Pending state ownership: handler owns AHashMap for matching responses (no Arc<DashMap> between layers).
  • Message buffering: handler uses VecDeque<{Venue}WsMessage> for frames that produce multiple output messages. The next() method drains the queue before polling channels.
  • Python constraint: client uses Arc<DashMap> only for state Python might query; handler uses AHashMap for internal matching.

Connection-state tracking - Arc<ArcSwap<AtomicU8>>

use arc_swap::ArcSwap;
 
pub struct MyWebSocketClient {
    connection_mode: Arc<ArcSwap<AtomicU8>>,
    signal: Arc<AtomicBool>,
}

Pattern:

  • Outer Arc: shared across all clones.
  • ArcSwap: enables atomic pointer replacement via .store().
  • Inner Arc<AtomicU8>: actual connection state from WebSocketClient::connection_mode_atomic().

Initialize with placeholder atomic (ConnectionMode::Closed); in connect() call .store(client.connection_mode_atomic()) to atomically swap to the real client’s state. All clones see updates instantly through lock-free .load() calls in is_active().

Subscription lifecycle (the SubscriptionState machine)

TriggerMethodFrom stateTo stateNotes
User subscribesmark_subscribe()-PendingTopic added to pending set
Venue confirmsconfirm()PendingConfirmedMoved from pending to confirmed
Venue rejectsmark_failure()PendingPendingStays pending for retry on reconnect
User unsubscribesmark_unsubscribe()ConfirmedPendingTemporarily pending until ack
Unsubscribe ackclear_pending()PendingRemovedTopic fully removed

Key principles:

  • subscription_count() reports only confirmed subscriptions, not pending.
  • Failed subscriptions remain pending and are automatically retried on reconnect.
  • Both confirmed and pending subscriptions are restored after reconnection.
  • Unsubscribe operations check the op field in acknowledgments to avoid re-confirming topics.

WsDispatchState - execution dispatch state

For execution adapters only. Lives in websocket/dispatch.rs. Tracks which lifecycle events have already been emitted to prevent duplicates across reconnections and fast-fill races:

#[derive(Debug, Default)]
pub struct WsDispatchState {
    pub order_identities: DashMap<ClientOrderId, OrderIdentity>,
    pub emitted_accepted: DashSet<ClientOrderId>,
    pub triggered_orders: DashSet<ClientOrderId>,
    pub filled_orders: DashSet<ClientOrderId>,
    clearing: AtomicBool,
}

Each DashSet is bounded by DEDUP_CAPACITY (typically 10,000). When full, evict_if_full() clears atomically using compare-exchange on clearing flag.

Cross-source fill dedup - BoundedDedup<T>

For adapters receiving fills from multiple sources (WebSocket user data + HTTP reconciliation):

struct BoundedDedup<T> {
    order: VecDeque<T>,
    set: AHashSet<T>,
    capacity: usize,
}

Fixed-capacity FIFO. insert() returns true if value already present (signals duplicate). Used for trade IDs, typically (Ustr, i64) of (symbol, trade_id). Capacity 10,000 sufficient for most venues without unbounded memory growth.

Order book delta F_LAST / F_SNAPSHOT - the silent-bug invariant

When implementing _subscribe_order_book_deltas or streaming book data, RecordFlag flags must be set correctly:

  • F_LAST: set on the last delta of every logical event group. DataEngine uses this as the flush signal when buffer_deltas is enabled. Without it, deltas accumulate indefinitely and are never published to subscribers.
  • F_SNAPSHOT: set on all deltas belonging to a snapshot sequence (a Clear action followed by Add actions reconstructing the book).
  • Empty book snapshot: the Clear delta must have F_SNAPSHOT | F_LAST.
  • Incremental updates: each venue update message ends with a delta with F_LAST set. If the venue batches multiple updates into one message, terminate each logical group with F_LAST.
from nautilus_trader.model.enums import RecordFlag
 
# Incremental update (single event)
delta = OrderBookDelta(
    instrument_id=instrument_id,
    action=BookAction.UPDATE,
    order=order,
    flags=RecordFlag.F_LAST,
    sequence=sequence,
    ts_event=ts_event,
    ts_init=ts_init,
)
 
# Snapshot sequence
clear_delta = OrderBookDelta(
    action=BookAction.CLEAR,
    flags=RecordFlag.F_SNAPSHOT,  # Not last
    ...
)
last_add_delta = OrderBookDelta(
    action=BookAction.ADD,
    flags=RecordFlag.F_SNAPSHOT | RecordFlag.F_LAST,  # End of snapshot
    ...
)

A missing F_LAST is a silent bug: no error raised, but subscribers never receive the data when buffering is enabled.

For UW: not relevant (UW doesn’t publish books). For any future adapter with book data, this is non-negotiable.

Reconciliation report variants - the four shapes

The page (paired with Nautilus Execution) mandates that every LiveExecutionClient must produce one of four reconciliation report variants on demand:

VariantUse case
OrderStatusReportStandalone order state update (Accepted, PartiallyFilled, Canceled, Expired).
FillReportStandalone execution. Used by venues that surface fills for venue-initiated closures without opening user-level orders (e.g., Hyperliquid liquidations).
OrderWithFillsStatus update bundled with fills atomically. Used by Binance Futures for ADL/liquidation/settlement.
PositionStatusReportPosition snapshot from venue. Logged but advisory; positions are derived from fills, not bootstrapped from this report.

For UW: skipped (data-only). For IBKR (already shipped adapter): all four are implemented.

The page details a comprehensive testing matrix. Full detail in the two parallel pages:

  • Data testing (nautilus-dev-spec-data-testing.md) - the DataTester matrix (TC-D01..TC-D60+: instruments, book deltas, book at interval, book depth, snapshots, managed-book-from-deltas, quotes, trades, bars, mark/index/funding, custom data).
  • Execution testing (nautilus-dev-testing.md) - the ExecTester matrix (TC-E01..TC-E50+: market BUY/SELL, IOC/FOK/GTC TIF, limit, post-only, cancel, modify, stop-loss, take-profit, brackets, batch, rejection, reconnect reconciliation).

Each adapter must pass the subset matching its supported capabilities - this is the contract for shippability.

The page itself enumerates HTTP integration coverage:

  • Happy paths - fetch a public resource, verify Nautilus-domain conversion.
  • Credential guard - call private endpoint without creds, assert structured error; repeat with creds, prove success.
  • Rate limiting / retry mapping - surface venue rate-limit responses and verify correct error variant.
  • Query builders - exercise paginated/time-bounded endpoints, assert emitted query string matches venue spec.
  • Error translation - non-2xx responses map to adapter error enums with original code/message.

And WebSocket integration coverage:

  • Login handshake - successful + failed cases.
  • Ping/Pong - both text-based and control-frame pings.
  • Subscription lifecycle - public + private channels.
  • Reconnect behaviour - re-auth + subscription replay.
  • Message routing - payloads arrive as correct {Venue}WsMessage variant.
  • Quota tagging - operations tagged with appropriate quota label.

CI robustness:

  • Never use bare tokio::time::sleep() with arbitrary durations. Tests become flaky under CI load.
  • Use wait_until_async test helper to poll for conditions with timeout.
  • Prefer event-driven assertions with shared state.

Cortana MK3 implications - the UW adapter authoring plan

This is the load-bearing section. The concept page nautilus-adapters.md gave the LOC estimate (~870 Python LOC for v0). This section gives the complete file-by-file blueprint that turns that estimate into a build-able artifact.

Why UW v0 ships Python-only (not Rust)

The page is clear that the Rust core is the supported production path for new work. But Python-only Phase 1-3 collapse into data.py + parsing.py is a documented escape hatch for venues where the Python WS parse path is not a measured bottleneck. UW peaks at ~10 alerts/sec; the Python path is fine. v0 = Python-only. v1 = Rust + PyO3 only if profiling shows hot path.

Component-status reminder

ComponentStatusCortana UW v0 path
HttpClientRequiredhttpx.AsyncClient wrapper in data.py (REST chain pulls)
WebSocketClientRequiredwebsockets library wrapper in data.py (alerts)
InstrumentProviderRequired (near-no-op)providers.py returns empty list
LiveDataClientRequired (the main work)data.py - UWLiveDataClient subclass
LiveExecutionClientSkippedUW is read-only; execution stays on IBKR

File-by-file blueprint for cortana_mk3/adapters/uw/

The adapter lives under the Cortana MK3 source tree, shaped after the Python tree of a Nautilus adapter. Each file has a target LOC budget derived from the dev guide patterns + the concept-page estimate. Total target: ~870 LOC Python, including ~300 LOC of tests.

FileLOCPurpose
__init__.py~15Re-exports public surface (UnusualWhalesLiveDataClientFactory, UWConfig, UWFlowAlert, OptionChainSnapshot, UW ClientId constant); declares __all__.
constants.py~30URL constants (UW_WS_URL, UW_REST_URL); env-var keys (UW_API_KEY); rate-limit quota constants (UW_REST_QUOTA = 120/min); UW = ClientId("UW") constant.
config.py~60UWConfig(LiveDataClientConfig) dataclass: api_key: str | None, api_secret: str | None (env-fallback in __post_init__), channels: list[str], historical_lookback_secs: int = 60, reconnect_max_attempts: int = 10, reconnect_backoff_max_secs: int = 60, ws_heartbeat_secs: int = 30. Inherits all LiveDataClientConfig knobs.
credential.py~30resolve_credentials(api_key, api_secret) -> tuple[str, str] mirroring the Rust Credential::resolve() pattern. Centralized env-var resolution; fail-fast on invalid creds; never silently degrade to unauthenticated.
parsing.py~80Frame → typed payload converters. parse_flow_alert(raw: dict) -> UWFlowAlert (the load-bearing parser ported from cortanaroi/data/uw_ws_parser.py - battle-tested against UW quirks #54 strike-format and #59 timestamp-unit). parse_option_chain(raw: dict) -> OptionChainSnapshot. Each parser returns a @customdataclass instance with ts_event = int(raw["ts_ms"]) * 1_000_000 (ms → ns) and ts_init = self._clock.timestamp_ns().
messages.py~50Wire-message dataclasses (msgspec) for UW’s WS frames: UWFlowAlertFrame, UWChannelAck, UWPing, UWHeartbeat, UWErrorFrame. Mirrors Rust {Venue}WsFrame enum. Internal to the adapter; never exposed to consumers.
custom_data.py~80The two @customdataclass types Cortana publishes: UWFlowAlert (instrument_id, strike, expiry, option_side, aggressor_side, premium_usd, size_contracts, is_sweep, is_block, flow_score, underlying_price, raw_id) and OptionChainSnapshot (instrument_id, expiry, chain_json). Imported by every consumer (strategy, scoring actor, brain-logger).
providers.py~30UWInstrumentProvider(InstrumentProvider) - near-no-op. load_all_async returns empty list (UW does not define instruments). load_async and load_ids_async delegate to the shared Cache populated by the IBKR adapter when an InstrumentId is requested.
http.py~100UWHttpClient wrapping httpx.AsyncClient. Methods: connect() (auth handshake), disconnect(), get_option_chain(ticker, expiry) -> dict, get_net_flow(expiry) -> dict. Uses retry classification (Retryable/NonRetryable/Fatal) implemented as Python exceptions; respects UW_REST_QUOTA via aiolimiter.AsyncLimiter.
websocket.py~150UWWebSocketClient wrapping websockets.connect. Mirrors the dev-guide outer/inner pattern as a single-process simplification: connection-state asyncio.Event, cmd_queue: asyncio.Queue, out_queue: asyncio.Queue, subscription_state: dict[str, Literal["pending","confirmed"]], subscription_args: dict[str, dict] (for replay). On RECONNECTED sentinel: re-auth, re-subscribe via subscription_args replay. Three-step close() (send Disconnect → set stop signal → await task with timeout, abort if stuck). Heartbeat / ping handling.
data.py~200The heart of the adapter. UWLiveDataClient(LiveDataClient) subclass. Method-ordering convention: connect → subscribe → unsubscribe → request. _connect: opens WS via UWWebSocketClient, opens REST via UWHttpClient, primes instrument cache (no-op), signals connected. _disconnect: three-step shutdown of both clients. _subscribe(SubscribeData): matches on data_type.type; if UWFlowAlert, joins UW channel; tracks subscription via subscription_state.mark_subscribe. _unsubscribe(UnsubscribeData): parallel path. _request(RequestData): matches on data_type.type; if OptionChainSnapshot, issues REST chain-pull, parses, calls self._handle_data_response(request_id, snapshot). _on_ws_message(frame): parses to UWFlowAlert, calls self._handle_data(alert).
factories.py~60UnusualWhalesLiveDataClientFactory.create(loop, name, config: UWConfig, msgbus, cache, clock) -> UWLiveDataClient - the registration seam called by LiveNode.builder().add_data_client(...). Wires the config to the live client via UWHttpClient + UWWebSocketClient instantiation in correct order (REST first, then WS). Also factory for execution-side noop (raises clear error: “UW is data-only; route execution through IBKR”).
tests/test_data_client.py~120Pytest integration tests against mock UW WebSocket fixtures. Covers connect/disconnect, subscribe → confirm → emit, reconnect → re-subscribe, frame-parse error handling (drop with warn, never panic), _handle_data invocation count. Uses wait_until_async-style helpers, no bare asyncio.sleep.
tests/test_parsing.py~80Frame-parser unit tests. Property tests for ts_ms × 1e6 == ts_event_ns invariant. Regression tests for GH #54 strike-format and GH #59 timestamp-unit issues.
tests/test_factories.py~50Factory wiring tests; config validation; env-var fallback verification; fail-fast on invalid creds.
tests/test_providers.py~50UWInstrumentProvider returns empty list; load_async delegates correctly to shared Cache; multi-tenant isolation (per-tenant UW ClientId).

Total: ~1,185 LOC (15+30+60+30+80+50+80+30+100+150+200+60+120+80+50+50). The concept-page estimate of ~870 LOC is the non-test core (~885 LOC: 15+30+60+30+80+50+80+30+100+150+200+60 = 885). Tests add ~300 LOC. Numbers align.

Realistic v0 ship: 2-3 working days for the non-test core, plus 1-2 days for tests. Matches the mk3-roadmap estimate.

Phase mapping

PhaseUW v0 work
1 - Rust coreSkipped (Python-only). Equivalent: http.py + websocket.py + parsing.py (~330 LOC).
2 - InstrumentsNear-no-op. providers.py (~30 LOC).
3 - Market dataThe bulk. data.py + custom_data.py (~280 LOC).
4 - ExecutionSkipped. UW is data-only.
5 - Advanced featuresdata.py::_request for option-chain snapshot (~30 LOC inside data.py).
6 - Config + factoriesconfig.py + factories.py + credential.py + constants.py (~180 LOC).
7 - Testing + docstests/* (~300 LOC).

Wiring into LiveNode (Cortana MK3 sketch)

from nautilus_trader.live.node import LiveNode, Environment
from nautilus_trader.config import LiveDataEngineConfig
from nautilus_trader.adapters.interactive_brokers.factories import (
    InteractiveBrokersLiveDataClientFactory,
    InteractiveBrokersLiveExecClientFactory,
)
from cortana_mk3.adapters.uw.factories import UnusualWhalesLiveDataClientFactory
from cortana_mk3.adapters.uw.config import UWConfig
 
uw_config = UWConfig(
    api_key=None,         # falls back to UW_API_KEY env
    api_secret=None,      # falls back to UW_API_SECRET env
    channels=["flow-alerts:SPY"],
)
 
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()
)

Two add_data_client calls, one add_exec_client. UW never sees an order command.

Anti-patterns (page-derived)

  • Subscribing without preserving original args. Reconnect replay via topic-string parsing is brittle; preserve original SubscriptionArgs in a separate Arc<DashMap<String, SubscriptionArgs>>.
  • Setting stop signal before sending Disconnect. Handler exits before processing the disconnect command. Send Disconnect first.
  • Bare tokio::time::sleep / asyncio.sleep in tests. Tests flake under CI load. Use wait_until_async polling helpers.
  • Centralizing env-var name strings as duplicated literals. Always go through credential_env_vars() (Rust) or the equivalent Python helper.
  • Plain tokio::spawn from a Rust adapter. Tasks must use nautilus_common::live::get_runtime().spawn(...). Plain spawn panics from Python threads. Pre-commit check_tokio_usage.sh enforces.
  • Skipping await_account_registered in execution _connect. Orders denied during reconciliation if portfolio doesn’t yet know the account.
  • Forgetting F_LAST on the last delta of a book event group. Silent bug: subscribers never receive the data when buffering is enabled.
  • Holding object references to emulated orders. They transform on release; query the cache by client_order_id.
  • Using Arc<DashMap> between client/handler for state only the handler needs. Handler owns single-threaded AHashMap; no locking required because the handler runs on a dedicated task.
  • Manually ordering subscribe/unsubscribe pairs in adapter classes. Use the doc-blessed grouping: connect → subscribe → unsubscribe → request.
  • Documenting individual parameters in # Arguments sections. The doc explicitly says: don’t. Type signatures + descriptive names should be self-explanatory.
  • Putting credential resolution in Python bindings. Resolution happens in core Rust code; Python only receives the resolved values.
  • Returning Result::Err from extern "C" boundaries. Rust panics must never unwind across the FFI boundary. Wrap in crate::ffi::abort_on_panic(|| { ... }).
  • Using as_legacy_cython=True for IMBALANCE/STATISTICS. PyO3-only types; raises ValueError. (Nautilus-data quirk; mentioned for cross-reference completeness.)

When this concept applies

  • Authoring the UW WebSocket adapter for MK3 (the primary use case).
  • Authoring any future data adapter (Tradier, Polygon options, CME data feed, Tardis pull-through).
  • Authoring any future execution adapter (alternate broker, prime broker integration).
  • Reviewing the shipped IBKR adapter source code for pattern conformance.
  • Triaging adapter test failures by knowing what spec the test is enforcing.
  • Estimating effort for a “venue X support” feature request.

When it does not apply

  • The page does not document the LiveNode builder or strategy registration - see nautilus-live.md / nautilus-architecture.md.
  • The page does not document custom-data-class authoring ergonomics
  • The page does not document the test-tier ladder (unit → parametrized → property → integration → fuzz → spec → DST → formal)
  • The page does not document the spec-acceptance test matrices in detail - see nautilus-dev-spec-data-testing.md and the execution-spec parallel.
  • IBKR-specific quirks (OCA / OCO not auto-creating IB-side OCA groups, fetch_all_open_orders=True, IB pacing limits) are in nautilus-ib.md, not here.

See Also


Timeline

2026-05-07 | Cody - Filed during pre-spike concept mastery sweep batch 7 (developer guide).