How-To - Write a Strategy

Recipe-shaped reference for authoring a Nautilus Strategy. Pairs the official Rust how-to (HTTP 200 at /docs/latest/how_to/write_rust_strategy/) with the conceptual canon in nautilus-strategies.md. The parallel Python how-to (/docs/latest/how_to/write_python_strategy/) is HTTP 404 as of 2026-05-07 - there is no separate Python how-to page; the Python authoring guidance lives inside concepts/strategies/ (already filed as nautilus-strategies.md). This page therefore folds in the Rust how-to verbatim, derives the Python equivalent by analogy from nautilus-strategies.md, and grounds Cortana’s spike Step 5 implementation on a CortanaStrategy skeleton that submits a market entry plus an emulated bracket exit (emulation_trigger=TriggerType.MARK_PRICE) per feedback_dual_tp_defense_in_depth.md. Sizing is not in the Strategy

  • it lives in a custom RiskEngine rule (per nautilus-strategies.md “Sizing multiplier” and nautilus-execution.md Q5) so dead-code regressions like GH #88 are impossible by construction.

Python how-to status (Spike Plan Step 0)

HTTP HEAD /docs/latest/how_to/write_python_strategy/  →  404
HTTP HEAD /docs/latest/how_to/write_rust_strategy/    →  200

There is no parallel Python how-to as of the 2026-05-07 docs snapshot. The Python authoring path is documented inside the concept page (concepts/strategies/) which we have filed as nautilus-strategies.md. The Rust how-to is the only “how-to” shaped page; we lift its structure and map every step to its Python equivalent below. This is the official answer to Spike Plan Step 0 - Python guidance is in the concept page, not a how-to.

TL;DR - minimum viable Strategy (Python)

from decimal import Decimal
from nautilus_trader.config import StrategyConfig
from nautilus_trader.model.enums import OrderSide, TimeInForce, TriggerType
from nautilus_trader.model.identifiers import InstrumentId
from nautilus_trader.trading.strategy import Strategy
 
 
class MyStrategyConfig(StrategyConfig, frozen=True):
    instrument_id: InstrumentId
    trade_size: Decimal
    order_id_tag: str = "001"
 
 
class MyStrategy(Strategy):
    def __init__(self, config: MyStrategyConfig) -> None:
        super().__init__(config)
        self.instrument_id = config.instrument_id
        self.trade_size = config.trade_size
 
    def on_start(self) -> None:
        self.subscribe_quote_ticks(self.instrument_id)
 
    def on_quote_tick(self, tick) -> None:
        instrument = self.cache.instrument(self.instrument_id)
        if instrument is None:
            return
        order = self.order_factory.market(
            instrument_id=self.instrument_id,
            order_side=OrderSide.BUY,
            quantity=instrument.make_qty(self.trade_size),
            time_in_force=TimeInForce.DAY,
        )
        self.submit_order(order)

That is the entire shape. Everything below is detail.

Step 1 - Define the Strategy class

A Strategy extends Actor and adds order management. From the Rust how-to (verbatim): “A strategy extends an actor with order management. This guide walks through building a minimal strategy that subscribes to quotes and submits market orders.”

The Rust struct holds a StrategyCore field (which itself wraps DataActorCore and adds an OrderFactory, OrderManager, and portfolio integration). The Python equivalent: subclass Strategy directly - the core is provided by the framework when you call super().__init__(config).

// Rust
pub struct MyStrategy {
    core: StrategyCore,
    instrument_id: InstrumentId,
    trade_size: Quantity,
}
# Python
class MyStrategy(Strategy):
    def __init__(self, config: MyStrategyConfig) -> None:
        super().__init__(config)
        self.instrument_id = config.instrument_id
        self.trade_size = config.trade_size

Constructor rule (verbatim from concepts/strategies/): “Do not call components such as clock and logger in the __init__ constructor (which is prior to registration).” Subscriptions, indicator registration, clock/log access - all go in on_start.

Step 2 - Define the Config class

Both Rust and Python require a paired config class. Rust uses StrategyConfig { strategy_id, order_id_tag, .. }. Python defines a StrategyConfig subclass:

class MyStrategyConfig(StrategyConfig, frozen=True):
    instrument_id: InstrumentId
    bar_type: BarType | None = None
    trade_size: Decimal
    order_id_tag: str = "001"
    score_threshold: int = 65

Quoted from concepts/strategies/: “Configurations serialize over the wire, enabling distributed backtesting and remote live trading.” This serialization property is what makes the Cody-as-customer-#1 multi-tenant framing tractable - each tenant ships JSON-encoded config.

Order-ID-tag rule (verbatim from the Rust how-to): StrategyConfig takes a strategy_id and an order_id_tag. The tag is appended to all client order IDs from this strategy, preventing collisions when multiple strategies trade the same instrument.” For Cortana multi-tenant deploy, order_id_tag = tenant_id resolves the multi-instance problem cleanly. Duplicate strategy IDs raise RuntimeError at registration.

Step 3 - Wire the core (Rust only)

Rust requires the nautilus_strategy! macro plus a manual Debug impl. Verbatim from the Rust how-to: “The nautilus_strategy! macro generates Deref<Target = DataActorCore>, DerefMut, and the Strategy trait impl (the core() / core_mut() accessors). By default it delegates to a field named core; pass a second argument for a different field name. Debug is a trait bound on DataActor, so implement it manually or derive it.”

nautilus_strategy!(MyStrategy);
 
impl std::fmt::Debug for MyStrategy {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("MyStrategy").finish()
    }
}

Python has no equivalent step - Strategy.__init__ does the wiring under the hood.

Step 4 - Implement lifecycle hooks

The full lifecycle hook set (from nautilus-strategies.md):

def on_start(self) -> None              # Subscribe, register indicators
def on_stop(self) -> None               # Cancel orders, close positions
def on_resume(self) -> None             # Resume from paused/degraded
def on_reset(self) -> None              # Reset between backtest runs
def on_degrade(self) -> None            # Adapter degraded
def on_fault(self) -> None              # Unrecoverable; engine FAULTED
def on_dispose(self) -> None            # Final cleanup
def on_save(self) -> dict[str, bytes]   # Persist user state across restart
def on_load(self, state: dict[str, bytes]) -> None

on_start rules (compiled from the docs):

  1. Subscribe to data feeds (subscribe_quote_ticks, subscribe_bars, subscribe_data for custom data, subscribe_signal for primitives).
  2. Register indicators with register_indicator_for_*.
  3. Optionally request_* for historical hydration → flows to on_historical_data.
  4. Set clock.set_time_alert / clock.set_timer for scheduled events.
  5. Fetch instruments via self.cache.instrument(...) if the adapter has already loaded them.

on_stop rules:

  1. Cancel any local timers (self.clock.cancel_timer(name)).
  2. Optional cancel_all_orders() and close_all_positions() - but market_exit() is the supported sequenced helper; prefer it.
  3. Unsubscribe from custom data subscriptions if you opened any in on_start that the framework doesn’t auto-unsubscribe.

on_save returns a dict[str, bytes] payload that the kernel writes to the Cache database (Redis-backed when configured). On restart on_load receives the same payload and restores state. Cortana state to persist: cooldown timers, per-trigger debounce buffers, meta-prob smoothing buffer. State NOT to persist (owned by the platform): open orders, open positions, account balances.

Step 5 - Implement data handlers

Handlers map 1:1 to subscription/request pairs (full table in nautilus-actors.md and nautilus-strategies.md):

def on_quote_tick(self, tick: QuoteTick) -> None:    ...
def on_trade_tick(self, tick: TradeTick) -> None:    ...
def on_bar(self, bar: Bar) -> None:                  ...
def on_order_book_deltas(self, deltas) -> None:      ...
def on_option_greeks(self, greeks) -> None:          ...
def on_option_chain(self, chain) -> None:            ...
def on_data(self, data: Data) -> None                # Custom data
def on_signal(self, signal) -> None                  # Primitive notifications
def on_historical_data(self, data: Data) -> None     # request_*() results

Cache-then-publish invariant (from nautilus-concepts.md): inside on_quote_tick, self.cache.quote_tick(instrument_id) returns the very tick that triggered the handler - no race. This is what makes Cortana’s 2026-05-06 stale-mark-price class of bug structurally impossible in Nautilus.

Rust how-to verbatim: “Data handling works the same as in an actor. Subscribe in on_start, respond in handlers.” The Python equivalent is identical in shape.

// Rust example from the how-to
impl DataActor for MyStrategy {
    fn on_start(&mut self) -> anyhow::Result<()> {
        self.subscribe_quotes(self.instrument_id, None, None);
        Ok(())
    }
    fn on_quote(&mut self, quote: &QuoteTick) -> anyhow::Result<()> {
        let order = self.core.order_factory().market(
            self.instrument_id,
            OrderSide::Buy,
            self.trade_size,
            None, None, None, None, None, None, None,
        );
        self.submit_order(order, None, None)?;
        Ok(())
    }
}

The Python equivalent (without the Result<()> plumbing) is the TL;DR example above.

Step 6 - Implement event handlers

Order/position events follow specific-then-generic dispatch. A Strategy can implement any subset.

# Order events (specific → generic)
def on_order_initialized(self, event): ...
def on_order_denied(self, event):       ...   # RiskEngine pre-trade reject
def on_order_emulated(self, event):     ...   # Held by OrderEmulator
def on_order_released(self, event):     ...   # Released from emulator
def on_order_submitted(self, event):    ...
def on_order_rejected(self, event):     ...   # Venue-side reject
def on_order_accepted(self, event):     ...
def on_order_canceled(self, event):     ...
def on_order_expired(self, event):      ...
def on_order_triggered(self, event):    ...   # Venue-side STOP fire
def on_order_pending_update(self, event): ...
def on_order_pending_cancel(self, event): ...
def on_order_modify_rejected(self, event): ...
def on_order_cancel_rejected(self, event): ...
def on_order_updated(self, event):      ...
def on_order_filled(self, event):       ...   # CRITICAL: drives exit logic
def on_order_event(self, event):        ...   # Generic order sink
def on_event(self, event):              ...   # Generic event sink
 
# Position events
def on_position_opened(self, event):    ...
def on_position_changed(self, event):   ...
def on_position_closed(self, event):    ...
def on_position_event(self, event):     ...   # Generic position sink

Rust how-to overriding pattern - pass the override block to the macro:

nautilus_strategy!(MyStrategy, {
    fn on_order_rejected(&mut self, event: OrderRejected) {
        log::warn!("Order rejected: {}", event.reason);
    }
});

In Python, simply define the method on the subclass.

Step 7 - Use OrderFactory to construct orders

Every order construction flows through self.order_factory. The kernel auto-fills trader_id, strategy_id, and client_order_id.

Available factory methods (full list)

self.order_factory.market(...)
self.order_factory.limit(...)
self.order_factory.stop_market(...)
self.order_factory.stop_limit(...)
self.order_factory.market_to_limit(...)
self.order_factory.market_if_touched(...)
self.order_factory.limit_if_touched(...)
self.order_factory.trailing_stop_market(...)
self.order_factory.trailing_stop_limit(...)
self.order_factory.bracket(...)             # entry + TP + SL atomic

(The Rust how-to lists 7 factory methods on self.core.order_factory(): market, limit, stop_market, stop_limit, market_if_touched, limit_if_touched, trailing_stop_market. The Python factory adds market_to_limit, trailing_stop_limit, and the load-bearing bracket(...) per nautilus-strategies.md.)

Market entry pattern

order = self.order_factory.market(
    instrument_id=self.instrument_id,
    order_side=OrderSide.BUY,
    quantity=instrument.make_qty(self.config.trade_size),
    time_in_force=TimeInForce.DAY,   # 0DTE: never GTC
)
self.submit_order(order)

Limit entry pattern

order = self.order_factory.limit(
    instrument_id=self.instrument_id,
    order_side=OrderSide.BUY,
    quantity=instrument.make_qty(qty),
    price=instrument.make_price(limit_px),
    time_in_force=TimeInForce.DAY,
    post_only=False,
)
self.submit_order(order)

Bracket pattern (the Cortana-load-bearing one)

bracket = self.order_factory.bracket(
    instrument_id=self.instrument_id,
    order_side=OrderSide.BUY,
    quantity=instrument.make_qty(contracts),
    entry_order_type=OrderType.MARKET,
    tp_price=instrument.make_price(entry_px * Decimal("1.10")),
    sl_trigger_price=instrument.make_price(entry_px * Decimal("0.75")),
    emulation_trigger=TriggerType.MARK_PRICE,   # SW-fallback DiD
    time_in_force=TimeInForce.DAY,
    contingency_type=ContingencyType.OCO,
)
self.submit_order_list(bracket)

The submit_order_list call (note plural) is required for the bracket because it’s an atomic group: the parent + TP + SL ship together so the engine can wire the OTO+OCO contingency.

emulation_trigger - the load-bearing parameter

This is the parameter that makes Cortana’s dual-TP defense-in-depth (feedback_dual_tp_defense_in_depth.md) work in MK3 with one line.

emulation_trigger valueMeaning
omitted / NO_TRIGGERVenue handles trigger logic. No SW fallback.
BID_ASK (a.k.a. DEFAULT)Local emulation against best bid/ask.
LAST_PRICELocal emulation against last trade.
MARK_PRICECortana’s choice - local emulation against venue mark.
MID_POINTLocal emulation against bid-ask mid.
INDEX_PRICELocal emulation against underlying index.
DOUBLE_BID_ASK / DOUBLE_LAST / LAST_OR_BID_ASKSpecialized variants.

Verbatim from nautilus-orders.md: “The platform makes it possible to emulate most order types locally, regardless of whether the type is supported on a trading venue.” And: NO_TRIGGER: disables local emulation completely and order is fully submitted to the venue.”

Why MARK_PRICE for Cortana 0DTE: the option chain delivery already includes mark via OptionGreeks, so emulation is essentially free (emulator subscribes opportunistically; we already have the feed). Last-trade can be stale or one-sided in thin contracts.

Common parameters across factory methods

  • time_in_force: GTC | IOC | FOK | GTD | DAY. Cortana 0DTE must use DAY. A runaway GTC after 16:00 ET is a P0 invariant violation.
  • post_only=True: refuse taking liquidity on a LIMIT (refused if it would cross at submission). Not relevant to Cortana V1.
  • reduce_only=True: order can only reduce an existing position; cannot flip direction. Mandatory on every Cortana SL leg.
  • emulation_trigger: see above. Set on bracket builder propagates to child legs.
  • exec_algorithm_id + exec_algorithm_params: hand off to a custom ExecAlgorithm (e.g., ExecAlgorithmId("TWAP")). Not relevant to Cortana V1.
  • display_qty: iceberg display. None = full display; 0 = hidden where supported. Not relevant.
  • trigger_type / trigger_offset_type for trailing stops: PRICE, BPS, TICKS, PRICE_TIER. Cortana V1 does not use trailing stops per feedback_no_hwm_trailing_language.md.

Submitting

self.submit_order(order)              # single
self.submit_order_list(order_list)    # atomic group (brackets)

Routing rule, verbatim from nautilus-strategies.md: “If an emulation_trigger is specified, the command will firstly be sent to the OrderEmulator. If an exec_algorithm_id is specified (with no emulation_trigger), the command will firstly be sent to the relevant ExecAlgorithm. Otherwise, the command will firstly be sent to the RiskEngine.” In all paths the RiskEngine ultimately validates before the venue.

Step 8 - Modify and cancel

self.modify_order(order, new_quantity=...)
self.modify_order(order, new_price=...)
self.modify_order(order, new_trigger_price=...)
self.cancel_order(order)
self.cancel_orders([o1, o2])
self.cancel_all_orders()                  # Optional instrument/side filters

Emulated-order gotcha (verbatim from nautilus-orders.md): “the order object transforms when the emulated order is released.”

# WRONG - reference goes stale on release
self._sl = self.order_factory.stop_market(..., emulation_trigger=...)
self.submit_order(self._sl)
# ...later, after OrderReleased fires:
print(self._sl.trigger_price)   # may not exist!
 
# RIGHT - query through cache
order = self.order_factory.stop_market(..., emulation_trigger=...)
self._sl_id = order.client_order_id
self.submit_order(order)
# ...later:
order = self.cache.order(self._sl_id)
if order is not None and not order.is_closed:
    self.modify_order(order, new_trigger_price=instrument.make_price(new_sl))

Step 9 - Close positions

self.close_position(position)
self.close_all_positions()

These submit market orders sized to the position’s current qty with reduce_only=True. Per the Rust how-to’s order management table, close_position and close_all_positions are part of the Strategy trait and available on self.

Step 10 - market_exit() - graceful shutdown

The supported EOD/halt path (per nautilus-strategies.md):

self.market_exit()
 
def on_market_exit(self) -> None:    # Hook at start of exit
    self.log.info("Market exit beginning")
 
def post_market_exit(self) -> None:  # Hook once flat (or max attempts)
    self.log.info("Market exit complete")
 
def is_exiting(self) -> bool:        # Predicate to prevent re-entry
    ...

Behavior (verbatim from nautilus-strategies.md):

  1. Cancels all open and in-flight orders.
  2. Closes all open positions with reduce-only market orders.
  3. Periodically re-checks (market_exit_interval_ms / market_exit_max_attempts).
  4. Calls post_market_exit once flat or after max attempts.

Critical guarantee: “During a market exit, non-reduce-only orders are automatically denied.” This is what makes EOD-flat safe - no in-flight on_data can sneak a fresh entry past the cancel sweep. Aligns with feedback_no_kill_with_open_positions.md.

StrategyConfig knobs:

StrategyConfig(
    manage_stop=True,              # Auto-call market_exit on stop()
    market_exit_interval_ms=100,
    market_exit_max_attempts=100,
    market_exit_time_in_force=None,   # Defaults to GTC
    market_exit_reduce_only=True,
)

Step 11 - Indicator integration

def on_start(self) -> None:
    self.fast_ema = ExponentialMovingAverage(period=10)
    self.register_indicator_for_bars(self.bar_type, self.fast_ema)
    self.register_indicator_for_quote_ticks(self.instrument_id, my_ind)
    self.register_indicator_for_trade_ticks(self.instrument_id, my_ind)
 
def on_bar(self, bar: Bar) -> None:
    if not self.fast_ema.initialized:
        return
    value = self.fast_ema.value
    ...

Once registered the engine drives the indicator on every matching update. The strategy reads indicator.value in handlers; no manual feed needed.

For hydration: request_bars(...) followed by subscribe_bars(...). History flows through on_historical_data, then the live feed through on_bar. The engine warms the indicator across both paths.

Step 12 - Configuration injection

The pattern (Python):

class CortanaConfig(StrategyConfig, frozen=True):
    instrument_id: InstrumentId
    bar_type: BarType
    score_threshold: int = 65
    meta_prob_threshold: float = 0.55
    trade_size: Decimal
    tp_pct: Decimal = Decimal("0.10")
    sl_pct: Decimal = Decimal("0.25")
    emulation_trigger: TriggerType = TriggerType.MARK_PRICE
    order_id_tag: str = "001"
 
 
class CortanaStrategy(Strategy):
    def __init__(self, config: CortanaConfig) -> None:
        super().__init__(config)
        # Configuration accessible as self.config.score_threshold, etc.

Quoted from concepts/strategies/: “A separate configuration class gives full flexibility over where and how a strategy is instantiated.” Configurations serialize over the wire - multi-tenant SaaS shipped from one process to another.


Cortana-applicable example: CortanaStrategy skeleton

This is the spike Step 5 implementation target. ONE Strategy, branching inside handlers for the 5 entry triggers, submitting market entry + emulated bracket exit. Sizing is not here - it lives in a custom RiskEngine rule (per nautilus-strategies.md, nautilus-execution.md Q5).

"""
CortanaStrategy - MK3 spike Step 5 target.
 
ONE Strategy class. Branches inside on_data for the 5 entry triggers.
Submits a market entry + emulated bracket (TP/SL) per entry.
 
Sizing/meta-prob gating: NOT here. Lives in a custom RiskEngine rule
or pre-submit Actor (see nautilus-execution.md Q5 resolution).
 
Position management: bracket OCO on exit + emulator local trigger watch
+ on_quote_tick fallback handle theta-bleed edge cases.
 
EOD flatten: clock.set_time_alert at 14:55 CT calls self.market_exit().
"""
from __future__ import annotations
 
from decimal import Decimal
from typing import Any
 
import pandas as pd
from nautilus_trader.config import StrategyConfig
from nautilus_trader.core.data import Data
from nautilus_trader.model.enums import (
    ContingencyType,
    OrderSide,
    OrderType,
    TimeInForce,
    TriggerType,
)
from nautilus_trader.model.events import (
    OrderFilled,
    OrderRejected,
    PositionClosed,
    PositionOpened,
    TimeEvent,
)
from nautilus_trader.model.identifiers import InstrumentId, ClientId
from nautilus_trader.trading.strategy import Strategy
 
 
# ---------------------------------------------------------------------------
# Custom data shapes (defined elsewhere; imported here)
# ---------------------------------------------------------------------------
# from cortana.events import ScoreUpdate, FlowAlert, ImpulseEvent
# Each is a Data subclass (or @customdataclass) with ts_event/ts_init.
 
 
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
class CortanaConfig(StrategyConfig, frozen=True):
    underlying_id: InstrumentId            # SPY equity for chain context
    base_trade_size: Decimal               # contracts; meta-prob scales in RiskEngine
    score_threshold: int = 65
    meta_prob_threshold: float = 0.55
    tp_pct: Decimal = Decimal("0.10")
    sl_pct: Decimal = Decimal("0.25")
    emulation_trigger: TriggerType = TriggerType.MARK_PRICE
    eod_flatten_time_ct: str = "14:55"     # 5 minutes before close
    order_id_tag: str = "001"
    cooldown_seconds: int = 60
 
 
# ---------------------------------------------------------------------------
# Strategy
# ---------------------------------------------------------------------------
class CortanaStrategy(Strategy):
    """One Strategy. 5 trigger branches in on_data. Bracket exits."""
 
    def __init__(self, config: CortanaConfig) -> None:
        super().__init__(config)
        self._last_entry_ns: int = 0
        self._open_position_id: Any = None
        self._open_bracket_ids: dict[str, Any] = {}    # {"entry","tp","sl"}
 
    # ------------------------------------------------------------------
    # Lifecycle
    # ------------------------------------------------------------------
    def on_start(self) -> None:
        # Subscribe to the score feed published by ScoringActor (Actor).
        self.subscribe_data(
            data_type=ScoreUpdate,
            client_id=ClientId("CORTANA-FEATURES"),
        )
        # Subscribe to the UW flow alerts published by UWFlowActor (Actor).
        self.subscribe_data(
            data_type=FlowAlert,
            client_id=ClientId("CORTANA-UW"),
        )
        # Subscribe to impulse events published by ImpulseActor (Actor).
        self.subscribe_data(
            data_type=ImpulseEvent,
            client_id=ClientId("CORTANA-IMPULSE"),
        )
        # Subscribe to quotes for the underlying (regime + power-hour gate).
        self.subscribe_quote_ticks(self.config.underlying_id)
 
        # Recurring "timer trigger" - every 30s, check for slow-build setups.
        self.clock.set_timer(
            "trigger_timer",
            interval=pd.Timedelta(seconds=30),
        )
        # EOD flatten alert (CT → UTC offset done by caller / config).
        self.clock.set_time_alert(
            "eod_flatten",
            alert_time=self._next_eod_flatten_ts(),
        )
 
    def on_stop(self) -> None:
        self.clock.cancel_timer("trigger_timer")
        # Don't manually flatten - engine.stop() with manage_stop=True
        # invokes market_exit() which is the supported sequenced path.
 
    # ------------------------------------------------------------------
    # Data dispatch - branch by custom data type
    # ------------------------------------------------------------------
    def on_data(self, data: Data) -> None:
        if isinstance(data, ScoreUpdate):
            self._handle_score_update(data)
        elif isinstance(data, FlowAlert):
            self._handle_flow_alert(data)
        elif isinstance(data, ImpulseEvent):
            self._handle_impulse_event(data)
 
    # ------------------------------------------------------------------
    # 5 entry triggers - branch dispatch
    # ------------------------------------------------------------------
    def _handle_score_update(self, score: "ScoreUpdate") -> None:
        # Triggers: repeated_hits, cumulative_flow
        if score.score < self.config.score_threshold:
            return
        if score.kind == "repeated_hits":
            self._attempt_entry(score.side, score.conviction, "repeated_hits")
        elif score.kind == "cumulative_flow":
            self._attempt_entry(score.side, score.conviction, "cumulative_flow")
 
    def _handle_flow_alert(self, alert: "FlowAlert") -> None:
        # Trigger: flow_alert (UW unusual flow)
        if alert.severity < 2:
            return
        self._attempt_entry(alert.side, alert.conviction, "flow_alert")
 
    def _handle_impulse_event(self, ev: "ImpulseEvent") -> None:
        # Trigger: impulse:strike_stack
        if not ev.strike_stack_aligned:
            return
        self._attempt_entry(ev.side, ev.conviction, "impulse:strike_stack")
 
    def on_event(self, event) -> None:
        # Trigger: timer (slow-build setup that didn't fire on a discrete event)
        if isinstance(event, TimeEvent):
            if event.name == "trigger_timer":
                self._attempt_timer_trigger()
            elif event.name == "eod_flatten":
                self.market_exit()
 
    def _attempt_timer_trigger(self) -> None:
        latest = self.cache.data(ScoreUpdate, count=1)
        if not latest:
            return
        score = latest[-1]
        if score.score >= self.config.score_threshold and score.aging_setup:
            self._attempt_entry(score.side, score.conviction, "timer")
 
    # ------------------------------------------------------------------
    # Shared entry path - gates, then bracket submission
    # ------------------------------------------------------------------
    def _attempt_entry(self, side: str, conviction: float, source: str) -> None:
        # Cooldown gate (re-entry debounce).
        now_ns = self.clock.timestamp_ns()
        if (now_ns - self._last_entry_ns) < self.config.cooldown_seconds * 1_000_000_000:
            self.log.debug(f"[{source}] cooldown active; skip")
            return
        # Existing-position gate (single position at a time).
        if self._open_position_id is not None:
            self.log.debug(f"[{source}] position open; skip")
            return
 
        # Pick the strike (delegated; ChainSelectorActor publishes the choice).
        chosen = self.cache.data(StrikeChoice, count=1)
        if not chosen:
            self.log.warning(f"[{source}] no strike choice in cache; skip")
            return
        strike = chosen[-1]
        instrument = self.cache.instrument(strike.instrument_id)
        if instrument is None:
            self.log.warning(f"[{source}] instrument {strike.instrument_id} not loaded")
            return
 
        order_side = OrderSide.BUY        # Always BUY premium for V1
        entry_px = instrument.last_quote_or_mark or strike.expected_entry_px
        tp_px = entry_px * (Decimal("1") + self.config.tp_pct)
        sl_px = entry_px * (Decimal("1") - self.config.sl_pct)
 
        bracket = self.order_factory.bracket(
            instrument_id=strike.instrument_id,
            order_side=order_side,
            quantity=instrument.make_qty(self.config.base_trade_size),
            entry_order_type=OrderType.MARKET,
            tp_price=instrument.make_price(tp_px),
            sl_trigger_price=instrument.make_price(sl_px),
            emulation_trigger=self.config.emulation_trigger,    # MARK_PRICE
            time_in_force=TimeInForce.DAY,
            contingency_type=ContingencyType.OCO,
        )
        self._open_bracket_ids = {
            "entry": bracket.first.client_order_id,
            "tp": bracket.orders[1].client_order_id,
            "sl": bracket.orders[2].client_order_id,
        }
        self.submit_order_list(bracket)
        self._last_entry_ns = now_ns
        self.log.info(
            f"[{source}] bracket submitted: "
            f"qty={self.config.base_trade_size} entry=MKT "
            f"tp={tp_px} sl={sl_px} emul={self.config.emulation_trigger.name}"
        )
 
    # ------------------------------------------------------------------
    # Order/position event handlers
    # ------------------------------------------------------------------
    def on_order_filled(self, event: OrderFilled) -> None:
        # Entry fill - record the position. TP/SL are wired by the bracket
        # contingency; nothing else to do here for V1.
        if event.client_order_id == self._open_bracket_ids.get("entry"):
            self.log.info(f"Entry filled: {event.last_qty} @ {event.last_px}")
 
    def on_order_rejected(self, event: OrderRejected) -> None:
        # Critical: surface broker rejects via Telegram (the trading channel).
        self.log.error(f"Order rejected: {event.client_order_id} - {event.reason}")
        # Per feedback_watchdog_to_telegram.md, this IS a trading event.
        # The audit Actor subscribed to events.* will publish to Telegram.
 
    def on_position_opened(self, event: PositionOpened) -> None:
        self._open_position_id = event.position_id
 
    def on_position_closed(self, event: PositionClosed) -> None:
        self._open_position_id = None
        self._open_bracket_ids = {}
        self.log.info(
            f"Position closed: realized_pnl={event.realized_pnl} "
            f"duration_ns={event.duration_ns}"
        )
 
    # ------------------------------------------------------------------
    # Helpers
    # ------------------------------------------------------------------
    def _next_eod_flatten_ts(self) -> pd.Timestamp:
        # Compute the next 14:55 CT in UTC, given the current self.clock.
        # (Implementation detail; helper keeps on_start tidy.)
        now = self.clock.utc_now()
        # 14:55 CT = 19:55 UTC during CDT, 20:55 UTC during CST. Caller's
        # config-file resolves the offset; this stub assumes CDT for clarity.
        return now.normalize() + pd.Timedelta(hours=19, minutes=55)

What this skeleton demonstrates:

  1. One Strategy, multiple Actors - ScoringActor, UWFlowActor, ImpulseActor, ChainSelectorActor all publish custom data; the Strategy subscribes and branches.
  2. Five trigger branches inside handlers - _handle_score_update (repeated_hits, cumulative_flow), _handle_flow_alert, _handle_impulse_event, timer in on_event. All converge on _attempt_entry.
  3. Market entry + emulated bracket in one call (order_factory.bracket(...) with emulation_trigger=TriggerType.MARK_PRICE). The OrderEmulator owns trigger logic locally; IBKR sees the bracket OCO; both run.
  4. No sizing logic in Strategy - base size from config, RiskEngine rule scales. GH #88 dead-code regression impossible by construction.
  5. No hand-rolled SW fallback - emulation_trigger=MARK_PRICE is the SW fallback. Delete position_manager.on_quote_tick from MK2.
  6. market_exit() via time alert at 14:55 CT - supported sequenced exit; reduce-only-during-exit guarantee handles the feedback_no_kill_with_open_positions.md invariant for free.
  7. Cache-then-publish queries - self.cache.data(ScoreUpdate, count=1) returns the latest score consistently; no race against the dispatch.

Cortana MK3 implications - port plan for spike Step 5

What goes inline (Strategy)

  • The 5 trigger branches.
  • The cooldown / single-position gate.
  • The bracket construction (entry/TP/SL prices, emulation_trigger).
  • The EOD-flatten alert wiring.
  • Order/position event handlers (logging, position bookkeeping).

What gets pushed to RiskEngine (custom rule or pre-submit Actor)

  • Meta-prob gating (veto when meta_prob < threshold).
  • Meta-prob sizing (scale quantity by meta-prob weight).
  • Per-day max-loss kill switch.
  • Per-trigger contradiction check (refuse a BULL bracket if a BEAR trigger fired in the last N seconds).

Per nautilus-execution.md Q5, the public extension API is unconfirmed - either we register a custom RiskRule (preferred), or we implement the gate as a pre-submit Actor (EntryIntent → EntryApproved/Denied) and the Strategy fires the bracket only on EntryApproved. Spike Saturday resolves this by reading crates/risk/src/engine.rs.

What gets pushed to other Actors

ConcernComponentPublishes
Composite scoringScoringActorScoreUpdate
UW flow ingestionUWFlowActor (spike) → LiveDataClient (prod)FlowAlert
Impulse / strike-stackImpulseActorImpulseEvent
Strike selectionChainSelectorActorStrikeChoice
Meta-model classifierMetaModelActorMetaProbUpdate (consumed by RiskEngine rule)
Regime detectionRegimeDetectorActorRegimeUpdate (e.g., POWER_HOUR)
Brain loggerBrainLoggerActor(consumes PositionClosed; writes to disk)
Audit loggerAuditActor(consumes events.*; writes Parquet + Telegram)

Dual-trigger pattern from nautilus-tutorial-delta-neutral-options.md

The delta-neutral options tutorial demonstrates a Strategy that subscribes to both the option chain AND the underlying quote stream, gating entries on a function of both. Cortana inherits this pattern verbatim:

  • Subscribe to OptionChainSlice (for chain-level features used by scoring) AND the underlying QuoteTick stream (for regime / mark-price context).
  • Decision logic in _attempt_entry reads both via self.cache.data(...) / self.cache.quote_tick(underlying_id).
  • Cache-then-publish ensures both reads are consistent at the dispatch moment.

What we delete from MK2

  • position_manager.on_quote_tick SW-fallback loop (emulation_trigger=MARK_PRICE replaces it).
  • Hand-rolled oca_group IBKR tagging (ContingencyType.OCO is the framework primitive).
  • Tracker-vs-state dual store (Cache.position(...) is the single source of truth).
  • Cancel-retry loop (OrderCancelRejected is a typed event; Strategy decides; no blind retry).
  • Manual reduce-only flag bookkeeping (set once on the SL leg; engine propagates).
  • Inline meta-prob sizing in scoring engine (moves to RiskEngine rule).

Spike Step 5 line-count target

The MK2 entry path (composite gate + 5 triggers + sizing + bracket construction + PM fallback) is ~600 LOC across position_manager.py, scoring_engine.py, and entry_decision.py. The MK3 skeleton above is ~180 LOC for the Strategy class. Composing actors out brings the total LOC roughly equal, but the order-touching surface area shrinks from ~600 to ~180 - a structural win for feedback_dual_tp_defense_in_depth.md audit and for GH 88-class dead-code prevention.


See Also

  • Nautilus Strategies - concept canon for Strategy; full lifecycle/handler surface; market_exit() semantics
  • Nautilus Actors - parallel concept canon; what ScoringActor / UWFlowActor / RegimeDetectorActor look like
  • Nautilus Rust - Rust authoring path (the official how-to is Rust-only)
  • Nautilus Orders - bracket primitive, emulation_trigger, OCO/OUO contingency, two-RiskEngine-checks pattern for emulated orders
  • Nautilus Execution - RiskEngine routing, meta-gate placement (Q5), reconciliation surface
  • Nautilus Events - 17 order events + 3 position events for handler authoring
  • Nautilus Portfolio - self.portfolio queries (read-only from Strategy)
  • nautilus-howto-write-actor.md - parallel how-to for Actor authoring (filed in same batch)
  • 2026-05-09 Nautilus Spike Plan: ~/conductor/workspaces/cortanaroi-mk2/belo-horizonte/plans/2026-05-09-nautilus-spike.md
    • Step 5 implementation reference
  • feedback_dual_tp_defense_in_depth.md - TP must have SW fallback (resolved by emulation_trigger=MARK_PRICE)
  • feedback_no_hwm_trailing_language.md - single-shot fixed TP, no trailing (excludes trailing_stop_* factory methods)
  • feedback_no_kill_with_open_positions.md - never kill engine while position open (resolved by market_exit() reduce-only-during-exit guarantee)
  • project_codex_review_p2s.md - GH #88 dead-code meta sizing context (resolved by RiskEngine-rule placement)
  • project_eod_power_hour.md - last 15-30min regime (drives 14:55 CT flatten + RegimeDetectorActor)
  • Source URL (Rust): https://nautilustrader.io/docs/latest/how_to/write_rust_strategy/
  • Source URL (Python): 404 - guidance lives at https://nautilustrader.io/docs/latest/concepts/strategies/

Timeline

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