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::{
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
58const 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
63const 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#[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 #[builder(default = true)]
93 pub time_bars_build_with_no_updates: bool,
94 #[builder(default = true)]
97 pub time_bars_timestamp_on_close: bool,
98 #[builder(default)]
100 pub time_bars_skip_first_non_full_bar: bool,
101 #[builder(default = BarIntervalType::LeftOpen)]
103 pub time_bars_interval_type: BarIntervalType,
104 #[builder(default)]
106 pub time_bars_build_delay: u64,
107 #[builder(default)]
111 pub time_bars_origin_offset: HashMap<String, u64>,
112 pub time_bars_time_zone: Option<String>,
114 #[builder(default)]
116 pub validate_data_sequence: bool,
117 #[builder(default)]
119 pub buffer_deltas: bool,
120 #[builder(default)]
122 pub emit_quotes_from_book: bool,
123 #[builder(default)]
125 pub emit_quotes_from_book_depths: bool,
126 pub external_clients: Option<Vec<ClientId>>,
130 #[builder(default)]
132 pub debug: bool,
133 #[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#[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 #[builder(default)]
192 pub bypass: bool,
193 #[builder(default = DEFAULT_ORDER_RATE_LIMIT.to_string())]
195 pub max_order_submit_rate: String,
196 #[builder(default = DEFAULT_ORDER_RATE_LIMIT.to_string())]
198 pub max_order_modify_rate: String,
199 #[builder(default)]
203 pub max_notional_per_order: HashMap<String, String>,
204 #[builder(default)]
208 pub full_position_exit_venues: Vec<Venue>,
209 #[builder(default)]
211 pub debug: bool,
212 #[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(¬ional)
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#[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 #[builder(default = true)]
412 pub load_cache: bool,
413 #[builder(default)]
417 pub snapshot_orders: bool,
418 #[builder(default)]
423 pub snapshot_positions: bool,
424 pub snapshot_positions_interval_secs: Option<f64>,
428 pub external_clients: Option<Vec<ClientId>>,
433 #[builder(default)]
435 pub debug: bool,
436 #[builder(default = true)]
438 pub reconciliation: bool,
439 #[builder(default = 10.0)]
441 pub reconciliation_startup_delay_secs: f64,
442 pub reconciliation_lookback_mins: Option<u32>,
444 pub reconciliation_instrument_ids: Option<Vec<String>>,
446 #[builder(default)]
448 pub filter_unclaimed_external_orders: bool,
449 #[builder(default)]
451 pub filter_position_reports: bool,
452 pub filtered_client_order_ids: Option<Vec<String>>,
454 #[builder(default = true)]
456 pub generate_missing_orders: bool,
457 #[builder(default = 2_000)]
459 pub inflight_check_interval_ms: u32,
460 #[builder(default = 5_000)]
462 pub inflight_check_threshold_ms: u32,
463 #[builder(default = 5)]
465 pub inflight_check_retries: u32,
466 #[builder(default)]
468 pub submission_recovery_policy: SubmissionRecoveryPolicy,
469 pub open_check_interval_secs: Option<f64>,
471 pub open_check_lookback_mins: Option<u32>,
474 #[builder(default = 5_000)]
476 pub open_check_threshold_ms: u32,
477 #[builder(default = 5)]
479 pub open_check_missing_retries: u32,
480 #[builder(default = true)]
482 pub open_check_open_only: bool,
483 #[builder(default = 10)]
485 pub max_single_order_queries_per_cycle: u32,
486 #[builder(default = 100)]
488 pub single_order_query_delay_ms: u32,
489 pub position_check_interval_secs: Option<f64>,
491 #[builder(default = 60)]
493 pub position_check_lookback_mins: u32,
494 #[builder(default = 5_000)]
496 pub position_check_threshold_ms: u32,
497 #[builder(default = 3)]
499 pub position_check_retries: u32,
500 pub purge_closed_orders_interval_mins: Option<u32>,
502 pub purge_closed_orders_buffer_mins: Option<u32>,
504 pub purge_closed_positions_interval_mins: Option<u32>,
506 pub purge_closed_positions_buffer_mins: Option<u32>,
508 pub purge_account_events_interval_mins: Option<u32>,
510 pub purge_account_events_lookback_mins: Option<u32>,
512 #[builder(default)]
514 pub purge_from_database: bool,
515 pub own_books_audit_interval_secs: Option<f64>,
518 #[builder(default = 100_000)]
520 pub qsize: u32,
521 #[builder(default)]
524 pub allow_overfills: bool,
525 #[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 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 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#[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 #[builder(default)]
633 pub default: bool,
634 pub venues: Option<Vec<String>>,
636}
637
638#[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 #[builder(default)]
652 pub load_all: bool,
653 pub load_ids: Option<Vec<String>>,
655 #[builder(default)]
657 pub filters: HashMap<String, serde_json::Value>,
658 pub filter_callable: Option<String>,
660 #[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#[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 #[builder(default)]
685 pub handle_revised_bars: bool,
686 #[builder(default)]
688 pub instrument_provider: InstrumentProviderConfig,
689 #[builder(default)]
691 pub routing: RoutingConfig,
692}
693
694#[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 #[builder(default)]
708 pub instrument_provider: InstrumentProviderConfig,
709 #[builder(default)]
711 pub routing: RoutingConfig,
712}
713
714#[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 pub path: String,
728 pub type_name: String,
730 #[builder(default)]
732 pub config: HashMap<String, serde_json::Value>,
733 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#[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 #[builder(default = Environment::Live)]
764 pub environment: Environment,
765 #[builder(default = TraderId::from("TRADER-001"))]
767 pub trader_id: TraderId,
768 #[builder(default)]
770 pub load_state: bool,
771 #[builder(default)]
773 pub save_state: bool,
774 #[builder(default)]
778 pub shutdown_on_error: bool,
779 #[builder(default)]
781 pub logging: LoggerConfig,
782 pub instance_id: Option<UUID4>,
784 #[builder(default = Duration::from_mins(1))]
786 pub timeout_connection: Duration,
787 #[builder(default = Duration::from_secs(30))]
789 pub timeout_reconciliation: Duration,
790 #[builder(default = Duration::from_secs(10))]
792 pub timeout_portfolio: Duration,
793 #[builder(default = Duration::from_secs(10))]
795 pub timeout_disconnection: Duration,
796 #[builder(default = Duration::from_secs(10))]
799 pub delay_post_stop: Duration,
800 #[builder(default = Duration::from_secs(5))]
802 pub timeout_shutdown: Duration,
803 pub cache: Option<CacheConfig>,
805 pub msgbus: Option<MessageBusConfig>,
807 pub portfolio: Option<PortfolioConfig>,
809 pub emulator: Option<OrderEmulatorConfig>,
811 #[cfg(feature = "streaming")]
813 pub streaming: Option<StreamingConfig>,
814 #[cfg(feature = "streaming")]
816 #[builder(default)]
817 pub catalogs: Vec<DataCatalogConfig>,
818 pub queue_monitor: Option<QueueMonitorConfig>,
820 pub event_store: Option<EventStoreConfig>,
826 #[builder(default)]
828 pub loop_debug: bool,
829 #[builder(default)]
831 pub data_engine: LiveDataEngineConfig,
832 #[builder(default)]
834 pub risk_engine: LiveRiskEngineConfig,
835 #[builder(default)]
837 pub exec_engine: LiveExecutionEngineConfig,
838 #[builder(default)]
840 pub data_clients: HashMap<String, DataClientConfig>,
841 #[builder(default)]
843 pub exec_clients: HashMap<String, ExecutionClientConfig>,
844 pub controller: Option<ImportableControllerConfig>,
846 #[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 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 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 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 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(
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}