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 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/// Represents a synthetic instrument with prices derived from component instruments using a
66/// formula.
67///
68/// The `id` for the synthetic will become `{symbol}.{SYNTH}`.
69#[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    /// The unique identifier for the synthetic instrument.
80    pub id: InstrumentId,
81    /// The price precision for the synthetic instrument.
82    pub price_precision: u8,
83    /// The minimum price increment.
84    pub price_increment: Price,
85    /// The component instruments for the synthetic instrument.
86    pub components: Vec<InstrumentId>,
87    /// The derivation formula for the synthetic instrument.
88    pub formula: String,
89    /// UNIX timestamp (nanoseconds) when the data event occurred.
90    pub ts_event: UnixNanos,
91    /// UNIX timestamp (nanoseconds) when the data object was initialized.
92    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    /// Returns a fluent builder for a [`SyntheticInstrument`] instance.
192    ///
193    /// The builder derives the instrument ID and price increment, compiles the formula against the
194    /// component identifiers, and stores the compiled expression on `build`.
195    ///
196    /// # Errors
197    ///
198    /// Returns an error if input validation or formula compilation fails.
199    #[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    /// Returns whether the given formula compiles against the provided components.
219    #[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    /// Returns whether the given formula compiles against this instrument's components.
226    #[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    /// Replaces the derivation formula, recompiling it against the existing components.
232    ///
233    /// # Errors
234    ///
235    /// Returns an error if parsing the new formula fails.
236    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    /// Calculates the price of the synthetic instrument based on component input prices provided as a map.
244    ///
245    /// # Errors
246    ///
247    /// Returns an error if formula evaluation fails or a required component price is missing from
248    /// the input map.
249    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            // Fallback for large component sets
269            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    /// 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 (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
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, 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}