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