Nautilus Actors

An Actor in Nautilus is the base reactive component: it receives data, handles events, manages state, and publishes messages - but it does NOT place orders. Strategy extends Actor and 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 in Strategy. 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: ActorStrategy. 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 Actor receives data, handles events, and manages state. The Strategy class extends Actor with order management capabilities.”

And the inheritance contract (from the Strategies page):

“The Strategy class inherits from Actor, which means strategies have access to all actor functionality plus order management capabilities.”

That is the entire conceptual difference. Everything else flows from this:

CapabilityActorStrategy
Subscribe to data (bars, quotes, trades)yesyes
Request historical datayesyes
Publish custom messages on MessageBusyesyes
Set timers / time alertsyesyes
Read Cacheyesyes
Read Portfolioyesyes
Subscribe to order fills (read-only)yesyes
Subscribe to order cancels (read-only)yesyes
Lifecycle hooks (on_start, on_stop…)yesyes
Indicator registrationyesyes
submit_order(), submit_order_list()NOyes
cancel_order(), cancel_all_orders()NOyes
modify_order(), market_exit()NOyes
on_order_accepted, on_order_filledNOyes
on_position_opened, on_position_closedNOyes
OrderFactoryNOyes
Strategy ID + order_id_tag registrationNOyes

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 ScoreUpdate events).
  • Monitors execution events read-only (e.g., a fill-quality auditor that subscribes to subscribe_order_fills for 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 into CustomData subclasses 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 += 1

Two things to notice:

  1. Configuration is a separate ActorConfig subclass, not constructor args. The config serializes over the wire (so distributed backtests and remote live trading can ship a config to a worker).
  2. Subscriptions live in on_start(), NOT __init__(). The constructor runs before the Kernel has wired the clock, log, and msgbus - touching self.clock / self.log from __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:

MethodWhen 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 callback to direct TimeEvent objects to your own method. If you omit the callback, the event is delivered to on_event instead.”

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:

PropertyDescription
self.cacheShared state for instruments, orders, positions, custom
self.portfolioPortfolio state (equity, net exposure, P&L queries)
self.clockCurrent time + timer/alert scheduling
self.logStructured logging (non-blocking MPSC channel underneath)
self.msgbusPublish/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 triggered on_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_bar does 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 like on_bar(), on_quote_tick(), on_trade_tick(), etc.

The full mapping (every method an Actor can call and the handler each ends up at):

OperationCategoryHandler
subscribe_data()Real-timeon_data()
subscribe_instrument()Real-timeon_instrument()
subscribe_instruments()Real-timeon_instrument()
subscribe_order_book_deltas()Real-timeon_order_book_deltas()
subscribe_order_book_depth()Real-timeon_order_book_depth()
subscribe_order_book_at_interval()Real-timeon_order_book()
subscribe_quote_ticks()Real-timeon_quote_tick()
subscribe_trade_ticks()Real-timeon_trade_tick()
subscribe_mark_prices()Real-timeon_mark_price()
subscribe_index_prices()Real-timeon_index_price()
subscribe_bars()Real-timeon_bar()
subscribe_funding_rates()Real-timeon_funding_rate()
subscribe_instrument_status()Real-timeon_instrument_status()
subscribe_instrument_close()Real-timeon_instrument_close()
subscribe_option_greeks()Real-timeon_option_greeks()
subscribe_option_chain()Real-timeon_option_chain()
subscribe_order_fills()Real-timeon_order_filled()
subscribe_order_cancels()Real-timeon_order_canceled()
request_data()Historicalon_historical_data()
request_order_book_deltas()Historicalon_historical_data()
request_order_book_depth()Historicalon_historical_data()
request_order_book_snapshot()Historicalon_historical_data()
request_instrument()Historicalon_instrument()
request_instruments()Historicalon_instrument()
request_quote_ticks()Historicalon_historical_data()
request_trade_ticks()Historicalon_historical_data()
request_bars()Historicalon_historical_data()
request_aggregated_bars()Historicalon_historical_data()
request_funding_rates()Historicalon_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:

  1. Low-level pub/sub on string topics - flexible but typo-prone.
  2. Actor-based custom data publishing via Data subclasses or @customdataclass - preferred. Carries ts_event / ts_init timestamps so backtest ordering stays correct.
  3. 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 = 10

Why 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_event trigger the same way.”

Concrete consequences for Actors:

  • The same Actor class runs in BacktestEngine, sandbox, and TradingNode / LiveNode with no code change.
  • self.clock reads 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 ScoreUpdate custom data on the MessageBus. Other Actors and the Strategy subscribe.
  • EMA flow decay calculator - pure derived state from a flow feed. Actor. Either a separate Actor publishing FlowDecay events, 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, publishes MetaScore (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 ~/brain markdown for relevant postmortems / loss clusters / patterns and publishes them as BrainContext events. 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 is on_position_closed, which only Strategy has - so this is either a small extension on the Strategy itself, or an Actor that uses subscribe_order_fills to 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. See nautilus-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 > X checks.
  • 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:

  1. Does it submit / modify / cancel orders? → Strategy
  2. Does it ingest bytes from a venue / data vendor? → DataClient (Rust core + Python wiring)
  3. Does it submit / cancel / track orders at a venue? → ExecutionClient
  4. Does it enforce pre-trade limits across all strategies? → RiskEngine config
  5. Does it consume normalized data and produce derived data, signals, or events? → Actor
  6. 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’s scoring_events rows as ScoreUpdate custom data on the bus. <100 LOC.
  • 1 MetaGateActor(Actor) (optional, can be inlined into Strategy for the spike) - consumes ScoreUpdate, applies meta-prob filter, publishes EntrySignal. <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_load are 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_bar blocks 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 use self.clock / self.log / self.msgbus - they are only wired after registration with the Kernel. Subscribe in on_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) so ts_event / ts_init are present. Raw dicts on msgbus.publish work 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


Timeline

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