nautilus_model/instruments/
synthetic.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 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    hash::{Hash, Hasher},
19};
20
21use derive_builder::Builder;
22use evalexpr::{ContextWithMutableVariables, DefaultNumericTypes, HashMapContext, Node, Value};
23use nautilus_core::{correctness::FAILED, UnixNanos};
24
25use crate::{
26    identifiers::{InstrumentId, Symbol, Venue},
27    types::Price,
28};
29
30/// Represents a synthetic instrument with prices derived from component instruments using a
31/// formula.
32///
33/// The `id` for the synthetic will become `{symbol}.{SYNTH}`.
34#[derive(Clone, Debug, Builder)]
35#[cfg_attr(
36    feature = "python",
37    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model")
38)]
39pub struct SyntheticInstrument {
40    /// The unique identifier for the synthetic instrument.
41    pub id: InstrumentId,
42    /// The price precision for the synthetic instrument.
43    pub price_precision: u8,
44    /// The minimum price increment.
45    pub price_increment: Price,
46    /// The component instruments for the synthetic instrument.
47    pub components: Vec<InstrumentId>,
48    /// The derivation formula for the synthetic instrument.
49    pub formula: String,
50    /// UNIX timestamp (nanoseconds) when the data event occurred.
51    pub ts_event: UnixNanos,
52    /// UNIX timestamp (nanoseconds) when the data object was initialized.
53    pub ts_init: UnixNanos,
54    context: HashMapContext,
55    variables: Vec<String>,
56    operator_tree: Node,
57}
58
59impl SyntheticInstrument {
60    /// Creates a new [`SyntheticInstrument`] instance with correctness checking.
61    ///
62    /// # Notes
63    ///
64    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
65    pub fn new_checked(
66        symbol: Symbol,
67        price_precision: u8,
68        components: Vec<InstrumentId>,
69        formula: String,
70        ts_event: UnixNanos,
71        ts_init: UnixNanos,
72    ) -> anyhow::Result<Self> {
73        let price_increment = Price::new(10f64.powi(-i32::from(price_precision)), price_precision);
74
75        // Extract variables from the component instruments
76        let variables: Vec<String> = components
77            .iter()
78            .map(std::string::ToString::to_string)
79            .collect();
80
81        let operator_tree = evalexpr::build_operator_tree(&formula)?;
82
83        Ok(Self {
84            id: InstrumentId::new(symbol, Venue::synthetic()),
85            price_precision,
86            price_increment,
87            components,
88            formula,
89            context: HashMapContext::new(),
90            variables,
91            operator_tree,
92            ts_event,
93            ts_init,
94        })
95    }
96
97    /// Creates a new [`SyntheticInstrument`] instance
98    pub fn new(
99        symbol: Symbol,
100        price_precision: u8,
101        components: Vec<InstrumentId>,
102        formula: String,
103        ts_event: UnixNanos,
104        ts_init: UnixNanos,
105    ) -> Self {
106        Self::new_checked(
107            symbol,
108            price_precision,
109            components,
110            formula,
111            ts_event,
112            ts_init,
113        )
114        .expect(FAILED)
115    }
116
117    #[must_use]
118    pub fn is_valid_formula(&self, formula: &str) -> bool {
119        evalexpr::build_operator_tree::<DefaultNumericTypes>(formula).is_ok()
120    }
121
122    pub fn change_formula(&mut self, formula: String) -> anyhow::Result<()> {
123        let operator_tree = evalexpr::build_operator_tree::<DefaultNumericTypes>(&formula)?;
124        self.formula = formula;
125        self.operator_tree = operator_tree;
126        Ok(())
127    }
128
129    /// Calculates the price of the synthetic instrument based on the given component input prices
130    /// provided as a map.
131    #[allow(dead_code)]
132    pub fn calculate_from_map(&mut self, inputs: &HashMap<String, f64>) -> anyhow::Result<Price> {
133        let mut input_values = Vec::new();
134
135        for variable in &self.variables {
136            if let Some(&value) = inputs.get(variable) {
137                input_values.push(value);
138                self.context
139                    .set_value(variable.clone(), Value::Float(value))
140                    .expect("TODO: Unable to set value");
141            } else {
142                panic!("Missing price for component: {variable}");
143            }
144        }
145
146        self.calculate(&input_values)
147    }
148
149    /// Calculates the price of the synthetic instrument based on the given component input prices
150    /// provided as an array of `f64` values.
151    pub fn calculate(&mut self, inputs: &[f64]) -> anyhow::Result<Price> {
152        if inputs.len() != self.variables.len() {
153            return Err(anyhow::anyhow!("Invalid number of input values"));
154        }
155
156        for (variable, input) in self.variables.iter().zip(inputs) {
157            self.context
158                .set_value(variable.clone(), Value::Float(*input))?;
159        }
160
161        let result: Value = self.operator_tree.eval_with_context(&self.context)?;
162
163        match result {
164            Value::Float(price) => Ok(Price::new(price, self.price_precision)),
165            _ => Err(anyhow::anyhow!(
166                "Failed to evaluate formula to a floating point number"
167            )),
168        }
169    }
170}
171
172impl PartialEq<Self> for SyntheticInstrument {
173    fn eq(&self, other: &Self) -> bool {
174        self.id == other.id
175    }
176}
177
178impl Eq for SyntheticInstrument {}
179
180impl Hash for SyntheticInstrument {
181    fn hash<H: Hasher>(&self, state: &mut H) {
182        self.id.hash(state);
183    }
184}
185
186////////////////////////////////////////////////////////////////////////////////
187// Tests
188///////////////////////////////////////////////////////////////////////////////
189#[cfg(test)]
190mod tests {
191    use rstest::rstest;
192
193    use super::*;
194
195    #[rstest]
196    fn test_calculate_from_map() {
197        let mut synth = SyntheticInstrument::default();
198        let mut inputs = HashMap::new();
199        inputs.insert("BTC.BINANCE".to_string(), 100.0);
200        inputs.insert("LTC.BINANCE".to_string(), 200.0);
201        let price = synth.calculate_from_map(&inputs).unwrap();
202
203        assert_eq!(price.as_f64(), 150.0);
204        assert_eq!(
205            synth.formula,
206            "(BTC.BINANCE + LTC.BINANCE) / 2.0".to_string()
207        );
208    }
209
210    #[rstest]
211    fn test_calculate() {
212        let mut synth = SyntheticInstrument::default();
213        let inputs = vec![100.0, 200.0];
214        let price = synth.calculate(&inputs).unwrap();
215        assert_eq!(price.as_f64(), 150.0);
216    }
217
218    #[rstest]
219    fn test_change_formula() {
220        let mut synth = SyntheticInstrument::default();
221        let new_formula = "(BTC.BINANCE + LTC.BINANCE) / 4".to_string();
222        synth.change_formula(new_formula.clone()).unwrap();
223
224        let mut inputs = HashMap::new();
225        inputs.insert("BTC.BINANCE".to_string(), 100.0);
226        inputs.insert("LTC.BINANCE".to_string(), 200.0);
227        let price = synth.calculate_from_map(&inputs).unwrap();
228
229        assert_eq!(price.as_f64(), 75.0);
230        assert_eq!(synth.formula, new_formula);
231    }
232}