Nautilus Positions

A Nautilus Position is the engine-owned, single-source-of-truth record of exposure for an instrument. It is created from an OrderFilled event, updated by subsequent fills, and closed when net quantity returns to zero - emitting PositionOpened / PositionChanged / PositionClosed along the way (see nautilus-events.md). Strategies and Actors do not maintain their own position tables; they query self.cache.position(...) and self.cache.positions_open(...). The kernel computes realized PnL from fills, accumulates commissions, snapshots closed positions on flip, and reconciles cached state against broker truth on startup. For Cortana MK3 this is the direct replacement for MK2’s position_state table plus the hand-rolled position_tracker. The entire bug class catalogued in exit-path-failure-modes.md - alert-without-action, status-without-truth, EXIT_PENDING leaks, tracker-vs-broker drift, orphan contamination - either disappears or is reduced to a thin operator-decision surface because there is no longer a second store to drift from.

Core claim

Position is the only authoritative record of exposure inside the running engine. The Cache holds it, the ExecutionEngine mutates it, the Portfolio queries it, and reconciliation realigns it to broker truth on startup. MK2’s multi-store design (position_state table + in-memory position_tracker + broker updatePortfolio callback all racing each other) collapses to one store with a deterministic mutation path. The remaining MK2 audit-trail value (STATE_TRANSITION log lines, decision rows) becomes a custom event subscriber on on_position_event rather than a parallel state machine.

Position object - full field reference

Verbatim from the docs (/concepts/positions/), grouped by purpose:

Identifiers

FieldTypeMeaning
idPositionIdUnique position identifier (per instrument under NETTING; per open under HEDGING)
instrument_idInstrumentIdTraded instrument
account_idAccountIdOwning account
trader_idTraderIdOwning trader (kernel-assigned)
strategy_idStrategyIdOwning strategy (auto-filled from OrderFactory)
opening_order_idClientOrderIdThe order that opened the position
closing_order_idClientOrderId | NoneThe order that closed it (set on PositionClosed only)
symbolSymbolConvenience accessor
venueVenueConvenience accessor

State

FieldTypeMeaning
sidePositionSideCurrent direction: LONG / SHORT / FLAT
entryOrderSideOpening direction (the side that opened the current run; updates on flip)
quantityQuantityAbsolute size
signed_qtyfloat (or Decimal)Signed magnitude - positive for LONG, negative for SHORT
peak_qtyQuantityMaximum absolute exposure ever reached during this position’s life
is_open / is_closedboolAggregate status
is_long / is_shortboolDirection predicates

Pricing and valuation

FieldTypeMeaning
avg_px_openfloatQuantity-weighted average entry price
avg_px_closefloat | NoneQuantity-weighted average exit price (None until any close fills)
realized_pnlMoneyRealized profit/loss from closed portions, in settlement currency
realized_returnfloatReturn as decimal (0.05 == 5%)
quote_currency, base_currency, settlement_currencyCurrencyCurrency taxonomy (settlement is the one PnL is denominated in)
multiplierDecimalContract multiplier (e.g., 100 for SPY options)
price_precision, size_precisionintTick rounding
is_inverseboolInverse-contract flag (BTC-perp style); changes PnL formula

Timestamps

FieldTypeMeaning
ts_initint (ns since epoch)When Nautilus created the Position object
ts_openedint (ns)When the first fill landed
ts_lastint (ns)Most recent update (any fill or adjustment)
ts_closedint | NoneWhen net qty returned to zero (None while open)
duration_nsint | Nonets_closed - ts_opened (None while open)

Associated history

FieldTypeMeaning
client_order_idslist[ClientOrderId]Every order that touched this position
venue_order_idslist[VenueOrderId]Same, broker-side
trade_idslist[TradeId]Every distinct trade execution applied
eventslist[OrderFilled | PositionAdjusted]Chronological event log applied to this position
event_countintConvenience count
last_event, last_trade_idvariousMost recent activity

The events list is load-bearing - it is the local audit trail for this position’s evolution and is the input to commission and PnL recomputation if events are ever replayed. MK2’s multi-source reconstruction (decisions.db + position_state row history + IBKR execDetails) collapses into this one list.

Lifecycle states and events

Three lifecycle moments, each emitted as a typed event on the MessageBus (see nautilus-events.md for full event field matrix):

                       OrderFilled (first fill)
                                │
                                ▼
                       ┌─────────────────┐
                       │  PositionOpened │
                       └────────┬────────┘
                                │
              additional fills  │
              (same direction or
               partial reductions)
                                ▼
                       ┌─────────────────┐
              ┌───────►│ PositionChanged │
              │        └────────┬────────┘
              │                 │
              │   additional    │
              └───── fills ─────┘
                                │
                                ▼
                       ┌─────────────────┐
                       │ PositionClosed  │  (net qty == 0)
                       └─────────────────┘

Key behaviors:

  • Opened. First fill creates a Position. The OMS type (NETTING vs HEDGING; see below) controls whether subsequent same-instrument fills create a new position or aggregate into the existing one.
  • Changed. Subsequent fills that do not net-to-zero update quantity / avg_px_open / avg_px_close / realized_pnl / peak_qty / signed_qty and emit PositionChanged.
  • Closed. Net quantity reaches zero. closing_order_id is recorded, ts_closed and duration_ns are populated, final realized_pnl is finalized.
  • Position flip. A single fill that crosses zero (e.g. SHORT→LONG) is split by the engine into close-then-open. You see two events (PositionClosed then PositionOpened) for one fill. Auditors must treat them as a pair.
  • Snapshotting (NETTING only). Per the docs, “when a NETTING position closes and then receives a new fill for the same instrument, the execution engine snapshots the closed position state before resetting it,” preserving final quantities, prices, realized PnL, fills, and commissions for historical accuracy. The Position object is reused; the snapshot lives in the cache as a read-only artifact.

The lifecycle is engine-owned. Strategies cannot manually transition a Position. They can only submit orders that cause fills, which the engine then applies. This is the structural change versus MK2.

Position events - verbatim from nautilus-events.md field matrix

FieldOpenedChangedClosed
trader_id, strategy_id, instrument_id, position_id, account_idyesyesyes
opening_order_idyesyesyes
closing_order_id--yes
entry, side, signed_qty, quantityyesyesyes
peak_qty, avg_px_close, realized_return, realized_pnl, unrealized_pnl-yesyes
duration_ns--yes
ts_opened, ts_closed, ts_event, ts_inityesyesyes

Position events are derived from OrderFilled events. The chain: OrderFilled arrives → ExecutionEngine resolves position_id (creating one under HEDGING, looking up under NETTING) → either creates a new position (emits PositionOpened), updates existing (PositionChanged), or closes it (PositionClosed).

Critical absence: there is no PositionMarkChanged or PositionPriceUpdated event. PositionChanged is fill-driven, not price-driven. Quote-tick movement that updates unrealized_pnl does not emit a position event - Cortana’s tick-driven mark telemetry must be a custom event (see nautilus-events.md § “PositionTelemetry”), not a built-in.

Net vs hedging position model - IBKR specifically

Two OMS (Order Management System) types control how positions aggregate. The strategy declares one and the venue has its own; the four-quadrant matrix is from the Nautilus docs verbatim:

Strategy OMSVenue OMSBehavior
NETTINGNETTINGSingle position per instrument both sides
HEDGINGHEDGINGMultiple independent positions, each with its own position_id
NETTINGHEDGINGVenue tracks multiple; Nautilus maintains single (aggregates internally)
HEDGINGNETTINGVenue tracks single; Nautilus maintains virtual sub-positions

IBKR specifically

IBKR is a NETTING venue at the broker level. Per-account, IBKR holds one net position per contract - you cannot simultaneously hold +100 and -50 SPY 0DTE 727C; IBKR will net them to +50. This matches MK2’s actual lived experience: every reconciliation against updatePortfolio returns one row per contract, never two.

Cortana MK3 should declare NETTING at the strategy level too. Reasons:

  1. Symmetry with broker truth means reconciliation is one-to-one. No virtual sub-positions to map.
  2. SPY 0DTE strategy is single-direction-per-contract by design (we don’t long-and-short the same strike simultaneously). HEDGING semantics buy nothing.
  3. Position flips are vanishingly rare in 0DTE (a flip would mean closing the call and opening a new contract with a different position_id). When they happen, NETTING’s snapshot-on-close-then-reopen is exactly the audit trail we want.

A future intra-day strategy that did hold long SPY calls AND long SPY puts (straddle) is still NETTING-compatible because the calls and puts are distinct instrument_ids. NETTING’s “single position per instrument” rule already supports this without HEDGING.

Long-only / short-only / mixed exposure

Cortana is structurally long-options-only - we buy calls or buy puts; we do not short-sell options. So at the position level Nautilus only ever sees LONG positions (side == LONG, signed_qty > 0). The SHORT and FLAT machinery is unused.

Implications:

  • is_net_long(instrument_id) is always True for any open Cortana position.
  • is_net_short(...) is always False.
  • is_flat(instrument_id) flips True when the position closes - this is the predicate Cortana’s EOD-flatten logic should check, not a custom flag.
  • The Portfolio’s “context-aware pricing” (long-only → bid for conservative liquidation, short-only → ask, mixed → mid; see nautilus-concepts.md § Portfolio) reduces to “always use bid” for Cortana valuation, which is appropriate - we exit by hitting bid.

P&L computation - semantics

Realized PnL (standard contracts, including SPY options)

realized_pnl = (exit_price - entry_price) * closed_quantity * multiplier

For SPY 0DTE options: multiplier = 100. A +10% TP at avg 1.75 exit, on 100 contracts:

realized_pnl = (1.75 - 1.59) * 100 * 100 = $1,600  (gross of commission)

Matches MK2’s intuitive math.

Realized PnL (inverse contracts) - not used by Cortana but documented for completeness

LONG:  realized_pnl = closed_qty * multiplier * (1/entry_px - 1/exit_px)
SHORT: realized_pnl = closed_qty * multiplier * (1/exit_px - 1/entry_px)

is_inverse is False for SPY options, so Cortana uses the standard formula.

Unrealized PnL

position.unrealized_pnl(price) accepts any reference price (bid/ask/mid/ last/mark) and returns Money in settlement currency. For a FLAT position returns Money(0, settlement_currency). Cortana valuation should pass the side-appropriate price (bid for our long-only book) to match true liquidation value.

Total PnL

position.total_pnl(current_price) = realized_pnl + unrealized_pnl(current_price).

Aggregation - FIFO vs weighted-average

The PnL formula above uses entry_price = avg_px_open (quantity-weighted average across all entry fills). Closes consume from this weighted average, not FIFO lots. For a single-strike single-entry-then-single-exit lifecycle (Cortana’s normal case) FIFO and weighted-average produce identical results. They diverge only when you average down or scale out - neither is in the current Cortana playbook. Tax-lot accounting (FIFO/LIFO/specific identification) is not a kernel concern; if Cortana ever needs it, that’s a downstream report computed from the events list.

Commission and fee handling

Commissions accumulate per currency and are accessible via position.commissions() which returns a list[Money] (one entry per distinct currency seen). They are tracked in OrderFilled.commission and applied to the position separately from the PnL formula above - i.e., the formula gives gross PnL; commissions are a separate accumulator.

Cortana net-of-fees PnL becomes:

net = position.realized_pnl - sum(position.commissions())

(within a currency).

Edge case from the docs: “Base Currency Commissions: Applied only to spot currency pairs where commission currency matches instrument.base_currency. Commission deducts from opening trades’ quantity; affects signed_qty on closing fills.” This is FX-specific and does not apply to SPY options.

Funding payments (perpetual futures) are tracked as PositionAdjusted events with quantity_change=None. Also not a Cortana concern but preserved in position.adjustments.

Query API - Strategy / Actor

All position reads route through self.cache:

# Lookup by ID
self.cache.position(position_id) -> Position | None
 
# Filtered listings
self.cache.positions(
    venue=None,              # filter by Venue
    instrument_id=None,      # filter by instrument
    strategy_id=None,        # filter by owning strategy
    side=None,               # PositionSide.LONG / SHORT / FLAT
) -> list[Position]
 
self.cache.positions_open(...) -> list[Position]      # only is_open
self.cache.positions_closed(...) -> list[Position]    # only is_closed

Plus the Portfolio convenience surface (per nautilus-strategies.md):

# Per-instrument predicates
self.portfolio.is_net_long(instrument_id) -> bool
self.portfolio.is_net_short(instrument_id) -> bool
self.portfolio.is_flat(instrument_id) -> bool
self.portfolio.is_completely_flat() -> bool
 
# Per-instrument values
self.portfolio.net_position(instrument_id) -> Decimal
self.portfolio.net_exposure(instrument_id) -> Money
self.portfolio.unrealized_pnl(instrument_id) -> Money
self.portfolio.realized_pnl(instrument_id) -> Money
 
# Per-venue aggregates
self.portfolio.unrealized_pnls(venue) -> dict[Currency, Money]
self.portfolio.realized_pnls(venue) -> dict[Currency, Money]
self.portfolio.net_exposures(venue) -> dict[Currency, Money]

These are pull-style queries against the kernel’s central Portfolio. Per nautilus-concepts.md, valuation uses a fallback chain (cached mark, side-appropriate quote, last trade, recent bar close) and explicitly flags unpriceable positions rather than silently zero-valuing them.

Cache-then-publish guarantee

From nautilus-events.md: “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.”

For positions: when on_order_filled fires, the OrderFilled event payload itself is authoritative. Re-reading cache.position(...) mid- handler may return state from a tick or two ago in live mode. Use the event payload for exact-at-event values; use the Cache for current state.

Reconciliation - broker truth wins on startup

Per nautilus-concepts.md and nautilus-events.md, the LiveExecutionEngine performs reconciliation on startup:

  1. Connect to venue.
  2. Pull broker positions, orders, and fills.
  3. For every cached position with no broker counterpart → close it (broker-truth).
  4. For every broker position with no cached counterpart → open it (broker-truth).
  5. For mismatches → reconcile to broker numbers.
  6. Synthesize the necessary order/fill/position events to align the cache with broker reality. Each synthesized event has event.reconciliation = True so audit consumers can distinguish them from fresh broker activity.

Critical implication: the Cache is never authoritative on its own. The broker is. Restart-and-rebuild is the recovery model. Crash-only recovery is a property of the engine, not a feature you have to design.

This is the structural property MK2 lacked. MK2 had a position_state table that was intended to be a persistence cache for reconciliation but in practice diverged from position_tracker (in-memory) and from updatePortfolio (broker callback), with no single arbiter. Three “sources of truth” race; bugs ensue (see exit-path-failure-modes.md Class 1, 2, 3).

Numerical precision

Per the docs: “Testing confirms f64 arithmetic maintains accuracy for typical trading scenarios” - standard amounts ≥ 0.01, 9-decimal crypto prices within 1e-6 tolerance, and 100-fill sequential trades without drift. Range validated 0.00001 to 99,999.99999 without overflow.

For SPY options at 25.00 with 100-multiplier this is well within the safe envelope. No precision-loss concern for Cortana sizes.

Cortana MK3 implications - MK2 mapping

The two MK2 components that disappear under MK3:

1. position_state SQLite table

MK2 location: src/cortana/positions/store.py:13 (the PositionState enum) plus a corresponding row schema persisting state across restarts.

MK3 replacement: none - directly removed. The Nautilus Cache (Redis- or Postgres-backed) holds open positions. Restart triggers reconcile-with- broker, which authoritatively rebuilds. The “we have a position state DB and it’s the source of truth” mental model goes away.

2. position_tracker in-memory

MK2 location: src/cortana/positions/manager.py and friends. Hand-rolled in-memory dict keyed by contract identity, with a custom transition helper, custom STATE_TRANSITION log lines, custom orphan-detection logic, custom watchdog gating.

MK3 replacement: the Nautilus Position object inside Cache. All three custom subsystems (transition helper, log lines, orphan detection) become either kernel-provided (transitions, events) or unnecessary (orphan detection: with single-store + on-startup reconcile, “orphan” just means “broker has a position the engine doesn’t know about” → log once, operator decides; no auto-adopt logic to write).

MK2 bug class → MK3 mechanism

This is the load-bearing table. Each of the four MK2 bug classes catalogued in exit-path-failure-modes.md and position-state-machine.md maps to a Nautilus mechanism that prevents it by construction:

MK2 bug classConcrete MK2 incidentNautilus mechanism that prevents it
(a) State divergence between two stores - position_state table says X, position_tracker dict says Y, broker says Z2026-04-24: tracker said EXIT_PENDING with growing ghost-PnL while broker said position=0. Four hours of lying UI.Single store. Position lives only in Cache. There is no second store to drift from. cache.position(id) is the only read path; OrderFilled → ExecutionEngine is the only mutation path. Eliminated by design.
(b) updatePortfolio reconciliation drift - broker reports position=0 but engine never finalized, kept emitting EXIT_PENDING and ghost unrealizedSame 2026-04-24 incident - required fdcf6ad shipping a callback handler that finalized tracker on position=0.Engine-owned reconciliation. LiveExecutionEngine consumes broker position reports, resolves them against Cache, and emits the necessary PositionClosed synthetic events with reconciliation=True. No custom callback to wire. The “broker says zero, finalize the position” logic is platform code, tested across thousands of users.
(c) EXIT_PENDING leaks - exit-in-progress flag on Position never cleared, alerts kept firing after broker truth was flat2026-04-24: alert spam during exit-in-progress; required edge-trigger dedup and _exit_in_progress flag handling in 9a118b1.State-machine engine-owned. Position has only three observable states: open / closed (intermediate “exit in progress” is a property of the order, not the position). The Position closes when net qty hits zero - full stop. No EXIT_PENDING sentinel, no flag to get stuck. The order-side state machine tracks PENDING_CANCEL etc.; the position-side machine has no such transient.
(d) Tracker-vs-broker drift - engine qty != broker qty, with reconciler sometimes acting on PENDING_ENTRY positions2026-04-27 187: reconciler acted during entry-fill window, orphaned 100 contracts and contaminated TP qty for a sibling position; required 83ba9eb state gates.Reconcile-on-startup, broker-as-truth. The MK2 issue was acting during entry - there is no equivalent MK3 path because reconciliation is a startup phase, not a continuous loop racing live fills. Subsequent broker-vs-cache divergence emits a synthetic event with reconciliation=True; consumers (including the audit logger) can distinguish synthesized events from fresh ones. The “PENDING_ENTRY exists and reconciler must skip it” gate becomes unnecessary because there is no parallel reconcile loop.

Which MK2 bug class is most cleanly killed?

Class (a) - state divergence. It’s killed structurally, not by adding a guard. Classes (b), (c), and (d) are killed by the framework’s reconciler behaving correctly, the position state machine being engine-owned, and reconciliation being a startup phase. Those are mechanism wins. But (a) is the deepest - there is no second store. The bug is unrepresentable because the data model doesn’t permit it. That’s the strongest kind of prevention.

This is also why class (a) was the longest-lived MK2 bug class - it kept re-appearing as new code paths added new accidental “state” (the _exit_in_progress flag, the _last_tick_at timestamp, the tp_status field, the tp_order_id string). Each was a tiny private state shard that drifted independently. MK3 removes the soil they grew in.

What MK3 gains beyond bug elimination

  • Position events are auditable for free. Subscribe one Actor to on_position_event and Parquet every event. Replaces MK2’s bespoke STATE_TRANSITION log lines with an indexable, replayable record.
  • peak_qty is platform-tracked. MK2 didn’t have a clean field for high-water-mark size; MK3 gets it for free.
  • Position duration is platform-tracked. duration_ns arrives in PositionClosed; we don’t compute it ourselves.
  • Position flip semantics are clean. Close-then-open as a pair, not a hand-rolled “did the qty go negative” check.
  • Snapshot-on-flip preserves history. A NETTING-mode SPY 0DTE strategy that closes one contract and opens a new strike for the same instrument gets a snapshot of the closed position automatically. Cortana barely uses this today, but it’s a clean property to have for postmortems.

What Cortana still owns at the strategy/actor layer

  • Why a position exited. Nautilus records closing_order_id but does not encode the reason (TP / SL / time-in-trade / EOD-flat / manual / thesis-invalid). Cortana publishes a custom ExitReason event tied to the PositionClosed (see nautilus-events.md mapping table).
  • Tick-driven mark telemetry. Cortana’s “current %-PnL printed every quote tick” UI element is not a position event. Build a custom PositionTelemetry event that subscribes to on_quote_tick and reads cache.position(id).unrealized_pnl(price) on the fly.
  • TP/SL geometry. Bracket order construction, software-fallback trigger logic, and EOD-flatten lives in CortanaStrategy. The Position itself just records what happened.
  • Cooldowns and sizing decisions. Pre-entry, before any Position exists. No platform analog.

Caveats and gotchas

  • PositionChanged is fill-driven, not price-driven. Quote ticks do not emit position events. Custom telemetry handles between-fill mark changes.
  • Position flips emit two events (PositionClosed then PositionOpened). Audit consumers must treat them as a pair, not as a single transition.
  • Snapshot-on-flip is NETTING-only. HEDGING gets a fresh position_id per open; the old position remains in cache.positions_closed(). NETTING reuses the position slot but preserves the closed state in a snapshot.
  • reconciliation=True events look like real broker events. Any audit consumer (logger, telegram alerter, dashboard) must check this flag before treating a synthesized event as fresh activity. Otherwise: Telegram pings you for every position that was open across a restart.
  • Cache update lag in live. “You might see a brief delay between an event and its appearance in the Cache.” Use the event payload for exact-at-event state; use the Cache for “what’s true right now.” Don’t re-read Cache mid-handler if you need the values that triggered the handler.
  • avg_px_close is None until the first close fill. Code that formats this for display must handle None - easy to miss in templating.
  • Inverse contracts have side-aware PnL formulas. Cortana doesn’t use inverse contracts (is_inverse=False for SPY options) but if Nautilus is later used for crypto perps, the formula switches.
  • Quanto contracts are documented as a limitation. “Limitations include panics if inverse instruments lack base_currency and improper handling of quanto contracts.” Not a Cortana concern; document for future work.
  • Numerical drift over thousands of fills is bounded but not zero. f64 arithmetic - drift testing covers 100-fill sequences. Cortana’s per-position fill count is ≤ 5; well inside the safe envelope.

When this concept applies

  • Designing the MK3 position-management surface.
  • Mapping MK2’s position_state + position_tracker to a single Cache store.
  • Reasoning about reconcile-on-startup as the recovery model.
  • Auditing Cortana’s exit-path bug catalog against MK3 prevention.
  • Computing realized/unrealized/total PnL inside a Strategy or Actor.
  • Subscribing to position lifecycle events from an audit-logger Actor.

When it breaks / does not apply

  • Mid-position price annotations (e.g., “this position drew down to -8% at 10:32:14”) - those are not events Nautilus emits. Build a custom PositionTelemetry event or persist unrealized_pnl(quote.bid) from on_quote_tick.
  • Reason taxonomies for closure - Nautilus records closing_order_id but not “why” (TP / SL / time / manual). Cortana publishes a custom ExitReason event in tandem.
  • Tax-lot accounting (FIFO/LIFO/specific ID) - kernel uses weighted- average cost basis. Tax reports are a downstream computation off the events list.
  • Operator-overrideable position state - MK2 had operator UI actions that mutated Position.tp_status directly. MK3 equivalent is to submit an order (cancel TP, place new one) and let the engine emit events. No direct field mutation.

See Also

  • Nautilus Events - full PositionEvent field matrix and how positions hook the audit-trail subscriber.
  • Nautilus Strategies - self.portfolio / self.cache query API and order submission that causes position events.
  • Nautilus Execution - parallel page; ExecutionEngine fill resolution, position_id assignment, OMS reconciliation paths.
  • Nautilus Cache - parallel page; Cache as the single store, Redis/Postgres backends, query semantics.
  • Nautilus Concepts - Portfolio section, lines 389-414; cross-instrument aggregation and price-fallback chain.
  • Nautilus Actors - Actor capabilities; position-event subscription from non-order-submitting components.
  • Exit-path failure modes - MK2 catalog of bug classes (Class 1/2/3) that Nautilus’s Position model prevents.
  • Position state machine - MK2’s hand-rolled three-state machine (PENDING_ENTRY / OPEN / CLOSED) that Nautilus replaces with engine-owned lifecycle.
  • Position lifecycle - MK2’s end-to-end walkthrough; MK3 consolidates entry/exit/reconcile under engine ownership.
  • 2026-05-09 Nautilus Spike Plan - Saturday evaluation; this page is reference for Steps 4, 5, and 7.
  • Brain RESOLVER - page filing rules.

Timeline

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