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