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 therefore nautilus-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 - DataActorCore composition, the nautilus_actor! macro, DataActor trait override, lifecycle handlers, subscription methods, registration with BacktestEngine / LiveNode - all of which map 1:1 to the Python Actor base class. This page is the recipe Cortana follows when standing up the first MK3 Python actor (the ScoringActor subscribing to UWFlowAlert, computing ScoreUpdate, 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; substitute nautilus-actors.md. This is filed for Saturday 2026-05-09 spike use.

Source URL availability - Spike Step 0 result

URLStatusImplication
https://nautilustrader.io/docs/latest/how_to/write_rust_actor/200 OKRust how-to exists; we mine its shape and translate to Python
https://nautilustrader.io/docs/latest/how_to/write_python_actor/404 Not FoundNo 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:

  1. Define a struct that owns a DataActorCore plus actor-specific state.
  2. Construct it with a DataActorConfig carrying an ActorId.
  3. Wire the core with the nautilus_actor! macro to get Deref<Target = DataActorCore> - this gives the struct direct access to subscription methods, cache, and clock without an explicit field reference.
  4. Implement DataActor to override lifecycle and data-handler methods.
  5. Register with BacktestEngine::add_actor(actor) or LiveNode::add_actor(actor).

In Python the same sequence is:

  1. Define a class that subclasses Actor (from nautilus_trader.common.actor) - Python inheritance replaces the Rust composition + macro pattern.
  2. Define an ActorConfig subclass (from nautilus_trader.config) with type-annotated fields - replaces the Rust DataActorConfig.
  3. Override __init__(self, config) to call super().__init__(config) and store any local state. Do not call self.clock / self.log / self.msgbus here - they are not yet wired (see nautilus-actors.md “Limits and gotchas”).
  4. Override lifecycle methods (on_start, on_stop, etc.) and data handlers (on_bar, on_quote_tick, on_data, …).
  5. Register with BacktestEngine.add_actor(actor) or pass via TradingNodeConfig.

The full mapping table:

Rust how-to stepPython 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:

  1. Config fields are typed and live on a separate ActorConfig subclass. This is what makes the actor serializable for distributed backtests and remote live deployments (see nautilus-strategies.md for the same rule on StrategyConfig).
  2. Subscriptions, indicators, timers, and any self.clock / self.log / self.msgbus access live in on_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.

HookWhen firedTypical Cortana use
on_start()Actor entering RUNNING - Kernel has wired self.clock, self.log, self.msgbus, self.cache, self.portfoliosubscribe_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 stateRe-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 functionalityLog + continue with reduced subscriptions
on_fault()Unrecoverable - actor moving to FAULTEDFinal log; do not attempt recovery here
on_dispose()Final cleanup before discardingClose 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 callHandler
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_data

Custom 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):

  1. Type-annotate every field. Use PEP 604 unions (InstrumentId | None) not Optional[...]. The pre-commit hooks reject the older syntax.
  2. Configurations serialize over the wire. Distributed backtests ship the actor + config to a worker. Constructor args do not serialize.
  3. 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, paired ActorConfig subclass with typed fields
  • ✅ Local state in __init__; no self.clock / self.log / self.msgbus access in the constructor
  • on_start subscribes to custom data (UWFlowAlert) AND built-in data (Bar) AND sets a recurring timer
  • on_stop cancels the timer (avoids leaks across stop/resume)
  • on_data discriminates custom-data subclasses by isinstance
  • on_bar consumes built-in market data
  • ✅ Timer callback uses the named-callback pattern, not the on_event fallback
  • ✅ Publishes a typed @customdataclass event with explicit ts_event/ts_init for replay-determinism
  • ✅ All time goes through self.clock - no time.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 ScoreUpdate flowing.
  • 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 ScoringActor or CortanaStrategy.
  • Note: per nautilus-strategies.md § “Sizing multiplier - where does meta-prob live?”, the recommended placement for enforcement is a RiskEngine rule, not just an Actor. The Actor publishes MetaProbUpdate for 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 ScoringActor works, 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.md says 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 ingestionLiveDataClient, 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 CortanaStrategy because it touches orders
  • Pre-trade meta-prob enforcement → custom RiskEngine rule, 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.

  1. Using __init__ for subscriptions. Will silently no-op or raise. Move to on_start.
  2. Reading self.clock / self.log / self.msgbus in __init__. Same problem - those subsystems aren’t wired yet.
  3. Calling time.time() / datetime.now() in handlers. Breaks backtest determinism. Always use self.clock.
  4. 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.
  5. Forgetting to cancel timers in on_stop. Timers leak across stop/resume cycles.
  6. Naming custom-data handlers anything other than on_data. The handler name is fixed; multiplex multiple custom types via isinstance.
  7. Calling submit_order / cancel_order on an Actor. Won’t compile (those methods only exist on Strategy). If you find yourself wanting to, the component is actually a Strategy.
  8. Persisting state via on_save/on_load on an Actor. Those are Strategy-only. For Actor state durability use Cache.add(...) with Redis backing.
  9. Publishing high-frequency raw data on the bus. Will flood any external Redis stream. Use types_filter in MessageBusConfig to drop the firehose; only stream derived events.
  10. 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


Timeline

  • 2026-05-07 | Cody - Filed during pre-spike concept mastery sweep batch 6 (how-tos).