How-To - Write an Actor
The Rust how-to (
/how_to/write_rust_actor/) returns HTTP 200. The Python how-to (/how_to/write_python_actor/) returns HTTP 404 as of 2026-05-07 - there is no parallel published recipe. Authoritative source for Cortana MK3 (Python path) is thereforenautilus-actors.md(the official/concepts/actors/page filed in batch 1) plus the Strategy docs (since Strategy extends Actor and shares the entire authoring surface) plus translation-by-analogy from the Rust how-to. The Rust how-to teaches the shape -DataActorCorecomposition, thenautilus_actor!macro,DataActortrait override, lifecycle handlers, subscription methods, registration withBacktestEngine/LiveNode- all of which map 1:1 to the PythonActorbase class. This page is the recipe Cortana follows when standing up the first MK3 Python actor (theScoringActorsubscribing toUWFlowAlert, computingScoreUpdate, publishing on the bus). Spike Plan Step 0 (verifying Python how-tos exist) is therefore partial fail - for Actor specifically, the Python recipe is missing; substitutenautilus-actors.md. This is filed for Saturday 2026-05-09 spike use.
Source URL availability - Spike Step 0 result
| URL | Status | Implication |
|---|---|---|
https://nautilustrader.io/docs/latest/how_to/write_rust_actor/ | 200 OK | Rust how-to exists; we mine its shape and translate to Python |
https://nautilustrader.io/docs/latest/how_to/write_python_actor/ | 404 Not Found | No published Python how-to. Cortana reads nautilus-actors.md (filed 2026-05-07 batch 1) by analogy + this page |
Spike Step 0 verdict for Actor: ❌ no Python how-to. Mitigation: the
/concepts/actors/ page is the de facto Python recipe - it is comprehensive,
the example is in Python, and the lifecycle/subscription/publishing surfaces
match what the Rust how-to teaches. No blocker. Proceed.
Why a how-to page in addition to nautilus-actors.md?
nautilus-actors.md is the concept page - it explains what an Actor is,
when to use Actor vs Strategy vs DataClient, and lists every subscription
operation with its handler. This page is the recipe - given that you’ve
decided to build an Actor, here is the exact sequence of code you write,
file-by-file, plus the Cortana-shaped end-to-end example. The two pages
together replicate what a hypothetical “Write a Python Actor” how-to would
have covered.
Reference - what the Rust how-to teaches (translated)
The Rust how-to walks through a SpreadMonitor actor that subscribes to
QuoteTick and logs the bid-ask spread. The mechanical pattern is:
- Define a struct that owns a
DataActorCoreplus actor-specific state. - Construct it with a
DataActorConfigcarrying anActorId. - Wire the core with the
nautilus_actor!macro to getDeref<Target = DataActorCore>- this gives the struct direct access to subscription methods, cache, and clock without an explicit field reference. - Implement
DataActorto override lifecycle and data-handler methods. - Register with
BacktestEngine::add_actor(actor)orLiveNode::add_actor(actor).
In Python the same sequence is:
- Define a class that subclasses
Actor(fromnautilus_trader.common.actor) - Python inheritance replaces the Rust composition + macro pattern. - Define an
ActorConfigsubclass (fromnautilus_trader.config) with type-annotated fields - replaces the RustDataActorConfig. - Override
__init__(self, config)to callsuper().__init__(config)and store any local state. Do not callself.clock/self.log/self.msgbushere - they are not yet wired (seenautilus-actors.md“Limits and gotchas”). - Override lifecycle methods (
on_start,on_stop, etc.) and data handlers (on_bar,on_quote_tick,on_data, …). - Register with
BacktestEngine.add_actor(actor)or pass viaTradingNodeConfig.
The full mapping table:
| Rust how-to step | Python equivalent |
|---|---|
pub struct X { core: DataActorCore, ... } | class X(Actor): |
DataActorConfig { actor_id: ..., ..Default::default() } | class XConfig(ActorConfig): instrument_id: InstrumentId; ... |
nautilus_actor!(X); | (not needed - Python inheritance) |
impl DataActor for X { fn on_start(&mut self) -> Result<()> { ... } } | def on_start(self) -> None: |
self.subscribe_quotes(self.instrument_id, None, None) | self.subscribe_quote_ticks(self.config.instrument_id) |
engine.add_actor(actor)? | engine.add_actor(actor) |
node.add_actor(actor)? | (in Python, actors are passed via TradingNodeConfig rather than a runtime add - see registration section) |
Reference - subclassing pattern (Python)
The minimum viable Actor:
from nautilus_trader.common.actor import Actor
from nautilus_trader.config import ActorConfig
from nautilus_trader.model import InstrumentId, BarType
class MyActorConfig(ActorConfig):
instrument_id: InstrumentId
bar_type: BarType
lookback_period: int = 10
class MyActor(Actor):
def __init__(self, config: MyActorConfig) -> None:
super().__init__(config)
self._counter: int = 0 # local state OK in __init__
def on_start(self) -> None:
self.subscribe_bars(self.config.bar_type)
def on_bar(self, bar) -> None:
self._counter += 1
self.log.info(f"bar #{self._counter}: {bar}")Two non-negotiable rules:
- Config fields are typed and live on a separate
ActorConfigsubclass. This is what makes the actor serializable for distributed backtests and remote live deployments (seenautilus-strategies.mdfor the same rule onStrategyConfig). - Subscriptions, indicators, timers, and any
self.clock/self.log/self.msgbusaccess live inon_start(), never in__init__(). The Kernel wires those subsystems after registration; the constructor runs before registration. Touching them in__init__raises or no-ops.
Reference - lifecycle hooks
Every Actor follows the same finite state machine. Override only the hooks you need; the framework provides safe no-op defaults for the rest.
| Hook | When fired | Typical Cortana use |
|---|---|---|
on_start() | Actor entering RUNNING - Kernel has wired self.clock, self.log, self.msgbus, self.cache, self.portfolio | subscribe_bars, subscribe_data, register_indicator_for_bars, clock.set_time_alert("EOD_FLAT", ...) |
on_stop() | Actor exiting RUNNING (graceful) | unsubscribe_*, cancel_timer, flush in-memory buffers |
on_resume() | RESUME from a previously stopped state | Re-establish subscriptions if on_stop torn them down |
on_reset() | Backtest-only reset between runs (BacktestEngine.reset()) | Zero internal counters, reseed RNG |
on_degrade() | Adapter degraded - partial functionality | Log + continue with reduced subscriptions |
on_fault() | Unrecoverable - actor moving to FAULTED | Final log; do not attempt recovery here |
on_dispose() | Final cleanup before discarding | Close file handles, drop external connections |
on_save() / on_load() | Strategy-only per nautilus-actors.md § Limits - Actor state durability uses Cache + Redis backing instead |
The data-handler hooks are also lifecycle-shaped - they only fire while the
Actor is in RUNNING. The complete list (from nautilus-actors.md):
| Subscription call | Handler |
|---|---|
subscribe_data(DataType) | on_data(data: Data) |
subscribe_signal(name) | on_signal(signal) |
subscribe_bars(bar_type) | on_bar(bar: Bar) |
subscribe_quote_ticks(instrument_id) | on_quote_tick(tick: QuoteTick) |
subscribe_trade_ticks(instrument_id) | on_trade_tick(tick: TradeTick) |
subscribe_order_book_deltas(instrument_id) | on_order_book_deltas(deltas) |
subscribe_order_book_at_interval(instrument_id, interval_ms=...) | on_order_book(book) |
subscribe_instrument_status(instrument_id) | on_instrument_status(status) |
subscribe_option_greeks(instrument_id) | on_option_greeks(greeks) |
subscribe_option_chain(instrument_id) | on_option_chain(chain) |
subscribe_order_fills(instrument_id) | on_order_filled(event) |
subscribe_order_cancels(instrument_id) | on_order_canceled(event) |
request_bars(...) (historical) | on_historical_data(data) |
request_quote_ticks(...) (historical) | on_historical_data(data) |
Catch-all dispatch: anything that does not have a specific handler and is
delivered on subscribe_signal falls into on_signal; anything custom on
subscribe_data falls into on_data; framework Events (timers, fills,
position changes, etc.) hit on_event last.
Reference - data subscription (built-in + custom)
Built-in data
def on_start(self) -> None:
# Built-in market data
self.subscribe_quote_ticks(self.config.instrument_id)
self.subscribe_bars(self.config.bar_type)
# Optional: hydrate historical state first
self.request_bars(self.config.bar_type) # routes to on_historical_dataCustom data (Cortana case - UW flow alerts)
Define a custom-data class once (typically in
cortana/nautilus/custom_data.py):
from nautilus_trader.model.custom import customdataclass
from nautilus_trader.core import Data
from nautilus_trader.model import InstrumentId
@customdataclass
class UWFlowAlert(Data):
instrument_id: InstrumentId
underlying: str = "SPY"
side: str = "" # "CALL" / "PUT"
strike: float = 0.0
premium: float = 0.0
aggressor: str = "" # "BUY" / "SELL"
confidence: float = 0.0(Per nautilus-custom-data.md the @customdataclass decorator is the 90%
case; it auto-registers the type with the DataRegistry, gives free
ts_event / ts_init ordering, and is catalog-persistable.)
Then subscribe in on_start:
def on_start(self) -> None:
self.subscribe_data(UWFlowAlert)
def on_data(self, data: Data) -> None:
if isinstance(data, UWFlowAlert):
self._on_uw_flow(data)The on_data handler name is fixed - multiple custom data types
multiplex through the same handler with isinstance discrimination
(see nautilus-message-bus.md § Three publishing styles).
Reference - publishing custom data
Two paths matter for Cortana:
# Typed data with ts_event / ts_init (preferred - ordering-safe, catalog-able)
update = ScoreUpdate(
instrument_id=self.config.instrument_id,
composite_score=score,
bias=bias,
conviction=conviction,
ts_event=self.clock.timestamp_ns(),
ts_init=self.clock.timestamp_ns(),
)
self.publish_data(ScoreUpdate, update)
# Lightweight primitive notification (no replay determinism, no schema)
self.publish_signal(name="NEW_HIGHEST_SCORE", value=98.4,
ts_event=self.clock.timestamp_ns())Rule of thumb (per nautilus-custom-data.md): prefer publish_data with
a @customdataclass for any event that should be logged, replayed, or
queried in a postmortem. publish_signal is only for ephemeral notifications
you would be willing to lose.
Reference - MessageBus interaction
Three styles, all available on every Actor (see nautilus-message-bus.md):
# Style 1: low-level topic pub/sub (typo-prone - avoid for anything load-bearing)
self.msgbus.subscribe("custom.cortana.heartbeat", self._on_heartbeat)
self.msgbus.publish("custom.cortana.heartbeat", {"ts": now})
# Style 2: typed Data subclass (preferred for structured events)
self.subscribe_data(ScoreUpdate)
self.publish_data(ScoreUpdate, update)
# Style 3: primitive signal (one float/int/str/bool)
self.subscribe_signal("REGIME_CHANGE")
self.publish_signal("REGIME_CHANGE", value="POWER_HOUR",
ts_event=self.clock.timestamp_ns())Immutability rule (from nautilus-message-bus.md): once published, a message
must not be mutated. If a downstream consumer needs a different shape, it
constructs a new local representation. This is what enables backtest replay
determinism and audit-trail reconstruction.
Reference - Cache reads
def on_bar(self, bar: Bar) -> None:
# Most-recent cached bar of the same type (index=0 = most recent)
last_bar = self.cache.bar(self.config.bar_type, index=0)
# Most-recent quote tick
last_quote = self.cache.quote_tick(self.config.instrument_id)
# Last price (BID/ASK/MID/LAST)
from nautilus_trader.model import PriceType
last_mid = self.cache.price(self.config.instrument_id, PriceType.MID)
# Custom data lookups - Cache stores @customdataclass instances too
last_score = self.cache.custom_data(ScoreUpdate, instrument_id=self.config.instrument_id)The cache-then-publish invariant (from nautilus-architecture.md) means
that inside on_bar/on_quote_tick/etc., reading self.cache.bar(...)
returns the bar that triggered the handler - there is no race window. The
2026-05-06 stale spy_price class of bug is structurally impossible.
Reference - Clock and timer usage
from datetime import timedelta
def on_start(self) -> None:
# Recurring timer - routes to the named callback
self.clock.set_timer(
name="heartbeat_5s",
interval=timedelta(seconds=5),
callback=self._on_heartbeat,
)
# One-shot alert at a specific simulated/wall-clock time
self.clock.set_time_alert(
name="EOD_FLAT_1455CT",
alert_time=self.clock.utc_now() + timedelta(minutes=5),
callback=self._on_eod_flat_alert,
)
def on_stop(self) -> None:
# Timers leak across stop/resume if not cancelled
self.clock.cancel_timer("heartbeat_5s")
def _on_heartbeat(self, event) -> None:
self.log.info("heartbeat")
def _on_eod_flat_alert(self, event) -> None:
self.log.info("EOD flatten alert fired")Same Clock interface in backtest (data-driven) and live (wall-clock backed). This is what enables “write-once, run-anywhere” Actors.
Hard rule: never call time.time() / datetime.now() / os.environ
reads from inside an Actor handler. Always go through self.clock. This is
what makes backtests deterministic.
Reference - configuration injection
from decimal import Decimal
from nautilus_trader.config import ActorConfig
from nautilus_trader.model import InstrumentId, BarType
class ScoringActorConfig(ActorConfig):
instrument_id: InstrumentId
bar_type: BarType
score_threshold: int = 65
flow_decay_half_life_seconds: float = 30.0
publish_topic: str = "cortana.scores"Three properties of ActorConfig worth remembering (per the Strategy docs,
which apply equally to Actor):
- Type-annotate every field. Use PEP 604 unions (
InstrumentId | None) notOptional[...]. The pre-commit hooks reject the older syntax. - Configurations serialize over the wire. Distributed backtests ship the actor + config to a worker. Constructor args do not serialize.
- Defaults belong on the config, not on the actor. Cleaner, more testable, and serializable.
Inside the actor, read config as self.config.score_threshold.
Reference - registration with TradingNode / BacktestEngine
Backtest registration (Python)
from nautilus_trader.backtest.engine import BacktestEngine, BacktestEngineConfig
engine_config = BacktestEngineConfig(
actors=[
ImportableActorConfig(
actor_path="cortana.nautilus.scoring_actor:ScoringActor",
config_path="cortana.nautilus.scoring_actor:ScoringActorConfig",
config={
"instrument_id": "SPY.ARCA",
"bar_type": "SPY.ARCA-1-MINUTE-LAST-EXTERNAL",
"score_threshold": 65,
},
),
],
strategies=[
ImportableStrategyConfig(
strategy_path="cortana.nautilus.cortana_strategy:CortanaStrategy",
config_path="cortana.nautilus.cortana_strategy:CortanaConfig",
config={...},
),
],
)
engine = BacktestEngine(config=engine_config)Or, equivalently, instantiate and add_actor:
engine = BacktestEngine()
engine.add_actor(ScoringActor(ScoringActorConfig(...)))
engine.add_strategy(CortanaStrategy(CortanaConfig(...)))Live registration via TradingNode
from nautilus_trader.live.config import TradingNodeConfig
from nautilus_trader.live.node import TradingNode
config = TradingNodeConfig(
trader_id="CORTANA-001",
actors=[
ImportableActorConfig(
actor_path="cortana.nautilus.scoring_actor:ScoringActor",
config_path="cortana.nautilus.scoring_actor:ScoringActorConfig",
config={"instrument_id": "SPY.ARCA", "bar_type": "SPY.ARCA-1-MINUTE-LAST-EXTERNAL"},
),
],
strategies=[...],
data_clients={...}, # IBKR + UW DataClient configs
exec_clients={...}, # IBKR ExecClient config
)
node = TradingNode(config=config)
node.build()
node.run()The ImportableActorConfig form is what makes the actor remotely
deployable - the entire config is JSON-serializable and the framework
resolves the import paths at build time.
Cortana-applicable example - ScoringActor skeleton
This is the recipe for the first Cortana MK3 actor: a ScoringActor that
subscribes to UWFlowAlert custom data, computes a composite ScoreUpdate,
and publishes it on the bus for downstream meta-gate / strategy consumers.
"""Cortana MK3 ScoringActor - Python how-to-write-an-actor reference shape.
Subscribes to UWFlowAlert custom data + 1-minute SPY bars, computes a
composite score on each new flow alert, publishes ScoreUpdate on the bus.
Order placement does NOT live here - that is CortanaStrategy's job. This
Actor is a pure derived-state producer. See:
- ~/brain/concepts/nautilus-actors.md (Actor vs Strategy boundary)
- ~/brain/concepts/nautilus-howto-write-actor.md (this recipe)
- ~/brain/concepts/nautilus-custom-data.md (@customdataclass mechanics)
"""
from datetime import timedelta
from nautilus_trader.common.actor import Actor
from nautilus_trader.config import ActorConfig
from nautilus_trader.core import Data
from nautilus_trader.model import Bar, BarType, InstrumentId
from nautilus_trader.model.custom import customdataclass
# --- Custom data types (typically live in cortana/nautilus/custom_data.py) ---
@customdataclass
class UWFlowAlert(Data):
instrument_id: InstrumentId
side: str = "" # "CALL" / "PUT"
strike: float = 0.0
premium: float = 0.0
aggressor: str = "" # "BUY" / "SELL"
confidence: float = 0.0
@customdataclass
class ScoreUpdate(Data):
instrument_id: InstrumentId
composite_score: float = 0.0
bias: str = "" # "BULL" / "BEAR" / "NEUTRAL"
conviction: str = "" # "LOW" / "MED" / "HIGH"
flow_pressure: float = 0.0
# --- Config ---
class ScoringActorConfig(ActorConfig):
instrument_id: InstrumentId
bar_type: BarType
score_threshold: int = 65
flow_decay_half_life_seconds: float = 30.0
# --- Actor ---
class ScoringActor(Actor):
"""Consume UWFlowAlert + Bar; publish ScoreUpdate. No order placement."""
def __init__(self, config: ScoringActorConfig) -> None:
super().__init__(config)
# Local state only - never touch self.clock/log/msgbus here
self._flow_pressure: float = 0.0
self._last_bar_close: float | None = None
# --- Lifecycle ---
def on_start(self) -> None:
self.subscribe_data(UWFlowAlert)
self.subscribe_bars(self.config.bar_type)
# EMA-style decay on flow pressure: tick every second
self.clock.set_timer(
name="flow_decay_1s",
interval=timedelta(seconds=1),
callback=self._on_decay_tick,
)
self.log.info(f"ScoringActor started for {self.config.instrument_id}")
def on_stop(self) -> None:
self.clock.cancel_timer("flow_decay_1s")
self.log.info("ScoringActor stopped")
# --- Handlers ---
def on_data(self, data: Data) -> None:
if isinstance(data, UWFlowAlert):
self._on_uw_flow(data)
def on_bar(self, bar: Bar) -> None:
self._last_bar_close = float(bar.close)
self._publish_score(triggered_by="bar")
def _on_decay_tick(self, event) -> None:
# Half-life decay on flow pressure
half_life = self.config.flow_decay_half_life_seconds
decay = 0.5 ** (1.0 / half_life)
self._flow_pressure *= decay
# --- Internals ---
def _on_uw_flow(self, alert: UWFlowAlert) -> None:
# Add directional flow pressure (sign by side, magnitude by premium*confidence)
sign = 1.0 if alert.side == "CALL" else -1.0
if alert.aggressor == "SELL":
sign = -sign
self._flow_pressure += sign * alert.premium * alert.confidence
self._publish_score(triggered_by="uw_flow")
def _publish_score(self, triggered_by: str) -> None:
if self._last_bar_close is None:
return # not enough state yet
composite = self._compute_composite()
bias = "BULL" if composite > 0 else "BEAR" if composite < 0 else "NEUTRAL"
conviction = self._conviction_bucket(abs(composite))
update = ScoreUpdate(
instrument_id=self.config.instrument_id,
composite_score=composite,
bias=bias,
conviction=conviction,
flow_pressure=self._flow_pressure,
ts_event=self.clock.timestamp_ns(),
ts_init=self.clock.timestamp_ns(),
)
self.publish_data(ScoreUpdate, update)
self.log.debug(f"ScoreUpdate published ({triggered_by}): {composite:.2f} {bias}")
def _compute_composite(self) -> float:
# Placeholder: real Cortana composite is 78-feature weighted sum.
# For the spike, flow pressure alone is the proxy.
return self._flow_pressure
@staticmethod
def _conviction_bucket(magnitude: float) -> str:
if magnitude >= 100:
return "HIGH"
if magnitude >= 40:
return "MED"
return "LOW"What this skeleton demonstrates (Python how-to checklist)
- ✅ Subclass
Actor, pairedActorConfigsubclass with typed fields - ✅ Local state in
__init__; noself.clock/self.log/self.msgbusaccess in the constructor - ✅
on_startsubscribes to custom data (UWFlowAlert) AND built-in data (Bar) AND sets a recurring timer - ✅
on_stopcancels the timer (avoids leaks across stop/resume) - ✅
on_datadiscriminates custom-data subclasses byisinstance - ✅
on_barconsumes built-in market data - ✅ Timer callback uses the named-callback pattern, not the
on_eventfallback - ✅ Publishes a typed
@customdataclassevent with explicitts_event/ts_initfor replay-determinism - ✅ All time goes through
self.clock- notime.time()/datetime.now() - ✅ NO order placement (this is an Actor, not a Strategy)
Drop-in registration
engine.add_actor(
ScoringActor(
ScoringActorConfig(
instrument_id=InstrumentId.from_str("SPY.ARCA"),
bar_type=BarType.from_str("SPY.ARCA-1-MINUTE-LAST-EXTERNAL"),
score_threshold=65,
flow_decay_half_life_seconds=30.0,
)
)
)Cortana MK3 implications - the Actor build order
Per nautilus-actors.md § “Cortana MK3 implications”, Cortana decomposes
into roughly 5–7 Actors plus one Strategy. The right build sequence for the
spike and immediate post-spike work:
1. ScoringActor (first - the spike target)
- Subscribes to:
UWFlowAlert(custom),Bar(built-in 1-minute SPY) - Publishes:
ScoreUpdate(composite + bias + conviction + flow_pressure) - Config:
instrument_id,bar_type,score_threshold,flow_decay_half_life_seconds - Why first: validates the whole custom-data pub/sub spine end-to-end
in one actor. Nothing downstream works without
ScoreUpdateflowing. - Spike LOC target: <200 lines.
2. MetaGateActor (second - adds the meta-prob layer)
- Subscribes to:
ScoreUpdate - Publishes:
MetaProbUpdate(prob: float, gate_open: bool) - Config:
meta_prob_threshold: float = 0.55, model-artifact path - Why second: decouples scoring from gating; lets us A/B-test meta
models without touching
ScoringActororCortanaStrategy. - Note: per
nautilus-strategies.md§ “Sizing multiplier - where does meta-prob live?”, the recommended placement for enforcement is aRiskEnginerule, not just an Actor. The Actor publishesMetaProbUpdatefor telemetry; the RiskEngine reads it from Cache to gate orders. Both exist together.
3. EmaDecayActor (third - pull EMA decay out of ScoringActor)
- Subscribes to:
UWFlowAlert - Publishes:
EmaDecayValue(value: float, half_life_seconds: float) - Config:
half_life_seconds,update_interval_ms - Why third: once
ScoringActorworks, refactor flow decay into its own actor so multiple consumers (scoring + dashboard + risk) share one decay computation.
4. RegimeDetectorActor (fourth - power-hour, chop, trend)
- Subscribes to:
Bar,QuoteTick - Publishes:
RegimeUpdate(regime: str, confidence: float) - Config:
power_hour_start_ct: time = 14:30,chop_atr_multiple: float - Why fourth:
project_eod_power_hour.mdsays power-hour is a first-class regime - this actor lives independently and broadcasts the current regime for any downstream consumer.
5. BrainLoggerActor (fifth - post-trade outcome write)
- Subscribes to:
subscribe_order_fills+subscribe_order_cancels(read-only execution telemetry) - Publishes: nothing on the bus - writes to
~/brain/writing/files - Config:
brain_writing_path,tenant_id - Why fifth: brain integration is value-added but not blocking; layer it in once the trading loop is solid.
What stays out of Actors (per nautilus-actors.md)
- UW WebSocket ingestion →
LiveDataClient, not an Actor (bytes-in, normalized-Data-out is the DataClient boundary) - IBKR adapter → already shipped; DataClient + ExecutionClient
- Position manager (TP/SL fallback) → lives inside
CortanaStrategybecause it touches orders - Pre-trade meta-prob enforcement → custom
RiskEnginerule, not an Actor (so it cannot be silently bypassed; see #88) - Dashboard → out-of-band Redis subscriber, not an Actor
Common pitfalls when authoring an Actor
These bite first-time authors and are worth re-reading before the spike.
- Using
__init__for subscriptions. Will silently no-op or raise. Move toon_start. - Reading
self.clock/self.log/self.msgbusin__init__. Same problem - those subsystems aren’t wired yet. - Calling
time.time()/datetime.now()in handlers. Breaks backtest determinism. Always useself.clock. - Mutating a published message. Forbidden - once
publish_data(...)is called, the message must not be touched. Construct a new message if you need a derived shape. - Forgetting to cancel timers in
on_stop. Timers leak across stop/resume cycles. - Naming custom-data handlers anything other than
on_data. The handler name is fixed; multiplex multiple custom types viaisinstance. - Calling
submit_order/cancel_orderon an Actor. Won’t compile (those methods only exist onStrategy). If you find yourself wanting to, the component is actually a Strategy. - Persisting state via
on_save/on_loadon an Actor. Those are Strategy-only. For Actor state durability useCache.add(...)with Redis backing. - Publishing high-frequency raw data on the bus. Will flood any
external Redis stream. Use
types_filterinMessageBusConfigto drop the firehose; only stream derived events. - Heavy ML inference inside
on_bar. Blocks the kernel thread. Either batch on a timer, or push inference to a separate process consuming the bus over Redis.
See Also
- Nautilus Actors - the concept page; canonical Python recipe absent a published Python how-to; explains Actor vs Strategy vs DataClient
- Nautilus Rust - Cortana MK3 does NOT require Rust; Python is the path. The Rust how-to is the only published Actor how-to; we translate by analogy
- Nautilus Strategies - Strategy extends Actor; every Actor capability is also a Strategy capability
- Nautilus Message Bus - pub/sub spine, three publishing styles, immutability contract
- Nautilus Data - built-in data types, ts_event vs ts_init, catalog write path
- Nautilus Custom Data -
@customdataclassis the 90% case for Cortana custom events - 2026-05-09 Nautilus Spike Plan:
~/conductor/workspaces/cortanaroi-mk2/belo-horizonte/plans/2026-05-09-nautilus-spike.md - Source (Rust how-to, 200 OK): https://nautilustrader.io/docs/latest/how_to/write_rust_actor/
- Source attempted (Python how-to, 404): https://nautilustrader.io/docs/latest/how_to/write_python_actor/
Timeline
- 2026-05-07 | Cody - Filed during pre-spike concept mastery sweep batch 6 (how-tos).