Nautilus Actors
An
Actorin Nautilus is the base reactive component: it receives data, handles events, manages state, and publishes messages - but it does NOT place orders.StrategyextendsActorand adds order management on top. Anything in a Cortana-style system that consumes data and emits derived signals (scoring engines, EMA decay calculators, regime detectors, meta-model wrappers, brain readers) is the natural shape of an Actor. Order placement and position lifecycle stay inStrategy. Same Actor class runs in backtest, sandbox, and live with no code change - the Kernel feeds the same lifecycle hooks regardless of environment.
Core claim
In Nautilus’s component taxonomy there is a single inheritance line:
Actor → Strategy. The Actor base class is the universal reactive
component (data subscriber, event publisher, state holder); the
Strategy subclass adds order/position management. Any module in your
system that does not need to submit, modify, or cancel orders should
be an Actor, not a Strategy. This keeps order-management surface area
small (one Strategy per trade flow) while letting analytics, scoring,
filtering, and signal-generation components compose freely on the
MessageBus.
Actor vs. Strategy - the load-bearing distinction
The official one-liner from the docs:
“An
Actorreceives data, handles events, and manages state. TheStrategyclass extendsActorwith order management capabilities.”
And the inheritance contract (from the Strategies page):
“The
Strategyclass inherits fromActor, which means strategies have access to all actor functionality plus order management capabilities.”
That is the entire conceptual difference. Everything else flows from this:
| Capability | Actor | Strategy |
|---|---|---|
| Subscribe to data (bars, quotes, trades) | yes | yes |
| Request historical data | yes | yes |
| Publish custom messages on MessageBus | yes | yes |
| Set timers / time alerts | yes | yes |
| Read Cache | yes | yes |
| Read Portfolio | yes | yes |
| Subscribe to order fills (read-only) | yes | yes |
| Subscribe to order cancels (read-only) | yes | yes |
Lifecycle hooks (on_start, on_stop…) | yes | yes |
| Indicator registration | yes | yes |
submit_order(), submit_order_list() | NO | yes |
cancel_order(), cancel_all_orders() | NO | yes |
modify_order(), market_exit() | NO | yes |
on_order_accepted, on_order_filled | NO | yes |
on_position_opened, on_position_closed | NO | yes |
| OrderFactory | NO | yes |
Strategy ID + order_id_tag registration | NO | yes |
The doc itself recommends “reviewing the Actors guide before diving into strategy development” - Actor is the foundational class, Strategy is the specialization.
When to use Actor over Strategy
Pick Actor when the component:
- Consumes data and produces derived data / signals / events but does NOT itself place orders.
- Aggregates or fans out across multiple instruments (e.g., a market scanner, a flow monitor, a regime detector).
- Provides shared services to one or more strategies (e.g., a feature
pipeline that publishes
ScoreUpdateevents). - Monitors execution events read-only (e.g., a fill-quality auditor
that subscribes to
subscribe_order_fillsfor analytics). - Wraps an external system (e.g., a brain-page reader, an outbound queue to a dashboard, a logger that writes trade outcomes to disk).
Pick Strategy when the component:
- Decides to enter, modify, or exit positions.
- Owns order lifecycle (submission → fill → close) and needs the
on_order_*/on_position_*handlers. - Should appear in the Portfolio’s per-strategy P&L view.
The architectural recommendation in nautilus-developer-guide.md is
explicit: “Most signal/scoring engines are better as actors composed
under a thin order-placing strategy.” Cortana’s MK3 shape should
follow this - many Actors, few (often one) thin Strategy.
When Actor is the wrong fit (use DataClient instead)
A common mistake is to express a venue / data-vendor adapter as an
Actor. It is not. An adapter that ingests bytes from a WebSocket or
REST endpoint, parses them into Nautilus types, and feeds the
DataEngine is a DataClient (per nautilus-developer-guide.md
“Writing custom data adapters”). Specifically:
- UW WebSocket → Nautilus: this is a
LiveDataClient/LiveMarketDataClient(Rust core + Python wiring), NOT an Actor. The DataClient normalizes UW frames intoCustomDatasubclasses and the DataEngine writes them to Cache and publishes on the bus. - An Actor that subscribes to UW data: that is the correct shape
for, e.g., a “score updater” that consumes UW custom data + bars
and republishes a
ScoreUpdate. The Actor is downstream of the DataClient.
Mental model: DataClient = bytes-in, normalized-Data-out (one per venue or vendor). Actor = normalized-Data-in, derived-event-out (one per analytical / monitoring concern). Strategy = events-in, orders-out (one per trade flow).
Basic Actor example (canonical from the docs)
from nautilus_trader.config import ActorConfig
from nautilus_trader.model import InstrumentId
from nautilus_trader.model import Bar, BarType
from nautilus_trader.common.actor import Actor
class MyActorConfig(ActorConfig):
instrument_id: InstrumentId # e.g. "ETHUSDT-PERP.BINANCE"
bar_type: BarType # e.g. "ETHUSDT-PERP.BINANCE-15-MINUTE[LAST]-INTERNAL"
lookback_period: int = 10
class MyActor(Actor):
def __init__(self, config: MyActorConfig) -> None:
super().__init__(config)
self.count_of_processed_bars: int = 0
def on_start(self) -> None:
self.subscribe_bars(self.config.bar_type)
def on_bar(self, bar: Bar) -> None:
self.count_of_processed_bars += 1Two things to notice:
- Configuration is a separate
ActorConfigsubclass, not constructor args. The config serializes over the wire (so distributed backtests and remote live trading can ship a config to a worker). - Subscriptions live in
on_start(), NOT__init__(). The constructor runs before the Kernel has wired the clock, log, and msgbus - touchingself.clock/self.logfrom__init__will fail or fire on a not-yet-initialized subsystem.
Lifecycle
Actors follow a finite state machine. Override these methods to hook into transitions:
| Method | When called |
|---|---|
on_start() | Actor is starting - subscribe to data, register indicators, request history |
on_stop() | Actor is stopping - cancel timers, unsubscribe, clean up resources |
on_resume() | Actor is resuming from a stopped state |
on_reset() | Reset internal state (called between backtest runs) |
on_degrade() | Entering a degraded state (partial functionality) |
on_fault() | Encountered a fault (unrecoverable for this actor) |
on_dispose() | Final cleanup - about to be discarded |
The same lifecycle is shared with Strategy (since Strategy extends Actor). What Actor adds vs. Strategy: nothing here is gated by “is this thing trading or not?” - both classes participate identically in the Kernel’s start/stop/reset cycle.
on_save() / on_load(state) exist on Strategy for cross-restart
state persistence - the Actor docs page does not list them, so treat
that pair as Strategy-only for current scope. If you need Actor state
to survive a restart, the supported path is to write to Cache (with
the Cache database / Redis backing) rather than rely on
on_save/on_load.
Timers and alerts
Every Actor has self.clock, which is the same Clock Strategy uses
and the same Clock the simulated venue uses in backtest. The interface
does not change between environments - backtest fires alerts at the
simulated time, live fires at wall-clock. This is what makes Actor
code identical across backtest and live.
def on_start(self) -> None:
# Recurring timer (every 5 seconds), routed to a custom callback
self.clock.set_timer(
"my_timer",
timedelta(seconds=5),
callback=self._on_timer,
)
# One-shot alert, routed to a custom callback
self.clock.set_time_alert(
"my_alert",
self.clock.utc_now() + timedelta(minutes=1),
callback=self._on_alert,
)
def on_stop(self) -> None:
self.clock.cancel_timer("my_timer") # avoid leaks across stop/resume
def _on_timer(self, event: TimeEvent) -> None:
self.log.info("Timer fired!")
def _on_alert(self, event: TimeEvent) -> None:
self.log.info("Alert triggered!")“Pass a
callbackto directTimeEventobjects to your own method. If you omit the callback, the event is delivered toon_eventinstead.”
That fallback to on_event is the generic event sink - useful when
you want one funnel for many alert types but harder to read at scale.
Prefer named callbacks unless the actor genuinely wants a single
event handler.
System access
Actors have direct attribute access to core system components:
| Property | Description |
|---|---|
self.cache | Shared state for instruments, orders, positions, custom |
self.portfolio | Portfolio state (equity, net exposure, P&L queries) |
self.clock | Current time + timer/alert scheduling |
self.log | Structured logging (non-blocking MPSC channel underneath) |
self.msgbus | Publish/subscribe to custom messages |
A few invariants worth remembering:
- Cache writes are not the Actor’s job. The DataEngine writes to
Cache before publishing on the bus (cache-then-publish). An Actor’s
handler can rely on
self.cache.bar(bar_type)returning the bar that triggeredon_bar()- there is no race. - Logs are non-blocking. The log subsystem has an MPSC channel to
a separate thread, so heavy logging in
on_bardoes not stall the deterministic single-threaded core. - Portfolio queries are read-only from an Actor. Actor cannot alter positions; Strategy does that via order submission, and the ExecutionEngine flows the resulting fills into Portfolio.
Data handling and callbacks
Nautilus distinguishes historical vs real-time data flows explicitly, and the handler routing is different:
- Historical data (from
request_*()) →on_historical_data() - Real-time data (from
subscribe_*()) → specific handler likeon_bar(),on_quote_tick(),on_trade_tick(), etc.
The full mapping (every method an Actor can call and the handler each ends up at):
| Operation | Category | Handler |
|---|---|---|
subscribe_data() | Real-time | on_data() |
subscribe_instrument() | Real-time | on_instrument() |
subscribe_instruments() | Real-time | on_instrument() |
subscribe_order_book_deltas() | Real-time | on_order_book_deltas() |
subscribe_order_book_depth() | Real-time | on_order_book_depth() |
subscribe_order_book_at_interval() | Real-time | on_order_book() |
subscribe_quote_ticks() | Real-time | on_quote_tick() |
subscribe_trade_ticks() | Real-time | on_trade_tick() |
subscribe_mark_prices() | Real-time | on_mark_price() |
subscribe_index_prices() | Real-time | on_index_price() |
subscribe_bars() | Real-time | on_bar() |
subscribe_funding_rates() | Real-time | on_funding_rate() |
subscribe_instrument_status() | Real-time | on_instrument_status() |
subscribe_instrument_close() | Real-time | on_instrument_close() |
subscribe_option_greeks() | Real-time | on_option_greeks() |
subscribe_option_chain() | Real-time | on_option_chain() |
subscribe_order_fills() | Real-time | on_order_filled() |
subscribe_order_cancels() | Real-time | on_order_canceled() |
request_data() | Historical | on_historical_data() |
request_order_book_deltas() | Historical | on_historical_data() |
request_order_book_depth() | Historical | on_historical_data() |
request_order_book_snapshot() | Historical | on_historical_data() |
request_instrument() | Historical | on_instrument() |
request_instruments() | Historical | on_instrument() |
request_quote_ticks() | Historical | on_historical_data() |
request_trade_ticks() | Historical | on_historical_data() |
request_bars() | Historical | on_historical_data() |
request_aggregated_bars() | Historical | on_historical_data() |
request_funding_rates() | Historical | on_historical_data() |
Mixed historical + real-time example
Canonical pattern for hydrating state at startup and then consuming live updates:
from nautilus_trader.common.actor import Actor
from nautilus_trader.config import ActorConfig
from nautilus_trader.core.data import Data
from nautilus_trader.model import Bar, BarType
from nautilus_trader.model import ClientId, InstrumentId
class MyActorConfig(ActorConfig):
instrument_id: InstrumentId # e.g. "AAPL.XNAS"
bar_type: BarType # e.g. "AAPL.XNAS-1-MINUTE-LAST-EXTERNAL"
class MyActor(Actor):
def __init__(self, config: MyActorConfig) -> None:
super().__init__(config)
self.bar_type = config.bar_type
def on_start(self) -> None:
# Historical hydration → on_historical_data()
self.request_bars(
bar_type=self.bar_type,
start=None,
end=None,
callback=None,
update_catalog_mode=None,
params=None,
)
# Live feed → on_bar()
self.subscribe_bars(
bar_type=self.bar_type,
client_id=None,
params=None,
)
def on_historical_data(self, data: Data) -> None:
if isinstance(data, Bar):
self.log.info(f"Received historical bar: {data}")
def on_bar(self, bar: Bar) -> None:
self.log.info(f"Received real-time bar: {bar}")The split lets you do different work at hydration vs steady state -
e.g., warm an indicator in on_historical_data without tripping the
“is this a real signal?” path that lives in on_bar.
MessageBus pub/sub
The MessageBus is shared with Strategies and other Actors. Three
publishing styles per nautilus-concepts.md:
- Low-level pub/sub on string topics - flexible but typo-prone.
- Actor-based custom data publishing via
Datasubclasses or@customdataclass- preferred. Carriests_event/ts_inittimestamps so backtest ordering stays correct. - Actor-based signal publishing for primitive notifications
(int / float / str) - lightweight alerts, delivered to subscribers’
on_signal()handler.
Use self.msgbus.publish(...) for raw topic publishing, or the
typed publish_data / publish_signal helpers on Actor for the
ordering-safe paths. Other Actors subscribe via subscribe_data (for
custom data subclasses) or subscribe_signal (for primitives).
For the Cortana case, the natural shape is: Scoring Actor publishes
a ScoreUpdate @customdataclass event; meta-model Actor subscribes
to ScoreUpdate, computes meta-prob, publishes MetaScore; Strategy
subscribes to MetaScore and decides whether to enter.
The fundamental contract on every message is immutability: once published, a message must not be mutated. If a downstream Actor needs a different shape, it derives a new local representation. This prevents the “did the previous consumer mutate what I’m seeing?” class of bugs that bites hand-rolled pub/sub systems.
Order fill and cancel subscriptions (read-only)
This is the one place an Actor touches execution events. Both
subscribe_order_fills(instrument_id) and
subscribe_order_cancels(instrument_id) are read-only - the Actor
sees the events but cannot alter them. Useful for monitoring, fill
analysis, audit trails.
class FillMonitorActor(Actor):
def on_start(self) -> None:
self.subscribe_order_fills(self.config.instrument_id)
def on_order_filled(self, event: OrderFilled) -> None:
self.fill_count += 1
self.total_volume += float(event.last_qty)
self.log.info(
f"Fill: {event.order_side} {event.last_qty} @ {event.last_px}, "
f"total fills={self.fill_count}, vol={self.total_volume}"
)
def on_stop(self) -> None:
self.unsubscribe_order_fills(self.config.instrument_id)“Order fill subscriptions use the message bus only and do not involve the data engine. The
on_order_filled()handler receives events only while the actor is running.”
That parenthetical - only while the actor is running - means an Actor is NOT the right place to durably record fills for offline analysis. Use the Cache database (Redis) or a dedicated persistence sink for that. The fill subscription is for while-running monitoring.
Configuration and dependency injection
Every Actor has a paired ActorConfig subclass:
class MyActorConfig(ActorConfig):
instrument_id: InstrumentId
bar_type: BarType
lookback_period: int = 10Why a separate config class (mirrors what the Strategy docs say about StrategyConfig):
“Configurations serialize over the wire, enabling distributed backtesting and remote live trading.”
This is the same property that lets Nautilus ship a backtest spec from one machine to a worker - the entire Actor (class + config) is serializable. Code-level constructor args are not.
Typing rules from the developer guide carry over: type-annotate every
config field, use PEP 604 unions (InstrumentId | None), avoid
Optional[...]. The pre-commit hooks reject the older syntax.
Indicator integration
The Actors page in the docs does not show indicator wiring directly, but the Strategy docs show the pattern (and Strategy inherits this from Actor):
self.register_indicator_for_bars(bar_type, my_indicator)
self.register_indicator_for_quote_ticks(instrument_id, my_indicator)
self.register_indicator_for_trade_ticks(instrument_id, my_indicator)After registration, the indicator’s handle_bar / handle_quote_tick
/ handle_trade_tick is called automatically before your on_bar
handler runs, so by the time your handler executes the indicator is
already updated for the current event. The indicator lives on the
Actor; queries (indicator.value, indicator.initialized) work
exactly the same as in any Strategy example.
Backtest vs live equivalence
This is the entire point of the Actor abstraction. From
nautilus-concepts.md:
“Same Strategy/Actor lifecycle -
on_start,on_quote_tick,on_eventtrigger the same way.”
Concrete consequences for Actors:
- The same Actor class runs in
BacktestEngine, sandbox, andTradingNode/LiveNodewith no code change. self.clockreads simulated time in backtest, wall-clock in live - the interface is identical, so handler logic that relies on time works in both.- Subscriptions resolve through the same MessageBus, fed by either a
simulated DataEngine (backtest) or
LiveDataEngine(live). - Reconciliation (the live-only execution behavior) does not affect Actor code at all - Actors do not own orders, so they have no reconciliation surface.
The discipline this enforces: never call time.time() /
datetime.now() / os.environ reads from inside an Actor handler.
Everything time-related goes through self.clock. Everything
config-related goes through the ActorConfig. This is what keeps
backtest determinism intact.
Cortana MK3 implications
The MK2 codebase has several components that look like Strategies because everything is currently in one process, but architecturally most of them are Actors. Mapping each candidate:
Strong Actor candidates (no order placement)
- Composite scoring engine (
cortanaroi/engine/scoring_engine.py)- consumes UW flow + bars, produces a 78-feature score and composite.
No orders. Actor. Publishes
ScoreUpdatecustom data on the MessageBus. Other Actors and the Strategy subscribe.
- consumes UW flow + bars, produces a 78-feature score and composite.
No orders. Actor. Publishes
- EMA flow decay calculator - pure derived state from a flow
feed. Actor. Either a separate Actor publishing
FlowDecayevents, or an indicator registered to the scoring Actor. The “is this reusable across multiple consumers?” question decides: shared = Actor; tightly-coupled = indicator. - Meta-model wrapper - takes the composite score, runs it through
the secondary classifier, emits a meta-prob gate. No orders.
Actor subscribed to
ScoreUpdate, publishesMetaScore(or the gate decision boolean). Keeps ML inference out of the Strategy’s hot path. - Regime detector - observes the data stream, classifies the
regime, publishes
RegimeUpdate. No orders. Actor. - Brain reader - reads
~/brainmarkdown for relevant postmortems / loss clusters / patterns and publishes them asBrainContextevents. Actor. This is a one-way pull from a filesystem; the publish path is identical to any other Actor. - Brain writer (post-trade outcome logger) - after a position
closes, writes the trade outcome to
~/brain/writing/. The trade-close trigger ison_position_closed, which only Strategy has - so this is either a small extension on the Strategy itself, or an Actor that usessubscribe_order_fillsto detect closes read-only. The latter is cleaner because it keeps the brain integration out of the trading hot path. - Cyclical encoding pipeline - derives sin/cos features for minute-of-day, day-of-week, days-to-expiry. Actor. Publishes features as custom data alongside the core feature stream.
NOT Actors - these belong elsewhere
- UW WebSocket ingestion - this is a
LiveDataClient/LiveMarketDataClient, not an Actor. Bytes-in, normalized-Data-out. Seenautilus-developer-guide.md“Writing custom data adapters”. The Phase 1-7 implementation sequence applies. - IBKR adapter - already shipped as part of Nautilus
(
nautilus_trader/adapters/interactive_brokers/). DataClient + ExecutionClient. Not an Actor. - Position manager (TP/SL fallback, time-in-trade exits) -
Strategy. Owns order modify/cancel, must have
on_position_*handlers. - Cortana entry strategy - Strategy. Subscribes to
MetaScore/ScoreUpdate, applies the gate, sizes via meta-prob, submits brackets via the IBKR exec client. - Pre-trade risk checks (size limits, max-loss) - RiskEngine
config, not an Actor. Centralized at the engine layer so the
Strategy never has defensive
if size > Xchecks. - Dashboard - out-of-band Redis subscriber to the MessageBus (Nautilus supports remote consumers). Not an Actor - it lives outside the trading process entirely.
Decision rule for “Actor or something else?”
Walk the tree:
- Does it submit / modify / cancel orders? → Strategy
- Does it ingest bytes from a venue / data vendor? → DataClient (Rust core + Python wiring)
- Does it submit / cancel / track orders at a venue? → ExecutionClient
- Does it enforce pre-trade limits across all strategies? → RiskEngine config
- Does it consume normalized data and produce derived data, signals, or events? → Actor
- Does it run outside the trading process entirely? → out-of-band subscriber (Redis / file / network), not an Actor.
For Cortana MK3 the proposal: ~5-7 Actors composing a feature/scoring pipeline, one Strategy doing entry decisions + order placement, one Strategy (or extension on the entry strategy) doing position management. The clean separation is what unlocks the “swap one component for an experiment” workflow that MK2 cannot do today.
Why this matters for the spike
The 2026-05-09 NautilusTrader spike’s Step 5 suggests porting a
single Cortana signal as a Strategy subclass. Read carefully - the
spike’s success condition is “<300 LOC for the strategy” but the
architectural test is whether the scoring/gate logic decomposes
cleanly into Actors. If you find yourself stuffing the entire
scoring engine inside Strategy.on_data, that’s a sign you’re not
using Actors. The right shape for the spike is probably:
- 1 thin
CortanaBullCallStrategy(Strategy)- gate check, sizing, bracket submission. <100 LOC. - 1
ScoringActor(Actor)- replays today’sscoring_eventsrows asScoreUpdatecustom data on the bus. <100 LOC. - 1
MetaGateActor(Actor)(optional, can be inlined into Strategy for the spike) - consumesScoreUpdate, applies meta-prob filter, publishesEntrySignal. <50 LOC.
If decomposition feels natural during the spike, that is direct evidence MK3 architecture will hold up at scale (multi-tenant, multi-strategy). If it feels forced, the spike has surfaced a real DX problem.
Limits and gotchas
on_save/on_loadare listed in the Strategy docs but NOT in the Actor docs page. Treat them as Strategy-only until proven otherwise; for Actor state durability, use Cache + Redis backing.- Actor handlers run on the same single-threaded deterministic core
as Strategies. Heavy ML inference inside
on_barblocks every other component. If the meta-model is non-trivial, batch it on a timer or push it to a dedicated process consuming the message bus over Redis. __init__cannot useself.clock/self.log/self.msgbus- they are only wired after registration with the Kernel. Subscribe inon_start, log in handlers, never in the constructor.- Order-fill and order-cancel subscriptions deliver events only while the actor is running - they are not durable. For durable trade outcomes use the Cache database or an explicit persistence sink.
- Custom data published from an Actor must subclass
Data(or use@customdataclass) sots_event/ts_initare present. Raw dicts onmsgbus.publishwork but bypass the ordering guarantees and are not what backtest replay assumes.
When it applies
- Anywhere Cortana currently has a “computation that wraps data and emits derived state” - scoring, EMA decay, regime detection, meta gate, feature engineering, brain integration.
- Anywhere a feature consumer needs read-only execution telemetry (fill auditor, trade outcome logger).
- Any new analytical surface area added during MK3 - default to Actor unless the surface area places orders.
When it breaks
- When the component places, modifies, or cancels orders. That is the explicit Strategy boundary.
- When the component ingests venue/vendor bytes. That is the DataClient boundary.
- When the component enforces firm-wide pre-trade rules. That is the RiskEngine boundary.
- When the component needs to outlive the trading process (e.g., the operator dashboard). That is an out-of-band subscriber.
See Also
- Nautilus Strategies - what Strategy adds on top of Actor (filed in parallel during this concept sweep)
- Nautilus Concepts - full architecture reference: Kernel, MessageBus, Cache, DataEngine, ExecutionEngine, RiskEngine
- Nautilus Developer Guide - adapter authoring (DataClient / ExecutionClient), Cortana → Nautilus translation table
- Spike plan:
~/conductor/workspaces/cortanaroi-mk2/belo-horizonte/plans/2026-05-09-nautilus-spike.md - Source: https://nautilustrader.io/docs/latest/concepts/actors/
Timeline
- 2026-05-07 | Cody - Filed during pre-spike concept mastery sweep.