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_filled→on_order_event→on_event). Custom events plug in via@customdataclassorDatasubclasses, share the same bus, and inherit the framework’sts_event/ts_initordering guarantees. For Cortana MK3, this taxonomy is the direct replacement for MK2’s hand-rolleddecisions.dbSQLite 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 toevents.*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:
| Event | Handler method | Meaning |
|---|---|---|
OrderInitialized | on_order_initialized | Order constructed by OrderFactory; not yet sent anywhere |
OrderDenied | on_order_denied | RiskEngine pre-trade check rejected the order; never reaches venue |
OrderEmulated | on_order_emulated | Order accepted by local OrderEmulator (venue does not natively support the type) |
OrderReleased | on_order_released | Emulator released the order to a real venue (LIMIT→MARKET, STOP_LIMIT→LIMIT transformation) |
OrderSubmitted | on_order_submitted | ExecutionClient sent the order to the venue |
OrderAccepted | on_order_accepted | Venue acknowledged and resting (or working) on its book |
OrderRejected | on_order_rejected | Venue rejected outright (bad params, account state, risk, etc.) |
OrderTriggered | on_order_triggered | Stop or conditional-order trigger price hit |
OrderPendingUpdate | on_order_pending_update | Modify request in flight to venue |
OrderPendingCancel | on_order_pending_cancel | Cancel request in flight to venue |
OrderUpdated | on_order_updated | Venue confirmed modification |
OrderModifyRejected | on_order_modify_rejected | Venue refused the modification |
OrderCancelRejected | on_order_cancel_rejected | Venue refused the cancel |
OrderCanceled | on_order_canceled | Cancel confirmed |
OrderExpired | on_order_expired | TIF (GTD/DAY/IOC/FOK) elapsed without fill |
OrderFilled | on_order_filled | Partial 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)
| Event | Handler | When it fires |
|---|---|---|
PositionOpened | on_position_opened | First fill establishes a position (NETTING: per-instrument; HEDGING: per position_id) |
PositionChanged | on_position_changed | Subsequent fill modifies qty / avg-price / realized PnL while position remains open |
PositionClosed | on_position_closed | Net qty reaches zero; closing_order_id recorded, realized_pnl finalized, duration_ns populated |
Field matrix (from Nautilus docs verbatim):
| Field | Opened | Changed | Closed |
|---|---|---|---|
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)
| Event | Meaning |
|---|---|
AccountState | Snapshot 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
| Event | Created by | Delivered to |
|---|---|---|
TimeEvent | Clock.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
paramsmaps.” 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
Datasubclasses or@customdataclassis 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_canceledCustom events - defining new event types
Two supported paths for Cortana-style domain events (scoring, gating, regime classification, premium flow):
Datasubclass. Subclassnautilus_trader.core.data.Dataand define typed fields plusts_event/ts_init. Nautilus serializes it natively and routes it through the DataEngine cache-then-publish path.@customdataclassdecorator. Annotate a Python dataclass; Nautilus generates the properts_event/ts_initfields and registers it for bus transport. Lighter weight than fullDatasubclassing.- 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_eventvsts_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 fromts_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 forData-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
| Source | Shape | Reverse-indexed? | Cap | Use |
|---|---|---|---|---|
Cache.orders(...) | Live state of all orders, filterable by state/strategy/instrument | n/a | unbounded (Redis-backed if configured) | “What orders are open right now?” |
Cache.positions(...) | Live positions, filterable by venue/strategy/instrument/side | n/a | unbounded | ”What’s my exposure right now?” |
Cache.account(...) | Latest AccountState snapshot | n/a | one per venue | ”What’s my balance?” |
Cache.bar(bar_type, index=N) | Recent bars; index=0 = most recent | yes | 10,000 per bar type | Recent market context |
Cache.quote_tick(instrument_id, index=N) | Recent quotes | yes | 10,000 per instrument | Recent price action |
| MessageBus events | Stream-only; flow past | n/a | retention 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 class | Nautilus equivalent | Type |
|---|---|---|
| Bar tick / quote tick observation | QuoteTick, TradeTick, Bar (Data, not Event) - already in Cache | Built-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-fields | Custom |
| Gate evaluation (entry gate accept/reject + reason) | Custom Event GateDecision carrying the score, the gate vote, and the reject-reason enum | Custom |
| Win-prob meta-classifier output | Custom Event WinProbEstimate | Custom |
| Position sizing decision (contracts, notional, kelly fraction) | Custom Event SizingDecision | Custom |
| Order submitted to IBKR | OrderInitialized + OrderSubmitted | Built-in |
| Order accepted / working at venue | OrderAccepted | Built-in |
| Order rejected by venue | OrderRejected | Built-in |
| Order denied by risk pre-trade | OrderDenied | Built-in |
| Fill (partial or full) | OrderFilled | Built-in |
| Position opened | PositionOpened | Built-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 placed | OrderInitialized + 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 fired | Mixed |
| Order canceled (by PM, by EOD flat, by user) | OrderCanceled | Built-in |
| Cancel rejected by venue | OrderCancelRejected | Built-in |
| Modify rejected | OrderModifyRejected | Built-in |
| Order expired (GTD/DAY) | OrderExpired | Built-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 that | Mixed |
| Account balance refresh | AccountState | Built-in |
| Cooldown engaged / cooldown expired | Custom Event CooldownTransition | Custom |
| Regime classification flip (chop ↔ trend ↔ power-hour) | Custom Event RegimeChange | Custom |
| EOD close-all-positions trigger | Custom 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 EngineHealth | Mixed |
| Replay-tag / scenario marker (manual annotation) | Custom Event ReplayMarker | Custom |
MK2 row classes with no native Nautilus counterpart (must be custom events):
ScoreUpdate- composite scoring engine outputEngineSignal- per-sub-engine outputGateDecision- entry-gate accept/reject + reason taxonomyWinProbEstimate- meta-classifier outputSizingDecision- contracts / notional / kelly-fraction calcPositionTelemetry- tick-driven mark updates between fillsExitDecision- software-fallback / PM-initiated exit trigger logicExitReason- taxonomy that explains why aPositionClosedhappenedCooldownTransition- cooldown engaged / expiredRegimeChange- chop ↔ trend ↔ power-hour reclassificationMarketExit- EOD close-all-positions triggerEngineHealth- beyond Nautilus’s built-in component state machineReplayMarker- 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 appendOne 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, nevertime.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
LiveExecutionEnginemay synthesize order/fill/position events to align cached state with broker truth. Checkevent.reconciliationbefore treating an event as fresh broker action - otherwise you’ll double-count or alert spuriously. AccountStatepartial snapshots overwrite. Margin entries omitted from anAccountStateevent 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_idbecause the order shape changes when the emulator releases it. Events for emulated orders includeOrderEmulated(intake) andOrderReleased(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.
PositionChangedis 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, notEvent. 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
- Nautilus Concepts - MessageBus, Cache, ExecutionEngine, persistence.
- Nautilus Developer Guide - custom adapter and strategy patterns, including custom data publishing.
- Brain RESOLVER - page filing rules.
Timeline
- 2026-05-07 | Cody - Filed during pre-spike concept mastery sweep.