Nautilus Developer Guide - Python

The Python authoring contract for any code that touches the Nautilus runtime. Python is the user-facing layer on top of the Rust core: every Strategy, Actor, custom data class, custom RiskEngine rule, custom DataClient, and config object is written in Python and wired into a Rust kernel that owns dispatch, cache, persistence, and FFI. The dev guide page is short on words but load-bearing on rules: PEP-8 with one notable departure (always is None for non-collection nullity checks), type hints mandatory on every signature using PEP 604 unions (Instrument | None, never Optional[Instrument]), NumPy-style docstrings in imperative mood, no docstrings on private (_-prefixed) methods, descriptive test_* names, ruff enforced via pre-commit, and a property-vs-method discipline at the PyO3 boundary that controls whether a Rust field is exposed as obj.value or obj.value(). Cython (.pyx/.pxd) is legacy - new code uses Python + PyO3, and any Cython function returning void/bint/int/double must declare except * or Python exceptions are silently swallowed. This page is the canonical Python authoring reference for every Cortana MK3 component (CortanaStrategy, ScoringActor, MetaGateActor, RegimeDetectorActor, BrainLoggerActor, the UW LiveDataClient, custom RiskEngine rules, and every @customdataclass).

Source URL availability

URLStatusNotes
/docs/latest/developer_guide/python/200 OKCanonical Python style + boundary rules
/docs/latest/developer_guide/coding_standards/(parallel batch)Cross-language standards
/docs/latest/developer_guide/design_principles/(parallel batch)Architectural principles
/docs/latest/how_to/write_python_strategy/404Lives in concepts/strategies/
/docs/latest/how_to/write_python_actor/404Lives in concepts/actors/

Core claim

Python in Nautilus is not the implementation language for hot paths - it is the control plane language for user-replaceable logic. The dev guide encodes three structural facts:

  1. Type hints are not optional. Every signature is annotated. Pre- commit hooks reject untyped callables. PEP 604 unions are required; Optional[T] is rejected.
  2. The PyO3 boundary is asymmetric. Python sees Rust types as immutable views with #[getter]-exposed properties for cheap reads and explicit methods for anything that allocates, mutates, or does I/O. The cost-of-call is what dictates property-vs-method.
  3. Cython is legacy. New extensions go through PyO3 (Rust). Any Cython that remains must respect the except * rule or it silently swallows exceptions across the C boundary - a class of bug the Python+PyO3 path closes by construction.

Reference - code style (PEP-8 + departures)

The codebase generally follows PEP-8. The single notable departure from default Python idiom (per the dev guide):

“Always use if foo is None: (or is not None) to check for a None value. The other value might be a value that’s false in a boolean context!”

  • Non-collection nullity: explicit is None / is not None. Never truthiness for arguments that could legitimately be 0, 0.0, Decimal("0"), an empty Price, etc.
  • Collection emptiness: if not my_list: (truthiness OK here).
  • String emptiness: if not s: is acceptable for strings only if empty-string is the same as missing in context. In doubt, prefer explicit is None first then a separate emptiness check.

For Cortana: scoring features are floats where 0.0 is a meaningful neutral value. Never write if feature_value: to check whether a feature is set - write if feature_value is None: because 0.0 is a valid score.

Reference - type hints (PEP 604 unions, mandatory)

“All function and method signatures must include type annotations.”

# Required
def __init__(self, config: ScoringActorConfig) -> None:
def on_bar(self, bar: Bar) -> None:
def on_save(self) -> dict[str, bytes]:
def on_load(self, state: dict[str, bytes]) -> None:
 
# Required: PEP 604 unions
def get_instrument(self, id: InstrumentId) -> Instrument | None:
 
# Rejected: legacy Optional[]
def get_instrument(self, id: InstrumentId) -> Optional[Instrument]:

Generic types use TypeVar:

T = TypeVar("T")
 
class ThrottledEnqueuer(Generic[T]):
    def enqueue(self, item: T) -> None: ...

Cortana migration hazard. Any MK2 file using Optional[X] or Union[A, B] will fail Nautilus’s pre-commit hook. The mechanical fix is from __future__ import annotations plus X | None everywhere. Codex handoff: do this in one bulk pass during the migration; don’t mix syntaxes within a module.

Reference - docstrings (NumPy style, imperative)

NumPy docstring spec is used throughout. Imperative mood:

def make_qty(self, value: float) -> Quantity:
    """
    Return a Quantity for the instrument with size_precision applied.
 
    Parameters
    ----------
    value : float
        The raw quantity value to round.
 
    Returns
    -------
    Quantity
        Rounded to the instrument's size_increment.
 
    Raises
    ------
    ValueError
        If value is negative or exceeds the precision capacity.
    """

“Python docstrings should be written in the imperative mood - e.g. ‘Return a cached client.‘”

Public class/method/module docstrings are part of the auto-generated docs. Private methods (prefixed _) must not carry docstrings:

“Docstrings on private methods incorrectly imply they are part of the public API.”

Two narrow exceptions where private docstrings are acceptable per the dev guide:

  • Very complex methods with non-trivial logic, multiple steps, or important edge cases.
  • Methods requiring detailed parameter or return value documentation due to complexity.

For everything else, a single inline # comment near the relevant logic is preferred over a _method docstring.

Reference - properties vs methods (PyO3 boundary)

This is the rule that controls whether a Rust field shows up in Python as obj.value or obj.value(). The dev guide is explicit (verbatim):

“When exposing Rust types to Python via PyO3, use #[getter] (property) or a plain method based on what the call site communicates, not whether the value can change.”

When to use a property (#[getter])

Cheap, side-effect-free, attribute-like view of current state. Scalar fields, predicates, lightweight derived values.

Examples from the dev guide: status, side, quantity, price, is_open, has_inputs, realized_pnl, venue_order_id.

order.status         # property - cheap read
order.is_open        # property - cheap predicate
position.quantity    # property - scalar field view

When to use a method (no #[getter])

Actions, mutations, nontrivial work, allocations/copies, I/O, or anything that takes arguments.

Examples from the dev guide: apply(fill), unrealized_pnl(price), calculate_pnl(...).

position.apply(fill)               # mutation
position.unrealized_pnl(price)     # takes argument
account.calculate_pnl(...)         # takes argument + computation

Gray area - getters that allocate

“Gray area (prefer method): getters that clone or allocate a collection each call. Using a method signals the cost to the caller.”

Examples: events(), adjustments(), client_order_ids(), trade_ids(). These return a fresh Vec<T> each call - the parens are the cost signal.

Cortana implication. When reading from a Position or Order in a hot loop (e.g., on_quote_tick running at 100Hz), prefer scalar properties. If you need a list of fills, call position.events() once and cache locally - don’t call it on every tick.

Reference - test naming

Descriptive names that explain the scenario; no test_foo_1, test_foo_2. The dev guide examples:

def test_currency_with_negative_precision_raises_overflow_error(self):
def test_sma_with_no_inputs_returns_zero_count(self):
def test_sma_with_single_input_returns_expected_value(self):

The pattern: test_<subject>_<condition>_<expected_outcome>. Test names are part of CI output; they read as English specifications.

For Cortana: test_scoring_actor_with_stale_uw_alert_drops_event, test_meta_gate_with_prob_below_threshold_blocks_order, etc.

Reference - Ruff (linter)

ruff is the canonical Python linter. Rules live in the top-level pyproject.toml with ignore justifications commented in-line. Pre- commit runs ruff check and ruff format; CI fails on any unjustified ignore.

For the Cortana spike: install ruff in the venv (uv pip install ruff) and run before any commit - Nautilus’s CI uses the same rules.

Reference - Cython (legacy)

The .pyx / .pxd path is legacy. New extensions go through PyO3 (Rust). For any remaining Cython:

“For .pyx and .pxd files, make sure all functions and methods returning void or a primitive C type (such as bint, int, double) include the except * keyword in the signature. Without it, Python exceptions are silently ignored.”

cdef int my_function(int x) except *:    # required
    if x < 0:
        raise ValueError("must be non-negative")
    return x * 2

Cortana implication. None of MK3’s user-replaceable surface is Cython. If you find yourself reading a .pyx file, you’re inside the v1 legacy runtime - note the path you’re on (TradingNode is v1 Cython; LiveNode is v2 Rust+PyO3 - see nautilus-developer-guide.md) and do not author new code there.

Reference - what the page does NOT cover (cross-references)

The Python page is intentionally narrow - style, types, docstrings, PyO3 boundary, Cython legacy. The full Python authoring contract spans several pages:

  • concepts/strategies/nautilus-strategies.md - Strategy authoring, StrategyConfig rules, lifecycle, sizing-via-RiskEngine pattern.
  • concepts/actors/nautilus-actors.md - Actor authoring, ActorConfig rules, what an Actor cannot do (no order placement, no on_save/on_load).
  • concepts/custom_data/nautilus-custom-data.md - @customdataclass decorator, DataRegistry, JSON/Arrow envelope.
  • concepts/value_types/nautilus-value-types.md - Price, Quantity, Money, Decimal widening rules.
  • concepts/message_bus/nautilus-message-bus.md - three publishing styles, immutability contract.
  • developer_guide/index/nautilus-developer-guide.md - adapter layered structure, FFI memory contract.
  • developer_guide/coding_standards/ (parallel batch) - full cross-language standards.
  • developer_guide/design_principles/ (parallel batch) - broader architectural principles.

This page is the Python-specific authoring contract; the others are the surface-area-specific recipes.

Cortana MK3 implications - idiomatic patterns by component

Cortana MK3 writes a lot of Python: one Strategy, ~5–7 Actors, ~5 custom-data classes, one custom RiskEngine rule, one custom DataClient. The dev guide rules apply uniformly; this section is the per-component checklist.

Custom data class (UWFlowAlert, ScoreUpdate, MetaProb,

EmaDecayValue, RegimeChange)

Canonical pattern. Use the @customdataclass decorator with typed fields and PEP 604 union syntax. The decorator auto-generates to_dict/from_dict/to_bytes/from_bytes/schema and registers the class with the DataRegistry.

from nautilus_trader.model.custom import customdataclass
from nautilus_trader.core import Data
from nautilus_trader.model import InstrumentId
 
 
@customdataclass
class UWFlowAlert(Data):
    """A single Unusual Whales flow alert.
 
    Subscribed by ScoringActor; published by UWLiveDataClient.
    """
 
    instrument_id: InstrumentId = InstrumentId.from_str("SPY.ARCA")
    underlying: str = "SPY"
    side: str = ""               # "CALL" / "PUT"
    strike: float = 0.0
    expiry: str = ""
    aggressor: str = ""          # "BUY" / "SELL" / "MIXED"
    premium_usd: float = 0.0
    size_contracts: int = 0
    is_sweep: bool = False
    is_block: bool = False
    flow_score: float = 0.0
    underlying_price: float = 0.0
    raw_id: str = ""

Rules baked in.

  • Every field is type-annotated (mandatory per dev guide).
  • Defaults are explicit so from_dict can reconstruct partial payloads.
  • No Optional[float] - if a field can be missing, default to 0.0, "", or False. PEP 604 float | None would work but the decorator’s Arrow encoder is happier with non-null defaults.
  • Class lives in a shared module (cortana/mk3/data/uw_types.py) imported by every producer and every consumer (the live DataClient, the scoring Actor, the strategy, the backtest harness, the brain logger). If two modules each declare their own UWFlowAlert, the DataRegistry registers two distinct type_name values and the bus topic mismatch silently drops every message (per nautilus-custom-data.md).
  • Docstring is on the public class - fine. Private helper methods on the class would not get docstrings per the dev guide rule.

MK2 pattern that breaks on Nautilus. MK2 used dataclass (stdlib) with Optional[float] fields and __post_init__ validation. Three issues: (1) Optional[float] is rejected by ruff; (2) stdlib dataclass doesn’t auto-register with DataRegistry, so the bus and catalog can’t route the type; (3) __post_init__ can mutate fields, violating Nautilus’s “messages are immutable post-publish” rule. Fix: @customdataclass instead of @dataclass; defaults instead of Optional; validation moves to the publisher (DataClient or Actor) before construction, not after.

Strategy (CortanaStrategy)

Canonical pattern. Subclass Strategy, paired StrategyConfig subclass with frozen=True, type-annotated fields, lifecycle hooks in on_start/on_stop. No order placement in __init__ (clock/log/ msgbus aren’t wired yet).

from decimal import Decimal
from nautilus_trader.config import StrategyConfig
from nautilus_trader.model import InstrumentId, BarType
from nautilus_trader.trading.strategy import Strategy
 
 
class CortanaStrategyConfig(StrategyConfig, frozen=True):
    instrument_id: InstrumentId
    bar_type: BarType
    trade_size: Decimal
    score_threshold: int = 65
    meta_prob_threshold: float = 0.55
    order_id_tag: str = "CORTANA-001"
 
 
class CortanaStrategy(Strategy):
    """Submits bracket orders on score+meta-prob confluence."""
 
    def __init__(self, config: CortanaStrategyConfig) -> None:
        super().__init__(config)
        # Local state ONLY - no clock/log/msgbus access here
        self._last_score: float | None = None
        self._last_meta_prob: float | None = None
 
    def on_start(self) -> None:
        # Subscriptions, indicators, timers - all here
        self.subscribe_data(ScoreUpdate)
        self.subscribe_data(MetaProb)
 
    def on_stop(self) -> None:
        # Cancel timers, flush state
        pass
 
    def on_data(self, data: Data) -> None:
        if isinstance(data, ScoreUpdate):
            self._last_score = data.composite_score
        elif isinstance(data, MetaProb):
            self._last_meta_prob = data.meta_prob
        self._maybe_submit()
 
    def _maybe_submit(self) -> None:
        # Private - no docstring per dev guide
        if self._last_score is None or self._last_meta_prob is None:
            return
        if self._last_score < self.config.score_threshold:
            return
        if self._last_meta_prob < self.config.meta_prob_threshold:
            return
        self._submit_bracket()

MK2 patterns that break.

  • MK2 instantiates clock/log in __init__. Fix: move all subscriptions and clock/log access to on_start. The Kernel wires these after registration; the constructor runs before.
  • MK2 stores config values as instance attributes with mutable defaults (e.g., self.threshold = 65). Fix: read through self.config.threshold so distributed-backtest serialization round- trips correctly. StrategyConfig(frozen=True) makes this enforceable.
  • MK2 calls time.time() and datetime.now() directly. Fix: self.clock.timestamp_ns() everywhere. time.time() in handlers breaks backtest determinism - the kernel’s data-driven clock is ignored.

Actor (ScoringActor, MetaGateActor, etc.)

Canonical pattern. Same shape as Strategy but without order placement. Subclass Actor; paired ActorConfig; subscriptions in on_start; on_data discriminates custom-data subclasses by isinstance.

from nautilus_trader.common.actor import Actor
from nautilus_trader.config import ActorConfig
from nautilus_trader.core import Data
from nautilus_trader.model import InstrumentId, BarType
 
 
class ScoringActorConfig(ActorConfig):
    instrument_id: InstrumentId
    bar_type: BarType
    flow_decay_half_life_seconds: float = 30.0
 
 
class ScoringActor(Actor):
    def __init__(self, config: ScoringActorConfig) -> None:
        super().__init__(config)
        self._flow_pressure: float = 0.0
 
    def on_start(self) -> None:
        self.subscribe_data(UWFlowAlert)
        self.subscribe_bars(self.config.bar_type)
 
    def on_data(self, data: Data) -> None:
        if isinstance(data, UWFlowAlert):
            self._on_flow(data)

MK2 pattern that breaks. MK2 has one engine.py module with a fat Engine class that does scoring, position management, and order placement. Fix: decompose into one Strategy (orders) + N Actors (scoring, regime, decay). Actors cannot call submit_order - the method doesn’t exist on the base class. If you find yourself wanting to call it from an Actor, the component is actually a Strategy.

Custom DataClient (UW WebSocket adapter)

Canonical pattern. Subclass LiveMarketDataClient; bytes-in → Data-subclass-out. The DataClient owns network I/O; the Actor owns derived computation.

from nautilus_trader.live.data_client import LiveMarketDataClient
 
 
class UWLiveDataClient(LiveMarketDataClient):
    async def _on_message(self, frame: dict) -> None:
        # frame is the parsed UW WebSocket payload
        alert = UWFlowAlert(
            instrument_id=InstrumentId.from_str("SPY.ARCA"),
            side=frame["side"],
            strike=float(frame["strike"]),
            premium_usd=float(frame["premium"]),
            aggressor=frame["aggressor"],
            flow_score=float(frame["score"]),
            ts_event=int(frame["ts_ms"]) * 1_000_000,  # ms → ns
            ts_init=self._clock.timestamp_ns(),
        )
        # Routes through DataEngine → Cache → MessageBus
        self._handle_data(alert)

Async/await rules. The DataClient is the one place in user code where async def is the norm - the underlying network loop is async. But the data-handler call (self._handle_data(alert)) is sync - it crosses into the kernel dispatch, which is single-threaded and synchronous. Strategy/Actor handlers are always sync (on_bar, on_data, on_quote_tick); never declare them async def.

MK2 pattern that breaks. MK2’s UW client is a synchronous polling loop in a thread. Nautilus expects an asyncio task that the runtime manages. Fix: LiveMarketDataClient.connect() returns a coroutine; the runtime spawns and supervises it. Don’t manage your own thread.

Custom RiskEngine rule (meta-prob gate, max-position)

Canonical pattern. Subclass the framework’s risk-rule base, read from self.cache.custom_data(MetaProb, ...) to gate orders pre-trade. Per nautilus-strategies.md § “Sizing multiplier”, enforcement lives in a RiskEngine rule, not in the strategy - so dead-code regressions (GH 88-class) are impossible by construction.

class MetaProbGateRule:
    """Reject orders when meta-prob is below threshold."""
 
    def __init__(self, threshold: float = 0.55) -> None:
        self._threshold = threshold
 
    def check(self, order, cache) -> RiskCheckResult:
        meta_prob = cache.custom_data(
            MetaProb, instrument_id=order.instrument_id,
        )
        if meta_prob is None or meta_prob.meta_prob < self._threshold:
            return RiskCheckResult.reject("meta-prob below threshold")
        return RiskCheckResult.pass_()

Decimal vs float. Meta-prob is a probability (0.0–1.0) - float is fine. Sizing math that produces a contract count must round through instrument.make_qty(...) per nautilus-value-types.md; never use int(qty) or qty // 1.

BrainLoggerActor

Canonical pattern. Subscribes to subscribe_order_fills and subscribe_order_cancels; writes markdown to ~/brain/writing/ on on_order_filled. No bus publication - output is a side-effect file write.

class BrainLoggerActor(Actor):
    def on_start(self) -> None:
        self.subscribe_order_fills(self.config.instrument_id)
 
    def on_order_filled(self, event) -> None:
        # File-write side-effect - fine in an Actor
        path = f"{self.config.brain_path}/trades/{event.ts_event}.md"
        with open(path, "w") as f:
            f.write(self._format_trade(event))

Heavy-work rule. File I/O on every fill is fine at Cortana volumes (double-digit fills/day). At higher volume, queue writes and flush on a timer or push to a separate process consuming the bus over Redis (per nautilus-howto-write-actor.md § “Common pitfalls”).

Cortana MK3 implications - async/await rules summary

SurfaceSync or asyncNotes
Strategy.on_* handlersSyncKernel dispatch is single-threaded sync
Actor.on_* handlersSyncSame as Strategy
LiveDataClient._on_message etc.AsyncNetwork loop is async
LiveDataClient._handle_dataSync callCrosses into kernel dispatch
Custom RiskEngine rule checkSyncPre-trade synchronous gate
Timers (clock.set_timer)Callback is syncDon’t declare async def callbacks
on_save / on_loadSyncPersistence path

If you need to do async work from inside a sync handler (e.g., HTTP call from on_bar), spawn a task on the kernel runtime - never block the dispatcher. For Cortana, the right answer is almost always “don’t do async work from a handler; queue the work and let an async-aware Actor consume the queue.”

Cortana MK3 implications - error handling

The dev guide doesn’t enumerate Python error-handling rules explicitly, but the framework’s behavior is consistent across the concept pages:

  • Construction errors (Price, Quantity, Money, InstrumentId, identifier types) raise ValueError at the boundary. The MK2 NaN-into-sizing bug class is closed by construction (nautilus-value-types.md).
  • Risk rejections flow as OrderDenied events, not exceptions.
  • Adapter errors propagate as OrderRejected / OrderDenied events on the bus; the strategy reads them via on_order_rejected.
  • Inside handlers, raising any exception puts the component in FAULTED state - the kernel disposes it. Raise only when the only correct response is “stop trading”; otherwise log + recover.
  • Logging. Use self.log.info(...) / self.log.error(...) / self.log.debug(...) - not print(), not logging.getLogger(...). The framework’s logger is structured, queue-backed, and routes to the configured sink (file, stdout, remote).
def on_data(self, data: Data) -> None:
    if isinstance(data, ScoreUpdate):
        try:
            self._handle_score(data)
        except (ValueError, KeyError) as e:
            # Log + recover; don't let one bad event kill the actor
            self.log.error(f"score handler failed: {e}")
            return

Cortana MK3 implications - logging from Python

self.log.debug("verbose debug")
self.log.info("normal flow event")
self.log.warning("recoverable anomaly")
self.log.error("operation failed; recovering")
self.log.exception("unexpected; printing stack")

Three rules:

  1. Don’t use print(). It bypasses the structured logger and doesn’t show up in remote sinks.
  2. Don’t capture log output for assertions. Per nautilus-developer-guide.md testing section: “Don’t capture log output to assert on log messages - verify observable behavior instead.” For Cortana, this means don’t assert on log strings; assert on emitted bus events or cache state.
  3. No private-method docstrings, but inline # comments are fine. Pair a tricky log line with a one-line # comment if the why isn’t obvious from the message text.

Cortana MK3 implications - migration hazards from MK2

A consolidated list of patterns in MK2 that silently misbehave on Nautilus, with the fix:

MK2 patternWhy it breaksFix
from typing import Optional; def f() -> Optional[X]Pre-commit ruff rejectsdef f() -> X | None
@dataclass for bus eventsNo DataRegistry registration; bus topic miss@customdataclass
__post_init__ validation that mutatesViolates “messages are immutable post-publish”Validate at the publisher before construction
time.time() / datetime.now() in handlersBreaks backtest determinismself.clock.timestamp_ns()
print() statementsBypasses structured loggerself.log.info(...)
Float prices in comparisons (price == 100.0)Float driftPrice value-type with precision-aware equality
qty // 3 integer-division on contractsTruncates silentlyQuantity / N → Decimal; round via instrument.make_qty
NaN mark-price into sizingNaN propagates through float mathPrice/Money constructors raise on non-finite input
Subscriptions in __init__Clock/log/msgbus not wired yetMove to on_start
submit_order from a non-StrategyMethod doesn’t exist on ActorRefactor: that component IS a Strategy
Sync polling thread for UWBypasses kernel runtime supervisionasync def in LiveDataClient
Truthiness check for nullity (if score:)0.0 is a valid scoreif score is None: then separate logic
Optional[float] field on a custom-data classDecorator’s Arrow encoder unhappyDefault to 0.0; if truly nullable, document and test round-trip
Stdlib logging.getLogger(__name__)Bypasses framework loggerself.log (already wired by Actor/Strategy)
Mutating a published event downstreamViolates immutabilityConstruct a new local event with derived fields
Capturing log output in testsFragile - global logger, non-deterministic test orderAssert on observable behavior (bus events, cache state)

When this concept applies

Every Python file authored for MK3 - Strategy, Actor, custom-data class, RiskEngine rule, DataClient, ConfigClass, test. The dev guide rules are uniform across all of them.

When this concept does NOT apply

  • Rust crates under crates/. Those follow the Rust dev guide (separate page). New adapter cores are Rust-first per the developer guide; only the wiring layer is Python.
  • Cython .pyx/.pxd files in the legacy v1 runtime. New code doesn’t go there. If you find yourself reading one, you’re on the v1 path - switch to the v2 LiveNode route.
  • External Python tooling (CI scripts, ad-hoc analysis notebooks) that doesn’t import Nautilus. The dev guide rules are conventions for the framework codebase; external scripts can be more relaxed but Cortana benefits from holding the same line for consistency.

See Also


Timeline

  • 2026-05-07 | Cody - Filed during pre-spike concept mastery sweep batch 7 (developer guide).