1use std::{
17 collections::HashMap,
18 error::Error,
19 hash::{Hash, Hasher},
20};
21
22use derive_builder::Builder;
23use nautilus_core::{
24 UnixNanos,
25 correctness::{CorrectnessError, FAILED},
26};
27use serde::{Deserialize, Serialize};
28
29use crate::{
30 expressions::{Bindings, CompiledExpression, ExpressionError, compile_numeric},
31 identifiers::{InstrumentId, Symbol, Venue},
32 types::Price,
33};
34
35const MAX_INLINE_COMPONENTS: usize = 8;
36
37#[derive(Debug, thiserror::Error)]
38pub enum SyntheticInstrumentError {
39 #[error("{0}")]
40 Validation(#[from] CorrectnessError),
41 #[error("{source}")]
42 Expression {
43 #[source]
44 source: Box<dyn Error + Send + Sync + 'static>,
45 },
46 #[error("Missing price for component: {component_name}")]
47 MissingInput { component_name: String },
48 #[error("Expected {expected} input values, received {actual}")]
49 InputCountMismatch { expected: usize, actual: usize },
50 #[error("Non-finite input price for component {component_name}: {value}")]
51 NonFiniteInput { component_name: String, value: f64 },
52 #[error("Formula result produced invalid price: {source}")]
53 InvalidPriceResult {
54 #[source]
55 source: CorrectnessError,
56 },
57}
58
59impl SyntheticInstrumentError {
60 fn expression(source: ExpressionError) -> Self {
61 Self::Expression {
62 source: Box::new(source),
63 }
64 }
65}
66
67#[derive(Clone, Debug, Builder)]
72#[cfg_attr(
73 feature = "python",
74 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", from_py_object)
75)]
76#[cfg_attr(
77 feature = "python",
78 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
79)]
80pub struct SyntheticInstrument {
81 pub id: InstrumentId,
83 pub price_precision: u8,
85 pub price_increment: Price,
87 pub components: Vec<InstrumentId>,
89 pub formula: String,
91 pub ts_event: UnixNanos,
93 pub ts_init: UnixNanos,
95 #[builder(setter(skip), default)]
96 component_names: Vec<String>,
97 #[builder(setter(skip), default)]
98 compiled_formula: CompiledExpression,
99}
100
101impl Serialize for SyntheticInstrument {
102 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
103 where
104 S: serde::Serializer,
105 {
106 use serde::ser::SerializeStruct;
107 let mut state = serializer.serialize_struct("SyntheticInstrument", 7)?;
108 state.serialize_field("id", &self.id)?;
109 state.serialize_field("price_precision", &self.price_precision)?;
110 state.serialize_field("price_increment", &self.price_increment)?;
111 state.serialize_field("components", &self.components)?;
112 state.serialize_field("formula", &self.formula)?;
113 state.serialize_field("ts_event", &self.ts_event)?;
114 state.serialize_field("ts_init", &self.ts_init)?;
115 state.end()
116 }
117}
118
119impl<'de> Deserialize<'de> for SyntheticInstrument {
120 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
121 where
122 D: serde::Deserializer<'de>,
123 {
124 #[derive(Deserialize)]
125 struct Fields {
126 id: InstrumentId,
127 price_precision: u8,
128 price_increment: Price,
129 components: Vec<InstrumentId>,
130 formula: String,
131 ts_event: UnixNanos,
132 ts_init: UnixNanos,
133 }
134
135 let fields = Fields::deserialize(deserializer)?;
136 let component_names = component_names_from_components(&fields.components);
137 let compiled_formula =
138 compile_formula(&fields.formula, &component_names).map_err(serde::de::Error::custom)?;
139
140 Ok(Self {
141 id: fields.id,
142 price_precision: fields.price_precision,
143 price_increment: fields.price_increment,
144 components: fields.components,
145 formula: fields.formula,
146 ts_event: fields.ts_event,
147 ts_init: fields.ts_init,
148 component_names,
149 compiled_formula,
150 })
151 }
152}
153
154impl SyntheticInstrument {
155 pub fn new_checked(
165 symbol: Symbol,
166 price_precision: u8,
167 components: Vec<InstrumentId>,
168 formula: &str,
169 ts_event: UnixNanos,
170 ts_init: UnixNanos,
171 ) -> Result<Self, SyntheticInstrumentError> {
172 let price_increment =
173 Price::new_checked(10f64.powi(-i32::from(price_precision)), price_precision)?;
174 let component_names = component_names_from_components(&components);
175 let compiled_formula = compile_formula(formula, &component_names)?;
176
177 Ok(Self {
178 id: InstrumentId::new(symbol, Venue::synthetic()),
179 price_precision,
180 price_increment,
181 components,
182 formula: formula.to_string(),
183 component_names,
184 compiled_formula,
185 ts_event,
186 ts_init,
187 })
188 }
189
190 #[must_use]
192 pub fn is_valid_formula_for_components(formula: &str, components: &[InstrumentId]) -> bool {
193 let component_names = component_names_from_components(components);
194 compile_formula(formula, &component_names).is_ok()
195 }
196
197 #[must_use]
203 pub fn new(
204 symbol: Symbol,
205 price_precision: u8,
206 components: Vec<InstrumentId>,
207 formula: &str,
208 ts_event: UnixNanos,
209 ts_init: UnixNanos,
210 ) -> Self {
211 Self::new_checked(
212 symbol,
213 price_precision,
214 components,
215 formula,
216 ts_event,
217 ts_init,
218 )
219 .unwrap_or_else(|e| panic!("{FAILED}: {e}"))
220 }
221
222 #[must_use]
224 pub fn is_valid_formula(&self, formula: &str) -> bool {
225 Self::is_valid_formula_for_components(formula, &self.components)
226 }
227
228 pub fn change_formula(&mut self, formula: &str) -> Result<(), SyntheticInstrumentError> {
234 let compiled_formula = compile_formula(formula, &self.component_names)?;
235 self.formula = formula.to_string();
236 self.compiled_formula = compiled_formula;
237 Ok(())
238 }
239
240 pub fn calculate_from_map(
247 &self,
248 inputs: &HashMap<String, f64>,
249 ) -> Result<Price, SyntheticInstrumentError> {
250 let n = self.component_names.len();
251 let mut buf = [0.0_f64; MAX_INLINE_COMPONENTS];
252 let input_values: &[f64] = if n <= MAX_INLINE_COMPONENTS {
253 for (i, component_name) in self.component_names.iter().enumerate() {
254 buf[i] = *inputs.get(component_name).ok_or_else(|| {
255 SyntheticInstrumentError::MissingInput {
256 component_name: component_name.clone(),
257 }
258 })?;
259 }
260 &buf[..n]
261 } else {
262 let v: Result<Vec<f64>, _> = self
264 .component_names
265 .iter()
266 .map(|name| {
267 inputs.get(name).copied().ok_or_else(|| {
268 SyntheticInstrumentError::MissingInput {
269 component_name: name.clone(),
270 }
271 })
272 })
273 .collect();
274 return self.calculate(&v?);
275 };
276
277 self.calculate(input_values)
278 }
279
280 pub fn calculate(&self, inputs: &[f64]) -> Result<Price, SyntheticInstrumentError> {
288 if inputs.len() != self.component_names.len() {
289 return Err(SyntheticInstrumentError::InputCountMismatch {
290 expected: self.component_names.len(),
291 actual: inputs.len(),
292 });
293 }
294
295 for (i, value) in inputs.iter().enumerate() {
296 if !value.is_finite() {
297 return Err(SyntheticInstrumentError::NonFiniteInput {
298 component_name: self.component_names[i].clone(),
299 value: *value,
300 });
301 }
302 }
303
304 let price = self
305 .compiled_formula
306 .eval_number(inputs)
307 .map_err(SyntheticInstrumentError::expression)?;
308 Price::new_checked(price, self.price_precision)
309 .map_err(|source| SyntheticInstrumentError::InvalidPriceResult { source })
310 }
311}
312
313fn component_names_from_components(components: &[InstrumentId]) -> Vec<String> {
314 components.iter().map(ToString::to_string).collect()
315}
316
317fn build_bindings(component_names: &[String]) -> Result<Bindings, SyntheticInstrumentError> {
321 let mut bindings = Bindings::new();
322
323 for (slot, component_name) in component_names.iter().enumerate() {
324 bindings
325 .add(slot, component_name)
326 .map_err(SyntheticInstrumentError::expression)?;
327 }
328
329 for (slot, component_name) in component_names.iter().enumerate() {
330 let legacy_name = component_name.replace('-', "_");
331
332 if legacy_name != *component_name {
333 let _ = bindings.add_alias(slot, &legacy_name);
335 }
336 }
337
338 Ok(bindings)
339}
340
341fn compile_formula(
345 formula: &str,
346 component_names: &[String],
347) -> Result<CompiledExpression, SyntheticInstrumentError> {
348 let bindings = build_bindings(component_names)?;
349 compile_numeric(formula, &bindings).map_err(SyntheticInstrumentError::expression)
350}
351
352impl PartialEq<Self> for SyntheticInstrument {
353 fn eq(&self, other: &Self) -> bool {
354 self.id == other.id
355 }
356}
357
358impl Eq for SyntheticInstrument {}
359
360impl Hash for SyntheticInstrument {
361 fn hash<H: Hasher>(&self, state: &mut H) {
362 self.id.hash(state);
363 }
364}
365
366#[cfg(test)]
367mod tests {
368 use std::str::FromStr;
369
370 use rstest::rstest;
371
372 use super::*;
373 use crate::types::fixed::FIXED_PRECISION;
374
375 #[rstest]
376 fn test_calculate_from_map() {
377 let synth = SyntheticInstrument::default();
378 let mut inputs = HashMap::new();
379 inputs.insert("BTC.BINANCE".to_string(), 100.0);
380 inputs.insert("LTC.BINANCE".to_string(), 200.0);
381 let price = synth.calculate_from_map(&inputs).unwrap();
382
383 assert_eq!(price, Price::from("150.0"));
384 assert_eq!(
385 synth.formula,
386 "(BTC.BINANCE + LTC.BINANCE) / 2.0".to_string()
387 );
388 }
389
390 #[rstest]
391 fn test_calculate() {
392 let synth = SyntheticInstrument::default();
393 let inputs = vec![100.0, 200.0];
394 let price = synth.calculate(&inputs).unwrap();
395 assert_eq!(price, Price::from("150.0"));
396 }
397
398 #[rstest]
399 fn test_change_formula() {
400 let mut synth = SyntheticInstrument::default();
401 let new_formula = "(BTC.BINANCE + LTC.BINANCE) / 4";
402 synth.change_formula(new_formula).unwrap();
403
404 let mut inputs = HashMap::new();
405 inputs.insert("BTC.BINANCE".to_string(), 100.0);
406 inputs.insert("LTC.BINANCE".to_string(), 200.0);
407 let price = synth.calculate_from_map(&inputs).unwrap();
408
409 assert_eq!(price, Price::from("75.0"));
410 assert_eq!(synth.formula, new_formula);
411 }
412
413 #[rstest]
414 fn test_hyphenated_instrument_ids_preserve_raw_formula() {
415 let comp1 = InstrumentId::from_str("ETHUSDC-PERP.BINANCE_FUTURES").unwrap();
416 let comp2 = InstrumentId::from_str("ETH_USDC-PERP.HYPERLIQUID").unwrap();
417 let components = vec![comp1, comp2];
418 let raw_formula = format!("({comp1} + {comp2}) / 2.0");
419 let symbol = Symbol::from("ETH-USDC");
420 let synth =
421 SyntheticInstrument::new(symbol, 2, components, &raw_formula, 0.into(), 0.into());
422 let price = synth.calculate(&[100.0, 200.0]).unwrap();
423
424 assert_eq!(price, Price::from("150.0"));
425 assert_eq!(synth.formula, raw_formula);
426 }
427
428 #[rstest]
429 fn test_hyphenated_instrument_ids_support_legacy_sanitized_formula() {
430 let comp1 = InstrumentId::from_str("ETH-USDT-SWAP.OKX").unwrap();
431 let comp2 = InstrumentId::from_str("ETH-USDC-PERP.HYPERLIQUID").unwrap();
432 let components = vec![comp1, comp2];
433 let legacy_formula = format!(
434 "({} + {}) / 2.0",
435 components[0].to_string().replace('-', "_"),
436 components[1].to_string().replace('-', "_"),
437 );
438 let symbol = Symbol::from("ETH-USD");
439 let synth = SyntheticInstrument::new(
440 symbol,
441 2,
442 components.clone(),
443 &legacy_formula,
444 0.into(),
445 0.into(),
446 );
447 let mut inputs = HashMap::new();
448 inputs.insert(components[0].to_string(), 100.0);
449 inputs.insert(components[1].to_string(), 200.0);
450 let price = synth.calculate_from_map(&inputs).unwrap();
451
452 assert_eq!(price, Price::from("150.0"));
453 assert_eq!(synth.formula, legacy_formula);
454 }
455
456 #[rstest]
457 fn test_slashed_instrument_ids_calculate_from_map() {
458 let comp1 = InstrumentId::from_str("AUD/USD.SIM").unwrap();
459 let comp2 = InstrumentId::from_str("NZD/USD.SIM").unwrap();
460 let components = vec![comp1, comp2];
461 let raw_formula = format!("({} + {}) / 2.0", components[0], components[1]);
462
463 let synth = SyntheticInstrument::new(
464 Symbol::from("FX-BASKET"),
465 5,
466 components.clone(),
467 &raw_formula,
468 0.into(),
469 0.into(),
470 );
471 let mut inputs = HashMap::new();
472 inputs.insert(components[0].to_string(), 0.65001);
473 inputs.insert(components[1].to_string(), 0.59001);
474
475 let price = synth.calculate_from_map(&inputs).unwrap();
476
477 assert_eq!(price, Price::from("0.62001"));
478 assert_eq!(synth.formula, raw_formula);
479 }
480
481 #[rstest]
482 fn test_new_checked_rejects_unknown_formula_symbol_with_expression_error() {
483 let components = vec![
484 InstrumentId::from_str("BTC.BINANCE").unwrap(),
485 InstrumentId::from_str("LTC.BINANCE").unwrap(),
486 ];
487
488 let error = SyntheticInstrument::new_checked(
489 Symbol::from("BTC-LTC"),
490 2,
491 components,
492 "BTC.BINANCE + missing",
493 0.into(),
494 0.into(),
495 )
496 .unwrap_err();
497
498 assert!(matches!(
499 &error,
500 SyntheticInstrumentError::Expression { .. }
501 ));
502 assert_eq!(error.to_string(), "Unknown symbol `missing`");
503 }
504
505 #[rstest]
506 fn test_new_checked_rejects_invalid_precision_with_validation_error() {
507 let components = vec![
508 InstrumentId::from_str("BTC.BINANCE").unwrap(),
509 InstrumentId::from_str("LTC.BINANCE").unwrap(),
510 ];
511
512 let error = SyntheticInstrument::new_checked(
513 Symbol::from("BTC-LTC"),
514 FIXED_PRECISION + 1,
515 components,
516 "BTC.BINANCE + LTC.BINANCE",
517 0.into(),
518 0.into(),
519 )
520 .unwrap_err();
521
522 match &error {
523 SyntheticInstrumentError::Validation(CorrectnessError::PredicateViolation {
524 message,
525 }) => {
526 assert!(message.contains("precision"), "{message}");
527 }
528 _ => panic!("Expected validation error, received {error:?}"),
529 }
530 }
531
532 #[rstest]
533 fn test_deserialize_rejects_unknown_formula_symbol() {
534 let synth = SyntheticInstrument::default();
535 let payload = serde_json::to_string(&synth).unwrap().replace(
536 "\"(BTC.BINANCE + LTC.BINANCE) / 2.0\"",
537 "\"BTC.BINANCE + missing\"",
538 );
539
540 let error = serde_json::from_str::<SyntheticInstrument>(&payload).unwrap_err();
541
542 assert!(
543 error.to_string().contains("Unknown symbol `missing`"),
544 "{error}",
545 );
546 }
547
548 #[rstest]
549 fn test_calculate_rejects_wrong_input_count() {
550 let synth = SyntheticInstrument::default();
551 let error = synth.calculate(&[100.0]).unwrap_err();
552
553 match &error {
554 SyntheticInstrumentError::InputCountMismatch { expected, actual } => {
555 assert_eq!((*expected, *actual), (2, 1));
556 }
557 _ => panic!("Expected input count mismatch, received {error:?}"),
558 }
559 assert_eq!(error.to_string(), "Expected 2 input values, received 1");
560 }
561
562 #[rstest]
563 fn test_change_formula_rejects_invalid_formula_without_mutation() {
564 let mut synth = SyntheticInstrument::default();
565 let original_formula = synth.formula.clone();
566 let original_price = synth.calculate(&[100.0, 200.0]).unwrap();
567
568 let error = synth.change_formula("BTC.BINANCE + missing").unwrap_err();
569 let current_price = synth.calculate(&[100.0, 200.0]).unwrap();
570
571 assert!(matches!(
572 &error,
573 SyntheticInstrumentError::Expression { .. }
574 ));
575 assert_eq!(error.to_string(), "Unknown symbol `missing`");
576 assert_eq!(synth.formula, original_formula);
577 assert_eq!(current_price, original_price);
578 }
579
580 #[rstest]
581 fn test_calculate_from_map_rejects_missing_component() {
582 let synth = SyntheticInstrument::default();
583 let mut inputs = HashMap::new();
584 inputs.insert("BTC.BINANCE".to_string(), 100.0);
585
586 let error = synth.calculate_from_map(&inputs).unwrap_err();
587
588 match &error {
589 SyntheticInstrumentError::MissingInput { component_name } => {
590 assert_eq!(component_name, "LTC.BINANCE");
591 }
592 _ => panic!("Expected missing input, received {error:?}"),
593 }
594 assert_eq!(
595 error.to_string(),
596 "Missing price for component: LTC.BINANCE",
597 );
598 }
599
600 #[rstest]
601 fn test_calculate_from_map_fallback_rejects_missing_component() {
602 let count = MAX_INLINE_COMPONENTS + 2;
603 let components: Vec<InstrumentId> = (0..count)
604 .map(|i| InstrumentId::from(format!("C{i}.VENUE").as_str()))
605 .collect();
606 let terms: Vec<String> = components.iter().map(ToString::to_string).collect();
607 let formula = terms.join(" + ");
608 let missing_component = components.last().unwrap().to_string();
609
610 let synth = SyntheticInstrument::new(
611 Symbol::from("BIG"),
612 2,
613 components.clone(),
614 &formula,
615 0.into(),
616 0.into(),
617 );
618
619 let mut inputs = HashMap::new();
620 for component in components.iter().take(count - 1) {
621 inputs.insert(component.to_string(), 10.0);
622 }
623
624 let error = synth.calculate_from_map(&inputs).unwrap_err();
625
626 match &error {
627 SyntheticInstrumentError::MissingInput { component_name } => {
628 assert_eq!(component_name, &missing_component);
629 }
630 _ => panic!("Expected missing input, received {error:?}"),
631 }
632 assert_eq!(
633 error.to_string(),
634 format!("Missing price for component: {missing_component}"),
635 );
636 }
637
638 #[rstest]
639 fn test_calculate_rejects_invalid_price_result() {
640 let mut synth = SyntheticInstrument::default();
641 synth
642 .change_formula("BTC.BINANCE / (LTC.BINANCE - LTC.BINANCE)")
643 .unwrap();
644
645 let error = synth.calculate(&[100.0, 100.0]).unwrap_err();
646
647 match &error {
648 SyntheticInstrumentError::InvalidPriceResult {
649 source: CorrectnessError::InvalidValue { param, .. },
650 } => {
651 assert_eq!(param, "value");
652 }
653 _ => panic!("Expected invalid price result, received {error:?}"),
654 }
655 assert_eq!(
656 error.to_string(),
657 "Formula result produced invalid price: invalid f64 for 'value', was inf",
658 );
659 }
660
661 #[rstest]
662 fn test_is_valid_formula() {
663 let synth = SyntheticInstrument::default();
664
665 assert!(synth.is_valid_formula("(BTC.BINANCE + LTC.BINANCE) / 3"));
666 assert!(!synth.is_valid_formula("UNKNOWN.VENUE + 1"));
667 assert!(!synth.is_valid_formula(""));
668 }
669
670 #[rstest]
671 #[case(f64::NAN, 100.0, "Non-finite input price")]
672 #[case(100.0, f64::INFINITY, "Non-finite input price")]
673 #[case(f64::NEG_INFINITY, 100.0, "Non-finite input price")]
674 fn test_calculate_rejects_non_finite_inputs(
675 #[case] a: f64,
676 #[case] b: f64,
677 #[case] expected_msg: &str,
678 ) {
679 let synth = SyntheticInstrument::default();
680 let error = synth.calculate(&[a, b]).unwrap_err();
681
682 match &error {
683 SyntheticInstrumentError::NonFiniteInput { component_name, .. } => {
684 assert!(["BTC.BINANCE", "LTC.BINANCE"].contains(&component_name.as_str()));
685 }
686 _ => panic!("Expected non-finite input, received {error:?}"),
687 }
688 assert!(error.to_string().contains(expected_msg), "{error}");
689 }
690
691 #[rstest]
692 fn test_components_with_colliding_legacy_aliases_coexist() {
693 let comp1 = InstrumentId::from_str("FOO-BAR.VENUE").unwrap();
694 let comp2 = InstrumentId::from_str("FOO_BAR.VENUE").unwrap();
695 let formula = format!("{comp1} + {comp2}");
696 let synth = SyntheticInstrument::new(
697 Symbol::from("TEST"),
698 2,
699 vec![comp1, comp2],
700 &formula,
701 0.into(),
702 0.into(),
703 );
704 let price = synth.calculate(&[100.0, 200.0]).unwrap();
705
706 assert_eq!(price, Price::from("300.0"));
707 }
708
709 #[rstest]
710 fn test_calculate_from_map_fallback_for_many_components() {
711 let count = MAX_INLINE_COMPONENTS + 2;
712 let components: Vec<InstrumentId> = (0..count)
713 .map(|i| InstrumentId::from(format!("C{i}.VENUE").as_str()))
714 .collect();
715 let terms: Vec<String> = components.iter().map(|c| c.to_string()).collect();
716 let formula = terms.join(" + ");
717
718 let synth = SyntheticInstrument::new(
719 Symbol::from("BIG"),
720 2,
721 components.clone(),
722 &formula,
723 0.into(),
724 0.into(),
725 );
726
727 let mut inputs = HashMap::new();
728 for component in &components {
729 inputs.insert(component.to_string(), 10.0);
730 }
731
732 let price = synth.calculate_from_map(&inputs).unwrap();
733
734 assert_eq!(price, Price::from("100.0"));
735 }
736}