Skip to main content

nautilus_model/instruments/
synthetic.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use 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/// Represents a synthetic instrument with prices derived from component instruments using a
68/// formula.
69///
70/// The `id` for the synthetic will become `{symbol}.{SYNTH}`.
71#[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    /// The unique identifier for the synthetic instrument.
82    pub id: InstrumentId,
83    /// The price precision for the synthetic instrument.
84    pub price_precision: u8,
85    /// The minimum price increment.
86    pub price_increment: Price,
87    /// The component instruments for the synthetic instrument.
88    pub components: Vec<InstrumentId>,
89    /// The derivation formula for the synthetic instrument.
90    pub formula: String,
91    /// UNIX timestamp (nanoseconds) when the data event occurred.
92    pub ts_event: UnixNanos,
93    /// UNIX timestamp (nanoseconds) when the data object was initialized.
94    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    /// Creates a new [`SyntheticInstrument`] instance with correctness checking.
156    ///
157    /// # Errors
158    ///
159    /// Returns an error if input validation or formula compilation fails.
160    ///
161    /// # Notes
162    ///
163    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
164    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    /// Returns whether the given formula compiles against the provided components.
191    #[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    /// Creates a new [`SyntheticInstrument`] instance, parsing the given formula.
198    ///
199    /// # Panics
200    ///
201    /// Panics if the provided formula is invalid and cannot be parsed.
202    #[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    /// Returns whether the given formula compiles against this instrument's components.
223    #[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    /// Replaces the derivation formula, recompiling it against the existing components.
229    ///
230    /// # Errors
231    ///
232    /// Returns an error if parsing the new formula fails.
233    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    /// Calculates the price of the synthetic instrument based on component input prices provided as a map.
241    ///
242    /// # Errors
243    ///
244    /// Returns an error if formula evaluation fails or a required component price is missing from
245    /// the input map.
246    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            // Fallback for large component sets
263            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    /// Calculates the price of the synthetic instrument based on the given component input prices
281    /// provided as an array of `f64` values.
282    ///
283    /// # Errors
284    ///
285    /// Returns an error if the input length does not match, any input is non-finite, or formula
286    /// evaluation fails.
287    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
317/// # Errors
318///
319/// Returns an error if primary component names collide.
320fn 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            // Best-effort: skip if alias collides with a primary binding
334            let _ = bindings.add_alias(slot, &legacy_name);
335        }
336    }
337
338    Ok(bindings)
339}
340
341/// # Errors
342///
343/// Returns an error if parsing or semantic validation fails.
344fn 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}