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