Nautilus Events

Nautilus models every meaningful state change as an immutable Event object that flows through the MessageBus on a single deterministic dispatch thread. Orders, positions, accounts, and timer fires all emit named events with stable field shapes. Strategies and actors react via specific-to-generic handler dispatch (on_order_filledon_order_eventon_event). Custom events plug in via @customdataclass or Data subclasses, share the same bus, and inherit the framework’s ts_event/ts_init ordering guarantees. For Cortana MK3, this taxonomy is the direct replacement for MK2’s hand-rolled decisions.db SQLite audit trail: every row class either maps onto a built-in event (orders, fills, positions, accounts, timers) or becomes a custom event (scoring, gating, regime, premium-flow). Subscribing one logging Actor to events.* and writing Parquet replicates the MK2 audit surface for free, with replay determinism we never had.

Core claim

The MessageBus event taxonomy is the audit trail. There is no separate logging layer to reconcile against - if a state change happened in Nautilus, an Event crossed the bus, and any subscriber including a Parquet sink saw it in order. The work for MK3 is (1) enumerate which built-in events cover MK2’s audit rows, (2) define custom events for the gaps, (3) wire the persistence subscriber.

Built-in event taxonomy

Order lifecycle events (17 variants)

Every order state transition publishes a typed event. Listed in roughly the order they appear during a normal order’s life:

EventHandler methodMeaning
OrderInitializedon_order_initializedOrder constructed by OrderFactory; not yet sent anywhere
OrderDeniedon_order_deniedRiskEngine pre-trade check rejected the order; never reaches venue
OrderEmulatedon_order_emulatedOrder accepted by local OrderEmulator (venue does not natively support the type)
OrderReleasedon_order_releasedEmulator released the order to a real venue (LIMIT→MARKET, STOP_LIMIT→LIMIT transformation)
OrderSubmittedon_order_submittedExecutionClient sent the order to the venue
OrderAcceptedon_order_acceptedVenue acknowledged and resting (or working) on its book
OrderRejectedon_order_rejectedVenue rejected outright (bad params, account state, risk, etc.)
OrderTriggeredon_order_triggeredStop or conditional-order trigger price hit
OrderPendingUpdateon_order_pending_updateModify request in flight to venue
OrderPendingCancelon_order_pending_cancelCancel request in flight to venue
OrderUpdatedon_order_updatedVenue confirmed modification
OrderModifyRejectedon_order_modify_rejectedVenue refused the modification
OrderCancelRejectedon_order_cancel_rejectedVenue refused the cancel
OrderCanceledon_order_canceledCancel confirmed
OrderExpiredon_order_expiredTIF (GTD/DAY/IOC/FOK) elapsed without fill
OrderFilledon_order_filledPartial or complete fill report; carries last_qty, last_px, trade_id, commission

Order-event-shared fields: trader_id, strategy_id, instrument_id, client_order_id, venue_order_id, account_id, reconciliation (true if synthesized by reconciliation), event_id, ts_event, ts_init.

The reconciliation flag is load-bearing - it lets a logger distinguish “the venue genuinely did this just now” from “the LiveExecutionEngine synthesized this on startup to align cached state with broker truth.”

Position lifecycle events (3 variants)

EventHandlerWhen it fires
PositionOpenedon_position_openedFirst fill establishes a position (NETTING: per-instrument; HEDGING: per position_id)
PositionChangedon_position_changedSubsequent fill modifies qty / avg-price / realized PnL while position remains open
PositionClosedon_position_closedNet qty reaches zero; closing_order_id recorded, realized_pnl finalized, duration_ns populated

Field matrix (from Nautilus docs verbatim):

FieldOpenedChangedClosed
trader_id, strategy_id, instrument_id, position_id, account_id
opening_order_id
closing_order_id--
entry, side, signed_qty, quantity
peak_qty, avg_px_close, realized_return, realized_pnl, unrealized_pnl-
duration_ns--
ts_opened, ts_closed, ts_event, ts_init

Position events are derived. The chain: OrderFilled arrives → ExecutionEngine resolves position_id → either creates a new position (emits PositionOpened), updates existing (PositionChanged), or closes it (PositionClosed). For position flips (long→short or short→long in one fill), the engine splits the fill into a close-then-open pair so each event has clean semantics.

Account events (1 variant)

EventMeaning
AccountStateSnapshot of AccountBalance (total, locked, free) per currency, plus margin entries (per-instrument and account-wide) for margin accounts. Fires on venue update OR after a portfolio recalculation.

Critical adapter contract: AccountState events for margin accounts must carry complete margin snapshots. MarginAccount.apply() replaces both the per-instrument and account-wide stores from the incoming event - partial snapshots that omit live entries cause those entries to disappear silently until the next full snapshot. (See nautilus-concepts.md § Accounting.)

Time events

EventCreated byDelivered to
TimeEventClock.set_time_alert() (one-shot) or Clock.set_timer() (recurring)Custom callback if supplied at creation, otherwise on_event

Same Clock interface in backtest (data-stream-driven) and live (wall-clock-driven), so timer-driven logic is reproducible across replays.

Handler dispatch - specific to generic

Both order and position events follow the same dispatch ladder. Nautilus calls the most specific handler first; if you don’t override it, dispatch falls through to the broader handler.

OrderFilled  →  on_order_filled(event)
             →  on_order_event(event)        # any order event, generic
             →  on_event(event)              # any event at all
PositionOpened →  on_position_opened(event)
               →  on_position_event(event)    # any position event
               →  on_event(event)             # any event at all
TimeEvent → registered callback (if any)
          → on_event(event)

Implication for an audit logger: subscribe at the on_event layer (or equivalent bus topic wildcard) and you observe everything the framework emits. No per-handler bookkeeping.

MessageBus mechanics for events

Events are one of three message categories on the bus (Data, Events, Commands). Three publishing/consumption patterns are available (Point-to-Point, Publish/Subscribe, Request/Response); events use Pub/Sub.

Hard contracts (from nautilus-concepts.md § MessageBus):

  • Immutability. “Once a message is created, its fields must not be mutated. This includes container fields such as params maps.” A consumer cannot patch an event before the next consumer sees it.
  • Single-threaded dispatch. “The kernel consumes and dispatches messages on a single thread,” yielding deterministic ordering for the trading core. Background I/O runs on Tokio runtimes and reports back to the bus.
  • Cache-then-publish for data. Data updates write Cache then publish on the bus, so handlers can rely on cache.quote_tick(...) returning the very tick that triggered them. Live execution events apply asynchronously and may show a “brief delay” before Cache reflects them
    • a documented caveat.
  • Topics. Low-level pub/sub on string topics is available but the docs warn “you must track topic names manually (typos could result in missed messages).” Actor-based publishing via typed Data subclasses or @customdataclass is the safer entry point.

Actors can subscribe outside the lifecycle handlers, e.g.:

self.subscribe_order_fills(instrument_id)        # routed to on_order_filled
self.subscribe_order_cancels(instrument_id)      # routed to on_order_canceled

Custom events - defining new event types

Two supported paths for Cortana-style domain events (scoring, gating, regime classification, premium flow):

  1. Data subclass. Subclass nautilus_trader.core.data.Data and define typed fields plus ts_event / ts_init. Nautilus serializes it natively and routes it through the DataEngine cache-then-publish path.
  2. @customdataclass decorator. Annotate a Python dataclass; Nautilus generates the proper ts_event/ts_init fields and registers it for bus transport. Lighter weight than full Data subclassing.
  3. Signals (lightweight). For primitive notifications (int/float/str) without structure, actors expose signal-publish helpers - fastest, but strings only.

Custom events ride the same MessageBus, get the same Pub/Sub semantics, inherit the same ts_event/ts_init ordering, and (per-Cortana use) can also persist via the Parquet path because the persistence layer accepts Data subclasses with a PyArrow backend.

Registration: instantiate the actor that will publish, declare the type in on_start, and any subscriber actor calls subscribe_data(MyEvent, ...) to receive them. (Strategies subscribe via Strategy.subscribe_data similarly.)

Ordering and determinism guarantees

  • Single-threaded dispatch on the kernel core ⇒ event order is the order they were placed on the bus.
  • ts_event vs ts_init. ts_event = when the event “occurred externally (at the exchange)”; ts_init = “when Nautilus created the corresponding internal object.” Their delta is system latency. Replay drives time from ts_event, not wall clock.
  • Cache-then-publish for data means a handler’s snapshot reads of Cache match the data that triggered it. For execution events the Cache update is asynchronous in live; rely on the event payload itself rather than re-reading Cache mid-handler if you need exact-at-event state.
  • Replay (DST + Backtest). A single seed plus identical binary/config produces “bitwise-identical” observable behaviour in Deterministic Simulation Testing. Backtests over the same data produce identical event streams across runs.
  • Reconciliation events are flagged (reconciliation=True) so downstream consumers can distinguish synthesized-from-broker-truth events from venue-originated ones.

Persistence and replay

Two layers, both relevant for an audit-trail subscriber:

  • ParquetDataCatalog - bulk historical catalog. Rust backend for core types; PyArrow backend for Data-subclass custom types. Files named {start_timestamp}_{end_timestamp}.parquet, partitioned by data type and identifier. Supported on local FS, S3, GCS, Azure Blob. This is the natural sink for an “everything that happened” Parquet audit log.
  • Cache database (Redis). Live execution state - orders, positions, accounts, emulated-order working state, MessageBus stream. Crash-only recovery rebuilds from this on restart, then reconciles against venue. Not the audit trail; it is the current state.

For replay: drive a BacktestEngine from a Parquet catalog and the same strategies + actors will consume the same events in the same order they saw originally. This is how “the rerun produces the original decisions” becomes a property rather than an aspiration.

What is queryable vs what flows past

SourceShapeReverse-indexed?CapUse
Cache.orders(...)Live state of all orders, filterable by state/strategy/instrumentn/aunbounded (Redis-backed if configured)“What orders are open right now?”
Cache.positions(...)Live positions, filterable by venue/strategy/instrument/siden/aunbounded”What’s my exposure right now?”
Cache.account(...)Latest AccountState snapshotn/aone per venue”What’s my balance?”
Cache.bar(bar_type, index=N)Recent bars; index=0 = most recentyes10,000 per bar typeRecent market context
Cache.quote_tick(instrument_id, index=N)Recent quotesyes10,000 per instrumentRecent price action
MessageBus eventsStream-only; flow pastn/aretention controlled by external sink (Parquet/Redis)“What sequence of decisions was made?”

Cache answers point-in-time questions. The event stream answers sequence/causality questions. MK2 conflated these by writing both into one SQLite file; MK3 should keep them separate by design.

Cortana MK3 implications - MK2 audit-row → Nautilus event mapping

MK2 decisions.db row classes (compiled from project memory + scoring/PM code) and their MK3 counterparts:

MK2 audit row classNautilus equivalentType
Bar tick / quote tick observationQuoteTick, TradeTick, Bar (Data, not Event) - already in CacheBuilt-in Data
Score breakdown row (composite, V1, impulse, BULL/BEAR bias)Custom Event ScoreUpdate (custom dataclass)Custom
Sub-engine output (impulse engine, gex engine, regime engine)Custom Event EngineSignal per engine, or one EngineFan event with sub-fieldsCustom
Gate evaluation (entry gate accept/reject + reason)Custom Event GateDecision carrying the score, the gate vote, and the reject-reason enumCustom
Win-prob meta-classifier outputCustom Event WinProbEstimateCustom
Position sizing decision (contracts, notional, kelly fraction)Custom Event SizingDecisionCustom
Order submitted to IBKROrderInitialized + OrderSubmittedBuilt-in
Order accepted / working at venueOrderAcceptedBuilt-in
Order rejected by venueOrderRejectedBuilt-in
Order denied by risk pre-tradeOrderDeniedBuilt-in
Fill (partial or full)OrderFilledBuilt-in
Position openedPositionOpenedBuilt-in
Position state change (price-update telemetry, HWM, %-PnL)PositionChanged for fill-driven changes; Custom Event PositionTelemetry for tick-driven mark updates (no native event for “price moved” - it is data, not a position event)Mixed
TP/SL working order placedOrderInitialized + OrderSubmitted (+ OrderAccepted when venue acks)Built-in
TP/SL software-fallback exit (PM-initiated)OrderInitialized + OrderSubmitted for the new exit order; the trigger logic itself = Custom Event ExitDecision so you can audit why fallback firedMixed
Order canceled (by PM, by EOD flat, by user)OrderCanceledBuilt-in
Cancel rejected by venueOrderCancelRejectedBuilt-in
Modify rejectedOrderModifyRejectedBuilt-in
Order expired (GTD/DAY)OrderExpiredBuilt-in
Position closed (TP, SL, manual, EOD)PositionClosed (with closing_order_id); Custom Event ExitReason to record taxonomy (TP / SL / time-in-trade / EOD-flat / manual) since the built-in event doesn’t carry thatMixed
Account balance refreshAccountStateBuilt-in
Cooldown engaged / cooldown expiredCustom Event CooldownTransitionCustom
Regime classification flip (chop ↔ trend ↔ power-hour)Custom Event RegimeChangeCustom
EOD close-all-positions triggerCustom Event MarketExit (Nautilus has a market_exit() helper but not an audit event for the trigger)Custom
Watchdog AI-meta event (engine restart, degraded state)Built-in lifecycle (on_degrade / on_fault produce component state transitions); for finer telemetry, Custom Event EngineHealthMixed
Replay-tag / scenario marker (manual annotation)Custom Event ReplayMarkerCustom

MK2 row classes with no native Nautilus counterpart (must be custom events):

  1. ScoreUpdate - composite scoring engine output
  2. EngineSignal - per-sub-engine output
  3. GateDecision - entry-gate accept/reject + reason taxonomy
  4. WinProbEstimate - meta-classifier output
  5. SizingDecision - contracts / notional / kelly-fraction calc
  6. PositionTelemetry - tick-driven mark updates between fills
  7. ExitDecision - software-fallback / PM-initiated exit trigger logic
  8. ExitReason - taxonomy that explains why a PositionClosed happened
  9. CooldownTransition - cooldown engaged / expired
  10. RegimeChange - chop ↔ trend ↔ power-hour reclassification
  11. MarketExit - EOD close-all-positions trigger
  12. EngineHealth - beyond Nautilus’s built-in component state machine
  13. ReplayMarker - manual annotation for replay sets

Everything order/position/account/fill side maps cleanly onto built-ins. The custom-event surface is exactly where Cortana’s trading IP lives - which is a positive sign: the framework absorbs all the boilerplate event plumbing and Cortana only needs to define and persist the events that encode its strategy logic.

Audit-logger Actor sketch

class AuditLogger(Actor):
    def on_start(self):
        self.subscribe_data(...)                # custom Cortana events
        # Built-in event handlers below capture everything else
 
    def on_event(self, event):
        # Catches OrderEvent + PositionEvent + AccountState + TimeEvent
        # AND any custom event whose handler we did not override.
        self._sink.write(event)                 # Parquet append

One actor, one sink, every event captured in causal order. The MK2 SQLite table count collapses to one Parquet file partitioned by event type and date.

Replay determinism - what MK2 gives up vs MK3 gains

MK2 decisions.db is a one-way audit trail: you can read it but you cannot re-run the engine and produce identical rows because the engine’s state was a tangle of in-memory dicts, file caches, and clock reads.

MK3 on Nautilus inherits replay-by-construction:

  • Strategies use Clock, never time.time().
  • All inputs (data + custom events) flow through the bus.
  • A single seed pins random choices.
  • Deterministic Simulation Testing extends this to async scheduling on the Rust core.

Loss-cluster postmortems (cf. project_losses_april16_chop) become reproducible: load the Parquet catalog for that date, re-run, observe the identical event sequence, and now experiments on counterfactual gating / sizing changes are valid because the baseline run is bit-identical.

Caveats and gotchas

  • Reconciliation events look real. On startup the LiveExecutionEngine may synthesize order/fill/position events to align cached state with broker truth. Check event.reconciliation before treating an event as fresh broker action - otherwise you’ll double-count or alert spuriously.
  • AccountState partial snapshots overwrite. Margin entries omitted from an AccountState event disappear from the account until the next full snapshot. Adapter authors must always emit complete margin snapshots; consumers should not assume partials are merge-style.
  • Emulated orders transform on release. Hold object references at your peril - query the Cache by client_order_id because the order shape changes when the emulator releases it. Events for emulated orders include OrderEmulated (intake) and OrderReleased (release).
  • Cache update lag for execution events in live. “you might see a brief delay between an event and its appearance in the Cache.” Use the event payload directly; don’t re-read Cache mid-handler if you need exact-at-event state.
  • PositionChanged is fill-driven, not price-driven. It does not fire on quote ticks. Custom telemetry events (e.g. PositionTelemetry) cover the price-walk between fills.
  • Position flips emit two events (close-then-open). Audit consumers must treat them as a pair, not as a single transition.

When this concept applies

  • Designing the MK3 audit-trail subscriber.
  • Defining new domain events for scoring / gating / regime / cooldown.
  • Wiring replay sets for postmortems.
  • Reasoning about backtest-vs-live event-stream parity.
  • Auditing whether an alert path corresponds to a real broker event vs. a reconciliation synthesis.

When it breaks / does not apply

  • Pure data observations (quotes, ticks, bars) are Data, not Event. Different lifecycle, different cache rules. Don’t treat them as audit rows - they’re inputs.
  • Component state transitions (STARTING/RUNNING/DEGRADED/etc.) are not events on the bus by default; they’re FSM transitions on the Component. If you need them in the audit trail, emit a custom event.
  • Logging messages are not events. The logging subsystem has its own MPSC channel and rotation/format policy. Don’t conflate.

See Also


Timeline

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