Nautilus Value Types

Nautilus represents prices, sizes, and money as immutable, precision-aware, fixed-point value types - Price, Quantity, Money - backed internally by scaled integers, not floats. Each type carries a precision field that controls display but not identity. Same-type arithmetic preserves the type; mixing dimensions returns Decimal. Quantity is unsigned. Money requires matching currencies. Identifiers (InstrumentId, Symbol, Venue, TraderId, StrategyId, ClientOrderId, etc.) are likewise typed wrappers that validate at construction. The contract is that any numeric anomaly fails fast at the boundary instead of propagating into sizing, P&L, or order submission.

Core claim

The MK2 bug pattern of “float-vs-Decimal price comparison off-by-cents, integer-division on a contract count, NaN mark-price into sizing” is closed by construction in Nautilus: Price and Quantity are integer-scaled and non-floating; cross-currency Money ops raise ValueError; Quantity cannot go negative; conversions are explicit, not silent.

Reference: value types

Price

  • Purpose. Market prices, quotes, price levels. Signed (can represent negative spreads, debits, etc. when used as a price-difference).
  • Internal representation. Fixed-point integer scaled to a global precision (the framework uses 10^16 internally). Not a float.
  • Precision field. Tracks decimal places for display and serialization. “Precision controls display, not identity.” Two Price values with the same decimal magnitude but different precisions compare equal.
  • Construction. Price(value, precision); Price.from_str(str) parses from strings; instrument.make_price(x) returns a Price that respects the instrument’s price_precision and price_increment (tick size).
  • Arithmetic (same-type). Price + Price → Price, Price - Price → Price. Result uses the max precision of the operands (e.g. Price(100.5, p=1) + Price(0.125, p=3) → Price(100.625, p=3)).
  • Arithmetic (cross-dimensional). Price * Price → Decimal, Price / Price → Decimal, floor-div and modulo also return Decimal. Rationale from the docs: “Multiplying a price by a price produces ‘price squared’, not a price.”
  • Unary. -Price → Price, +Price → Price, abs(Price) → Price.
  • Mixed scalar. Price ± int → Decimal, Price ± float → float, Price ± Decimal → Decimal. Both directions (scalar op Price and Price op scalar) follow the same widening rule.
  • Conversions. .as_decimal() (lossless), .as_double() (to float, use only at API boundaries), str(price) (formatted to precision).
  • Identity. Hashable; safe as dict keys.

Quantity

  • Purpose. Trade sizes, order amounts, position quantities. Unsigned by contract.
  • Hard constraint. “Attempting to create a negative quantity or subtract a larger quantity from a smaller one raises an error.” ValueError is thrown at construction or at the failing operation.
  • Internal representation. Same fixed-point integer scaling as Price.
  • Precision field. Mirrors size_precision on the instrument; lot-size rounding is the caller’s responsibility via instrument.make_qty(x).
  • Arithmetic (same-type). Quantity + Quantity → Quantity, Quantity - Quantity → Quantity (must not underflow zero). Quantity * Quantity / Quantity → Decimal.
  • Unary. -Quantity → Decimal (because Quantity is unsigned and cannot itself represent a negative); +Quantity → Quantity; abs(Quantity) → Quantity.
  • Mixed scalar. Same widening as Price: int → Decimal, float → float, DecimalDecimal.
  • Conversions. .as_decimal(), .as_double(), Quantity.from_str(s).
  • Construction discipline. Always go through instrument.make_qty(...) in any path that submits orders - RiskEngine, the matching engine in backtest, and the venue all enforce size_precision/size_increment.

Money

  • Purpose. Monetary amounts, P&L, balances. Signed.
  • Composition. Money(amount, Currency) - currency is part of identity.
  • Hard constraint. Addition or subtraction across different currencies “raises ValueError - currency mismatch.” USD cannot silently coerce to EUR; conversion is explicit and goes through the Portfolio’s FX path.
  • Internal representation. Fixed-point integer scaled to the Currency.precision (e.g., 2 for USD, 8 for BTC).
  • Arithmetic (same-currency, same-type). Money + Money → Money, Money - Money → Money. Cross-dim ops return Decimal.
  • Unary. -Money → Money, +Money → Money, abs(Money) → Money.
  • Mixed scalar. Same widening rule as Price and Quantity.
  • Conversions. .as_decimal(), .as_double(), Money.from_str(s).
  • Where it surfaces. AccountBalance triple, realized/unrealized PnL, commissions (accumulated by currency), total_margin_init / total_margin_maint totals.

Currency

  • Purpose. Identity for Money; carries precision (decimal places), iso4217 code (or zero for crypto), and a textual name.
  • Built-in catalog for fiat (USD, EUR, …) and major crypto (BTC, ETH, USDT, …); custom currencies registerable.
  • Equality. By code; precision is a property of the registered currency, so two Money(1.00, USD) values with different precisions are a currency-precision contradiction - construct via the catalog, not ad-hoc.

AccountBalance

  • Composition. Three Money values in one Currency plus invariant total == locked + free. Enforced at construction.
  • Behaviour. Cash accounts lock notional for pending orders; reduce-only orders do not contribute to balance_locked. Margin accounts reserve collateral via margin_init / margin_maint.
  • Querying. account.balance(currency), account.balances_total(), balances_free(), balances_locked(). Missing currency returns None for point queries; totals always return a Money (zero if empty) - robust to absent state.

MarginBalance

  • Composition. initial, maintenance, plus instrument scoping - per-instrument (isolated-margin venues) or account-wide (cross-margin).
  • Critical adapter contract. MarginAccount.apply() replaces both stores from the incoming event. Partial snapshots that omit live margin entries cause those entries to disappear until the next full snapshot. Adapters MUST send full margin snapshots.

Position (value-type aspects)

  • signed_qty is signed (long positive, short negative, flat zero) - this is the one signed quantity surface in the system, distinct from Quantity (unsigned).
  • Average prices, realized/unrealized PnL, and commissions are typed (Price, Money) - never raw floats.

Reference: identifier types

All identifiers are typed string newtypes that validate at construction (non-empty, often charset-restricted) and are hashable for dict keys / cache lookups. The framework prevents the class of bug where two strings that look the same to Python compare unequal due to whitespace, case, or encoding drift.

  • Symbol - venue-native ticker (e.g., "BTCUSDT", "ESM5").
  • Venue - venue identifier (e.g., "BINANCE", "IBKR").
  • InstrumentId - composite {Symbol}.{Venue}. Unique per Nautilus system. Construct via InstrumentId(Symbol("BTCUSDT"), Venue("BINANCE")) or InstrumentId.from_str("BTCUSDT.BINANCE").
  • TraderId - operator-level identity, e.g. TraderId("TESTER-001"). Format <name>-<tag>; tag must be unique within the system.
  • StrategyId - derived {ClassName}-{order_id_tag}; framework prevents duplicate registration.
  • ClientOrderId - pre-venue order ID assigned by Nautilus at submission. Used for OMS routing and reconciliation joins.
  • VenueOrderId - venue-assigned ID, populated on accept; used for modify/cancel after acceptance.
  • PositionId - synthetic under NETTING (deterministic across restarts), venue-supplied under HEDGING.
  • AccountId - {Venue}-{account_number}.
  • TradeId - fill identity used for duplicate-fill detection alongside (order_side, last_px, last_qty).

Equality, hashing, ordering

  • Equality is by value, not identity. Price(100.0, p=2) == Price(100.000, p=4) is True.
  • Hashable. All value types and identifiers can be dict keys.
  • Ordering. <, >, <=, >= defined for Price and Quantity; for Money, ordering requires same-currency or raises.

Conversion rules

  • To Decimal. .as_decimal() is the canonical exact conversion. Use anywhere precision matters (logging, display below the wire, ratios, statistics).
  • To float. .as_double() exists but is one-way and lossy. Reserved for ML feature pipelines and external APIs that demand float64. Never round-trip a Price through float and back.
  • From str. Type.from_str(s) parses canonical decimal strings. Used for config files, replay logs, and tests.
  • Cross-type. No implicit Price → Money or Quantity → Price. Conversions go through the instrument: instrument.notional_value(qty, price) → Money, instrument.calculate_pnl(...) etc.

Range validation and error conditions

  • Negative Quantity: ValueError at construction; Quantity - Quantity underflow → ValueError.
  • Cross-currency Money arithmetic: ValueError.
  • Precision overflow (a value that exceeds the global fixed-point capacity 10^16): construction raises rather than silently truncating.
  • Construction with a precision that exceeds the type’s max precision raises.
  • Identifier construction with empty/whitespace/invalid characters raises.
  • Order submission with a Price whose precision does not match the instrument’s price_precision is rejected by the RiskEngine pre-trade - “the framework does not round values automatically.”

NaN and infinity handling

Because Price, Quantity, and Money are integer-scaled fixed-point, they cannot represent NaN or infinity. There is no bit pattern for NaN in a Rust integer. Any NaN/inf entering at the boundary (a feed parser, an ML feature, a divide-by-zero in upstream code) must be caught and rejected before reaching Price.__init__ / Money.__init__, which raise on non-finite input from float. This is the structural fix for the MK2 mark-price-NaN-into-sizing bug class - there is no representation in which NaN can propagate through scoring into an order quantity.

Cortana MK3 implications

Each MK2 numeric bug class maps to a value-type rule that closes it.

  • MK2 bug: float-vs-Decimal price comparison off-by-cents. Price is integer-scaled with precision-aware equality (Price(100.0, p=2) == Price(100.000, p=4)). Cross-precision comparisons no longer drift; as_double() is one-way only. Cite: MK2 had a 1.0001 vs 1.00 comparison fail in the position-manager TP path.

  • MK2 bug: integer division on a contract count. Quantity is unsigned and divides to Decimal, not int (Quantity / Quantity → Decimal). Allocation math like target_notional / per_contract_notional returns a Decimal you must explicitly convert via instrument.make_qty(...) - and make_qty enforces size_increment, so a 0.5-lot answer rounds explicitly per the lot-size rule, not silently via int(). Cite: MK2 had position // 3 truncate three contracts to one in the scaling logic.

  • MK2 bug: NaN mark-price propagated into sizing. Price and Money constructors raise on non-finite float input; fixed-point integers have no NaN representation. A NaN at the feed is forced to fail at the boundary instead of crossing into the RiskEngine. Cite: MK2 had a mark_price=nan from UW pass through into a position-size calculation that returned nan contracts.

  • MK2 bug class: cross-currency P&L blending (latent). Money + Money across currencies raises ValueError. Commissions accumulate by currency. Portfolio FX conversion is the only path that changes a money’s currency. Cite: pre-emptive - MK2 is single-currency USD today, but multi-venue MK3 inherits the FX bug class for free.

  • MK2 bug class: identifier typos and string drift. InstrumentId, Symbol, Venue, ClientOrderId are typed wrappers that validate at construction; topic strings on the bus are derived, not hand-typed. Cite: MK2 had at least one place where "SPY " (with trailing space) hashed to a different cache key than "SPY".

When it applies

Any MK3 component that touches a price, a size, an account balance, P&L, or an identifier - which is to say all of execution, risk, portfolio, scoring, position management, and persistence. The rule for the MK3 codebase: floats only at adapter boundaries (in/out) and ML feature pipelines. All trading-state math runs on Price / Quantity / Money.

When it breaks

  • ML feature pipelines. sklearn / PyTorch want float64. Convert with .as_double() at the boundary; never feed a typed value into a numpy array shape-check that expects float.
  • External logging / dashboards. JSON serialization will downgrade to string or float; round-trip back through from_str to restore identity.
  • Custom adapters. If you write a UW or IBKR adapter and forget instrument.make_price() / make_qty(), the RiskEngine will reject your orders. That is the system working - not a bug to suppress with rounding.

See Also


Timeline

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