1use std::{
17 collections::HashMap,
18 error::Error,
19 hash::{Hash, Hasher},
20};
21
22use nautilus_core::{UnixNanos, correctness::CorrectnessError};
23use serde::{Deserialize, Serialize};
24
25#[cfg(feature = "defi")]
26use crate::types::fixed::MAX_FLOAT_PRECISION;
27use crate::{
28 expressions::{Bindings, CompiledExpression, ExpressionError, compile_numeric},
29 identifiers::{InstrumentId, Symbol, Venue},
30 types::Price,
31};
32
33const MAX_INLINE_COMPONENTS: usize = 8;
34
35#[derive(Debug, thiserror::Error)]
36pub enum SyntheticInstrumentError {
37 #[error("{0}")]
38 Validation(#[from] CorrectnessError),
39 #[error("{source}")]
40 Expression {
41 #[source]
42 source: Box<dyn Error + Send + Sync + 'static>,
43 },
44 #[error("Missing price for component: {component_name}")]
45 MissingInput { component_name: String },
46 #[error("Expected {expected} input values, received {actual}")]
47 InputCountMismatch { expected: usize, actual: usize },
48 #[error("Non-finite input price for component {component_name}: {value}")]
49 NonFiniteInput { component_name: String, value: f64 },
50 #[error("Formula result produced invalid price: {source}")]
51 InvalidPriceResult {
52 #[source]
53 source: CorrectnessError,
54 },
55}
56
57impl SyntheticInstrumentError {
58 fn expression(source: ExpressionError) -> Self {
59 Self::Expression {
60 source: Box::new(source),
61 }
62 }
63}
64
65#[derive(Clone, Debug)]
70#[cfg_attr(
71 feature = "python",
72 pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
73)]
74#[cfg_attr(
75 feature = "python",
76 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
77)]
78pub struct SyntheticInstrument {
79 pub id: InstrumentId,
81 pub price_precision: u8,
83 pub price_increment: Price,
85 pub components: Vec<InstrumentId>,
87 pub formula: String,
89 pub ts_event: UnixNanos,
91 pub ts_init: UnixNanos,
93 component_names: Vec<String>,
94 compiled_formula: CompiledExpression,
95}
96
97impl Serialize for SyntheticInstrument {
98 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
99 where
100 S: serde::Serializer,
101 {
102 use serde::ser::SerializeStruct;
103 let mut state = serializer.serialize_struct("SyntheticInstrument", 7)?;
104 state.serialize_field("id", &self.id)?;
105 state.serialize_field("price_precision", &self.price_precision)?;
106 state.serialize_field("price_increment", &self.price_increment)?;
107 state.serialize_field("components", &self.components)?;
108 state.serialize_field("formula", &self.formula)?;
109 state.serialize_field("ts_event", &self.ts_event)?;
110 state.serialize_field("ts_init", &self.ts_init)?;
111 state.end()
112 }
113}
114
115impl<'de> Deserialize<'de> for SyntheticInstrument {
116 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
117 where
118 D: serde::Deserializer<'de>,
119 {
120 #[derive(Deserialize)]
121 struct Fields {
122 id: InstrumentId,
123 price_precision: u8,
124 price_increment: Price,
125 components: Vec<InstrumentId>,
126 formula: String,
127 ts_event: UnixNanos,
128 ts_init: UnixNanos,
129 }
130
131 let fields = Fields::deserialize(deserializer)?;
132 let component_names = component_names_from_components(&fields.components);
133 let compiled_formula =
134 compile_formula(&fields.formula, &component_names).map_err(serde::de::Error::custom)?;
135
136 Ok(Self {
137 id: fields.id,
138 price_precision: fields.price_precision,
139 price_increment: fields.price_increment,
140 components: fields.components,
141 formula: fields.formula,
142 ts_event: fields.ts_event,
143 ts_init: fields.ts_init,
144 component_names,
145 compiled_formula,
146 })
147 }
148}
149
150#[bon::bon]
151impl SyntheticInstrument {
152 fn new_checked(
153 symbol: Symbol,
154 price_precision: u8,
155 components: Vec<InstrumentId>,
156 formula: &str,
157 ts_event: UnixNanos,
158 ts_init: UnixNanos,
159 ) -> Result<Self, SyntheticInstrumentError> {
160 #[cfg(feature = "defi")]
161 if price_precision > MAX_FLOAT_PRECISION {
162 return Err(CorrectnessError::PredicateViolation {
163 message: format!(
164 "`precision` exceeded maximum float precision ({MAX_FLOAT_PRECISION}), use `Price::from_wei()` for wei values instead"
165 ),
166 }
167 .into());
168 }
169
170 let price_increment = Price::from_mantissa_exponent_checked(
171 1,
172 -price_precision.cast_signed(),
173 price_precision,
174 )?;
175 let component_names = component_names_from_components(&components);
176 let compiled_formula = compile_formula(formula, &component_names)?;
177
178 Ok(Self {
179 id: InstrumentId::new(symbol, Venue::synthetic()),
180 price_precision,
181 price_increment,
182 components,
183 formula: formula.to_string(),
184 component_names,
185 compiled_formula,
186 ts_event,
187 ts_init,
188 })
189 }
190
191 #[builder(start_fn = builder, finish_fn = build)]
200 pub fn build_checked(
201 symbol: Symbol,
202 price_precision: u8,
203 components: Vec<InstrumentId>,
204 formula: &str,
205 ts_event: UnixNanos,
206 ts_init: UnixNanos,
207 ) -> Result<Self, SyntheticInstrumentError> {
208 Self::new_checked(
209 symbol,
210 price_precision,
211 components,
212 formula,
213 ts_event,
214 ts_init,
215 )
216 }
217
218 #[must_use]
220 pub fn is_valid_formula_for_components(formula: &str, components: &[InstrumentId]) -> bool {
221 let component_names = component_names_from_components(components);
222 compile_formula(formula, &component_names).is_ok()
223 }
224
225 #[must_use]
227 pub fn is_valid_formula(&self, formula: &str) -> bool {
228 Self::is_valid_formula_for_components(formula, &self.components)
229 }
230
231 pub fn change_formula(&mut self, formula: &str) -> Result<(), SyntheticInstrumentError> {
237 let compiled_formula = compile_formula(formula, &self.component_names)?;
238 self.formula = formula.to_string();
239 self.compiled_formula = compiled_formula;
240 Ok(())
241 }
242
243 pub fn calculate_from_map(
250 &self,
251 inputs: &HashMap<String, f64>,
252 ) -> Result<Price, SyntheticInstrumentError> {
253 let n = self.component_names.len();
254 let mut buf = [0.0_f64; MAX_INLINE_COMPONENTS];
255 let resolve_input = |component_name: &String| {
256 inputs.get(component_name).copied().ok_or_else(|| {
257 SyntheticInstrumentError::MissingInput {
258 component_name: component_name.clone(),
259 }
260 })
261 };
262 let input_values: &[f64] = if n <= MAX_INLINE_COMPONENTS {
263 for (i, component_name) in self.component_names.iter().enumerate() {
264 buf[i] = resolve_input(component_name)?;
265 }
266 &buf[..n]
267 } else {
268 let input_values = self
270 .component_names
271 .iter()
272 .map(resolve_input)
273 .collect::<Result<Vec<_>, _>>()?;
274 return self.calculate(&input_values);
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 (component_name, &value) in self.component_names.iter().zip(inputs) {
296 if !value.is_finite() {
297 return Err(SyntheticInstrumentError::NonFiniteInput {
298 component_name: component_name.clone(),
299 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, price::PriceRaw};
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 = SyntheticInstrument::builder()
421 .symbol(symbol)
422 .price_precision(2)
423 .components(components)
424 .formula(&raw_formula)
425 .ts_event(0.into())
426 .ts_init(0.into())
427 .build()
428 .unwrap();
429 let price = synth.calculate(&[100.0, 200.0]).unwrap();
430
431 assert_eq!(price, Price::from("150.0"));
432 assert_eq!(synth.formula, raw_formula);
433 }
434
435 #[rstest]
436 fn test_hyphenated_instrument_ids_support_legacy_sanitized_formula() {
437 let comp1 = InstrumentId::from_str("ETH-USDT-SWAP.OKX").unwrap();
438 let comp2 = InstrumentId::from_str("ETH-USDC-PERP.HYPERLIQUID").unwrap();
439 let components = vec![comp1, comp2];
440 let legacy_formula = format!(
441 "({} + {}) / 2.0",
442 components[0].to_string().replace('-', "_"),
443 components[1].to_string().replace('-', "_"),
444 );
445 let symbol = Symbol::from("ETH-USD");
446 let synth = SyntheticInstrument::builder()
447 .symbol(symbol)
448 .price_precision(2)
449 .components(components.clone())
450 .formula(&legacy_formula)
451 .ts_event(0.into())
452 .ts_init(0.into())
453 .build()
454 .unwrap();
455 let mut inputs = HashMap::new();
456 inputs.insert(components[0].to_string(), 100.0);
457 inputs.insert(components[1].to_string(), 200.0);
458 let price = synth.calculate_from_map(&inputs).unwrap();
459
460 assert_eq!(price, Price::from("150.0"));
461 assert_eq!(synth.formula, legacy_formula);
462 }
463
464 #[rstest]
465 fn test_slashed_instrument_ids_calculate_from_map() {
466 let comp1 = InstrumentId::from_str("AUD/USD.SIM").unwrap();
467 let comp2 = InstrumentId::from_str("NZD/USD.SIM").unwrap();
468 let components = vec![comp1, comp2];
469 let raw_formula = format!("({} + {}) / 2.0", components[0], components[1]);
470
471 let synth = SyntheticInstrument::builder()
472 .symbol(Symbol::from("FX-BASKET"))
473 .price_precision(5)
474 .components(components.clone())
475 .formula(&raw_formula)
476 .ts_event(0.into())
477 .ts_init(0.into())
478 .build()
479 .unwrap();
480 let mut inputs = HashMap::new();
481 inputs.insert(components[0].to_string(), 0.65001);
482 inputs.insert(components[1].to_string(), 0.59001);
483
484 let price = synth.calculate_from_map(&inputs).unwrap();
485
486 assert_eq!(price, Price::from("0.62001"));
487 assert_eq!(synth.formula, raw_formula);
488 }
489
490 #[rstest]
491 #[case(0)]
492 #[case(5)]
493 #[case(FIXED_PRECISION)]
494 fn test_new_checked_constructs_exact_price_increment(#[case] price_precision: u8) {
495 let components = vec![
496 InstrumentId::from_str("BTC.BINANCE").unwrap(),
497 InstrumentId::from_str("LTC.BINANCE").unwrap(),
498 ];
499
500 let synth = SyntheticInstrument::new_checked(
501 Symbol::from("BTC-LTC"),
502 price_precision,
503 components,
504 "BTC.BINANCE + LTC.BINANCE",
505 0.into(),
506 0.into(),
507 )
508 .unwrap();
509 let expected_raw = PriceRaw::from(10_u8).pow(u32::from(FIXED_PRECISION - price_precision));
510
511 assert_eq!(synth.price_precision, price_precision);
512 assert_eq!(synth.price_increment.raw, expected_raw);
513 assert_eq!(synth.price_increment.precision, price_precision);
514 }
515
516 #[rstest]
517 fn test_builder_matches_new_checked() {
518 let components = vec![
519 InstrumentId::from_str("BTC.BINANCE").unwrap(),
520 InstrumentId::from_str("LTC.BINANCE").unwrap(),
521 ];
522 let positional = SyntheticInstrument::new_checked(
523 Symbol::from("BTC-LTC"),
524 3,
525 components.clone(),
526 "BTC.BINANCE + LTC.BINANCE",
527 1.into(),
528 2.into(),
529 )
530 .unwrap();
531 let built = SyntheticInstrument::builder()
532 .symbol(Symbol::from("BTC-LTC"))
533 .price_precision(3)
534 .components(components)
535 .formula("BTC.BINANCE + LTC.BINANCE")
536 .ts_event(1.into())
537 .ts_init(2.into())
538 .build()
539 .unwrap();
540
541 assert_eq!(
542 serde_json::to_value(&positional).unwrap(),
543 serde_json::to_value(&built).unwrap(),
544 );
545 assert_eq!(
546 positional.calculate(&[100.0, 200.0]).unwrap(),
547 built.calculate(&[100.0, 200.0]).unwrap(),
548 );
549 }
550
551 #[rstest]
552 fn test_builder_rejects_unknown_formula_symbol() {
553 let components = vec![
554 InstrumentId::from_str("BTC.BINANCE").unwrap(),
555 InstrumentId::from_str("LTC.BINANCE").unwrap(),
556 ];
557
558 let error = SyntheticInstrument::builder()
559 .symbol(Symbol::from("BTC-LTC"))
560 .price_precision(2)
561 .components(components)
562 .formula("BTC.BINANCE + missing")
563 .ts_event(0.into())
564 .ts_init(0.into())
565 .build()
566 .unwrap_err();
567
568 assert!(matches!(
569 &error,
570 SyntheticInstrumentError::Expression { .. }
571 ));
572 assert_eq!(error.to_string(), "Unknown symbol `missing`");
573 }
574
575 #[rstest]
576 fn test_new_checked_rejects_unknown_formula_symbol_with_expression_error() {
577 let components = vec![
578 InstrumentId::from_str("BTC.BINANCE").unwrap(),
579 InstrumentId::from_str("LTC.BINANCE").unwrap(),
580 ];
581
582 let error = SyntheticInstrument::new_checked(
583 Symbol::from("BTC-LTC"),
584 2,
585 components,
586 "BTC.BINANCE + missing",
587 0.into(),
588 0.into(),
589 )
590 .unwrap_err();
591
592 assert!(matches!(
593 &error,
594 SyntheticInstrumentError::Expression { .. }
595 ));
596 assert_eq!(error.to_string(), "Unknown symbol `missing`");
597 }
598
599 #[rstest]
600 fn test_new_checked_rejects_excessive_expression_depth() {
601 let formula = std::iter::repeat_n("1", 129)
602 .collect::<Vec<_>>()
603 .join(" + ");
604
605 let error = SyntheticInstrument::new_checked(
606 Symbol::from("DEEP"),
607 2,
608 Vec::new(),
609 &formula,
610 0.into(),
611 0.into(),
612 )
613 .unwrap_err();
614
615 assert!(matches!(
616 &error,
617 SyntheticInstrumentError::Expression { .. }
618 ));
619 assert_eq!(
620 error.to_string(),
621 "Expression nesting depth 129 exceeds maximum 128 (the top-level expression counts as one level)"
622 );
623 }
624
625 #[rstest]
626 fn test_new_checked_rejects_invalid_precision_with_validation_error() {
627 let components = vec![
628 InstrumentId::from_str("BTC.BINANCE").unwrap(),
629 InstrumentId::from_str("LTC.BINANCE").unwrap(),
630 ];
631
632 let error = SyntheticInstrument::new_checked(
633 Symbol::from("BTC-LTC"),
634 FIXED_PRECISION + 1,
635 components,
636 "BTC.BINANCE + LTC.BINANCE",
637 0.into(),
638 0.into(),
639 )
640 .unwrap_err();
641
642 match &error {
643 SyntheticInstrumentError::Validation(CorrectnessError::PredicateViolation {
644 message,
645 }) => {
646 assert!(message.contains("precision"), "{message}");
647 }
648 _ => panic!("Expected validation error, received {error:?}"),
649 }
650 }
651
652 #[rstest]
653 fn test_serialization_roundtrip_rebuilds_formula() {
654 let components = vec![
655 InstrumentId::from_str("BTC.BINANCE").unwrap(),
656 InstrumentId::from_str("LTC.BINANCE").unwrap(),
657 ];
658 let synth = SyntheticInstrument::builder()
659 .symbol(Symbol::from("BTC-LTC"))
660 .price_precision(3)
661 .components(components.clone())
662 .formula("BTC.BINANCE / LTC.BINANCE")
663 .ts_event(11.into())
664 .ts_init(22.into())
665 .build()
666 .unwrap();
667 let json = serde_json::to_string(&synth).unwrap();
668
669 let deserialized: SyntheticInstrument = serde_json::from_str(&json).unwrap();
670 let price = deserialized.calculate(&[12.5, 2.0]).unwrap();
671
672 assert_eq!(deserialized.id, synth.id);
673 assert_eq!(deserialized.price_precision, 3);
674 assert_eq!(deserialized.price_increment, Price::from("0.001"));
675 assert_eq!(deserialized.components, components);
676 assert_eq!(deserialized.formula, "BTC.BINANCE / LTC.BINANCE");
677 assert_eq!(deserialized.ts_event, UnixNanos::from(11));
678 assert_eq!(deserialized.ts_init, UnixNanos::from(22));
679 assert_eq!(price.as_decimal(), rust_decimal_macros::dec!(6.25));
680 assert_eq!(price.precision, 3);
681 }
682
683 #[rstest]
684 fn test_deserialize_rejects_unknown_formula_symbol() {
685 let synth = SyntheticInstrument::default();
686 let payload = serde_json::to_string(&synth).unwrap().replace(
687 "\"(BTC.BINANCE + LTC.BINANCE) / 2.0\"",
688 "\"BTC.BINANCE + missing\"",
689 );
690
691 let error = serde_json::from_str::<SyntheticInstrument>(&payload).unwrap_err();
692
693 assert!(
694 error.to_string().contains("Unknown symbol `missing`"),
695 "{error}",
696 );
697 }
698
699 #[rstest]
700 fn test_calculate_rejects_wrong_input_count() {
701 let synth = SyntheticInstrument::default();
702 let error = synth.calculate(&[100.0]).unwrap_err();
703
704 match &error {
705 SyntheticInstrumentError::InputCountMismatch { expected, actual } => {
706 assert_eq!((*expected, *actual), (2, 1));
707 }
708 _ => panic!("Expected input count mismatch, received {error:?}"),
709 }
710 assert_eq!(error.to_string(), "Expected 2 input values, received 1");
711 }
712
713 #[rstest]
714 fn test_change_formula_rejects_invalid_formula_without_mutation() {
715 let mut synth = SyntheticInstrument::default();
716 let original_formula = synth.formula.clone();
717 let original_price = synth.calculate(&[100.0, 200.0]).unwrap();
718
719 let error = synth.change_formula("BTC.BINANCE + missing").unwrap_err();
720 let current_price = synth.calculate(&[100.0, 200.0]).unwrap();
721
722 assert!(matches!(
723 &error,
724 SyntheticInstrumentError::Expression { .. }
725 ));
726 assert_eq!(error.to_string(), "Unknown symbol `missing`");
727 assert_eq!(synth.formula, original_formula);
728 assert_eq!(current_price, original_price);
729 }
730
731 #[rstest]
732 fn test_calculate_from_map_rejects_missing_component() {
733 let synth = SyntheticInstrument::default();
734 let mut inputs = HashMap::new();
735 inputs.insert("BTC.BINANCE".to_string(), 100.0);
736
737 let error = synth.calculate_from_map(&inputs).unwrap_err();
738
739 match &error {
740 SyntheticInstrumentError::MissingInput { component_name } => {
741 assert_eq!(component_name, "LTC.BINANCE");
742 }
743 _ => panic!("Expected missing input, received {error:?}"),
744 }
745 assert_eq!(
746 error.to_string(),
747 "Missing price for component: LTC.BINANCE",
748 );
749 }
750
751 #[rstest]
752 fn test_calculate_from_map_fallback_rejects_missing_component() {
753 let count = MAX_INLINE_COMPONENTS + 2;
754 let components: Vec<InstrumentId> = (0..count)
755 .map(|i| InstrumentId::from(format!("C{i}.VENUE").as_str()))
756 .collect();
757 let terms: Vec<String> = components.iter().map(ToString::to_string).collect();
758 let formula = terms.join(" + ");
759 let missing_component = components.last().unwrap().to_string();
760
761 let synth = SyntheticInstrument::builder()
762 .symbol(Symbol::from("BIG"))
763 .price_precision(2)
764 .components(components.clone())
765 .formula(&formula)
766 .ts_event(0.into())
767 .ts_init(0.into())
768 .build()
769 .unwrap();
770
771 let mut inputs = HashMap::new();
772 for component in components.iter().take(count - 1) {
773 inputs.insert(component.to_string(), 10.0);
774 }
775
776 let error = synth.calculate_from_map(&inputs).unwrap_err();
777
778 match &error {
779 SyntheticInstrumentError::MissingInput { component_name } => {
780 assert_eq!(component_name, &missing_component);
781 }
782 _ => panic!("Expected missing input, received {error:?}"),
783 }
784 assert_eq!(
785 error.to_string(),
786 format!("Missing price for component: {missing_component}"),
787 );
788 }
789
790 #[rstest]
791 fn test_calculate_rejects_invalid_price_result() {
792 let mut synth = SyntheticInstrument::default();
793 synth
794 .change_formula("BTC.BINANCE / (LTC.BINANCE - LTC.BINANCE)")
795 .unwrap();
796
797 let error = synth.calculate(&[100.0, 100.0]).unwrap_err();
798
799 match &error {
800 SyntheticInstrumentError::InvalidPriceResult {
801 source: CorrectnessError::InvalidValue { param, .. },
802 } => {
803 assert_eq!(param, "value");
804 }
805 _ => panic!("Expected invalid price result, received {error:?}"),
806 }
807 assert_eq!(
808 error.to_string(),
809 "Formula result produced invalid price: invalid f64 for 'value', was inf",
810 );
811 }
812
813 #[rstest]
814 fn test_is_valid_formula() {
815 let synth = SyntheticInstrument::default();
816
817 assert!(synth.is_valid_formula("(BTC.BINANCE + LTC.BINANCE) / 3"));
818 assert!(!synth.is_valid_formula("UNKNOWN.VENUE + 1"));
819 assert!(!synth.is_valid_formula(""));
820 }
821
822 #[rstest]
823 #[case(f64::NAN, 100.0, "Non-finite input price")]
824 #[case(100.0, f64::INFINITY, "Non-finite input price")]
825 #[case(f64::NEG_INFINITY, 100.0, "Non-finite input price")]
826 fn test_calculate_rejects_non_finite_inputs(
827 #[case] a: f64,
828 #[case] b: f64,
829 #[case] expected_msg: &str,
830 ) {
831 let synth = SyntheticInstrument::default();
832 let error = synth.calculate(&[a, b]).unwrap_err();
833
834 match &error {
835 SyntheticInstrumentError::NonFiniteInput { component_name, .. } => {
836 assert!(["BTC.BINANCE", "LTC.BINANCE"].contains(&component_name.as_str()));
837 }
838 _ => panic!("Expected non-finite input, received {error:?}"),
839 }
840 assert!(error.to_string().contains(expected_msg), "{error}");
841 }
842
843 #[rstest]
844 fn test_components_with_colliding_legacy_aliases_coexist() {
845 let comp1 = InstrumentId::from_str("FOO-BAR.VENUE").unwrap();
846 let comp2 = InstrumentId::from_str("FOO_BAR.VENUE").unwrap();
847 let formula = format!("{comp1} + {comp2}");
848 let synth = SyntheticInstrument::builder()
849 .symbol(Symbol::from("TEST"))
850 .price_precision(2)
851 .components(vec![comp1, comp2])
852 .formula(&formula)
853 .ts_event(0.into())
854 .ts_init(0.into())
855 .build()
856 .unwrap();
857 let price = synth.calculate(&[100.0, 200.0]).unwrap();
858
859 assert_eq!(price, Price::from("300.0"));
860 }
861
862 #[rstest]
863 fn test_calculate_from_map_fallback_for_many_components() {
864 let count = MAX_INLINE_COMPONENTS + 2;
865 let components: Vec<InstrumentId> = (0..count)
866 .map(|i| InstrumentId::from(format!("C{i}.VENUE").as_str()))
867 .collect();
868 let terms: Vec<String> = components.iter().map(ToString::to_string).collect();
869 let formula = terms.join(" + ");
870
871 let synth = SyntheticInstrument::builder()
872 .symbol(Symbol::from("BIG"))
873 .price_precision(2)
874 .components(components.clone())
875 .formula(&formula)
876 .ts_event(0.into())
877 .ts_init(0.into())
878 .build()
879 .unwrap();
880
881 let mut inputs = HashMap::new();
882 for component in &components {
883 inputs.insert(component.to_string(), 10.0);
884 }
885
886 let price = synth.calculate_from_map(&inputs).unwrap();
887
888 assert_eq!(price, Price::from("100.0"));
889 }
890}