Nautilus Message Bus
The MessageBus is the spine of every Nautilus runtime: a single in-process, single-threaded, deterministically-ordered hub that delivers Data, Events, and Commands between Actors, Strategies, and the kernel’s engines via three messaging patterns (Pub/Sub, Request/Response, Point-to-Point) and three publishing styles (low-level topic pub/sub, Actor-based custom data, Actor-based primitive signals). Every message is immutable post-publish. Optional Redis Streams backing externalizes the bus for cross-process consumers (the dashboard story, the producer/consumer split for shared market data) - Redis writes happen on a Rust MPSC-fed background thread so the trading core never blocks on I/O. Same bus runs in backtest, sandbox, and live with identical dispatch semantics - backtest determinism is a property of the single-threaded kernel + stable-sort by
ts_init, not of the bus implementation. For Cortana MK3 this replaces hand-rolled function calls and ad-hoc SQLite writes with one typed pub/sub spine; the multi-tenant fan-out story is Producer/Consumer external streams over Redis - one process holds the upstream UW WebSocket and republishes onto a shared external stream, N tenantTradingNodeprocesses consume that stream as theirexternal_clientsdata source.
Core claim
The bus is the architecture. Every other component (DataEngine, ExecutionEngine, RiskEngine, Cache, Actor, Strategy) is wired together exclusively by sending messages to or receiving them from the bus. There are no direct method calls between strategy and execution, no shared mutable dictionaries, no callback chains. This is what gives Nautilus deterministic ordering, replayability, and backtest/live parity for free - properties MK2 has tried (and failed) to maintain through discipline alone.
Topology
In-process spine
“The
MessageBusis the central hub for all messages in NautilusTrader. It enables a publish/subscribe pattern where components can publish events to named topics, and other components can subscribe to receive those messages. This decouples components, allowing them to interact indirectly via the message bus.”
The bus is thread-local to the kernel (per nautilus-architecture.md “Threading model”). Every component inside the kernel - Actors, Strategies, DataEngine, ExecutionEngine, RiskEngine - calls into the same bus instance. Background services (Tokio I/O, network, persistence) run on their own threads and funnel results back to the kernel’s bus via MPSC channels.
Three messaging patterns
“There are three primary messaging patterns available in NautilusTrader: Point-to-Point, Publish/Subscribe, Request/Response.”
| Pattern | Use case | Examples in framework |
|---|---|---|
| Pub/Sub | One publisher → N subscribers, fire-and-forget | Data dispatch, OrderEvent dispatch, custom signals |
| Request/Response | One requester → one responder, with correlation | Historical data fetches (request_bars), instrument lookups |
| Point-to-Point | Direct delivery to a named target | Targeted command from one component to another |
Pub/Sub is the dominant pattern. R/R is what underpins request_bars() /
request_quote_ticks() / request_instrument() - strategy issues a request
correlated by ID, the data adapter responds with historical data, the bus
routes the response back to on_historical_data. P2P is rarely user-facing.
Three message categories
“Messages exchanged via the
MessageBusfall into three categories: Data, Events, Commands.”
- Data -
QuoteTick,TradeTick,Bar,OrderBookDelta, customDatasubclasses. Cache-then-publish ordering. - Events -
OrderFilled,PositionOpened,AccountState,TimeEvent, custom events. Pub/Sub only. - Commands -
SubmitOrder,CancelOrder,ModifyOrder, etc. Routed to the appropriate engine (RiskEngine → ExecutionEngine → ExecutionClient).
Three publishing styles (Actor/Strategy surface)
The doc enumerates these by capability and tradeoff. Verbatim from the page:
1. MessageBus pub/sub to topics (low-level)
“Low-level, direct access to the message bus.”
Use when:
- Cross-component communication.
- Flexibility to define any topic and send any type of payload (any Python object).
- Decoupling between publishers and subscribers who don’t need to know about each other.
- Global reach where messages can be received by multiple subscribers.
- Working with events that don’t fit within the predefined
Actormodel. - Advanced scenarios requiring full control over messaging.
Caveat: “you must track topic names manually (typos could result in missed messages).”
from nautilus_trader.core.message import Event
class Each10thBarEvent(Event):
TOPIC = "each_10th_bar"
def __init__(self, bar):
self.bar = bar
# Subscribe
self.msgbus.subscribe(Each10thBarEvent.TOPIC, self.on_each_10th_bar)
# Publish
self.msgbus.publish(Each10thBarEvent.TOPIC, Each10thBarEvent(bar))
# Handler (any name - wired by the subscribe call)
def on_each_10th_bar(self, event: Each10thBarEvent):
self.log.info(f"Received 10th bar: {event.bar}")self.msgbus.publish("MyTopic", "MyMessage") works at the rawest level -
arbitrary topic string, arbitrary Python payload.
2. Actor-based custom data pub/sub
Requires a class that subclasses Data or uses @customdataclass.
“Proper event ordering via built-in timestamps (
ts_event,ts_init) crucial for backtest accuracy. Data persistence and serialization. Standardized trading data exchange.”
from nautilus_trader.core.data import Data
from nautilus_trader.model.custom import customdataclass
@customdataclass
class GreeksData(Data):
delta: float
gamma: float
# Publish (Actor / Strategy method)
data = GreeksData(delta=0.75, gamma=0.1, ts_event=ns, ts_init=ns)
self.publish_data(GreeksData, data)
# Subscribe (Actor / Strategy method)
self.subscribe_data(GreeksData)
# Handler (FIXED name: on_data)
def on_data(self, data: Data):
if isinstance(data, GreeksData):
self.log.info(f"Delta: {data.delta}, Gamma: {data.gamma}")The on_data handler name is fixed - type-check inside to dispatch among
multiple custom data types subscribed by the same Actor.
3. Actor-based signal pub/sub
Lightweight notifications for primitive values (int, float, str).
“Each signal can contain only single value, and you can only differentiate between signals using
signal.value, as the signal name is not accessible in the handler.”
import types
signals = types.SimpleNamespace()
signals.NEW_HIGHEST_PRICE = "NewHighestPriceReached"
signals.NEW_LOWEST_PRICE = "NewLowestPriceReached"
# Subscribe
self.subscribe_signal(signals.NEW_HIGHEST_PRICE)
self.subscribe_signal(signals.NEW_LOWEST_PRICE)
# Publish
self.publish_signal(
name=signals.NEW_HIGHEST_PRICE,
value=signals.NEW_HIGHEST_PRICE,
ts_event=bar.ts_event,
)
# Handler (FIXED name: on_signal)
def on_signal(self, signal):
match signal.value:
case "NewHighestPriceReached":
self.log.info("New highest price reached")
case "NewLowestPriceReached":
self.log.info("New lowest price reached")The handler sees only signal.value. Encode the signal kind into the value
itself, or use richer custom-data subclasses.
Topic taxonomy
The framework derives topic names from data types and identifiers. Examples documented across the architecture and data pages:
| Source | Topic shape |
|---|---|
| Data dispatch | data.quotes.{venue}.{symbol} (e.g. data.quotes.BINANCE.BTCUSDT-PERP) |
| Trade ticks | data.trades.{venue}.{symbol} |
| Bars | data.bars.{bar_type} |
Custom data via publish_data | derived from DataType(cls, metadata={...}) - different metadata = different topic |
| User raw topics | arbitrary strings via msgbus.publish(topic, msg) |
Practical rule: prefer Actor-based publishing for anything that needs to
survive Redis serialization or replay. Raw msgbus.publish is for one-off
intra-process glue where you accept the typo risk and don’t need persistence.
Ordering and determinism
The message bus itself does not impose ordering - the single-threaded kernel core does. From nautilus-architecture.md:
“Within a node, the kernel consumes and dispatches messages on a single thread. The kernel encompasses: the MessageBus and actor callback dispatch, strategy logic and order management, risk engine checks and execution coordination, cache reads and writes.”
“This single-threaded core provides deterministic event ordering and helps maintain backtest-live parity, though live inputs and latency can still cause behavioral differences.”
Concrete ordering guarantees:
- No interleaving inside a callback. A handler runs to completion before the next message is dispatched. No re-entrancy, no thread-switch surprises.
- Cache-then-publish for data. The DataEngine writes Cache before publishing on the bus, so any subscriber’s first read of cache inside its handler returns the value that triggered it. (Live execution events apply asynchronously and may show “a brief delay before Cache reflects them” - per nautilus-events.md.)
- Stable-sort by
ts_initin backtest. The DataEngine orders historical replay byts_initwith stable sort (per nautilus-data.md). Identical inputs → identical event sequence across runs. - Immutability post-publish. From the doc: “Once a message is created,
its fields must not be mutated. This includes container fields such as
paramsmaps.” Replay, debugging, and audit depend on this.
Immutability rationale (verbatim)
“Immutable messages keep every consumer seeing the same input, preserve what was true at emission time, and remove a class of shared-state races. Replay, debugging, and audit all depend on messages remaining stable after dispatch.”
Three ownership rules follow:
- “Caller-supplied request options stay on the message.”
- “Response metadata returned to the caller stays on the response.”
- “Component workflow state (bounded date ranges, grouping state, replay cursors, counters, processing flags) stays in component-owned context keyed by message or request ID.”
“When a component needs a derived message, it creates a new one with the required values instead of rewriting the original.”
This is the key contract MK2 currently violates by mutating in-flight dicts between the scoring engine and the position manager.
Backpressure and queueing
The MessageBus docs do not explicitly document in-process backpressure
mechanisms or queue depth limits - because the in-process bus is synchronous
dispatch on the single kernel thread. There is no queue between in-process
publisher and subscriber: publish() walks the subscriber list and invokes
each handler inline. A slow handler blocks the kernel, period.
The two places queueing genuinely happens:
-
MPSC channel to the Redis writer thread. Outgoing-to-Redis messages are serialized and pushed onto an MPSC channel; a Rust thread drains the channel and writes to Redis Streams. The doc:
“When a backing database (or any other compatible technology) is configured, all outgoing messages are first serialized, then transmitted via a Multiple-Producer Single-Consumer (MPSC) channel to a separate thread (implemented in Rust). In this separate thread, the message is written to its final destination, which is presently Redis streams.” “Offloading I/O to a separate thread keeps the main thread unblocked.”
-
autotrim_minson Redis streams caps stream growth - not backpressure per se, but the storage-side bound. “The current Redis implementation will maintain theautotrim_minsas a maximum width (plus roughly a minute, as streams are trimmed no more than once per minute).”
If a handler is slow on the kernel thread, the only mitigation is “do less work in the handler” or “kick to a background thread + re-enter via the bus.”
Wildcard subscriptions
The MessageBus concept page does not explicitly document an in-process
wildcard subscription syntax (e.g. data.quotes.*). What it does say:
“Redis does not support wildcard stream topics. For better compatibility with Redis, it is recommended to set this option [
stream_per_topic] to False.”
That comment is about the external Redis stream layout, not in-process
subscription patterns. Implication: when external streaming is configured with
stream_per_topic=False, the consumer reads one combined stream rather than
per-topic streams - no wildcard listening on the Redis side.
For in-process “subscribe to everything” patterns, the supported route is the Actor lifecycle:
on_event(event)is the catch-all for any order/position/account/time event the framework emits (per nautilus-events.md).on_data(data)is the catch-all for custom Data subclasses.- A combination of these on a logging Actor effectively gives you “subscribe to everything” for audit-log purposes - without requiring a topic-string wildcard.
Practical answer for the audit-log Actor pattern (Cortana MK3): override
on_event + on_data on a single AuditLogger(Actor) and you observe every
event and every custom data the framework dispatches. No wildcard topic syntax
needed; the handler hierarchy provides the catch-all.
External MessageBus integration
This is the cross-process / cross-host story. Redis Streams is the only documented backing. No Kafka integration is documented on this page (the doc explicitly mentions only Redis).
Why externalize?
- Survive process restarts (crash-only recovery - see nautilus-architecture.md Crash-only design).
- Out-of-process subscribers (dashboards, monitors, replicators, brain-loggers).
- Producer/Consumer fan-out - the multi-tenant pattern (see below).
Backing technology
“Redis is currently supported for all serializable messages which are published externally. The minimum supported Redis version is 6.2 (required for streams functionality).”
Serialization
Two encodings:
msgpack(default) - “optimal serialization and memory performance.”json- “human-readable.”
Built-in serializable types: all Nautilus built-ins (serialized as
dict[str, Any]) plus Python primitives (str, int, float, bool,
bytes).
For custom types, register via:
def register_serializable_type(
cls,
to_dict: Callable[[Any], dict[str, Any]],
from_dict: Callable[[dict[str, Any]], Any],
):
...(See nautilus-data.md for the parallel
register_arrow registration that enables Parquet catalog persistence.)
Stream key format
Structure: trader:{trader_id}:{instance_id}:{streams_prefix}
Each segment is independently toggleable via MessageBusConfig:
| Option | Default | Effect |
|---|---|---|
use_trader_prefix | True | Prepend trader: |
use_trader_id | True | Include the node’s trader ID |
use_instance_id | False | Include UUIDv4 per-node instance ID - “useful when you need to track and identify traders across various streams in a multi-node trading system” |
streams_prefix | "streams" | Final segment, application-controlled |
stream_per_topic | configurable | If True, separate stream per topic. Disable for Redis because “Redis does not support wildcard stream topics.” |
Configuration shape
from nautilus_trader.config import MessageBusConfig, DatabaseConfig
from nautilus_trader.model.data import QuoteTick, TradeTick
message_bus = MessageBusConfig(
database=DatabaseConfig(), # defaults to local Redis
encoding="msgpack", # or "json"
timestamps_as_iso8601=True, # default: nanosecond ints
buffer_interval_ms=100,
autotrim_mins=30, # max stream age
use_trader_prefix=True,
use_trader_id=True,
use_instance_id=False,
streams_prefix="streams",
types_filter=[QuoteTick, TradeTick], # exclude high-frequency types
)Types filter - keep high-frequency data off external streams
“When messages are published on the message bus, they are serialized and written to a stream if a backing for the message bus is configured and enabled. To prevent flooding the stream with data like high-frequency quotes, you may filter out certain types of messages from external publication.”
“To enable this filtering mechanism, pass a list of
typeobjects to thetypes_filterparameter in the message bus configuration, specifying which types of messages should be excluded from external publication.”
Critical for Cortana: UW flow alerts at sub-second cadence will flood
Redis if every alert hits the external stream. Either filter out the raw
UWFlowAlert and only stream derived ScoringEvent, or accept the firehose
and rely on autotrim_mins.
Producer / Consumer pattern (the multi-tenant answer)
This is the page’s most load-bearing section for Cortana’s multi-tenant question. Verbatim concepts:
- Internal message bus - within a single
TradingNode. - Producer node - publishes to an external stream.
- Consumer node - listens to external streams.
Producer node configuration
message_bus = MessageBusConfig(
database=DatabaseConfig(
connection_timeout=2,
response_timeout=2,
),
use_trader_id=False,
use_trader_prefix=False,
use_instance_id=False,
streams_prefix="binance",
stream_per_topic=False,
autotrim_mins=30,
)Rationale (from the doc): “ensure a simple and predictable stream key that the consumer nodes can register for.”
Consumer node configuration
data_engine = LiveDataEngineConfig(
external_clients=[ClientId("BINANCE_EXT")],
)
message_bus = MessageBusConfig(
database=DatabaseConfig(
connection_timeout=2,
response_timeout=2,
),
external_streams=["binance"],
)“The
DataEnginewill filter out subscription commands for these clients, ensuring that the external streaming provides the necessary data for any subscriptions to these clients.”
In other words: the consumer node behaves as if it had a local DataClient
named BINANCE_EXT, but the data actually arrives via Redis from a separate
producer process. Subscriptions to that client ID are silently routed to the
external stream consumer.
This is the canonical pattern for sharing one upstream data source across many trading processes.
Backtest vs live behavior
The bus interface is identical across environments. What differs:
| Aspect | Backtest | Live |
|---|---|---|
| Dispatch thread | Single kernel thread | Single kernel thread (same) |
| Ordering | Stable-sort by ts_init over all data | Real-time arrival; ts_init is local creation time |
| Cache visibility for data | Cache-then-publish (synchronous) | Cache-then-publish for data; execution events Cache update is async |
| Reconciliation events | None - simulator owns truth | LiveExecutionEngine may synthesize events with reconciliation=True |
| Redis backing | Optional (rarely used in backtest) | Recommended for crash-only recovery |
request_* historical | Served from BacktestEngine data store | Served by adapter (REST / catalog / cache) |
Per nautilus-architecture.md: “Live trading involves real financial risk.” The bus itself doesn’t change - but live introduces non-determinism via wall-clock arrival times and venue-side state.
Performance characteristics
The MessageBus page does not publish benchmark numbers. What it does commit to:
- Single-threaded dispatch - no lock contention inside the kernel.
- MPSC offload for I/O - Redis writes never block the trading thread.
- MessagePack default encoding for “optimal serialization and memory performance.”
- Buffer interval (
buffer_interval_ms, default100) - coalesces external writes to reduce per-message Redis overhead. - Auto-trim (
autotrim_mins) keeps Redis memory bounded.
For absolute throughput numbers, the architecture page leans on the LMAX-disruptor lineage:
“Of interest is the LMAX exchange architecture, which achieves award winning performance running on a single thread.”
LMAX hit ~6M txn/sec on a single thread in 2010 hardware. Nautilus inherits the pattern (not the framework). For Cortana’s scale (sub-second UW alerts + SPY 0DTE), throughput is not the bottleneck - ML inference latency is.
Cortana MK3 implications
This is where MK2’s hand-rolled routing meets Nautilus’s typed pub/sub spine.
MK2 today: ad-hoc function calls + SQLite writes
MK2’s “bus” is a tangle of:
- Direct method invocation between modules (
scoring_engine.score()→position_manager.handle_score()→ibkr_client.submit_order()). - SQLite writes to
decisions.dbas the de facto audit log (which MK2 conflates with state - see nautilus-events.md “What is queryable vs what flows past”). - In-memory dicts shared across threads with no immutability guarantees.
- File-based caches (UW chain snapshots) read by multiple modules with no ordering contract.
The 2026-05-06 stale spy_price bug is a direct consequence: a strategy module
read a cached price before the cache had been updated by the data ingest
module, because there was no enforced ordering between cache write and
publish.
MK3 on Nautilus: bus-spine routing
| MK2 routing | Nautilus equivalent |
|---|---|
scoring_engine.score(bar) direct call | ScoringActor.on_bar(bar) → publish_data(ScoringEvent, …) |
position_manager.handle_score(score) direct call | CortanaStrategy.on_data(ScoringEvent) (subscribed) |
ibkr_client.submit_order(order) direct call | self.submit_order(order) → RiskEngine → ExecutionClient (all bus-mediated) |
decisions.db SQLite audit table | AuditLogger(Actor).on_event/on_data → Parquet / Redis stream |
| Cross-module dict mutation | Forbidden - every message immutable post-publish |
| File-based UW snapshot cache | Cache.add(key, bytes) keyed by instrument; updated by DataClient before publish |
(a) Cache-then-publish race elimination
Nautilus enforces by construction what MK2 tried (and failed) to enforce by discipline. From nautilus-architecture.md:
“For quotes, trades, and bars the cache-then-publish order means your strategy handler can always read the latest value from the cache.”
In MK3, the spy_price stale-cache bug is structurally impossible: the
DataEngine writes Cache before publishing on the bus, the kernel dispatches
on a single thread, and the handler runs to completion before the next message
is dispatched. There is no race window.
(b) Audit trail via wildcard-equivalent subscriber
MK2 audit trail = SQLite tables, one per row class, written from the module that produced the row. Reconciling these tables back into a causal sequence is a chronic source of drift.
MK3 audit trail = one AuditLogger(Actor) that overrides:
class AuditLogger(Actor):
def on_start(self):
# Subscribe to every Cortana-defined custom data type
self.subscribe_data(ScoringEvent)
self.subscribe_data(GateDecision)
self.subscribe_data(RegimeChange)
# … all custom Cortana types
def on_event(self, event):
# Catches every framework event - orders, positions, accounts, timers
self._sink.write(event)
def on_data(self, data):
# Catches every custom Cortana event we subscribed to above
self._sink.write(data)on_event is the framework’s catch-all dispatch level (see
nautilus-events.md “Handler dispatch - specific to
generic”). The handler hierarchy is the wildcard - no topic-string *
needed.
The _sink.write(...) target is ParquetDataCatalog for permanent storage
(per nautilus-data.md) or a Redis stream for live
out-of-process consumers.
This collapses MK2’s N audit tables into one Parquet file partitioned by event type and date, with replay determinism inherited from the framework.
(c) Multi-tenant fan-out - Producer/Consumer external streams
This is the Cortana spike Step 7.5 question, and the doc gives a direct answer.
Architecture:
┌───────────────────────────────┐
│ UW WebSocket (single upstream)│
└───────────────┬───────────────┘
▼
┌───────────────────────────────┐
│ Producer TradingNode │
│ - UWDataClient → DataEngine │
│ - ScoringActor │
│ - publishes ScoringEvent │
│ onto external Redis stream │
│ streams_prefix="cortana_uw" │
│ stream_per_topic=False │
└───────────────┬───────────────┘
▼
┌───────────────┐
│ Redis Stream │
│ cortana_uw │
└───────┬───────┘
│
┌───────────────────────┼───────────────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Tenant A │ │ Tenant B │ │ Tenant C │
│ TradingNode │ │ TradingNode │ │ TradingNode │
│ external_ │ │ external_ │ │ external_ │
│ streams= │ │ streams= │ │ streams= │
│ ["cortana_uw"]│ │ ["cortana_uw"]│ │ ["cortana_uw"]│
│ + own IBKR │ │ + own IBKR │ │ + own IBKR │
│ ExecClient │ │ ExecClient │ │ ExecClient │
└──────────────┘ └──────────────┘ └──────────────┘
Each tenant runs its own TradingNode process (per
nautilus-architecture.md: “Running multiple
TradingNode or BacktestNode instances concurrently in the same process is not
supported due to global singleton state”). Each tenant has its own IBKR
credentials in its own process. The shared upstream UW WebSocket is held by
one producer process and republished onto a single external Redis stream
that all tenants consume.
Why this answers the multi-tenant question
- One upstream UW subscription, N downstream tenant strategies. The producer pays the WebSocket cost once. Tenants pay only Redis read.
- Each tenant’s bus is its own. The producer’s
ScoringEventlands on every tenant’s internal bus as if produced by a local DataClient (BINANCE_EXTstyle - except renamedCORTANA_UW_EXT). Tenant strategies subscribe with the framework’s normalsubscribe_data(ScoringEvent)call. - No bus forking. Nautilus’s core does not need modification - the Producer/Consumer pattern is documented and supported.
- Per-tenant isolation is at the process boundary. Each tenant’s TradingNode owns its own Cache, its own ExecutionEngine, its own IBKR credentials, its own Redis cache database keyspace. A bug in tenant A’s strategy cannot affect tenant B’s positions.
Open questions for the spike
- Per-tenant
trader_id/streams_prefixkeying. Each tenant’s own internal events (orders, positions, fills) need a stream key that distinguishes them. The defaulttrader:{trader_id}:{instance_id}:streamsformat does this - settrader_id = tenant_idper tenant. - External stream filtering. The producer should publish only the events
tenants care about (e.g.,
ScoringEvent,RegimeChange) - not the rawUWFlowAlertfirehose. Usetypes_filterto drop high-frequency raw types. - Replay scope. When tenant A wants to replay yesterday, do they replay from the shared external Parquet catalog (producer-written) or from their own per-tenant catalog of decisions? Both are workable; the spike should pick one.
- Producer redundancy. If the producer process dies, all tenants stop getting new signals. Need a supervisor or hot-standby producer for production deployment.
Single bus or per-tenant bus?
Each tenant has its own internal bus (one per TradingNode process). The
Producer/Consumer pattern bridges between the producer’s internal bus and
each consumer’s internal bus via Redis Streams. There is no “single global
bus” across tenants - that would violate Nautilus’s process-singleton
constraint.
The mental model: every TradingNode is a complete kernel-with-bus. The external Redis stream is the integration seam between them, not a shared bus.
Wildcard subscriptions - answer for audit-log Actor
The doc does not document an in-process topic-string wildcard subscription syntax. The framework’s catch-all dispatch is achieved through the handler hierarchy:
on_eventcatches any framework Event (orders, positions, accounts, timers).on_datacatches any custom Data subclass the Actor explicitly subscribed to.
For a true “subscribe to every topic,” the only documented surface is the raw
self.msgbus.subscribe(topic, handler) API, which requires an exact topic
string. The audit-log Actor pattern works because on_event + on_data
together are sufficient - no wildcard needed.
If future Cortana needs require true wildcard pub/sub (e.g., debug topic
streaming for development), that’s a Codex investigation into the underlying
MessageBus.subscribe Rust implementation, not a documented public API.
Caveats and gotchas
- Manual topic tracking. Low-level pub/sub on string topics has no type-system protection - typos silently drop messages. Use Actor-based publishing (Style 2 or 3) for anything that matters.
on_dataandon_signalhandler names are fixed. You cannot name them arbitrarily and have them auto-dispatch. Multiple custom data types in the same Actor share oneon_dataand discriminate viaisinstance.on_save/on_loadare Strategy-only. Actors persist viaCache.add- Redis backing, not via lifecycle save/load.
- MessageBus is thread-local. Cross-thread delivery uses MPSC channels. Don’t try to share an MessageBus reference across threads.
- Live execution events lag Cache. “you might see a brief delay between an event and its appearance in the Cache.” Use the event payload directly; don’t re-read Cache mid-handler for execution-event-driven state.
- External streaming is Redis-only. No documented Kafka, NATS, or RabbitMQ backing. If Cortana needs Kafka for SaaS, it’s a Codex/contributor build.
stream_per_topic=Truebreaks Redis listening. Redis Streams have no wildcard topic match; multi-stream listening requires separate consumer groups per stream. Keepstream_per_topic=Falsefor the producer/consumer pattern.- Buffered Redis writes (default 100ms). External consumers see events
with up to ~100ms additional latency vs the producer’s internal bus. Tune
buffer_interval_msfor latency-sensitive consumers, accept higher Redis load.
When this concept applies
- Designing the inter-component routing layer for MK3.
- Wiring custom Cortana events (
ScoringEvent,GateDecision, etc.) onto a typed pub/sub spine. - Architecting multi-tenant fan-out (Producer/Consumer external streams).
- Replacing MK2’s
decisions.dbSQLite audit trail with a single catch-all Actor + Parquet sink. - Wiring the dashboard or any out-of-process consumer.
When it breaks / does not apply
- Bus-bridging Kafka or NATS. Not supported out of the box. Custom Rust work required.
- Cross-tenant atomic operations. The bus is per-process; cross-tenant coordination requires application-level logic (e.g., a coordinator service on the Redis stream side).
- Hard wildcard subscriptions on arbitrary topic strings. Use
on_event/on_datacatch-all dispatch instead. - Multi-million-msg/sec benchmarks. Not the target - the kernel is single-threaded by design. If you need that, you’re outside Nautilus’s scope.
See Also
- Nautilus Architecture - single-threaded LMAX-disruptor lineage; how the kernel dispatches messages
- Nautilus Events - event taxonomy that flows over the bus, handler dispatch ladder
- Nautilus Data - custom
Datasubclasses,@customdataclass,register_serializable_type/register_arrow - Nautilus Actors - pub/sub from the Actor side;
subscribe_data,publish_data,subscribe_signal - Nautilus Strategies - pub/sub from the Strategy side; how order events flow over the bus
- Nautilus Concepts - MessageBus in the broader architecture canon
- Nautilus Cache - parallel “what gets stored” vs the bus’s “what flows past”
- 2026-05-09 Nautilus Spike Plan:
~/conductor/workspaces/cortanaroi-mk2/belo-horizonte/plans/2026-05-09-nautilus-spike.md - Source: https://nautilustrader.io/docs/latest/concepts/message_bus/
Timeline
- 2026-05-07 | Cody - Filed during pre-spike concept mastery sweep batch 2.