Nautilus Configuration

Nautilus configuration is typed, builder-driven, and fail-fast. Every component (TradingNode, BacktestEngine, Cache, MessageBus, DataEngine, ExecEngine, RiskEngine, StreamingConfig, Strategy, Actor) accepts a dedicated config struct whose defaults resolve at the boundary, where None/Option<T> carries explicit absence semantics (not “use default”), and where unknown keys trigger fail-fast validation rather than silent drop. Python configs use msgspec.Struct with forbid_unknown_fields=True; Rust configs use bon::Builder with #[serde(deny_unknown_fields)]. The ImportableConfig family (ImportableStrategyConfig, ImportableActorConfig, ImportableExecAlgorithmConfig, ImportableControllerConfig) lets a config dict carry a path to its implementation class plus a config sub-struct, enabling YAML/JSON/dict-based instantiation without hand-wiring imports. Range validation is NOT promised at the framework layer - the doc commits to “defaults at the boundary” and “fail-fast on unknown fields” but type/range/semantic validation is the author’s responsibility (Pydantic-style range validators must be layered on top in user code). For Cortana MK3 multi-tenant: each tenant runs its own process with its own TradingNodeConfig, distinguished via trader_id + instance_id, with secrets injected from per-tenant encrypted stores.

This page specializes Nautilus Concepts (which covers config at the architectural-component level) and is the parallel of Nautilus Cache (which documents CacheConfig/ DatabaseConfig operationally) and Nautilus Message Bus (which documents MessageBusConfig and the producer/consumer external streams pattern). This is the config-system master reference - what the configs are, how they compose, how they load, how they validate, how they isolate tenants.

Why this page exists

The 2026-05-09 Nautilus spike Step 7.5 names this configuration doc as the place to verify multi-tenant patterns. The MK3 roadmap M4 (multi-tenant scaffolding) and M5 (Cody as Customer #1, web-app onboarding flow) both depend on per-tenant config injection working cleanly: each tenant must get its own TradingNodeConfig with its own IBKR credentials, its own Redis namespace, its own log identity, and its own strategy/actor wiring - without forking Nautilus core. This page collects everything the configuration concept doc says, cross-references it against the cache/bus/execution config knobs documented in sibling pages, and answers the spike’s standing questions on validation, secrets, and per-tenant injection.

Core design principles (verbatim)

The configuration concept page opens with four principles that define the entire system’s posture:

1. Defaults at the boundary

“Config structs carry concrete values for fields that always have a sensible default.”

Defaults live in one place - on the config struct itself, via bon::Builder annotations in Rust and msgspec defaults in Python. There is no “default applied at point of use” pattern; the engine sees a fully populated struct or it sees None (which means “off / unbounded / absent” - see #2).

2. Option semantics - None means absence, not “use default”

Option<T> fields appear only when None carries real meaning: a feature is off, a lookback window is unbounded, or a value is inherited from the environment at runtime.”

Concrete reading: if you see instrument_status_poll_secs: Option<u64> in a Rust config (or int | None in Python), None does not mean “fall back to a default”. It means “do not poll.” This semantic distinction is load-bearing for any code that reads a config field - absence is observable and meaningful.

config = BybitDataClientConfig(http_timeout_secs=30)
config = BybitDataClientConfig(instrument_status_poll_secs=None)
# The second config disables instrument status polling, NOT defaults to a poll interval.

3. Single source of truth for defaults

Defaults are defined once via bon::Builder #[builder(default = value)] annotations (Rust) or msgspec struct defaults (Python). There is no parallel “defaults dict” that can drift from the struct. This is what makes config refactors safe: change the default in one place, and every loader (YAML, JSON, dict, env) inherits it.

4. Fail-fast validation - forbid_unknown_fields=True

“Config decoding fails fast on unknown fields. Nautilus treats extra keys as bugs, not as harmless input.”

This is the strongest of the four principles. A typo in a YAML key (max_retires instead of max_retries) raises a hard error at load time, not a silent drop with the field at default. Python configs inherit this from NautilusConfig (the msgspec base class with forbid_unknown_fields=True); Rust configs declare it via #[serde(deny_unknown_fields)].

What range validation does NOT promise

The principles above do not include range validation. The doc commits to:

  • Defaults at the boundary (one-place defaults).
  • Option semantics (None means absence).
  • Single source of truth (no drifting defaults dict).
  • Unknown-field rejection (forbid extra keys).

What it does not commit to:

  • Range checks (e.g., “max_retries must be in [0, 100]”).
  • Cross-field invariants (e.g., “if database is set then flush_on_start must be False in production”).
  • Semantic validation (e.g., “trader_id must match a regex”).

If your config field has a meaningful range, you must enforce it in user code - typically via msgspec’s post-init validation hooks, Pydantic models composed alongside the Nautilus structs, or explicit assertion at component construction. The Nautilus framework’s role is to guarantee the shape and reject typos; the meaning is yours to defend.

This is the honest answer to the spike’s “is config range-validated?” question: NO at the framework level; YES if you layer Pydantic-style validators on top. See the Cortana MK3 implications section for the recommended pattern.

Python vs Rust - two parallel surfaces

Nautilus is Rust-core with Python bindings, so configs exist in both languages. The Python configs are msgspec structs that mirror the Rust bon::Builder structs field-for-field.

Python config base class

All Python configs inherit from NautilusConfig:

from nautilus_trader.config import NautilusConfig
 
class MyConfig(NautilusConfig):  # frozen, msgspec.Struct under the hood
    field_a: int
    field_b: str | None = None
    field_c: float = 1.5

Inherited behavior:

  • forbid_unknown_fields=True - typos raise.
  • frozen=True - configs are immutable post-construction.
  • msgspec encoding/decoding for fast YAML/JSON serialization.
  • __post_init__-style hooks available for cross-field validation in user-defined subclasses.

Rust config base shape

#[derive(Builder, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
#[builder(on(_, into))]
pub struct MyConfig {
    pub field_a: u32,
    #[builder(default)]
    pub field_b: Option<String>,
    #[builder(default = 1.5)]
    pub field_c: f64,
}
 
// Three construction patterns, all equivalent:
let cfg = MyConfig::builder().field_a(10).build();
let cfg = MyConfig { field_a: 10, field_b: None, field_c: 1.5 };
let cfg = serde_yaml::from_str::<MyConfig>(yaml).unwrap();

When the two surfaces drift

The Python structs are generated/maintained alongside the Rust ones; the doc treats them as pinned to the same shape. If you author a Python strategy and read its config fields in Python, you are reading the Python msgspec struct - not the Rust struct. Adapter authors who write Rust code see the Rust struct; the PyO3 binding layer translates.

For Cortana MK3 (Python-first authoring), the Python surface is the one to optimize for. The Rust surface matters only when reading adapter internals or contributing to the framework.

The configuration object hierarchy

Nautilus configs compose from the top down. Every concrete runtime (TradingNode, BacktestEngine, BacktestNode) takes a top-level config that contains the engine sub-configs.

Top-level - environment-specific

TradingNodeConfig                    (live + sandbox)
└── NautilusKernelConfig             (shared kernel base)
    ├── trader_id: str
    ├── instance_id: str | None      <-- multi-tenant key
    ├── log_level: str
    ├── log_file_format: str
    ├── log_component_levels: dict
    ├── cache: CacheConfig
    ├── message_bus: MessageBusConfig
    ├── data_engine: DataEngineConfig
    ├── exec_engine: ExecEngineConfig
    ├── risk_engine: RiskEngineConfig
    ├── streaming: StreamingConfig | None
    ├── controller: ImportableControllerConfig | None
    ├── timeout_connection: float
    ├── timeout_reconciliation: float
    ├── timeout_portfolio: float
    ├── timeout_disconnection: float
    ├── timeout_post_stop: float
    ├── strategies: list[ImportableStrategyConfig]
    ├── actors: list[ImportableActorConfig]
    ├── exec_algorithms: list[ImportableExecAlgorithmConfig]
    ├── data_clients: dict[str, LiveDataClientConfig]
    └── exec_clients: dict[str, LiveExecClientConfig]

BacktestEngineConfig                 (backtest)
└── NautilusKernelConfig             (same kernel base)
    └── ...                          (same engine sub-configs)

BacktestRunConfig                    (backtest orchestration)
├── engine: BacktestEngineConfig
├── data: list[BacktestDataConfig]   <-- Parquet catalog inputs
└── venues: list[BacktestVenueConfig] <-- simulated venues

Engine sub-configs

CacheConfig

(Full doc: Nautilus Cache.)

CacheConfig(
    database=None,                      # DatabaseConfig | None - None = in-memory
    encoding="msgpack",                 # or "json"
    timestamps_as_iso8601=False,
    buffer_interval_ms=None,
    bulk_read_batch_size=None,
    use_trader_prefix=True,             # Prefix Redis keys with trader ID
    use_instance_id=False,              # SET TRUE FOR MULTI-TENANT
    flush_on_start=False,               # Don't wipe Redis on boot
    drop_instruments_on_reset=True,
    tick_capacity=10_000,               # Per instrument
    bar_capacity=10_000,                # Per bar type
)

The use_instance_id=True knob is the load-bearing multi-tenant flag for cache key isolation - see Multi-tenant section below.

DatabaseConfig

DatabaseConfig(
    type="redis",
    host="localhost",
    port=6379,
    connection_timeout=2,
    response_timeout=2,
)

Used by both CacheConfig.database and MessageBusConfig.database. Per-tenant deployments either point at separate Redis instances or share one Redis with namespaced keys (via the use_instance_id / streams_prefix knobs).

MessageBusConfig

(Full doc: Nautilus Message Bus.)

MessageBusConfig(
    database=DatabaseConfig(),          # None = in-process only
    encoding="msgpack",
    timestamps_as_iso8601=True,
    buffer_interval_ms=100,
    autotrim_mins=30,
    use_trader_prefix=True,
    use_trader_id=True,
    use_instance_id=False,
    streams_prefix="streams",
    stream_per_topic=False,             # Disable for Redis (no wildcards)
    types_filter=[],
    external_streams=[],                # Consumer-node external stream names
)

Stream key format: trader:{trader_id}:{instance_id}:{streams_prefix}. Each segment toggleable. The Producer/Consumer pattern uses use_trader_id=False + use_trader_prefix=False + streams_prefix="..." on the producer to give consumers a predictable key.

DataEngineConfig / LiveDataEngineConfig

DataEngineConfig(
    time_bars_build_with_no_updates=True,
    time_bars_timestamp_on_close=True,
    time_bars_origins=None,
    validate_data_sequence=False,
    buffer_deltas=False,
    external_clients=[],                # Consumer node mode - see below
    debug=False,
)
 
LiveDataEngineConfig(
    # All of DataEngineConfig, plus:
    qsize=10_000,                       # Async queue depth
)

external_clients=[ClientId("CORTANA_UW_EXT")] is the consumer-node flag: the DataEngine treats the named client as if it were a local DataClient, but actually sources data from an external Redis stream defined in MessageBusConfig.external_streams. See nautilus-message-bus.md Producer/Consumer section.

ExecEngineConfig / LiveExecEngineConfig

(Full doc: Nautilus Execution.)

ExecEngineConfig(
    load_cache=True,
    allow_cash_positions=True,
    snapshot_orders=False,
    snapshot_positions=False,
    snapshot_positions_interval_secs=None,
    debug=False,
)
 
LiveExecEngineConfig(
    # All of ExecEngineConfig, plus:
    reconciliation=True,
    reconciliation_lookback_mins=None,
    reconciliation_startup_delay_secs=10.0,
    open_check_interval_secs=None,
    open_check_threshold_ms=5_000,
    inflight_check_interval_ms=2_000,
    inflight_check_threshold_ms=5_000,
    inflight_check_retries=5,
    own_books_audit_interval_secs=None,
    purge_closed_orders_interval_mins=None,
    purge_closed_positions_interval_mins=None,
    purge_account_events_interval_mins=None,
    qsize=10_000,
    allow_overfills=False,              # Default rejects duplicate fills
)

The reconciliation knobs are critical for the project_pm_ibkr_exit_invariant.md GH #46 story - open_check_interval_secs controls how often the engine polls IBKR for state divergence.

RiskEngineConfig

RiskEngineConfig(
    bypass=False,                       # Disable all checks (testing only)
    max_order_submit_rate="100/00:00:01",  # 100 orders per second
    max_order_modify_rate="100/00:00:01",
    max_notional_per_order={
        # InstrumentId -> Money
    },
    debug=False,
)

Per-instrument max_notional also lives on the Instrument itself. The bypass=True mode is for pure backtest research where you want to study unconstrained strategy behavior; never ship this to sandbox or live.

StreamingConfig

StreamingConfig(
    catalog_path="/path/to/catalog",
    fs_protocol=None,                   # "file" | "s3" | "gcs" | None
    fs_storage_options=None,
    flush_interval_ms=1_000,
    replace_existing=False,
    include_types=None,                 # list[type] | None
)

Used by BacktestEngineConfig for streaming results to ParquetDataCatalog during long backtests. For live, the catalog write path is via the ParquetDataCatalog directly (see nautilus-data.md).

Strategy and Actor configs

Every Strategy and Actor has a paired config class.

from nautilus_trader.config import StrategyConfig
 
class MyStrategyConfig(StrategyConfig, frozen=True):
    instrument_id: InstrumentId
    bar_type: BarType
    fast_ema_period: int = 10
    slow_ema_period: int = 20
    trade_size: Decimal = Decimal("100")
 
class MyStrategy(Strategy):
    def __init__(self, config: MyStrategyConfig):
        super().__init__(config)
        self.instrument_id = config.instrument_id
        # ...

The base StrategyConfig provides:

  • strategy_id: StrategyId | None - auto-derived if None.
  • order_id_tag: str | None - appended to client_order_id for multi-strategy disambiguation in shared Cache.
  • oms_type: OmsType - NETTING / HEDGING / UNSPECIFIED.
  • external_order_claims: list[InstrumentId] - instruments this strategy adopts orphaned external orders for (per nautilus-execution.md external-order creation).
  • manage_contingent_orders: bool - auto-cancel sibling OCO/OUO orders on fill.
  • manage_gtd_expiry: bool - local timer-based GTD enforcement on venues that don’t support GTD natively.

Actor configs (ActorConfig) are simpler - Actors lack lifecycle state-machine specifics that strategies need, so the base config has fewer fields. Both inherit from NautilusConfig so they get the fail-fast unknown-field rejection.

ImportableConfig - the YAML/dict instantiation pattern

This is how strategy and actor wiring works at config-file time.

The pattern

from nautilus_trader.config import ImportableStrategyConfig
 
ImportableStrategyConfig(
    strategy_path="my_package.my_module:MyStrategy",
    config_path="my_package.my_module:MyStrategyConfig",
    config={
        "instrument_id": "SPY.SMART",
        "bar_type": "SPY.SMART-1-MINUTE-LAST-EXTERNAL",
        "fast_ema_period": 10,
        "slow_ema_period": 20,
        "trade_size": "100",
    },
)

strategy_path and config_path are colon-separated module:class strings. The runtime imports the module, finds the class, instantiates the config struct from the config dict (with forbid_unknown_fields applied), and constructs the strategy with the config.

Why this matters

This is what makes a YAML/JSON config file possible:

strategies:
  - strategy_path: cortana_mk3.strategies:CortanaBullCallStrategy
    config_path: cortana_mk3.strategies:CortanaBullCallStrategyConfig
    config:
      instrument_id: SPY.SMART
      bar_type: SPY.SMART-1-SECOND-LAST-INTERNAL
      score_threshold: 65
      meta_prob_threshold: 0.55
      bias_filter: BULL

Loaded via:

import yaml
from nautilus_trader.live.config import TradingNodeConfig
 
with open("config.yaml") as f:
    raw = yaml.safe_load(f)
 
config = TradingNodeConfig(**raw)

The TradingNodeConfig constructor recursively decodes the strategies list into ImportableStrategyConfig objects, which the TradingNode then instantiates at startup.

Parallel forms - Actor, ExecAlgorithm, Controller

ImportableActorConfig(
    actor_path="...:MyActor",
    config_path="...:MyActorConfig",
    config={...},
)
 
ImportableExecAlgorithmConfig(
    exec_algorithm_path="...:MyExecAlg",
    config_path="...:MyExecAlgConfig",
    config={...},
)
 
ImportableControllerConfig(
    controller_path="...:MyController",
    config_path="...:MyControllerConfig",
    config={...},
)

Controller is the lifecycle manager for spawning/stopping actors and strategies dynamically at runtime - useful for hot-reload / per-tenant spawning patterns. Configured at the kernel level, not the strategy level.

Config loading approaches

Direct construction (Python)

from nautilus_trader.live.config import TradingNodeConfig
 
config = TradingNodeConfig(
    trader_id="CORTANA-001",
    instance_id="cody-tenant-paper-2026-05-09",
    log_level="INFO",
    cache=CacheConfig(
        database=DatabaseConfig(type="redis", host="localhost"),
        use_instance_id=True,
    ),
    # ...
)

Most direct. Fully type-checked at author time.

YAML

import yaml
with open("tenant-cody-paper.yaml") as f:
    raw = yaml.safe_load(f)
config = TradingNodeConfig(**raw)

Best for per-tenant config where the dev team commits a template and the deployment system fills in tenant-specific values.

JSON

import json
with open("tenant-cody-paper.json") as f:
    raw = json.load(f)
config = TradingNodeConfig(**raw)

Equivalent to YAML; pick by team preference. msgspec’s JSON decoder is faster than the stdlib json module if speed matters.

dict (programmatic)

raw = {
    "trader_id": tenant.trader_id,
    "instance_id": tenant.instance_id,
    "cache": {"use_instance_id": True, "database": {"type": "redis"}},
    "strategies": [
        {
            "strategy_path": "cortana_mk3.strategies:CortanaBullCallStrategy",
            "config_path": "cortana_mk3.strategies:CortanaBullCallStrategyConfig",
            "config": tenant.strategy_overrides,
        },
    ],
    # ...
}
config = TradingNodeConfig(**raw)

Best for programmatic per-tenant materialization - building the dict from a database row, then constructing the config struct.

Environment variables

The configuration concept doc does not document a built-in env-var interpolation mechanism (no Nautilus equivalent of dotenv-style ${VAR} expansion in YAML files). What’s recommended (and what the doc’s “value is inherited from the environment at runtime” line hints at):

  • Read env vars in user code, then pass into the config dict:
    raw["data_clients"]["IB"]["account_id"] = os.environ["IBKR_ACCOUNT"]
  • Use a config-loading library (Pydantic, dynaconf) that does env expansion, then hand the resolved dict to TradingNodeConfig(**raw).

The Option<T> semantic - “value is inherited from the environment at runtime” - applies to specific adapter fields (notably IBKR’s account_id which can be None to read from env), but it is adapter-by-adapter, not framework-wide.

TOML

Not natively supported in the documented loaders. If your team prefers TOML, decode with tomllib (Python 3.11+) and pass the resulting dict to TradingNodeConfig(**raw).

Config validation - what’s enforced where

At decoding time (msgspec / serde)

  • Type coercion - fields decoded to their declared type; mismatch raises.
  • Unknown field rejection - typos and stale keys raise hard errors.
  • Required vs optional - required fields with no default raise if missing.

At construction time (component init)

  • Cross-field invariants - entirely up to the component author. __post_init__ hooks (Python) or Builder::build (Rust) can enforce these.
  • Range checks - same. The framework does not enforce ranges.

At runtime (component behavior)

  • Semantic checks - the RiskEngine validates orders against config values (e.g., max_notional_per_order), but this is per-order enforcement, not config validation.
  • Adapter-level checks - connection timeouts, retry caps, etc. are enforced operationally.

What’s missing - the Pydantic gap

If you need:

  • “trader_id must match ^[A-Z][A-Z0-9-]{2,32}$
  • “buffer_interval_ms must be between 10 and 60000”
  • “if cache.database is set then flush_on_start must be False”

You must layer this on top. Recommended pattern for Cortana MK3:

from pydantic import BaseModel, Field, field_validator
from nautilus_trader.config import StrategyConfig
 
class CortanaStrategyValidatedConfig(BaseModel):
    """Pydantic-validated tenant-supplied config; converted to msgspec at boundary."""
    instrument_id: str = Field(pattern=r"^[A-Z]+\.[A-Z]+$")
    score_threshold: int = Field(ge=0, le=100)
    meta_prob_threshold: float = Field(ge=0.0, le=1.0)
    bias_filter: Literal["BULL", "BEAR", "ANY"]
 
    def to_nautilus(self) -> CortanaStrategyConfig:
        return CortanaStrategyConfig(**self.model_dump())

The Pydantic model is the validation gate; the msgspec struct is the runtime config. Bridge at the boundary.

Multi-tenant configuration patterns

This is the core question for MK3 M4-M5.

The hard constraint - one TradingNode per process

From nautilus-architecture.md:

“Running multiple TradingNode or BacktestNode instances concurrently in the same process is not supported due to global singleton state.”

This means multi-tenancy is process-level, not bus-level. Each tenant runs in its own process with its own TradingNodeConfig.

The canonical per-tenant pattern

def build_tenant_config(tenant: Tenant) -> TradingNodeConfig:
    return TradingNodeConfig(
        trader_id=f"CORTANA-{tenant.id}",        # <-- per-tenant identity
        instance_id=tenant.instance_id,          # <-- per-process identity
        log_level=tenant.log_level,
        log_component_levels={
            "RiskEngine": "DEBUG",
        },
        cache=CacheConfig(
            database=DatabaseConfig(
                type="redis",
                host=tenant.redis_host,          # <-- per-tenant Redis (or shared with namespace)
                port=tenant.redis_port,
            ),
            use_trader_prefix=True,
            use_instance_id=True,                # <-- LOAD-BEARING for namespacing
            tick_capacity=tenant.tick_capacity,
        ),
        message_bus=MessageBusConfig(
            database=DatabaseConfig(
                type="redis",
                host=tenant.redis_host,
                port=tenant.redis_port,
            ),
            use_trader_id=True,
            use_instance_id=True,                # <-- distinct stream keys
            streams_prefix=f"tenant_{tenant.id}",
            external_streams=["cortana_uw"],     # <-- shared upstream UW stream
        ),
        data_engine=LiveDataEngineConfig(
            external_clients=[ClientId("CORTANA_UW_EXT")],
        ),
        risk_engine=RiskEngineConfig(
            max_notional_per_order=tenant.risk_limits,
            max_order_submit_rate=tenant.rate_limits,
        ),
        data_clients={
            "IB": InteractiveBrokersDataClientConfig(
                ibg_host=tenant.ibg_host,
                ibg_port=tenant.ibg_port,
                ibg_client_id=tenant.ibg_client_id,  # <-- per-tenant client ID
                account_id=tenant.ibkr_account_id,
            ),
        },
        exec_clients={
            "IB": InteractiveBrokersExecClientConfig(
                ibg_host=tenant.ibg_host,
                ibg_port=tenant.ibg_port,
                ibg_client_id=tenant.ibg_client_id,
                account_id=tenant.ibkr_account_id,
                fetch_all_open_orders=False,
            ),
        },
        strategies=[
            ImportableStrategyConfig(
                strategy_path="cortana_mk3.strategies:CortanaBullCallStrategy",
                config_path="cortana_mk3.strategies:CortanaBullCallStrategyConfig",
                config=tenant.strategy_config_dict,
            ),
        ],
        actors=[
            ImportableActorConfig(
                actor_path="cortana_mk3.actors:AuditLoggerActor",
                config_path="cortana_mk3.actors:AuditLoggerConfig",
                config={"sink_path": tenant.audit_log_path},
            ),
        ],
    )

Per-tenant identity - trader_id + instance_id

  • trader_id - per-tenant, stable across restarts, used in the Cache Redis key prefix and the MessageBus stream prefix. Cortana convention: CORTANA-{tenant_id} where tenant_id is a stable UUID or short slug.
  • instance_id - per-process, can be regenerated on each start (UUIDv4 is the default if absent), or pinned to enable Redis state recovery across restarts. For multi-tenant, always pin to the tenant’s identity so Redis keys remain stable.

Per-tenant Redis isolation - three options

  1. Shared Redis, namespaced by instance_id (cheapest).
    • One Redis instance for all tenants.
    • Each tenant’s use_instance_id=True produces unique key prefixes.
    • Risk: a noisy tenant can degrade Redis for everyone (memory, CPU).
    • Compliance: a Redis bug or misconfigured key collision could leak tenant data.
  2. Redis-per-tenant (strongest isolation).
    • Each tenant gets its own Redis instance (or its own Redis ACL’d database number).
    • Cleanest blast-radius separation.
    • Highest ops cost at scale.
  3. Hybrid - shared Redis cluster with per-tenant ACL’d databases (recommended for SaaS).
    • One Redis cluster, but each tenant’s keys live in a separate ACL’d keyspace.
    • Still shares CPU/memory but logically isolated for compliance.

For MK3 M4 (Cody as developer-tenant + 1-2 test tenants), option 1 is fine. For MK3 M5+ (real customers), move to option 3 before any external user signs up.

Per-tenant venue credentials

Each customer brings their own IBKR account. The credentials flow into the per-tenant InteractiveBrokersExecClientConfig:

exec_clients={
    "IB": InteractiveBrokersExecClientConfig(
        ibg_host="127.0.0.1",            # tenant's local docker IB Gateway
        ibg_port=tenant.ibg_port,        # one port per tenant on shared host
        ibg_client_id=tenant.ibg_client_id,
        account_id=tenant.ibkr_account_id,
    ),
}

Each tenant’s process holds only its own credentials. The config-injection layer (web app + tenant-spawning service) is responsible for:

  1. Decrypting the tenant’s IBKR creds from the encrypted store.
  2. Materializing the config dict with the cleartext creds.
  3. Spawning the tenant’s TradingNode process with that config.
  4. Cleartext creds NEVER touch the shared web-app process or the shared Redis (they live only in the tenant’s process memory).

This is the M5 customer onboarding contract: “customer enters their own IBKR creds (we never see them in our DB; encrypted at rest, decrypted only in their tenant’s TradingNode process)” (per the MK3 roadmap).

Per-tenant strategy configuration

The web app’s “configure” step writes to the tenant’s strategy config dict. The tenant’s TradingNode reads the dict and instantiates the strategy:

# Web app writes (after validation via Pydantic):
tenant.strategy_config_dict = {
    "instrument_id": "SPY.SMART",
    "score_threshold": 65,                 # Risk preset = STANDARD
    "meta_prob_threshold": 0.55,
    "bias_filter": "ANY",
    # ...
}
 
# TradingNode reads via ImportableStrategyConfig:
ImportableStrategyConfig(
    strategy_path="cortana_mk3.strategies:CortanaBullCallStrategy",
    config_path="cortana_mk3.strategies:CortanaBullCallStrategyConfig",
    config=tenant.strategy_config_dict,
)

The risk presets (CONSERVATIVE / STANDARD / AGGRESSIVE per the M5 plan) are templates that map to specific config dicts. Customers don’t edit raw config; they pick a preset and fine-tune within bounded fields.

Shared upstream data - the producer/consumer split

Per nautilus-message-bus.md:

[Producer node]        [Tenant nodes]
UWDataClient   ──┐
ScoringActor    ─┼──► Redis Stream "cortana_uw" ──► N tenant nodes
                  └──         (shared)              (own bus, own ExecClient)

The producer’s config:

producer_config = TradingNodeConfig(
    trader_id="CORTANA-PRODUCER",
    instance_id="cortana-uw-producer",
    message_bus=MessageBusConfig(
        database=DatabaseConfig(type="redis"),
        use_trader_prefix=False,
        use_trader_id=False,
        use_instance_id=False,
        streams_prefix="cortana_uw",        # <-- predictable consumer key
        stream_per_topic=False,
    ),
    data_clients={
        "UW": UWDataClientConfig(...),       # one UW WebSocket
    },
    actors=[
        ImportableActorConfig(
            actor_path="cortana_mk3.actors:ScoringActor",
            config_path="cortana_mk3.actors:ScoringActorConfig",
            config={...},
        ),
    ],
    # No exec_clients - producer doesn't trade
)

Tenant configs (consumer mode):

tenant_config = TradingNodeConfig(
    # ... per-tenant trader_id, instance_id, etc.
    message_bus=MessageBusConfig(
        database=DatabaseConfig(type="redis"),
        external_streams=["cortana_uw"],     # <-- consume the producer's stream
    ),
    data_engine=LiveDataEngineConfig(
        external_clients=[ClientId("CORTANA_UW_EXT")],
    ),
    exec_clients={
        "IB": InteractiveBrokersExecClientConfig(...),  # <-- per-tenant IBKR
    },
    # ...
)

The producer config is identical across deployments (one of these runs per market segment); the tenant configs are per-customer and materialized from the customer’s web-app inputs.

Config secrets handling

The configuration concept doc does not document a built-in secrets management primitive. What it does provide:

  1. Option<T> semantics for fields that may “inherit from the environment at runtime” - e.g., account_id: str | None where None means “read from env at adapter init.”
  2. Type-driven serialization that doesn’t accidentally encode secrets - the YAML/JSON loaders deserialize whatever’s in the file. If you write a password into YAML, that’s on you.

What this implies for Cortana MK3

The framework gives no help with secrets. Three patterns the spike should validate:

  1. Env-var indirection at the boundary.
    • Customer’s IBKR creds stored encrypted in the web app’s database.
    • Tenant-spawning service decrypts and exports as env vars in the spawned process.
    • Adapter config reads account_id=os.environ["TENANT_IBKR_ACCT"] at config-build time.
    • Cleartext creds never touch a YAML file on disk.
  2. Per-process keystore.
    • Tenant’s process holds an in-memory decryption key delivered via IPC at startup (e.g., from a vault sidecar).
    • Configs reference encrypted blobs; adapter init decrypts in-process.
  3. Vault sidecar.
    • HashiCorp Vault, AWS Secrets Manager, or equivalent.
    • Tenant process authenticates to vault with its own service account; pulls creds at config-construction time.
    • Most operationally heavy; strongest compliance posture.

For M4 (Cody + a test tenant), pattern 1 is fine. For M5 (real customers), pattern 3 is the eventual target - but pattern 1 with filesystem encryption (e.g., systemd-creds, macOS Keychain) is acceptable for the first external beta.

What MUST NOT happen

  • IBKR passwords / API keys committed to YAML files in the repo.
  • Tenant credentials shared across tenant processes via a process-wide config dict.
  • Decrypted credentials persisted to Redis (which lives outside the tenant’s process).
  • Credentials logged in any form (Nautilus’s LoggingConfig should filter known-secret field names; this is a spike-time verification).

Logging configuration

LoggingConfig lives on NautilusKernelConfig:

LoggingConfig(
    log_level="INFO",                     # global default
    log_level_file=None,                  # file log level (None = same as console)
    log_directory=None,
    log_file_name=None,                   # auto-derived if None
    log_file_format=None,                 # text vs JSON
    log_colors=True,
    log_component_levels={
        "RiskEngine": "DEBUG",
        "ExecutionEngine": "DEBUG",
    },
    bypass_logging=False,                 # disable for benchmarks
    print_config=False,                   # log entire config at startup
)

The log_component_levels field lets a tenant’s config dial up debug logging on a single component without flipping the global level - useful for debugging meta-gate behavior in production without flooding logs with cache-write traces.

print_config=True is dangerous for multi-tenant: it would log secrets if any landed in adapter configs. Always False in production.

Backtest-specific config

BacktestEngineConfig

Same as TradingNodeConfig minus the live-only knobs (no reconciliation, no external_clients, no live exec clients). Plus:

  • run_analysis: bool - auto-generate analysis report on completion.
  • streaming: StreamingConfig | None - stream backtest data to a Parquet catalog.

BacktestRunConfig

Wraps BacktestEngineConfig with the data + venue inputs:

BacktestRunConfig(
    engine=BacktestEngineConfig(...),
    data=[
        BacktestDataConfig(
            catalog_path="/path/to/catalog",
            data_cls=QuoteTick,
            instrument_id="SPY.SMART",
            start_time="2026-05-06T00:00:00Z",
            end_time="2026-05-06T23:59:59Z",
        ),
    ],
    venues=[
        BacktestVenueConfig(
            name="SMART",
            oms_type="NETTING",
            account_type="MARGIN",
            base_currency="USD",
            starting_balances=["100_000 USD"],
            fill_model=FillModelConfig(...),
        ),
    ],
    chunk_size=None,                       # streaming chunk size
    dispose_on_completion=True,
    start=None,                            # override engine start
    end=None,                              # override engine end
)

BacktestNode

Runs N BacktestRunConfigs in parallel processes:

node = BacktestNode(configs=[run_a, run_b, run_c])
results = node.run()

Each run is its own process (per the singleton constraint); the node orchestrates. For Cortana M2 (parallel MK2/MK3 paper sessions) and hyperparameter sweeps, this is the entry point.

Cortana MK3 implications

Concrete answers to the questions the spike Step 7.5 names.

Multi-tenant config injection - confirmed clean

The pattern works:

  1. One process per tenant. Required by the singleton constraint; not a workaround.
  2. trader_id + instance_id distinguish tenants in Redis keys and stream names.
  3. use_instance_id=True on both CacheConfig and MessageBusConfig is the load-bearing knob.
  4. ImportableStrategyConfig + a per-tenant config dict provides per-tenant strategy parameter injection without code forking.
  5. Per-tenant venue creds flow through per-tenant ExecClient configs; each tenant’s process holds only its own.
  6. Shared upstream data via the Producer/Consumer pattern (external_streams + external_clients) avoids N × UW subscriptions.

The MK3 roadmap M4 “spinning up tenant=test-account-2 takes <30 min” target is realistic with this config shape.

Customer-supplied IBKR creds - encrypted at rest, decrypted in-tenant-process

The M5 contract - “customer enters their own IBKR creds (we never see them in our DB; encrypted at rest, decrypted only in their tenant’s TradingNode process)” - maps to:

  1. Web-app stores encrypted creds (keyed by tenant_id) in its DB.
  2. Tenant-spawning service: a. Pulls encrypted blob. b. Decrypts in a short-lived sidecar process (or via vault sidecar). c. Materializes the tenant config dict with cleartext creds. d. Spawns the tenant’s TradingNode process with the config. e. Wipes cleartext from the spawning service’s memory.
  3. Tenant process runs with creds in its own memory; no other process has access.
  4. On shutdown, process exit takes the creds with it; nothing persists.

The configuration system supports this - but the framework provides zero help. Every step above is application code. The risk surface is real (a bug in the spawning service could leak); the M5 plan must include a security review of this path.

Range-validated config - NO at framework, YES at user code

Direct answer: the doc does not promise range validation. What it promises:

  • Defaults at the boundary.
  • Option semantics for absence.
  • Single source of truth.
  • Fail-fast on unknown fields.

If your config has a meaningful range (e.g., meta_prob_threshold must be [0.0, 1.0], score_threshold must be [0, 100]), you must enforce it yourself. Recommended pattern for MK3:

  1. Customer-facing input validated by Pydantic (web app form).
  2. Validated dict materialized from Pydantic.
  3. Nautilus config constructed from the validated dict.
  4. Component init asserts cross-field invariants in __post_init__ or constructor body.

Three layers of validation:

  • Pydantic (web-form contract).
  • msgspec / serde (type + unknown-field).
  • Component code (semantic + cross-field).

Each catches different bug classes. The Pydantic layer is the one the MK3 roadmap reaches for; the doc’s “fail-fast” framing is layer 2; we own layer 3.

What “MK2 config-misconfig outages” become in MK3

The MK3 roadmap M3 done-when says: “zero state-divergence incidents, zero broker-truth violations, zero config-misconfig outages.”

For config-misconfig outages specifically:

  • MK2 had multiple incidents where a config typo or stale value produced wrong-environment behavior (e.g., paper port written to live config). MK3’s forbid_unknown_fields eliminates the typo class.
  • MK2 had incidents where a config field was renamed in code but old configs kept the old name silently. MK3’s fail-fast on unknown fields catches this on config load, not at first-use of the field.
  • MK2 had no centralized validation of cross-field invariants. **MK3
    • Pydantic gives a one-place validator.**

The class of “config typo silently mis-configures runtime” is structurally eliminated. Other config-related outages (range bugs, semantic bugs) still depend on user-code discipline.

Caveats and gotchas

  • forbid_unknown_fields is strict. Adding a field to a Nautilus config struct in a minor version means every config in the wild must add the field (or accept the default - but the default has to exist on the new field). For M4-M5, plan for tenant config migration on Nautilus version bumps.
  • Configs are frozen. You cannot mutate a TradingNodeConfig after construction. Build the dict, construct once.
  • No native env-var interpolation. Layer Pydantic / dynaconf / manual env reads on top.
  • No native secrets primitive. Layer your own secrets management.
  • Range validation is not the framework’s job. Layer Pydantic on top.
  • bypass=True on RiskEngine disables ALL pre-trade checks. Test fixture only.
  • flush_on_start=True wipes Redis state. Production = always False.
  • use_instance_id=False (default) is a multi-tenant data leakage risk. Always set True for any tenant-style deployment.
  • Per-tenant streams_prefix collisions. If two tenants accidentally share the same streams_prefix, their Redis streams interleave. Use f"tenant_{tenant.id}" consistently and assert uniqueness in the spawning service.
  • log_component_levels can produce massive log volume if mis-set. Audit per-tenant log configs in M5 onboarding flow.
  • print_config=True is a secrets-leak risk if any adapter config contains decrypted creds at log time. Always False in production.
  • ImportableConfig paths are runtime imports - a typo in strategy_path raises ImportError at TradingNode startup, not at config-decode time. Test paths in CI before deploying tenant configs.

When this concept applies

  • Designing the per-tenant config schema for MK3 M4-M5.
  • Configuring a single-tenant MK3 deployment for M1-M3.
  • Choosing where to layer Pydantic validation on top of msgspec configs.
  • Choosing the secrets-management story for tenant-supplied IBKR credentials.
  • Setting up the producer/consumer external streams pattern with per-tenant consumer configs.
  • Migrating a YAML config file from MK2 conventions to Nautilus shape.

When it breaks / does not apply

  • The page does not document built-in env-var interpolation; layer Pydantic or dynaconf if needed.
  • The page does not document a secrets primitive; layer Vault or systemd-creds.
  • The page does not document range validation; layer Pydantic.
  • The page does not document hot-reload of strategy configs; Controller patterns are referenced but not detailed.
  • The page does not document multi-tenant patterns explicitly - the mechanism is process-per-tenant + per-tenant config struct, not a framework-supplied “tenant” abstraction.

See Also

  • Nautilus Architecture - process-singleton constraint that makes multi-tenant = multi-process.
  • Nautilus Cache - CacheConfig / DatabaseConfig field reference + use_instance_id multi-tenant pattern.
  • Nautilus Message Bus - MessageBusConfig field reference + Producer/Consumer external streams pattern.
  • Nautilus Execution - LiveExecEngineConfig reconciliation knobs + RiskEngineConfig rule taxonomy.
  • Nautilus Concepts - config in the broader architecture canon.
  • 2026-05-09 Nautilus Spike Plan: ~/conductor/workspaces/cortanaroi-mk2/belo-horizonte/plans/2026-05-09-nautilus-spike.md (Step 7.5 multi-tenant feasibility).
  • 2026-05-09 MK3 Roadmap: ~/conductor/workspaces/cortanaroi-mk2/belo-horizonte/plans/2026-05-09-mk3-roadmap.md (M4 multi-tenant scaffolding + M5 customer onboarding flow).
  • Source: https://nautilustrader.io/docs/latest/concepts/configuration/

Timeline

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