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 innautilus-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 insideconcepts/strategies/(already filed asnautilus-strategies.md). This page therefore folds in the Rust how-to verbatim, derives the Python equivalent by analogy fromnautilus-strategies.md, and grounds Cortana’s spike Step 5 implementation on aCortanaStrategyskeleton that submits a market entry plus an emulated bracket exit (emulation_trigger=TriggerType.MARK_PRICE) perfeedback_dual_tp_defense_in_depth.md. Sizing is not in the Strategy
- it lives in a custom
RiskEnginerule (pernautilus-strategies.md“Sizing multiplier” andnautilus-execution.mdQ5) 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_sizeConstructor 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 = 65Quoted 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]) -> Noneon_start rules (compiled from the docs):
- Subscribe to data feeds (
subscribe_quote_ticks,subscribe_bars,subscribe_datafor custom data,subscribe_signalfor primitives). - Register indicators with
register_indicator_for_*. - Optionally
request_*for historical hydration → flows toon_historical_data. - Set
clock.set_time_alert/clock.set_timerfor scheduled events. - Fetch instruments via
self.cache.instrument(...)if the adapter has already loaded them.
on_stop rules:
- Cancel any local timers (
self.clock.cancel_timer(name)). - Optional
cancel_all_orders()andclose_all_positions()- butmarket_exit()is the supported sequenced helper; prefer it. - Unsubscribe from custom data subscriptions if you opened any in
on_startthat 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_*() resultsCache-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 sinkRust 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 value | Meaning |
|---|---|
omitted / NO_TRIGGER | Venue handles trigger logic. No SW fallback. |
BID_ASK (a.k.a. DEFAULT) | Local emulation against best bid/ask. |
LAST_PRICE | Local emulation against last trade. |
MARK_PRICE | Cortana’s choice - local emulation against venue mark. |
MID_POINT | Local emulation against bid-ask mid. |
INDEX_PRICE | Local emulation against underlying index. |
DOUBLE_BID_ASK / DOUBLE_LAST / LAST_OR_BID_ASK | Specialized 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 useDAY. 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 customExecAlgorithm(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_typefor trailing stops:PRICE,BPS,TICKS,PRICE_TIER. Cortana V1 does not use trailing stops perfeedback_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 filtersEmulated-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):
- Cancels all open and in-flight orders.
- Closes all open positions with reduce-only market orders.
- Periodically re-checks (
market_exit_interval_ms/market_exit_max_attempts). - Calls
post_market_exitonce 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:
- One Strategy, multiple Actors -
ScoringActor,UWFlowActor,ImpulseActor,ChainSelectorActorall publish custom data; the Strategy subscribes and branches. - Five trigger branches inside handlers -
_handle_score_update(repeated_hits, cumulative_flow),_handle_flow_alert,_handle_impulse_event, timer inon_event. All converge on_attempt_entry. - Market entry + emulated bracket in one call (
order_factory.bracket(...)withemulation_trigger=TriggerType.MARK_PRICE). The OrderEmulator owns trigger logic locally; IBKR sees the bracket OCO; both run. - No sizing logic in Strategy - base size from config, RiskEngine rule scales. GH #88 dead-code regression impossible by construction.
- No hand-rolled SW fallback -
emulation_trigger=MARK_PRICEis the SW fallback. Deleteposition_manager.on_quote_tickfrom MK2. market_exit()via time alert at 14:55 CT - supported sequenced exit; reduce-only-during-exit guarantee handles thefeedback_no_kill_with_open_positions.mdinvariant for free.- 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
quantityby 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
| Concern | Component | Publishes |
|---|---|---|
| Composite scoring | ScoringActor | ScoreUpdate |
| UW flow ingestion | UWFlowActor (spike) → LiveDataClient (prod) | FlowAlert |
| Impulse / strike-stack | ImpulseActor | ImpulseEvent |
| Strike selection | ChainSelectorActor | StrikeChoice |
| Meta-model classifier | MetaModelActor | MetaProbUpdate (consumed by RiskEngine rule) |
| Regime detection | RegimeDetectorActor | RegimeUpdate (e.g., POWER_HOUR) |
| Brain logger | BrainLoggerActor | (consumes PositionClosed; writes to disk) |
| Audit logger | AuditActor | (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 underlyingQuoteTickstream (for regime / mark-price context). - Decision logic in
_attempt_entryreads both viaself.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_tickSW-fallback loop (emulation_trigger=MARK_PRICEreplaces it).- Hand-rolled
oca_groupIBKR tagging (ContingencyType.OCOis the framework primitive). - Tracker-vs-state dual store (
Cache.position(...)is the single source of truth). - Cancel-retry loop (
OrderCancelRejectedis 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.portfolioqueries (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 byemulation_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 bymarket_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).