Skip to main content

nautilus_live/node/
config.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Configuration types for live Nautilus system nodes.
17
18use std::{collections::HashMap, str::FromStr, time::Duration};
19
20use ahash::AHashMap;
21use indexmap::IndexSet;
22use nautilus_common::{
23    cache::CacheConfig,
24    config::{
25        ConfigError, ConfigErrorCollector, ConfigResult, check_non_empty_field, check_range,
26        check_supported_field, check_valid_format,
27    },
28    enums::Environment,
29    logging::logger::LoggerConfig,
30    msgbus::MessageBusConfig,
31    throttler::RateLimit,
32};
33use nautilus_core::{DurationNanos, UUID4, datetime::secs_to_nanos};
34use nautilus_data::engine::config::DataEngineConfig;
35use nautilus_execution::{
36    engine::config::ExecutionEngineConfig, order_emulator::config::OrderEmulatorConfig,
37};
38use nautilus_model::{
39    enums::{BarAggregation, BarIntervalType},
40    identifiers::{ClientId, ClientOrderId, InstrumentId, TraderId, Venue},
41};
42use nautilus_portfolio::config::PortfolioConfig;
43use nautilus_risk::engine::config::RiskEngineConfig;
44#[cfg(feature = "streaming")]
45use nautilus_system::config::{DataCatalogConfig, StreamingConfig};
46use nautilus_system::{config::NautilusKernelConfig, event_store::EventStoreConfig};
47use nautilus_trading::ImportableControllerConfig;
48use rust_decimal::Decimal;
49use serde::{Deserialize, Serialize};
50
51pub use super::queue::QueueMonitorConfig;
52use crate::execution::manager::ExecutionManagerConfig;
53pub use crate::execution::submission::SubmissionRecoveryPolicy;
54
55/// The default rate limit string used for order submission and modification.
56const DEFAULT_ORDER_RATE_LIMIT: &str = "100/00:00:01";
57const RUST_RUNTIME_UNSUPPORTED: &str = "not supported by the Rust live runtime yet";
58const RATE_LIMIT_FORMAT: &str = "expected 'limit/HH:MM:SS'";
59
60// Bound delays to keep both Duration conversion and Instant addition within range
61const DELAY_MAX_SECS: f64 = 86_400.0;
62
63pub(crate) fn validate_live_environment(environment: Environment) -> anyhow::Result<()> {
64    match environment {
65        Environment::Sandbox | Environment::Live => Ok(()),
66        Environment::Backtest => {
67            anyhow::bail!("LiveNode cannot be used with Backtest environment")
68        }
69    }
70}
71
72/// Configuration for live data engines.
73#[cfg_attr(
74    feature = "python",
75    pyo3::pyclass(module = "nautilus_trader.live", from_py_object)
76)]
77#[cfg_attr(
78    feature = "python",
79    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.live")
80)]
81#[expect(
82    clippy::struct_excessive_bools,
83    reason = "config fields mirror the existing Python live data engine surface"
84)]
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
86#[serde(default, deny_unknown_fields)]
87pub struct LiveDataEngineConfig {
88    /// If time bar aggregators will build and emit bars with no new market updates.
89    #[builder(default = true)]
90    pub time_bars_build_with_no_updates: bool,
91    /// If time bar aggregators will timestamp `ts_event` on bar close.
92    /// If false, the aggregator will timestamp on bar open.
93    #[builder(default = true)]
94    pub time_bars_timestamp_on_close: bool,
95    /// If time bar aggregators will skip emitting a bar when aggregation starts mid-interval.
96    #[builder(default)]
97    pub time_bars_skip_first_non_full_bar: bool,
98    /// The interval semantics used for time aggregation.
99    #[builder(default = BarIntervalType::LeftOpen)]
100    pub time_bars_interval_type: BarIntervalType,
101    /// The build delay (microseconds) before a time bar is emitted.
102    #[builder(default)]
103    pub time_bars_build_delay: u64,
104    /// A mapping of time bar aggregation types to their origin time offsets (nanoseconds).
105    ///
106    /// Keys are `BarAggregation` variant names, values are offset durations in nanoseconds.
107    #[builder(default)]
108    pub time_bars_origin_offset: HashMap<String, u64>,
109    /// If data timestamp sequencing should be validated and handled.
110    #[builder(default)]
111    pub validate_data_sequence: bool,
112    /// If order book deltas should be buffered until the `F_LAST` flag is set for a delta.
113    #[builder(default)]
114    pub buffer_deltas: bool,
115    /// If quotes should be emitted on order book updates.
116    #[builder(default)]
117    pub emit_quotes_from_book: bool,
118    /// If quotes should be emitted on order book depth updates.
119    #[builder(default)]
120    pub emit_quotes_from_book_depths: bool,
121    /// Client IDs declared for external stream processing.
122    ///
123    /// The data engine will not attempt to send data commands to these client IDs.
124    pub external_clients: Option<Vec<ClientId>>,
125    /// If debug mode is active (will provide extra debug logging).
126    #[builder(default)]
127    pub debug: bool,
128    /// The queue size for the engine's internal queue buffers.
129    ///
130    /// Not implemented on the current live runtime; `validate_runtime_support` rejects
131    /// any value other than the default.
132    #[builder(default = 100_000)]
133    pub qsize: u32,
134}
135
136impl Default for LiveDataEngineConfig {
137    fn default() -> Self {
138        Self::builder().build()
139    }
140}
141
142impl From<LiveDataEngineConfig> for DataEngineConfig {
143    fn from(config: LiveDataEngineConfig) -> Self {
144        let time_bars_origin_offset = config
145            .time_bars_origin_offset
146            .into_iter()
147            .map(|(agg, nanos)| {
148                let agg = BarAggregation::from_str(&agg)
149                    .expect("validate_runtime_support must run before DataEngineConfig conversion");
150                (agg, Duration::from_nanos(nanos))
151            })
152            .collect();
153
154        Self {
155            time_bars_build_with_no_updates: config.time_bars_build_with_no_updates,
156            time_bars_timestamp_on_close: config.time_bars_timestamp_on_close,
157            time_bars_skip_first_non_full_bar: config.time_bars_skip_first_non_full_bar,
158            time_bars_interval_type: config.time_bars_interval_type,
159            time_bars_build_delay: config.time_bars_build_delay,
160            time_bars_origin_offset,
161            validate_data_sequence: config.validate_data_sequence,
162            buffer_deltas: config.buffer_deltas,
163            emit_quotes_from_book: config.emit_quotes_from_book,
164            emit_quotes_from_book_depths: config.emit_quotes_from_book_depths,
165            disable_historical_cache: false,
166            external_clients: config.external_clients,
167            debug: config.debug,
168        }
169    }
170}
171
172/// Configuration for live risk engines.
173#[cfg_attr(
174    feature = "python",
175    pyo3::pyclass(module = "nautilus_trader.live", from_py_object)
176)]
177#[cfg_attr(
178    feature = "python",
179    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.live")
180)]
181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
182#[serde(default, deny_unknown_fields)]
183pub struct LiveRiskEngineConfig {
184    /// If all pre-trade risk checks should be bypassed.
185    #[builder(default)]
186    pub bypass: bool,
187    /// The maximum submit order rate as `limit/HH:MM:SS`.
188    #[builder(default = DEFAULT_ORDER_RATE_LIMIT.to_string())]
189    pub max_order_submit_rate: String,
190    /// The maximum modify order rate as `limit/HH:MM:SS`.
191    #[builder(default = DEFAULT_ORDER_RATE_LIMIT.to_string())]
192    pub max_order_modify_rate: String,
193    /// The maximum notional per order keyed by instrument ID.
194    ///
195    /// Entries map instrument ID strings to decimal notional strings.
196    #[builder(default)]
197    pub max_notional_per_order: HashMap<String, String>,
198    /// Venues whose execution clients enforce whole-position conditional exits.
199    ///
200    /// Validated exits skip bounds that apply only to their placeholder quantity and notional.
201    #[builder(default)]
202    pub full_position_exit_venues: Vec<Venue>,
203    /// If debug mode is active (will provide extra debug logging).
204    #[builder(default)]
205    pub debug: bool,
206    /// The queue size for the engine's internal queue buffers.
207    ///
208    /// Not implemented on the current live runtime; `validate_runtime_support` rejects
209    /// any value other than the default.
210    #[builder(default = 100_000)]
211    pub qsize: u32,
212}
213
214impl Default for LiveRiskEngineConfig {
215    fn default() -> Self {
216        Self::builder().build()
217    }
218}
219
220impl From<LiveRiskEngineConfig> for RiskEngineConfig {
221    fn from(config: LiveRiskEngineConfig) -> Self {
222        let max_notional_per_order = config
223            .max_notional_per_order
224            .into_iter()
225            .map(|(instrument_id, notional)| {
226                let instrument_id = InstrumentId::from_str(&instrument_id)
227                    .expect("validate_runtime_support must run before RiskEngineConfig conversion");
228                let notional = Decimal::from_str(&notional)
229                    .expect("validate_runtime_support must run before RiskEngineConfig conversion");
230                (instrument_id, notional)
231            })
232            .collect::<AHashMap<_, _>>();
233
234        let full_position_exit_venues = config.full_position_exit_venues.into_iter().collect();
235
236        Self {
237            bypass: config.bypass,
238            max_order_submit: parse_rate_limit(
239                "LiveRiskEngineConfig.max_order_submit_rate",
240                &config.max_order_submit_rate,
241            )
242            .expect("validate_runtime_support must run before RiskEngineConfig conversion"),
243            max_order_modify: parse_rate_limit(
244                "LiveRiskEngineConfig.max_order_modify_rate",
245                &config.max_order_modify_rate,
246            )
247            .expect("validate_runtime_support must run before RiskEngineConfig conversion"),
248            max_notional_per_order,
249            full_position_exit_venues,
250            debug: config.debug,
251        }
252    }
253}
254
255pub(crate) fn parse_rate_limit(field: impl Into<String>, input: &str) -> ConfigResult<RateLimit> {
256    let field = field.into();
257    let (limit, interval) = input
258        .split_once('/')
259        .ok_or_else(|| ConfigError::invalid_format(field.clone(), RATE_LIMIT_FORMAT))?;
260
261    let limit = limit
262        .parse::<usize>()
263        .map_err(|e| ConfigError::invalid_format(field.clone(), format!("limit: {e}")))?;
264
265    let mut parts = interval.split(':');
266
267    let mut next = |label: &str| -> ConfigResult<u64> {
268        parts
269            .next()
270            .ok_or_else(|| {
271                ConfigError::invalid_format(field.clone(), format!("missing {label} component"))
272            })?
273            .parse::<u64>()
274            .map_err(|e| ConfigError::invalid_format(field.clone(), format!("{label}: {e}")))
275    };
276
277    let hours = next("hours")?;
278    let minutes = next("minutes")?;
279    let seconds = next("seconds")?;
280
281    check_valid_format(field.clone(), parts.next().is_none(), RATE_LIMIT_FORMAT)?;
282
283    let interval_secs = hours
284        .checked_mul(3_600)
285        .and_then(|total| {
286            minutes
287                .checked_mul(60)
288                .and_then(|mins| total.checked_add(mins))
289        })
290        .and_then(|total| total.checked_add(seconds))
291        .ok_or_else(|| ConfigError::range(field.clone(), "interval exceeds the supported range"))?;
292
293    let interval_ns = DurationNanos::try_from_secs(interval_secs)
294        .map_err(|e| ConfigError::range(field.clone(), e.to_string()))?;
295
296    RateLimit::new_checked(limit, interval_ns).map_err(|e| ConfigError::range(field, e.to_string()))
297}
298
299pub(crate) fn validate_max_notional_per_order(
300    field: &str,
301    max_notional_per_order: &HashMap<String, String>,
302) -> ConfigResult<()> {
303    let mut collector = ConfigErrorCollector::new();
304
305    for (instrument_id, notional) in max_notional_per_order {
306        let entry_path = format!("{field}[{instrument_id}]");
307        if let Err(e) = InstrumentId::from_str(instrument_id) {
308            collector.push(ConfigError::invalid_reference(
309                entry_path.clone(),
310                "instrument ID",
311                e.to_string(),
312            ));
313        }
314
315        if let Err(e) = Decimal::from_str(notional) {
316            collector.push(ConfigError::invalid_value(
317                entry_path,
318                format!("invalid notional: {e}"),
319            ));
320        }
321    }
322
323    collector.into_result()
324}
325
326pub(crate) fn validate_instrument_id_strings(field: &str, values: &[String]) -> ConfigResult<()> {
327    let mut collector = ConfigErrorCollector::new();
328
329    for (index, value) in values.iter().enumerate() {
330        if let Err(e) = InstrumentId::from_str(value) {
331            collector.push(ConfigError::invalid_reference(
332                format!("{field}[{index}]"),
333                "instrument ID",
334                e.to_string(),
335            ));
336        }
337    }
338
339    collector.into_result()
340}
341
342pub(crate) fn validate_client_order_id_strings(field: &str, values: &[String]) -> ConfigResult<()> {
343    let mut collector = ConfigErrorCollector::new();
344
345    for (index, value) in values.iter().enumerate() {
346        if let Err(e) = ClientOrderId::new_checked(value) {
347            collector.push(ConfigError::invalid_reference(
348                format!("{field}[{index}]"),
349                "client order ID",
350                e.to_string(),
351            ));
352        }
353    }
354
355    collector.into_result()
356}
357
358pub(crate) fn validate_delay_secs(field: &str, value: f64) -> ConfigResult<()> {
359    check_range(
360        field,
361        value.is_finite() && (0.0..=DELAY_MAX_SECS).contains(&value),
362        format!("{value} (must be finite, non-negative, and <= {DELAY_MAX_SECS})"),
363    )
364}
365
366pub(crate) fn validate_positive_interval_secs(field: &str, value: f64) -> ConfigResult<()> {
367    check_range(
368        field,
369        value.is_finite() && value > 0.0,
370        format!("{value} (must be a positive finite number)"),
371    )?;
372    let nanos = secs_to_nanos(value).map_err(|e| ConfigError::range(field, e.to_string()))?;
373    check_range(
374        field,
375        nanos > 0,
376        format!("{value} (must be at least one nanosecond)"),
377    )
378}
379
380#[cfg(feature = "python")]
381pub(crate) fn duration_from_secs_f64(field: &str, value: f64) -> ConfigResult<Duration> {
382    validate_delay_secs(field, value)?;
383
384    Ok(Duration::from_secs_f64(value))
385}
386
387/// Configuration for live execution engines.
388#[cfg_attr(
389    feature = "python",
390    pyo3::pyclass(module = "nautilus_trader.live", from_py_object)
391)]
392#[cfg_attr(
393    feature = "python",
394    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.live")
395)]
396#[expect(
397    clippy::struct_excessive_bools,
398    reason = "config fields mirror the existing Python live execution engine surface"
399)]
400#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, bon::Builder)]
401#[serde(default, deny_unknown_fields)]
402pub struct LiveExecutionEngineConfig {
403    /// If the cache should be loaded on initialization.
404    #[builder(default = true)]
405    pub load_cache: bool,
406    /// If order state snapshots should be persisted to a configured cache database.
407    ///
408    /// Snapshots are persisted during order submission processing and after each state change.
409    #[builder(default)]
410    pub snapshot_orders: bool,
411    /// If position state snapshots should be published and, with cache backing, persisted.
412    ///
413    /// Snapshots are published when positions open, change, or close. A configured cache database
414    /// backing also persists them.
415    #[builder(default)]
416    pub snapshot_positions: bool,
417    /// The interval (seconds) at which additional position state snapshots are published and,
418    /// with cache backing, persisted.
419    /// If `None` then no additional snapshots will be taken.
420    pub snapshot_positions_interval_secs: Option<f64>,
421    /// Client IDs declared for external stream processing.
422    ///
423    /// The execution engine will not attempt to send trading commands to these client
424    /// IDs, assuming an external process consumes them from the bus.
425    pub external_clients: Option<Vec<ClientId>>,
426    /// If debug mode is active (will provide extra debug logging).
427    #[builder(default)]
428    pub debug: bool,
429    /// If reconciliation is active at start-up.
430    #[builder(default = true)]
431    pub reconciliation: bool,
432    /// The delay (seconds) before starting reconciliation at startup.
433    #[builder(default = 10.0)]
434    pub reconciliation_startup_delay_secs: f64,
435    /// The maximum lookback minutes to reconcile state for.
436    pub reconciliation_lookback_mins: Option<u32>,
437    /// Specific instrument IDs to reconcile (if None, reconciles all).
438    pub reconciliation_instrument_ids: Option<Vec<String>>,
439    /// If unclaimed order events with an EXTERNAL strategy ID should be filtered/dropped.
440    #[builder(default)]
441    pub filter_unclaimed_external_orders: bool,
442    /// If position status reports are filtered from reconciliation.
443    #[builder(default)]
444    pub filter_position_reports: bool,
445    /// Client order IDs to filter from reconciliation.
446    pub filtered_client_order_ids: Option<Vec<String>>,
447    /// If MARKET order events will be generated during reconciliation to align discrepancies.
448    #[builder(default = true)]
449    pub generate_missing_orders: bool,
450    /// The interval (milliseconds) between checking whether in-flight orders have exceeded their threshold.
451    #[builder(default = 2_000)]
452    pub inflight_check_interval_ms: u32,
453    /// The threshold (milliseconds) beyond which an in-flight order's status is checked with the venue.
454    #[builder(default = 5_000)]
455    pub inflight_check_threshold_ms: u32,
456    /// The number of retry attempts for verifying in-flight order status.
457    #[builder(default = 5)]
458    pub inflight_check_retries: u32,
459    /// Policy when a submitted order exhausts automatic recovery.
460    /// Reserved for future use; the runtime currently resolves locally for both variants.
461    #[builder(default)]
462    pub submission_recovery_policy: SubmissionRecoveryPolicy,
463    /// The interval (seconds) between checks for open orders at the venue.
464    pub open_check_interval_secs: Option<f64>,
465    /// The lookback minutes for open order checks.
466    /// When `None`, the check is unbounded (no time filter).
467    pub open_check_lookback_mins: Option<u32>,
468    /// The minimum elapsed time (milliseconds) since an order update before acting on discrepancies.
469    #[builder(default = 5_000)]
470    pub open_check_threshold_ms: u32,
471    /// The number of retries for missing open orders.
472    #[builder(default = 5)]
473    pub open_check_missing_retries: u32,
474    /// If the `check_open_orders` requests only currently open orders from the venue.
475    #[builder(default = true)]
476    pub open_check_open_only: bool,
477    /// The maximum number of single-order queries per consistency check cycle.
478    #[builder(default = 10)]
479    pub max_single_order_queries_per_cycle: u32,
480    /// The delay (milliseconds) between consecutive single-order queries.
481    #[builder(default = 100)]
482    pub single_order_query_delay_ms: u32,
483    /// The interval (seconds) between checks for open positions at the venue.
484    pub position_check_interval_secs: Option<f64>,
485    /// The lookback minutes for position consistency checks.
486    #[builder(default = 60)]
487    pub position_check_lookback_mins: u32,
488    /// The minimum elapsed time (milliseconds) since a position update before acting on discrepancies.
489    #[builder(default = 5_000)]
490    pub position_check_threshold_ms: u32,
491    /// The maximum number of reconciliation attempts for a position discrepancy.
492    #[builder(default = 3)]
493    pub position_check_retries: u32,
494    /// The interval (minutes) between purging closed orders from the in-memory cache.
495    pub purge_closed_orders_interval_mins: Option<u32>,
496    /// The time buffer (minutes) before closed orders can be purged.
497    pub purge_closed_orders_buffer_mins: Option<u32>,
498    /// The interval (minutes) between purging closed positions from the in-memory cache.
499    pub purge_closed_positions_interval_mins: Option<u32>,
500    /// The time buffer (minutes) before closed positions can be purged.
501    pub purge_closed_positions_buffer_mins: Option<u32>,
502    /// The interval (minutes) between purging account events from the in-memory cache.
503    pub purge_account_events_interval_mins: Option<u32>,
504    /// The time buffer (minutes) before account events can be purged.
505    pub purge_account_events_lookback_mins: Option<u32>,
506    /// If purge operations should also delete from the backing database.
507    #[builder(default)]
508    pub purge_from_database: bool,
509    /// The interval (seconds) between auditing own books against public order books.
510    pub own_books_audit_interval_secs: Option<f64>,
511    /// The queue size for the engine's internal queue buffers.
512    #[builder(default = 100_000)]
513    pub qsize: u32,
514    /// If order fills exceeding order quantity are allowed (logs warning instead of raising).
515    /// Useful when position reconciliation races with exchange fill events.
516    #[builder(default)]
517    pub allow_overfills: bool,
518    /// If the execution engine should maintain own/user order books based on commands and events.
519    #[builder(default)]
520    pub manage_own_order_books: bool,
521}
522
523impl Default for LiveExecutionEngineConfig {
524    fn default() -> Self {
525        Self {
526            open_check_lookback_mins: Some(60),
527            ..Self::builder().build()
528        }
529    }
530}
531
532impl From<LiveExecutionEngineConfig> for ExecutionEngineConfig {
533    fn from(config: LiveExecutionEngineConfig) -> Self {
534        Self {
535            load_cache: config.load_cache,
536            manage_own_order_books: config.manage_own_order_books,
537            snapshot_orders: config.snapshot_orders,
538            snapshot_positions: config.snapshot_positions,
539            snapshot_positions_interval_secs: config.snapshot_positions_interval_secs,
540            // Live must carry replay state so prior-cycle void corrections still resolve
541            carry_replay_events_on_reopen: true,
542            allow_overfills: config.allow_overfills,
543            filter_unclaimed_external_orders: config.filter_unclaimed_external_orders,
544            external_clients: config.external_clients,
545            // Keep purge intervals on the ExecutionEngine clock-timer path.
546            // LiveNode also dispatches purge checks from its maintenance loop,
547            // but engine timers must remain controlled by the injected Clock
548            // for callers using a custom live/sandbox clock factory.
549            purge_closed_orders_interval_mins: config.purge_closed_orders_interval_mins,
550            purge_closed_orders_buffer_mins: config.purge_closed_orders_buffer_mins,
551            purge_closed_positions_interval_mins: config.purge_closed_positions_interval_mins,
552            purge_closed_positions_buffer_mins: config.purge_closed_positions_buffer_mins,
553            purge_account_events_interval_mins: config.purge_account_events_interval_mins,
554            purge_account_events_lookback_mins: config.purge_account_events_lookback_mins,
555            purge_from_database: config.purge_from_database,
556            debug: config.debug,
557        }
558    }
559}
560
561impl From<&LiveExecutionEngineConfig> for ExecutionManagerConfig {
562    fn from(config: &LiveExecutionEngineConfig) -> Self {
563        let filtered_client_order_ids: IndexSet<ClientOrderId> = config
564            .filtered_client_order_ids
565            .clone()
566            .unwrap_or_default()
567            .into_iter()
568            .map(|value| ClientOrderId::from(value.as_str()))
569            .collect();
570
571        let reconciliation_instrument_ids: IndexSet<InstrumentId> = config
572            .reconciliation_instrument_ids
573            .clone()
574            .unwrap_or_default()
575            .into_iter()
576            .map(InstrumentId::from)
577            .collect();
578
579        let open_check_threshold_ns =
580            DurationNanos::from_millis(u64::from(config.open_check_threshold_ms));
581        let position_check_threshold_ns =
582            DurationNanos::from_millis(u64::from(config.position_check_threshold_ms));
583
584        Self {
585            trader_id: TraderId::default(),
586            lookback_mins: config.reconciliation_lookback_mins.map(u64::from),
587            reconciliation_instrument_ids,
588            filter_unclaimed_external: config.filter_unclaimed_external_orders,
589            filter_position_reports: config.filter_position_reports,
590            filtered_client_order_ids,
591            generate_missing_orders: config.generate_missing_orders,
592            inflight_threshold_ms: u64::from(config.inflight_check_threshold_ms),
593            inflight_max_retries: config.inflight_check_retries,
594            submission_recovery_policy: config.submission_recovery_policy,
595            open_check_lookback_mins: config.open_check_lookback_mins.map(u64::from),
596            open_check_threshold_ns,
597            open_check_missing_retries: config.open_check_missing_retries,
598            open_check_open_only: config.open_check_open_only,
599            max_single_order_queries_per_cycle: config.max_single_order_queries_per_cycle,
600            single_order_query_delay_ms: config.single_order_query_delay_ms,
601            position_check_lookback_mins: u64::from(config.position_check_lookback_mins),
602            position_check_threshold_ns,
603            position_check_retries: config.position_check_retries,
604            purge_closed_orders_buffer_mins: config.purge_closed_orders_buffer_mins,
605            purge_closed_positions_buffer_mins: config.purge_closed_positions_buffer_mins,
606            purge_account_events_lookback_mins: config.purge_account_events_lookback_mins,
607            purge_from_database: config.purge_from_database,
608        }
609    }
610}
611
612/// Configuration for live client message routing.
613#[cfg_attr(
614    feature = "python",
615    pyo3::pyclass(module = "nautilus_trader.live", from_py_object)
616)]
617#[cfg_attr(
618    feature = "python",
619    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.live")
620)]
621#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, bon::Builder)]
622#[serde(default, deny_unknown_fields)]
623pub struct RoutingConfig {
624    /// If the client should be registered as the default routing client.
625    #[builder(default)]
626    pub default: bool,
627    /// The venues to register for routing.
628    pub venues: Option<Vec<String>>,
629}
630
631/// Configuration for instrument providers.
632#[cfg_attr(
633    feature = "python",
634    pyo3::pyclass(module = "nautilus_trader.live", from_py_object)
635)]
636#[cfg_attr(
637    feature = "python",
638    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.live")
639)]
640#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, bon::Builder)]
641#[serde(default, deny_unknown_fields)]
642pub struct InstrumentProviderConfig {
643    /// Whether to load all instruments on startup.
644    #[builder(default)]
645    pub load_all: bool,
646    /// Specific instrument IDs to load on startup (if `load_all` is false).
647    pub load_ids: Option<Vec<String>>,
648    /// Venue-specific instrument loading filters.
649    #[builder(default)]
650    pub filters: HashMap<String, serde_json::Value>,
651    /// A fully qualified path to a callable for custom instrument filtering.
652    pub filter_callable: Option<String>,
653    /// If parser warnings should be logged.
654    #[builder(default = true)]
655    pub log_warnings: bool,
656}
657
658impl Default for InstrumentProviderConfig {
659    fn default() -> Self {
660        Self::builder().build()
661    }
662}
663
664/// Shared configuration for data clients registered with a live node.
665#[cfg_attr(
666    feature = "python",
667    pyo3::pyclass(module = "nautilus_trader.live", subclass, from_py_object)
668)]
669#[cfg_attr(
670    feature = "python",
671    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.live")
672)]
673#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, bon::Builder)]
674#[serde(default, deny_unknown_fields)]
675pub struct DataClientConfig {
676    /// If `DataClient` will emit bar updates when a new bar opens.
677    #[builder(default)]
678    pub handle_revised_bars: bool,
679    /// The client's instrument provider configuration.
680    #[builder(default)]
681    pub instrument_provider: InstrumentProviderConfig,
682    /// The client's message routing configuration.
683    #[builder(default)]
684    pub routing: RoutingConfig,
685}
686
687/// Shared configuration for execution clients registered with a live node.
688#[cfg_attr(
689    feature = "python",
690    pyo3::pyclass(module = "nautilus_trader.live", subclass, from_py_object)
691)]
692#[cfg_attr(
693    feature = "python",
694    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.live")
695)]
696#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, bon::Builder)]
697#[serde(default, deny_unknown_fields)]
698pub struct ExecutionClientConfig {
699    /// The client's instrument provider configuration.
700    #[builder(default)]
701    pub instrument_provider: InstrumentProviderConfig,
702    /// The client's message routing configuration.
703    #[builder(default)]
704    pub routing: RoutingConfig,
705}
706
707/// Configuration for one Rust-native plug-in instance loaded by a live node.
708#[cfg_attr(
709    feature = "python",
710    pyo3::pyclass(module = "nautilus_trader.live", from_py_object)
711)]
712#[cfg_attr(
713    feature = "python",
714    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.live")
715)]
716#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, bon::Builder)]
717#[serde(default, deny_unknown_fields)]
718pub struct PluginConfig {
719    /// Path to the plug-in cdylib. Relative paths resolve from the process working directory.
720    pub path: String,
721    /// Type name from the plug-in manifest to instantiate.
722    pub type_name: String,
723    /// Per-instance JSON configuration passed to the plug-in `create` thunk.
724    #[builder(default)]
725    pub config: HashMap<String, serde_json::Value>,
726    /// Optional SHA-256 hex digest of the cdylib before loading.
727    pub sha256: Option<String>,
728}
729
730impl Default for PluginConfig {
731    fn default() -> Self {
732        Self::builder()
733            .path(String::new())
734            .type_name(String::new())
735            .build()
736    }
737}
738
739/// Configuration for live Nautilus system nodes.
740#[cfg_attr(
741    feature = "python",
742    pyo3::pyclass(module = "nautilus_trader.live", dict, from_py_object)
743)]
744#[cfg_attr(
745    feature = "python",
746    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.live")
747)]
748#[expect(
749    clippy::struct_excessive_bools,
750    reason = "config fields mirror the existing Python live node surface"
751)]
752#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
753#[serde(default, deny_unknown_fields)]
754pub struct LiveNodeConfig {
755    /// The trading environment.
756    #[builder(default = Environment::Live)]
757    pub environment: Environment,
758    /// The trader ID for the node.
759    #[builder(default = TraderId::from("TRADER-001"))]
760    pub trader_id: TraderId,
761    /// If actor and strategy state should be loaded from the database on start.
762    #[builder(default)]
763    pub load_state: bool,
764    /// If actor and strategy state should be saved to the database on stop.
765    #[builder(default)]
766    pub save_state: bool,
767    /// If the system should request shutdown when an error log is emitted.
768    ///
769    /// Filtered or bypassed error logs still request shutdown.
770    #[builder(default)]
771    pub shutdown_on_error: bool,
772    /// The logging configuration for the kernel.
773    #[builder(default)]
774    pub logging: LoggerConfig,
775    /// The unique instance identifier for the kernel
776    pub instance_id: Option<UUID4>,
777    /// The timeout for all clients to connect and initialize.
778    #[builder(default = Duration::from_mins(1))]
779    pub timeout_connection: Duration,
780    /// The timeout for startup reconciliation and each continuous report-collection task.
781    #[builder(default = Duration::from_secs(30))]
782    pub timeout_reconciliation: Duration,
783    /// The timeout for portfolio to initialize margins and unrealized pnls.
784    #[builder(default = Duration::from_secs(10))]
785    pub timeout_portfolio: Duration,
786    /// The timeout for all engine clients to disconnect.
787    #[builder(default = Duration::from_secs(10))]
788    pub timeout_disconnection: Duration,
789    /// The delay after stopping the node to await residual events before final shutdown.
790    #[builder(default = Duration::from_secs(10))]
791    pub delay_post_stop: Duration,
792    /// The timeout to await pending tasks cancellation during shutdown.
793    #[builder(default = Duration::from_secs(5))]
794    pub timeout_shutdown: Duration,
795    /// The cache configuration.
796    pub cache: Option<CacheConfig>,
797    /// The message bus configuration.
798    pub msgbus: Option<MessageBusConfig>,
799    /// The portfolio configuration.
800    pub portfolio: Option<PortfolioConfig>,
801    /// The order emulator configuration.
802    pub emulator: Option<OrderEmulatorConfig>,
803    /// The configuration for streaming to feather files.
804    #[cfg(feature = "streaming")]
805    pub streaming: Option<StreamingConfig>,
806    /// Catalogs registered with the data engine.
807    #[cfg(feature = "streaming")]
808    #[builder(default)]
809    pub catalogs: Vec<DataCatalogConfig>,
810    /// The optional runner queue pressure monitor configuration.
811    pub queue_monitor: Option<QueueMonitorConfig>,
812    /// The event-store configuration.
813    ///
814    /// When set, the live node boots a kernel-managed event-store run for audit and replay.
815    /// The caller supplies a factory via `LiveNodeBuilder::with_event_store` to construct
816    /// the concrete `KernelEventStore`; this field carries the configuration that factory reads.
817    pub event_store: Option<EventStoreConfig>,
818    /// If the asyncio event loop should run in debug mode.
819    #[builder(default)]
820    pub loop_debug: bool,
821    /// The live data engine configuration.
822    #[builder(default)]
823    pub data_engine: LiveDataEngineConfig,
824    /// The live risk engine configuration.
825    #[builder(default)]
826    pub risk_engine: LiveRiskEngineConfig,
827    /// The live execution engine configuration.
828    #[builder(default)]
829    pub exec_engine: LiveExecutionEngineConfig,
830    /// The data client configurations.
831    #[builder(default)]
832    pub data_clients: HashMap<String, DataClientConfig>,
833    /// The execution client configurations.
834    #[builder(default)]
835    pub exec_clients: HashMap<String, ExecutionClientConfig>,
836    /// The importable controller configuration.
837    pub controller: Option<ImportableControllerConfig>,
838    /// The Rust-native plug-in instances to load before startup.
839    #[builder(default)]
840    pub plugins: Vec<PluginConfig>,
841}
842
843impl Default for LiveNodeConfig {
844    fn default() -> Self {
845        Self::builder().build()
846    }
847}
848
849impl LiveNodeConfig {
850    /// Validates config fields that the Rust live runtime does not support yet, and checks
851    /// that supported fields hold values the downstream engine conversions can parse.
852    ///
853    /// # Errors
854    ///
855    /// Returns an error when a config field would otherwise be ignored at runtime, or when a
856    /// supported field holds a value that cannot be converted to its engine-side representation.
857    pub(crate) fn validate_runtime_support(&self) -> ConfigResult<()> {
858        let mut collector = ConfigErrorCollector::new();
859
860        collector.collect(check_supported_field(
861            "LiveNodeConfig.emulator",
862            self.emulator.is_none(),
863            RUST_RUNTIME_UNSUPPORTED,
864        ));
865        collector.collect(check_supported_field(
866            "LiveNodeConfig.loop_debug",
867            !self.loop_debug,
868            RUST_RUNTIME_UNSUPPORTED,
869        ));
870        collector.collect(self.data_engine.validate_runtime_support());
871        collector.collect(self.risk_engine.validate_runtime_support());
872        collector.collect(self.exec_engine.validate_runtime_support());
873
874        if let Some(queue_monitor) = &self.queue_monitor {
875            collector.collect(queue_monitor.validate());
876        }
877
878        collector.collect(self.validate_plugin_configs());
879
880        collector.into_result()
881    }
882
883    fn validate_plugin_configs(&self) -> ConfigResult<()> {
884        let mut collector = ConfigErrorCollector::new();
885
886        for (index, plugin) in self.plugins.iter().enumerate() {
887            collector.collect(plugin.validate_runtime_support(index));
888        }
889
890        collector.into_result()
891    }
892}
893
894impl PluginConfig {
895    pub(crate) fn validate_runtime_support(&self, index: usize) -> ConfigResult<()> {
896        let mut collector = ConfigErrorCollector::with_capacity(3);
897
898        collector.collect(check_non_empty_field(
899            format!("LiveNodeConfig.plugins[{index}].path"),
900            &self.path,
901        ));
902        collector.collect(check_non_empty_field(
903            format!("LiveNodeConfig.plugins[{index}].type_name"),
904            &self.type_name,
905        ));
906
907        if let Some(sha256) = &self.sha256 {
908            let valid = sha256.len() == 64 && sha256.bytes().all(|b| b.is_ascii_hexdigit());
909            collector.collect(check_valid_format(
910                format!("LiveNodeConfig.plugins[{index}].sha256"),
911                valid,
912                "must be a 64-character hex digest",
913            ));
914        }
915
916        collector.into_result()
917    }
918}
919
920impl LiveDataEngineConfig {
921    fn validate_runtime_support(&self) -> ConfigResult<()> {
922        let mut collector = ConfigErrorCollector::new();
923
924        for agg_str in self.time_bars_origin_offset.keys() {
925            if let Err(e) = BarAggregation::from_str(agg_str) {
926                collector.push(ConfigError::invalid_reference(
927                    format!("LiveDataEngineConfig.time_bars_origin_offset[{agg_str}]"),
928                    "bar aggregation",
929                    e.to_string(),
930                ));
931            }
932        }
933
934        let default = Self::default();
935        collector.collect(check_supported_field(
936            "LiveDataEngineConfig.qsize",
937            self.qsize == default.qsize,
938            RUST_RUNTIME_UNSUPPORTED,
939        ));
940
941        collector.into_result()
942    }
943}
944
945impl LiveRiskEngineConfig {
946    fn validate_runtime_support(&self) -> ConfigResult<()> {
947        let mut collector = ConfigErrorCollector::new();
948
949        collector.collect(
950            parse_rate_limit(
951                "LiveRiskEngineConfig.max_order_submit_rate",
952                &self.max_order_submit_rate,
953            )
954            .map(|_| ()),
955        );
956        collector.collect(
957            parse_rate_limit(
958                "LiveRiskEngineConfig.max_order_modify_rate",
959                &self.max_order_modify_rate,
960            )
961            .map(|_| ()),
962        );
963        collector.collect(validate_max_notional_per_order(
964            "LiveRiskEngineConfig.max_notional_per_order",
965            &self.max_notional_per_order,
966        ));
967
968        let default = Self::default();
969        collector.collect(check_supported_field(
970            "LiveRiskEngineConfig.qsize",
971            self.qsize == default.qsize,
972            RUST_RUNTIME_UNSUPPORTED,
973        ));
974
975        collector.into_result()
976    }
977}
978
979impl LiveExecutionEngineConfig {
980    pub(crate) fn validate_runtime_support(&self) -> ConfigResult<()> {
981        let mut collector = ConfigErrorCollector::new();
982
983        // `run()` feeds this value straight into the first reconciliation tick when
984        // reconciliation is enabled, so reject it at build rather than panicking once
985        // clients are connected.
986        collector.collect(validate_delay_secs(
987            "LiveExecutionEngineConfig.reconciliation_startup_delay_secs",
988            self.reconciliation_startup_delay_secs,
989        ));
990
991        for (field, value) in [
992            (
993                "LiveExecutionEngineConfig.snapshot_positions_interval_secs",
994                self.snapshot_positions_interval_secs,
995            ),
996            (
997                "LiveExecutionEngineConfig.open_check_interval_secs",
998                self.open_check_interval_secs,
999            ),
1000            (
1001                "LiveExecutionEngineConfig.position_check_interval_secs",
1002                self.position_check_interval_secs,
1003            ),
1004            (
1005                "LiveExecutionEngineConfig.own_books_audit_interval_secs",
1006                self.own_books_audit_interval_secs,
1007            ),
1008        ] {
1009            if let Some(value) = value {
1010                collector.collect(validate_positive_interval_secs(field, value));
1011            }
1012        }
1013
1014        for (field, value) in [
1015            (
1016                "LiveExecutionEngineConfig.open_check_lookback_mins",
1017                self.open_check_lookback_mins,
1018            ),
1019            (
1020                "LiveExecutionEngineConfig.purge_closed_orders_interval_mins",
1021                self.purge_closed_orders_interval_mins,
1022            ),
1023            (
1024                "LiveExecutionEngineConfig.purge_closed_positions_interval_mins",
1025                self.purge_closed_positions_interval_mins,
1026            ),
1027            (
1028                "LiveExecutionEngineConfig.purge_account_events_interval_mins",
1029                self.purge_account_events_interval_mins,
1030            ),
1031            (
1032                "LiveExecutionEngineConfig.purge_closed_orders_buffer_mins",
1033                self.purge_closed_orders_buffer_mins,
1034            ),
1035            (
1036                "LiveExecutionEngineConfig.purge_closed_positions_buffer_mins",
1037                self.purge_closed_positions_buffer_mins,
1038            ),
1039            (
1040                "LiveExecutionEngineConfig.purge_account_events_lookback_mins",
1041                self.purge_account_events_lookback_mins,
1042            ),
1043        ] {
1044            if let Some(mins) = value {
1045                collector.collect(check_range(
1046                    field,
1047                    DurationNanos::try_from_mins(u64::from(mins)).is_ok(),
1048                    format!("{mins} minutes (must fit in `u64` nanoseconds)"),
1049                ));
1050            }
1051        }
1052
1053        if let Some(instrument_ids) = &self.reconciliation_instrument_ids {
1054            collector.collect(validate_instrument_id_strings(
1055                "LiveExecutionEngineConfig.reconciliation_instrument_ids",
1056                instrument_ids,
1057            ));
1058        }
1059
1060        if let Some(client_order_ids) = &self.filtered_client_order_ids {
1061            collector.collect(validate_client_order_id_strings(
1062                "LiveExecutionEngineConfig.filtered_client_order_ids",
1063                client_order_ids,
1064            ));
1065        }
1066
1067        let default = Self::default();
1068        collector.collect(check_supported_field(
1069            "LiveExecutionEngineConfig.purge_from_database",
1070            self.purge_from_database == default.purge_from_database,
1071            RUST_RUNTIME_UNSUPPORTED,
1072        ));
1073        collector.collect(check_supported_field(
1074            "LiveExecutionEngineConfig.qsize",
1075            self.qsize == default.qsize,
1076            RUST_RUNTIME_UNSUPPORTED,
1077        ));
1078
1079        collector.into_result()
1080    }
1081}
1082
1083impl NautilusKernelConfig for LiveNodeConfig {
1084    fn environment(&self) -> Environment {
1085        self.environment
1086    }
1087
1088    fn trader_id(&self) -> TraderId {
1089        self.trader_id
1090    }
1091
1092    fn load_state(&self) -> bool {
1093        self.load_state
1094    }
1095
1096    fn save_state(&self) -> bool {
1097        self.save_state
1098    }
1099
1100    fn shutdown_on_error(&self) -> bool {
1101        self.shutdown_on_error
1102    }
1103
1104    fn logging(&self) -> LoggerConfig {
1105        self.logging.clone()
1106    }
1107
1108    fn instance_id(&self) -> Option<UUID4> {
1109        self.instance_id
1110    }
1111
1112    fn timeout_connection(&self) -> Duration {
1113        self.timeout_connection
1114    }
1115
1116    fn timeout_reconciliation(&self) -> Duration {
1117        self.timeout_reconciliation
1118    }
1119
1120    fn timeout_portfolio(&self) -> Duration {
1121        self.timeout_portfolio
1122    }
1123
1124    fn timeout_disconnection(&self) -> Duration {
1125        self.timeout_disconnection
1126    }
1127
1128    fn delay_post_stop(&self) -> Duration {
1129        self.delay_post_stop
1130    }
1131
1132    fn timeout_shutdown(&self) -> Duration {
1133        self.timeout_shutdown
1134    }
1135
1136    fn cache(&self) -> Option<CacheConfig> {
1137        self.cache.clone()
1138    }
1139
1140    fn msgbus(&self) -> Option<MessageBusConfig> {
1141        self.msgbus.clone()
1142    }
1143
1144    fn data_engine(&self) -> Option<DataEngineConfig> {
1145        Some(self.data_engine.clone().into())
1146    }
1147
1148    fn risk_engine(&self) -> Option<RiskEngineConfig> {
1149        Some(self.risk_engine.clone().into())
1150    }
1151
1152    fn exec_engine(&self) -> Option<ExecutionEngineConfig> {
1153        Some(self.exec_engine.clone().into())
1154    }
1155
1156    fn portfolio(&self) -> Option<PortfolioConfig> {
1157        self.portfolio
1158    }
1159
1160    #[cfg(feature = "streaming")]
1161    fn catalogs(&self) -> Vec<DataCatalogConfig> {
1162        self.catalogs.clone()
1163    }
1164
1165    #[cfg(feature = "streaming")]
1166    fn streaming(&self) -> Option<StreamingConfig> {
1167        self.streaming.clone()
1168    }
1169}
1170
1171#[cfg(test)]
1172mod tests {
1173    #[cfg(feature = "streaming")]
1174    use nautilus_system::config::RotationConfig;
1175    use rstest::rstest;
1176
1177    use super::*;
1178
1179    #[rstest]
1180    fn test_trading_node_config_default() {
1181        let config = LiveNodeConfig::default();
1182
1183        assert_eq!(config.environment, Environment::Live);
1184        assert_eq!(config.trader_id, TraderId::from("TRADER-001"));
1185        assert_eq!(config.data_engine.qsize, 100_000);
1186        assert_eq!(config.risk_engine.qsize, 100_000);
1187        assert_eq!(config.exec_engine.qsize, 100_000);
1188        assert_eq!(config.timeout_connection, Duration::from_mins(1));
1189        assert!(config.exec_engine.reconciliation);
1190        assert!(!config.exec_engine.filter_unclaimed_external_orders);
1191        assert!(config.data_clients.is_empty());
1192        assert!(config.exec_clients.is_empty());
1193        assert!(config.plugins.is_empty());
1194        assert!(config.queue_monitor.is_none());
1195    }
1196
1197    #[rstest]
1198    fn test_live_node_queue_monitor_config_serde_roundtrip() {
1199        let config: LiveNodeConfig = toml::from_str(
1200            "
1201[queue_monitor]
1202queue_depth_trigger = 100
1203queue_depth_clear = 60
1204mean_dispatch_ns_trigger = 1000
1205mean_dispatch_ns_clear = 700
1206",
1207        )
1208        .unwrap();
1209
1210        let expected = Some(
1211            QueueMonitorConfig::builder()
1212                .queue_depth_trigger(100)
1213                .queue_depth_clear(60)
1214                .mean_dispatch_ns_trigger(1_000)
1215                .mean_dispatch_ns_clear(700)
1216                .build(),
1217        );
1218        let json = serde_json::to_string(&config).unwrap();
1219        let restored: LiveNodeConfig = serde_json::from_str(&json).unwrap();
1220
1221        assert_eq!(config.queue_monitor, expected);
1222        assert_eq!(restored.queue_monitor, expected);
1223    }
1224
1225    #[rstest]
1226    #[case(
1227        QueueMonitorConfig {
1228            queue_depth_trigger: 10,
1229            queue_depth_clear: 10,
1230            mean_dispatch_ns_trigger: 100,
1231            mean_dispatch_ns_clear: 50,
1232        },
1233        "invalid LiveNodeConfig.queue_monitor.queue_depth: clear threshold 10 must be lower than trigger threshold 10"
1234    )]
1235    #[case(
1236        QueueMonitorConfig {
1237            queue_depth_trigger: 10,
1238            queue_depth_clear: 5,
1239            mean_dispatch_ns_trigger: 50,
1240            mean_dispatch_ns_clear: 50,
1241        },
1242        "invalid LiveNodeConfig.queue_monitor.mean_dispatch_ns: clear threshold 50 must be lower than trigger threshold 50"
1243    )]
1244    fn test_live_node_queue_monitor_config_validates_hysteresis(
1245        #[case] queue_monitor: QueueMonitorConfig,
1246        #[case] expected: &str,
1247    ) {
1248        let config = LiveNodeConfig {
1249            queue_monitor: Some(queue_monitor),
1250            ..Default::default()
1251        };
1252
1253        assert_eq!(
1254            config.validate_runtime_support().unwrap_err().to_string(),
1255            expected
1256        );
1257    }
1258
1259    #[rstest]
1260    fn test_trading_node_config_as_kernel_config() {
1261        let config = LiveNodeConfig::default();
1262
1263        assert_eq!(config.environment(), Environment::Live);
1264        assert_eq!(config.trader_id(), TraderId::from("TRADER-001"));
1265        assert!(config.data_engine().is_some());
1266        assert!(config.risk_engine().is_some());
1267        assert!(config.exec_engine().is_some());
1268        assert!(!config.load_state());
1269        assert!(!config.save_state());
1270    }
1271
1272    #[rstest]
1273    fn test_validate_runtime_support_with_defaults() {
1274        let config = LiveNodeConfig::default();
1275
1276        assert!(config.validate_runtime_support().is_ok());
1277    }
1278
1279    #[rstest]
1280    fn test_validate_runtime_support_accepts_msgbus_config() {
1281        let config = LiveNodeConfig {
1282            msgbus: Some(MessageBusConfig::default()),
1283            ..Default::default()
1284        };
1285
1286        assert!(config.validate_runtime_support().is_ok());
1287    }
1288
1289    #[rstest]
1290    fn test_validate_runtime_support_accepts_msgbus_external_streams() {
1291        let config = LiveNodeConfig {
1292            msgbus: Some(MessageBusConfig {
1293                external_streams: Some(vec!["stream".to_string()]),
1294                ..Default::default()
1295            }),
1296            ..Default::default()
1297        };
1298
1299        assert!(config.validate_runtime_support().is_ok());
1300    }
1301
1302    #[rstest]
1303    #[cfg(feature = "streaming")]
1304    fn test_validate_runtime_support_accepts_streaming_config() {
1305        let config = LiveNodeConfig {
1306            streaming: Some(StreamingConfig::new(
1307                "catalog".to_string(),
1308                "file".to_string(),
1309                1_000,
1310                false,
1311                RotationConfig::NoRotation,
1312            )),
1313            ..Default::default()
1314        };
1315
1316        assert_eq!(config.validate_runtime_support(), Ok(()));
1317    }
1318
1319    #[rstest]
1320    fn test_validate_runtime_support_collects_multiple_errors() {
1321        let config = LiveNodeConfig {
1322            msgbus: Some(MessageBusConfig {
1323                external_streams: Some(vec!["stream".to_string()]),
1324                ..Default::default()
1325            }),
1326            emulator: Some(OrderEmulatorConfig::default()),
1327            loop_debug: true,
1328            ..Default::default()
1329        };
1330
1331        let error = config.validate_runtime_support().unwrap_err();
1332
1333        match error {
1334            ConfigError::Multiple { errors } => {
1335                assert_eq!(errors.len(), 2);
1336                assert_eq!(
1337                    errors[0],
1338                    ConfigError::UnsupportedField {
1339                        field: "LiveNodeConfig.emulator".to_string(),
1340                        reason: RUST_RUNTIME_UNSUPPORTED.to_string(),
1341                    },
1342                );
1343                assert_eq!(
1344                    errors[1],
1345                    ConfigError::UnsupportedField {
1346                        field: "LiveNodeConfig.loop_debug".to_string(),
1347                        reason: RUST_RUNTIME_UNSUPPORTED.to_string(),
1348                    },
1349                );
1350            }
1351            _ => panic!("Expected multiple config errors, received {error:?}"),
1352        }
1353    }
1354
1355    #[rstest]
1356    fn test_validate_runtime_support_rejects_data_engine_qsize() {
1357        let config = LiveNodeConfig {
1358            data_engine: LiveDataEngineConfig {
1359                qsize: 1,
1360                ..Default::default()
1361            },
1362            ..Default::default()
1363        };
1364
1365        let error = config.validate_runtime_support().unwrap_err();
1366        assert_eq!(
1367            error.to_string(),
1368            "LiveDataEngineConfig.qsize is not supported by the Rust live runtime yet"
1369        );
1370    }
1371
1372    #[rstest]
1373    fn test_validate_runtime_support_rejects_risk_engine_qsize() {
1374        let config = LiveNodeConfig {
1375            risk_engine: LiveRiskEngineConfig {
1376                qsize: 1,
1377                ..Default::default()
1378            },
1379            ..Default::default()
1380        };
1381
1382        let error = config.validate_runtime_support().unwrap_err();
1383        assert_eq!(
1384            error.to_string(),
1385            "LiveRiskEngineConfig.qsize is not supported by the Rust live runtime yet"
1386        );
1387    }
1388
1389    #[rstest]
1390    fn test_live_data_engine_config_converts_to_data_engine_config() {
1391        let config = LiveDataEngineConfig {
1392            time_bars_build_with_no_updates: false,
1393            time_bars_timestamp_on_close: false,
1394            time_bars_skip_first_non_full_bar: true,
1395            time_bars_interval_type: BarIntervalType::RightOpen,
1396            time_bars_build_delay: 1_500,
1397            validate_data_sequence: true,
1398            buffer_deltas: true,
1399            external_clients: Some(vec![ClientId::from("EXTERNAL")]),
1400            debug: true,
1401            ..Default::default()
1402        };
1403
1404        let converted: DataEngineConfig = config.into();
1405
1406        assert!(!converted.time_bars_build_with_no_updates);
1407        assert!(!converted.time_bars_timestamp_on_close);
1408        assert!(converted.time_bars_skip_first_non_full_bar);
1409        assert_eq!(
1410            converted.time_bars_interval_type,
1411            BarIntervalType::RightOpen,
1412        );
1413        assert_eq!(converted.time_bars_build_delay, 1_500);
1414        assert!(converted.time_bars_origin_offset.is_empty());
1415        assert!(converted.validate_data_sequence);
1416        assert!(converted.buffer_deltas);
1417        assert!(!converted.emit_quotes_from_book);
1418        assert!(!converted.emit_quotes_from_book_depths);
1419        assert_eq!(
1420            converted.external_clients,
1421            Some(vec![ClientId::from("EXTERNAL")]),
1422        );
1423        assert!(converted.debug);
1424    }
1425
1426    #[rstest]
1427    fn test_live_data_engine_config_converts_time_bars_origin_offset() {
1428        let config = LiveDataEngineConfig {
1429            time_bars_origin_offset: HashMap::from([("Minute".to_string(), 5_000_000_000)]),
1430            emit_quotes_from_book: true,
1431            emit_quotes_from_book_depths: true,
1432            ..Default::default()
1433        };
1434
1435        let converted: DataEngineConfig = config.into();
1436
1437        assert_eq!(converted.time_bars_origin_offset.len(), 1);
1438        assert_eq!(
1439            converted.time_bars_origin_offset[&BarAggregation::Minute],
1440            Duration::from_secs(5),
1441        );
1442        assert!(converted.emit_quotes_from_book);
1443        assert!(converted.emit_quotes_from_book_depths);
1444    }
1445
1446    #[rstest]
1447    fn test_live_exec_engine_config_converts_to_exec_engine_config() {
1448        let config = LiveExecutionEngineConfig {
1449            load_cache: false,
1450            snapshot_orders: true,
1451            snapshot_positions_interval_secs: Some(30.0),
1452            filter_unclaimed_external_orders: true,
1453            purge_closed_orders_interval_mins: Some(5),
1454            purge_closed_orders_buffer_mins: Some(1),
1455            purge_closed_positions_interval_mins: Some(10),
1456            purge_closed_positions_buffer_mins: Some(2),
1457            purge_account_events_interval_mins: Some(15),
1458            purge_account_events_lookback_mins: Some(3),
1459            ..Default::default()
1460        };
1461
1462        let converted: ExecutionEngineConfig = config.into();
1463
1464        assert!(!converted.load_cache);
1465        assert!(converted.snapshot_orders);
1466        assert_eq!(converted.snapshot_positions_interval_secs, Some(30.0));
1467        assert!(converted.filter_unclaimed_external_orders);
1468        assert_eq!(converted.purge_closed_orders_interval_mins, Some(5));
1469        assert_eq!(converted.purge_closed_orders_buffer_mins, Some(1));
1470        assert_eq!(converted.purge_closed_positions_interval_mins, Some(10));
1471        assert_eq!(converted.purge_closed_positions_buffer_mins, Some(2));
1472        assert_eq!(converted.purge_account_events_interval_mins, Some(15));
1473        assert_eq!(converted.purge_account_events_lookback_mins, Some(3));
1474        // Pinned on for live regardless of the `ExecutionEngineConfig` default
1475        assert!(converted.carry_replay_events_on_reopen);
1476    }
1477
1478    #[rstest]
1479    fn test_live_exec_engine_config_converts_to_execution_manager_config() {
1480        let config = LiveExecutionEngineConfig {
1481            reconciliation: false,
1482            reconciliation_lookback_mins: Some(45),
1483            reconciliation_instrument_ids: Some(vec![
1484                "ETHUSDT.BINANCE".to_string(),
1485                "BTCUSDT.BINANCE".to_string(),
1486            ]),
1487            filter_unclaimed_external_orders: true,
1488            filter_position_reports: true,
1489            filtered_client_order_ids: Some(vec!["O-001".to_string(), "O-002".to_string()]),
1490            generate_missing_orders: false,
1491            inflight_check_interval_ms: 321,
1492            inflight_check_threshold_ms: 654,
1493            inflight_check_retries: 7,
1494            open_check_interval_secs: Some(1.5),
1495            open_check_lookback_mins: Some(9),
1496            open_check_threshold_ms: 234,
1497            open_check_missing_retries: 4,
1498            open_check_open_only: false,
1499            max_single_order_queries_per_cycle: 8,
1500            single_order_query_delay_ms: 76,
1501            position_check_interval_secs: Some(2.5),
1502            position_check_lookback_mins: 11,
1503            position_check_threshold_ms: 345,
1504            position_check_retries: 6,
1505            purge_closed_orders_buffer_mins: Some(12),
1506            purge_closed_positions_buffer_mins: Some(13),
1507            purge_account_events_lookback_mins: Some(14),
1508            purge_from_database: true,
1509            ..Default::default()
1510        };
1511
1512        let converted = ExecutionManagerConfig::from(&config);
1513
1514        assert_eq!(converted.lookback_mins, Some(45));
1515        assert_eq!(converted.reconciliation_instrument_ids.len(), 2);
1516        assert!(
1517            converted
1518                .reconciliation_instrument_ids
1519                .contains(&InstrumentId::from("ETHUSDT.BINANCE"))
1520        );
1521        assert!(
1522            converted
1523                .reconciliation_instrument_ids
1524                .contains(&InstrumentId::from("BTCUSDT.BINANCE"))
1525        );
1526        assert!(converted.filter_unclaimed_external);
1527        assert!(converted.filter_position_reports);
1528        assert_eq!(converted.filtered_client_order_ids.len(), 2);
1529        assert!(
1530            converted
1531                .filtered_client_order_ids
1532                .contains(&ClientOrderId::from("O-001"))
1533        );
1534        assert!(
1535            converted
1536                .filtered_client_order_ids
1537                .contains(&ClientOrderId::from("O-002"))
1538        );
1539        assert!(!converted.generate_missing_orders);
1540        assert_eq!(converted.inflight_threshold_ms, 654);
1541        assert_eq!(converted.inflight_max_retries, 7);
1542        assert_eq!(converted.open_check_lookback_mins, Some(9));
1543        assert_eq!(
1544            converted.open_check_threshold_ns,
1545            DurationNanos::from_millis(234)
1546        );
1547        assert_eq!(converted.open_check_missing_retries, 4);
1548        assert!(!converted.open_check_open_only);
1549        assert_eq!(converted.max_single_order_queries_per_cycle, 8);
1550        assert_eq!(converted.single_order_query_delay_ms, 76);
1551        assert_eq!(converted.position_check_lookback_mins, 11);
1552        assert_eq!(
1553            converted.position_check_threshold_ns,
1554            DurationNanos::from_millis(345)
1555        );
1556        assert_eq!(converted.position_check_retries, 6);
1557        assert_eq!(converted.purge_closed_orders_buffer_mins, Some(12));
1558        assert_eq!(converted.purge_closed_positions_buffer_mins, Some(13));
1559        assert_eq!(converted.purge_account_events_lookback_mins, Some(14));
1560        assert!(converted.purge_from_database);
1561    }
1562
1563    #[rstest]
1564    fn test_live_risk_engine_config_converts_to_risk_engine_config() {
1565        let config = LiveRiskEngineConfig {
1566            bypass: true,
1567            max_order_submit_rate: "12/00:00:03".to_string(),
1568            max_order_modify_rate: "7/00:00:05".to_string(),
1569            max_notional_per_order: HashMap::from([(
1570                "ETHUSDT.BINANCE".to_string(),
1571                "1000.5".to_string(),
1572            )]),
1573            full_position_exit_venues: vec![Venue::from("BINANCE")],
1574            debug: true,
1575            ..Default::default()
1576        };
1577
1578        let converted: RiskEngineConfig = config.into();
1579
1580        assert!(converted.bypass);
1581        assert_eq!(
1582            converted.max_order_submit,
1583            RateLimit::new(12, DurationNanos::from_secs(3))
1584        );
1585        assert_eq!(
1586            converted.max_order_modify,
1587            RateLimit::new(7, DurationNanos::from_secs(5))
1588        );
1589        assert_eq!(
1590            converted.max_notional_per_order[&"ETHUSDT.BINANCE".parse::<InstrumentId>().unwrap()],
1591            Decimal::from_str("1000.5").unwrap(),
1592        );
1593        assert_eq!(
1594            converted.full_position_exit_venues,
1595            [Venue::from("BINANCE")].into_iter().collect(),
1596        );
1597        assert!(converted.debug);
1598    }
1599
1600    #[rstest]
1601    fn test_validate_runtime_support_accepts_exec_engine_snapshot_orders() {
1602        let config = LiveNodeConfig {
1603            exec_engine: LiveExecutionEngineConfig {
1604                snapshot_orders: true,
1605                ..Default::default()
1606            },
1607            ..Default::default()
1608        };
1609
1610        assert!(config.validate_runtime_support().is_ok());
1611    }
1612
1613    #[rstest]
1614    fn test_validate_runtime_support_accepts_exec_engine_snapshot_positions() {
1615        let config = LiveNodeConfig {
1616            exec_engine: LiveExecutionEngineConfig {
1617                snapshot_positions: true,
1618                ..Default::default()
1619            },
1620            ..Default::default()
1621        };
1622
1623        assert!(config.validate_runtime_support().is_ok());
1624    }
1625
1626    #[rstest]
1627    fn test_validate_runtime_support_rejects_overflowing_minute_fields() {
1628        let config = LiveNodeConfig {
1629            exec_engine: LiveExecutionEngineConfig {
1630                open_check_lookback_mins: Some(u32::MAX),
1631                purge_closed_orders_interval_mins: Some(u32::MAX),
1632                purge_closed_positions_interval_mins: Some(u32::MAX),
1633                purge_account_events_interval_mins: Some(u32::MAX),
1634                ..Default::default()
1635            },
1636            ..Default::default()
1637        };
1638
1639        let error = config.validate_runtime_support().unwrap_err();
1640        assert_eq!(
1641            error,
1642            ConfigError::Multiple {
1643                errors: vec![
1644                    ConfigError::range(
1645                        "LiveExecutionEngineConfig.open_check_lookback_mins",
1646                        "4294967295 minutes (must fit in `u64` nanoseconds)",
1647                    ),
1648                    ConfigError::range(
1649                        "LiveExecutionEngineConfig.purge_closed_orders_interval_mins",
1650                        "4294967295 minutes (must fit in `u64` nanoseconds)",
1651                    ),
1652                    ConfigError::range(
1653                        "LiveExecutionEngineConfig.purge_closed_positions_interval_mins",
1654                        "4294967295 minutes (must fit in `u64` nanoseconds)",
1655                    ),
1656                    ConfigError::range(
1657                        "LiveExecutionEngineConfig.purge_account_events_interval_mins",
1658                        "4294967295 minutes (must fit in `u64` nanoseconds)",
1659                    ),
1660                ],
1661            }
1662        );
1663    }
1664
1665    #[rstest]
1666    #[case(0)]
1667    #[case(307_445_734)]
1668    fn test_validate_runtime_support_accepts_purge_retention_boundaries(#[case] mins: u32) {
1669        let config = LiveNodeConfig {
1670            exec_engine: LiveExecutionEngineConfig {
1671                purge_closed_orders_buffer_mins: Some(mins),
1672                purge_closed_positions_buffer_mins: Some(mins),
1673                purge_account_events_lookback_mins: Some(mins),
1674                ..Default::default()
1675            },
1676            ..Default::default()
1677        };
1678
1679        assert!(config.validate_runtime_support().is_ok());
1680    }
1681
1682    #[rstest]
1683    fn test_validate_runtime_support_rejects_overflowing_purge_retention_minutes() {
1684        let config = LiveNodeConfig {
1685            exec_engine: LiveExecutionEngineConfig {
1686                purge_closed_orders_buffer_mins: Some(307_445_735),
1687                purge_closed_positions_buffer_mins: Some(307_445_735),
1688                purge_account_events_lookback_mins: Some(307_445_735),
1689                ..Default::default()
1690            },
1691            ..Default::default()
1692        };
1693
1694        let error = config.validate_runtime_support().unwrap_err();
1695
1696        let ConfigError::Multiple { errors } = error else {
1697            panic!("Expected multiple config errors, received {error:?}");
1698        };
1699
1700        assert_eq!(errors.len(), 3);
1701
1702        for field in [
1703            "LiveExecutionEngineConfig.purge_closed_orders_buffer_mins",
1704            "LiveExecutionEngineConfig.purge_closed_positions_buffer_mins",
1705            "LiveExecutionEngineConfig.purge_account_events_lookback_mins",
1706        ] {
1707            assert!(errors.iter().any(
1708                |e| matches!(e, ConfigError::Range { field: error_field, .. } if error_field == field)
1709            ));
1710        }
1711    }
1712
1713    #[rstest]
1714    fn test_validate_runtime_support_rejects_invalid_rate_limit() {
1715        let config = LiveNodeConfig {
1716            risk_engine: LiveRiskEngineConfig {
1717                max_order_submit_rate: "bad-rate".to_string(),
1718                ..Default::default()
1719            },
1720            ..Default::default()
1721        };
1722
1723        let error = config.validate_runtime_support().unwrap_err().to_string();
1724        assert!(error.contains("LiveRiskEngineConfig.max_order_submit_rate"));
1725    }
1726
1727    #[rstest]
1728    fn test_parse_rate_limit_rejects_invalid_format_with_field_path() {
1729        let error =
1730            parse_rate_limit("LiveRiskEngineConfig.max_order_submit_rate", "bad-rate").unwrap_err();
1731
1732        assert_eq!(
1733            error,
1734            ConfigError::InvalidFormat {
1735                field: "LiveRiskEngineConfig.max_order_submit_rate".to_string(),
1736                expected: RATE_LIMIT_FORMAT.to_string(),
1737            },
1738        );
1739    }
1740
1741    #[rstest]
1742    fn test_validate_max_notional_per_order_collects_entry_errors() {
1743        let error = validate_max_notional_per_order(
1744            "LiveRiskEngineConfig.max_notional_per_order",
1745            &HashMap::from([("INVALID".to_string(), "not-a-decimal".to_string())]),
1746        )
1747        .unwrap_err();
1748
1749        match error {
1750            ConfigError::Multiple { errors } => {
1751                assert_eq!(errors.len(), 2);
1752                assert!(matches!(
1753                    &errors[0],
1754                    ConfigError::InvalidReference {
1755                        field,
1756                        reference,
1757                        ..
1758                    } if field == "LiveRiskEngineConfig.max_notional_per_order[INVALID]"
1759                        && reference == "instrument ID"
1760                ));
1761                assert!(matches!(
1762                    &errors[1],
1763                    ConfigError::InvalidValue { field, reason }
1764                        if field == "LiveRiskEngineConfig.max_notional_per_order[INVALID]"
1765                            && reason.contains("invalid notional")
1766                ));
1767            }
1768            _ => panic!("Expected multiple config errors, received {error:?}"),
1769        }
1770    }
1771
1772    #[rstest]
1773    #[case(-1.0)]
1774    #[case(f64::NAN)]
1775    #[case(f64::INFINITY)]
1776    #[case(f64::NEG_INFINITY)]
1777    #[case::above_max(DELAY_MAX_SECS + 1.0)]
1778    #[case::overflows_instant(1e19)]
1779    #[case::overflows_duration(1e20)]
1780    fn test_validate_runtime_support_rejects_hostile_startup_delay(#[case] value: f64) {
1781        let config = LiveNodeConfig {
1782            exec_engine: LiveExecutionEngineConfig {
1783                reconciliation_startup_delay_secs: value,
1784                ..Default::default()
1785            },
1786            ..Default::default()
1787        };
1788
1789        let error = config.validate_runtime_support().unwrap_err().to_string();
1790        assert!(error.contains("reconciliation_startup_delay_secs"));
1791    }
1792
1793    #[rstest]
1794    #[case(0.0)]
1795    #[case(10.0)]
1796    #[case::at_max(DELAY_MAX_SECS)]
1797    fn test_validate_runtime_support_accepts_bounded_startup_delay(#[case] value: f64) {
1798        let config = LiveNodeConfig {
1799            exec_engine: LiveExecutionEngineConfig {
1800                reconciliation_startup_delay_secs: value,
1801                ..Default::default()
1802            },
1803            ..Default::default()
1804        };
1805
1806        assert_eq!(config.validate_runtime_support(), Ok(()));
1807
1808        // An accepted delay must survive the schedule arithmetic in `LiveNode::run_with_mode`
1809        let delay = Duration::from_secs_f64(value);
1810        assert!(std::time::Instant::now().checked_add(delay).is_some());
1811    }
1812
1813    #[rstest]
1814    #[case(0.0)]
1815    #[case(0.5e-9)]
1816    #[case(-1.0)]
1817    #[case(f64::NAN)]
1818    #[case(f64::INFINITY)]
1819    #[case(f64::NEG_INFINITY)]
1820    #[case(f64::MAX)]
1821    fn test_validate_runtime_support_rejects_invalid_exec_intervals(#[case] value: f64) {
1822        let configs = [
1823            (
1824                "LiveExecutionEngineConfig.snapshot_positions_interval_secs",
1825                LiveExecutionEngineConfig {
1826                    snapshot_positions_interval_secs: Some(value),
1827                    ..Default::default()
1828                },
1829            ),
1830            (
1831                "LiveExecutionEngineConfig.open_check_interval_secs",
1832                LiveExecutionEngineConfig {
1833                    open_check_interval_secs: Some(value),
1834                    ..Default::default()
1835                },
1836            ),
1837            (
1838                "LiveExecutionEngineConfig.position_check_interval_secs",
1839                LiveExecutionEngineConfig {
1840                    position_check_interval_secs: Some(value),
1841                    ..Default::default()
1842                },
1843            ),
1844            (
1845                "LiveExecutionEngineConfig.own_books_audit_interval_secs",
1846                LiveExecutionEngineConfig {
1847                    own_books_audit_interval_secs: Some(value),
1848                    ..Default::default()
1849                },
1850            ),
1851        ];
1852
1853        for (expected_field, config) in configs {
1854            let error = config.validate_runtime_support().unwrap_err();
1855
1856            assert!(matches!(
1857                error,
1858                ConfigError::Range { field, .. } if field == expected_field
1859            ));
1860        }
1861    }
1862
1863    #[rstest]
1864    fn test_validate_runtime_support_accepts_valid_exec_intervals() {
1865        let config = LiveExecutionEngineConfig {
1866            snapshot_positions_interval_secs: Some(1.25),
1867            open_check_interval_secs: Some(2.5),
1868            position_check_interval_secs: Some(3.75),
1869            own_books_audit_interval_secs: Some(4.5),
1870            ..Default::default()
1871        };
1872
1873        assert!(config.validate_runtime_support().is_ok());
1874    }
1875
1876    #[cfg(feature = "python")]
1877    #[rstest]
1878    fn test_duration_from_secs_f64_accepts_valid_value() {
1879        let duration = duration_from_secs_f64("LiveNodeConfig.timeout_connection", 1.5).unwrap();
1880
1881        assert_eq!(duration, Duration::from_millis(1_500));
1882    }
1883
1884    #[cfg(feature = "python")]
1885    #[rstest]
1886    #[case(-1.0)]
1887    #[case(f64::NAN)]
1888    #[case(f64::INFINITY)]
1889    #[case(86_400.1)]
1890    fn test_duration_from_secs_f64_rejects_invalid_values(#[case] value: f64) {
1891        let error = duration_from_secs_f64("LiveNodeConfig.timeout_connection", value).unwrap_err();
1892
1893        match error {
1894            ConfigError::Range { field, reason } => {
1895                assert_eq!(field, "LiveNodeConfig.timeout_connection");
1896                assert!(reason.contains("must be finite, non-negative, and <= 86400"));
1897            }
1898            _ => panic!("Expected range config error, received {error:?}"),
1899        }
1900    }
1901
1902    #[rstest]
1903    fn test_validate_runtime_support_rejects_invalid_reconciliation_instrument_id() {
1904        let config = LiveNodeConfig {
1905            exec_engine: LiveExecutionEngineConfig {
1906                reconciliation_instrument_ids: Some(vec!["INVALID".to_string()]),
1907                ..Default::default()
1908            },
1909            ..Default::default()
1910        };
1911
1912        let error = config.validate_runtime_support().unwrap_err().to_string();
1913        assert!(error.contains("reconciliation_instrument_ids"));
1914    }
1915
1916    #[rstest]
1917    fn test_parse_rate_limit_happy_path() {
1918        let limit = parse_rate_limit("test.rate_limit", "150/00:00:02").unwrap();
1919        assert_eq!(limit, RateLimit::new(150, DurationNanos::from_secs(2)));
1920    }
1921
1922    #[rstest]
1923    fn test_parse_rate_limit_rejects_interval_overflow() {
1924        let err = parse_rate_limit("test.rate_limit", &format!("10/{:02}:00:00", u64::MAX))
1925            .unwrap_err()
1926            .to_string();
1927        assert!(err.contains("interval exceeds the supported range"));
1928    }
1929
1930    #[rstest]
1931    fn test_parse_rate_limit_rejects_trailing_component() {
1932        let err = parse_rate_limit("test.rate_limit", "10/00:00:01:99")
1933            .unwrap_err()
1934            .to_string();
1935        assert!(err.contains("expected 'limit/HH:MM:SS'"));
1936    }
1937
1938    #[rstest]
1939    fn test_parse_rate_limit_rejects_zero_limit() {
1940        let err = parse_rate_limit("test.rate_limit", "0/00:00:01")
1941            .unwrap_err()
1942            .to_string();
1943        assert!(err.contains("Invalid limit"));
1944        assert!(err.contains("must be non-zero"));
1945    }
1946
1947    #[rstest]
1948    fn test_parse_rate_limit_rejects_zero_interval() {
1949        let err = parse_rate_limit("test.rate_limit", "100/00:00:00")
1950            .unwrap_err()
1951            .to_string();
1952        assert!(err.contains("Invalid interval_ns"));
1953        assert!(err.contains("must be non-zero"));
1954    }
1955
1956    #[rstest]
1957    fn test_validate_runtime_support_rejects_exec_engine_qsize() {
1958        let config = LiveNodeConfig {
1959            exec_engine: LiveExecutionEngineConfig {
1960                qsize: 1,
1961                ..Default::default()
1962            },
1963            ..Default::default()
1964        };
1965
1966        let error = config.validate_runtime_support().unwrap_err();
1967        assert_eq!(
1968            error.to_string(),
1969            "LiveExecutionEngineConfig.qsize is not supported by the Rust live runtime yet"
1970        );
1971    }
1972
1973    #[rstest]
1974    fn test_validate_runtime_support_rejects_emulator() {
1975        let config = LiveNodeConfig {
1976            emulator: Some(OrderEmulatorConfig::default()),
1977            ..Default::default()
1978        };
1979
1980        let error = config.validate_runtime_support().unwrap_err().to_string();
1981        assert!(error.contains("emulator"));
1982    }
1983
1984    #[rstest]
1985    fn test_validate_runtime_support_rejects_loop_debug() {
1986        let config = LiveNodeConfig {
1987            loop_debug: true,
1988            ..Default::default()
1989        };
1990
1991        let error = config.validate_runtime_support().unwrap_err().to_string();
1992        assert!(error.contains("loop_debug"));
1993    }
1994
1995    #[rstest]
1996    fn test_validate_runtime_support_accepts_file_config() {
1997        use nautilus_common::logging::writer::FileWriterConfig;
1998
1999        let config = LiveNodeConfig {
2000            logging: LoggerConfig {
2001                file_config: Some(FileWriterConfig::default()),
2002                ..Default::default()
2003            },
2004            ..Default::default()
2005        };
2006
2007        assert!(config.validate_runtime_support().is_ok());
2008    }
2009
2010    #[rstest]
2011    fn test_validate_runtime_support_accepts_clear_log_file() {
2012        let config = LiveNodeConfig {
2013            logging: LoggerConfig {
2014                clear_log_file: true,
2015                ..Default::default()
2016            },
2017            ..Default::default()
2018        };
2019
2020        assert!(config.validate_runtime_support().is_ok());
2021    }
2022
2023    #[rstest]
2024    fn test_validate_runtime_support_rejects_invalid_time_bars_origin_offset_key() {
2025        let config = LiveNodeConfig {
2026            data_engine: LiveDataEngineConfig {
2027                time_bars_origin_offset: HashMap::from([("INVALID".to_string(), 1_000)]),
2028                ..Default::default()
2029            },
2030            ..Default::default()
2031        };
2032
2033        let error = config.validate_runtime_support().unwrap_err().to_string();
2034        assert!(error.contains("time_bars_origin_offset"));
2035    }
2036
2037    #[rstest]
2038    fn test_validate_runtime_support_rejects_empty_plugin_path() {
2039        let config = LiveNodeConfig {
2040            plugins: vec![PluginConfig {
2041                type_name: "ExampleActor".to_string(),
2042                ..Default::default()
2043            }],
2044            ..Default::default()
2045        };
2046
2047        let error = config.validate_runtime_support().unwrap_err().to_string();
2048        assert!(error.contains("plugins[0].path"));
2049    }
2050
2051    #[rstest]
2052    fn test_validate_runtime_support_rejects_empty_plugin_type_name() {
2053        let config = LiveNodeConfig {
2054            plugins: vec![PluginConfig {
2055                path: "./libexample.so".to_string(),
2056                ..Default::default()
2057            }],
2058            ..Default::default()
2059        };
2060
2061        let error = config.validate_runtime_support().unwrap_err().to_string();
2062        assert!(error.contains("plugins[0].type_name"));
2063    }
2064
2065    #[rstest]
2066    fn test_validate_runtime_support_rejects_invalid_plugin_sha256() {
2067        let config = LiveNodeConfig {
2068            plugins: vec![PluginConfig {
2069                path: "./libexample.so".to_string(),
2070                type_name: "ExampleActor".to_string(),
2071                sha256: Some("not-a-digest".to_string()),
2072                ..Default::default()
2073            }],
2074            ..Default::default()
2075        };
2076
2077        let error = config.validate_runtime_support().unwrap_err().to_string();
2078        assert!(error.contains("sha256"));
2079    }
2080
2081    #[rstest]
2082    // `allow` not `expect`: nightly clippy does not fire `float_cmp` inside `assert_eq!`
2083    #[allow(
2084        clippy::float_cmp,
2085        reason = "asserts the exact configured default with no arithmetic involved"
2086    )]
2087    fn test_live_exec_engine_config_defaults() {
2088        let config = LiveExecutionEngineConfig::default();
2089
2090        assert!(config.load_cache);
2091        assert!(!config.snapshot_orders);
2092        assert!(!config.snapshot_positions);
2093        assert_eq!(config.snapshot_positions_interval_secs, None);
2094        assert_eq!(config.external_clients, None);
2095        assert!(!config.debug);
2096        assert!(!config.manage_own_order_books);
2097        assert!(!config.allow_overfills);
2098        assert!(config.reconciliation);
2099        assert_eq!(config.reconciliation_startup_delay_secs, 10.0);
2100        assert_eq!(config.reconciliation_lookback_mins, None);
2101        assert_eq!(config.reconciliation_instrument_ids, None);
2102        assert_eq!(config.filtered_client_order_ids, None);
2103        assert!(!config.filter_unclaimed_external_orders);
2104        assert!(!config.filter_position_reports);
2105        assert!(config.generate_missing_orders);
2106        assert_eq!(config.inflight_check_interval_ms, 2_000);
2107        assert_eq!(config.inflight_check_threshold_ms, 5_000);
2108        assert_eq!(config.inflight_check_retries, 5);
2109        assert_eq!(
2110            config.submission_recovery_policy,
2111            SubmissionRecoveryPolicy::ResolveLocally,
2112        );
2113        assert_eq!(config.open_check_threshold_ms, 5_000);
2114        assert_eq!(config.open_check_lookback_mins, Some(60));
2115        assert_eq!(config.open_check_missing_retries, 5);
2116        assert!(config.open_check_open_only);
2117        assert_eq!(config.max_single_order_queries_per_cycle, 10);
2118        assert_eq!(config.position_check_threshold_ms, 5_000);
2119        assert_eq!(config.position_check_retries, 3);
2120        assert!(!config.purge_from_database);
2121        assert_eq!(config.qsize, 100_000);
2122    }
2123
2124    #[rstest]
2125    fn test_submission_recovery_policy_omitted() {
2126        let deserialized: LiveExecutionEngineConfig = serde_json::from_str("{}").unwrap();
2127        let built = LiveExecutionEngineConfig::builder().build();
2128
2129        assert_eq!(
2130            deserialized.submission_recovery_policy,
2131            SubmissionRecoveryPolicy::ResolveLocally,
2132        );
2133        assert_eq!(
2134            built.submission_recovery_policy,
2135            SubmissionRecoveryPolicy::ResolveLocally,
2136        );
2137    }
2138
2139    #[rstest]
2140    #[case(SubmissionRecoveryPolicy::ResolveLocally, "resolve_locally")]
2141    #[case(SubmissionRecoveryPolicy::RetainUnresolved, "retain_unresolved")]
2142    fn test_submission_recovery_policy_config_round_trip(
2143        #[case] policy: SubmissionRecoveryPolicy,
2144        #[case] serialized_policy: &str,
2145    ) {
2146        let config = LiveExecutionEngineConfig::builder()
2147            .submission_recovery_policy(policy)
2148            .build();
2149        let serialized = serde_json::to_value(&config).unwrap();
2150        let deserialized: LiveExecutionEngineConfig =
2151            serde_json::from_value(serialized.clone()).unwrap();
2152        let manager_config = ExecutionManagerConfig::from(&deserialized);
2153
2154        assert_eq!(serialized["submission_recovery_policy"], serialized_policy);
2155        assert_eq!(deserialized, config);
2156        assert_eq!(manager_config.submission_recovery_policy, policy);
2157    }
2158
2159    #[rstest]
2160    #[case(r#"{"submission_recovery_policy":"retry_forever"}"#)]
2161    #[case(r#"{"submission_recovery_policy":"RETAIN_UNRESOLVED"}"#)]
2162    #[case(r#"{"submission_recovery_policy":1}"#)]
2163    #[case(r#"{"submission_recovery_policy":null}"#)]
2164    fn test_submission_recovery_policy_rejects_invalid_json(#[case] json: &str) {
2165        assert!(serde_json::from_str::<LiveExecutionEngineConfig>(json).is_err());
2166    }
2167
2168    #[rstest]
2169    fn test_live_data_engine_config_defaults() {
2170        let config = LiveDataEngineConfig::default();
2171
2172        assert!(config.time_bars_build_with_no_updates);
2173        assert!(config.time_bars_timestamp_on_close);
2174        assert!(!config.time_bars_skip_first_non_full_bar);
2175        assert_eq!(config.time_bars_interval_type, BarIntervalType::LeftOpen);
2176        assert_eq!(config.time_bars_build_delay, 0);
2177        assert!(config.time_bars_origin_offset.is_empty());
2178        assert!(!config.validate_data_sequence);
2179        assert!(!config.buffer_deltas);
2180        assert!(!config.emit_quotes_from_book);
2181        assert!(!config.emit_quotes_from_book_depths);
2182        assert_eq!(config.external_clients, None);
2183        assert!(!config.debug);
2184        assert_eq!(config.qsize, 100_000);
2185    }
2186
2187    #[rstest]
2188    fn test_live_risk_engine_config_defaults() {
2189        let config = LiveRiskEngineConfig::default();
2190
2191        assert!(!config.bypass);
2192        assert_eq!(config.max_order_submit_rate, DEFAULT_ORDER_RATE_LIMIT);
2193        assert_eq!(config.max_order_modify_rate, DEFAULT_ORDER_RATE_LIMIT);
2194        assert!(config.max_notional_per_order.is_empty());
2195        assert!(config.full_position_exit_venues.is_empty());
2196        assert!(!config.debug);
2197        assert_eq!(config.qsize, 100_000);
2198    }
2199
2200    #[rstest]
2201    fn test_routing_config_default() {
2202        let config = RoutingConfig::default();
2203
2204        assert!(!config.default);
2205        assert_eq!(config.venues, None);
2206    }
2207
2208    #[rstest]
2209    fn test_data_client_config_default() {
2210        let config = DataClientConfig::default();
2211
2212        assert!(!config.handle_revised_bars);
2213        assert!(!config.instrument_provider.load_all);
2214        assert!(config.instrument_provider.load_ids.is_none());
2215        assert!(config.instrument_provider.filters.is_empty());
2216        assert!(config.instrument_provider.filter_callable.is_none());
2217        assert!(config.instrument_provider.log_warnings);
2218        assert!(!config.routing.default);
2219    }
2220
2221    #[rstest]
2222    fn test_data_client_config_rejects_unknown_field() {
2223        let error = serde_json::from_str::<DataClientConfig>(
2224            r#"{"handle_revised_bars":true,"unexpected":true}"#,
2225        )
2226        .unwrap_err();
2227
2228        assert!(error.to_string().contains("unknown field `unexpected`"));
2229    }
2230
2231    #[rstest]
2232    fn test_data_client_config_rejects_unknown_nested_field() {
2233        let error = serde_json::from_str::<DataClientConfig>(
2234            r#"{"instrument_provider":{"load_all":true,"instrument_provider":{"load_all":false}}}"#,
2235        )
2236        .unwrap_err();
2237
2238        assert!(
2239            error
2240                .to_string()
2241                .contains("unknown field `instrument_provider`")
2242        );
2243    }
2244
2245    #[rstest]
2246    fn test_live_node_config_toml_minimal() {
2247        let config: LiveNodeConfig = toml::from_str(
2248            r#"
2249environment = "Live"
2250trader_id = "TRADER-042"
2251
2252[data_engine]
2253debug = true
2254
2255[risk_engine]
2256bypass = false
2257
2258[exec_engine]
2259reconciliation = false
2260
2261[data_clients.hyperliquid]
2262handle_revised_bars = true
2263
2264[exec_clients.hyperliquid]
2265routing = { default = true, venues = ["HYPERLIQUID"] }
2266instrument_provider = { load_all = true }
2267
2268[[plugins]]
2269path = "./target/debug/examples/libcustom_data_plugin.so"
2270type_name = "ExampleStrategy"
2271config = { strategy_id = "ExampleStrategy-001", threshold = 10 }
2272"#,
2273        )
2274        .unwrap();
2275
2276        assert_eq!(config.environment, Environment::Live);
2277        assert_eq!(config.trader_id, TraderId::from("TRADER-042"));
2278        assert!(config.data_engine.debug);
2279        assert!(!config.risk_engine.bypass);
2280        assert!(!config.exec_engine.reconciliation);
2281        assert!(config.data_clients["hyperliquid"].handle_revised_bars);
2282        let exec_client = &config.exec_clients["hyperliquid"];
2283        assert!(exec_client.routing.default);
2284        assert_eq!(
2285            exec_client.routing.venues,
2286            Some(vec!["HYPERLIQUID".to_string()]),
2287        );
2288        assert!(exec_client.instrument_provider.load_all);
2289        assert_eq!(config.plugins.len(), 1);
2290        assert_eq!(
2291            config.plugins[0].path,
2292            "./target/debug/examples/libcustom_data_plugin.so"
2293        );
2294        assert_eq!(config.plugins[0].type_name, "ExampleStrategy");
2295        assert_eq!(
2296            config.plugins[0].config["strategy_id"],
2297            serde_json::json!("ExampleStrategy-001")
2298        );
2299        assert_eq!(config.plugins[0].config["threshold"], serde_json::json!(10));
2300    }
2301
2302    #[rstest]
2303    fn live_node_config_serde_roundtrip_with_event_store() {
2304        let config = LiveNodeConfig {
2305            event_store: Some(EventStoreConfig {
2306                channel_capacity: 5_000,
2307                ..Default::default()
2308            }),
2309            ..Default::default()
2310        };
2311
2312        let json = serde_json::to_string(&config).expect("serialize");
2313        let restored: LiveNodeConfig = serde_json::from_str(&json).expect("deserialize");
2314
2315        let restored_event_store = restored.event_store.expect("event_store present");
2316        assert_eq!(restored_event_store.channel_capacity, 5_000);
2317    }
2318
2319    #[rstest]
2320    fn live_node_config_default_has_no_event_store() {
2321        let config = LiveNodeConfig::default();
2322        assert!(config.event_store.is_none());
2323    }
2324}