nautilus_model/instruments/
synthetic.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 Posei Systems Pty Ltd. All rights reserved.
3//  https://poseitrader.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, HashMapContext, Node, Value};
23use nautilus_core::{UnixNanos, correctness::FAILED};
24use serde::{Deserialize, Serialize};
25
26use crate::{
27    identifiers::{InstrumentId, Symbol, Venue},
28    types::Price,
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 = "posei_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 Serialize for SyntheticInstrument {
60    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
61    where
62        S: serde::Serializer,
63    {
64        use serde::ser::SerializeStruct;
65        let mut state = serializer.serialize_struct("SyntheticInstrument", 7)?;
66        state.serialize_field("id", &self.id)?;
67        state.serialize_field("price_precision", &self.price_precision)?;
68        state.serialize_field("price_increment", &self.price_increment)?;
69        state.serialize_field("components", &self.components)?;
70        state.serialize_field("formula", &self.formula)?;
71        state.serialize_field("ts_event", &self.ts_event)?;
72        state.serialize_field("ts_init", &self.ts_init)?;
73        state.end()
74    }
75}
76
77impl<'de> Deserialize<'de> for SyntheticInstrument {
78    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
79    where
80        D: serde::Deserializer<'de>,
81    {
82        #[derive(Deserialize)]
83        struct Fields {
84            id: InstrumentId,
85            price_precision: u8,
86            price_increment: Price,
87            components: Vec<InstrumentId>,
88            formula: String,
89            ts_event: UnixNanos,
90            ts_init: UnixNanos,
91        }
92
93        let fields = Fields::deserialize(deserializer)?;
94
95        let variables = fields
96            .components
97            .iter()
98            .map(std::string::ToString::to_string)
99            .collect();
100
101        let operator_tree =
102            evalexpr::build_operator_tree(&fields.formula).map_err(serde::de::Error::custom)?;
103
104        Ok(SyntheticInstrument {
105            id: fields.id,
106            price_precision: fields.price_precision,
107            price_increment: fields.price_increment,
108            components: fields.components,
109            formula: fields.formula,
110            ts_event: fields.ts_event,
111            ts_init: fields.ts_init,
112            context: HashMapContext::new(),
113            variables,
114            operator_tree,
115        })
116    }
117}
118
119impl SyntheticInstrument {
120    /// Creates a new [`SyntheticInstrument`] instance with correctness checking.
121    ///
122    /// # Notes
123    ///
124    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
125    /// # Errors
126    ///
127    /// Returns an error if any input validation fails.
128    pub fn new_checked(
129        symbol: Symbol,
130        price_precision: u8,
131        components: Vec<InstrumentId>,
132        formula: String,
133        ts_event: UnixNanos,
134        ts_init: UnixNanos,
135    ) -> anyhow::Result<Self> {
136        let price_increment = Price::new(10f64.powi(-i32::from(price_precision)), price_precision);
137
138        // Extract variables from the component instruments
139        let variables: Vec<String> = components
140            .iter()
141            .map(std::string::ToString::to_string)
142            .collect();
143
144        let operator_tree = evalexpr::build_operator_tree(&formula)?;
145
146        Ok(Self {
147            id: InstrumentId::new(symbol, Venue::synthetic()),
148            price_precision,
149            price_increment,
150            components,
151            formula,
152            context: HashMapContext::new(),
153            variables,
154            operator_tree,
155            ts_event,
156            ts_init,
157        })
158    }
159
160    /// Creates a new [`SyntheticInstrument`] instance, parsing the given formula.
161    ///
162    /// # Panics
163    ///
164    /// Panics if the provided formula is invalid and cannot be parsed.
165    pub fn new(
166        symbol: Symbol,
167        price_precision: u8,
168        components: Vec<InstrumentId>,
169        formula: String,
170        ts_event: UnixNanos,
171        ts_init: UnixNanos,
172    ) -> Self {
173        Self::new_checked(
174            symbol,
175            price_precision,
176            components,
177            formula,
178            ts_event,
179            ts_init,
180        )
181        .expect(FAILED)
182    }
183
184    #[must_use]
185    pub fn is_valid_formula(&self, formula: &str) -> bool {
186        evalexpr::build_operator_tree(formula).is_ok()
187    }
188
189    /// # Errors
190    ///
191    /// Returns an error if parsing the new formula fails.
192    pub fn change_formula(&mut self, formula: String) -> anyhow::Result<()> {
193        let operator_tree = evalexpr::build_operator_tree(&formula)?;
194        self.formula = formula;
195        self.operator_tree = operator_tree;
196        Ok(())
197    }
198
199    /// Calculates the price of the synthetic instrument based on component input prices provided as a map.
200    ///
201    /// # Panics
202    ///
203    /// Panics if a required component price is missing from the input map,
204    /// or if setting the value in the evaluation context fails.
205    ///
206    /// # Errors
207    ///
208    /// Returns an error if formula evaluation fails.
209    pub fn calculate_from_map(&mut self, inputs: &HashMap<String, f64>) -> anyhow::Result<Price> {
210        let mut input_values = Vec::new();
211
212        for variable in &self.variables {
213            if let Some(&value) = inputs.get(variable) {
214                input_values.push(value);
215                self.context
216                    .set_value(variable.clone(), Value::Float(value))
217                    .expect("TODO: Unable to set value");
218            } else {
219                panic!("Missing price for component: {variable}");
220            }
221        }
222
223        self.calculate(&input_values)
224    }
225
226    /// Calculates the price of the synthetic instrument based on the given component input prices
227    /// provided as an array of `f64` values.
228    /// # Errors
229    ///
230    /// Returns an error if the input length does not match or formula evaluation fails.
231    pub fn calculate(&mut self, inputs: &[f64]) -> anyhow::Result<Price> {
232        if inputs.len() != self.variables.len() {
233            anyhow::bail!("Invalid number of input values");
234        }
235
236        for (variable, input) in self.variables.iter().zip(inputs) {
237            self.context
238                .set_value(variable.clone(), Value::Float(*input))?;
239        }
240
241        let result: Value = self.operator_tree.eval_with_context(&self.context)?;
242
243        match result {
244            Value::Float(price) => Ok(Price::new(price, self.price_precision)),
245            _ => anyhow::bail!("Failed to evaluate formula to a floating point number"),
246        }
247    }
248}
249
250impl PartialEq<Self> for SyntheticInstrument {
251    fn eq(&self, other: &Self) -> bool {
252        self.id == other.id
253    }
254}
255
256impl Eq for SyntheticInstrument {}
257
258impl Hash for SyntheticInstrument {
259    fn hash<H: Hasher>(&self, state: &mut H) {
260        self.id.hash(state);
261    }
262}
263
264////////////////////////////////////////////////////////////////////////////////
265// Tests
266///////////////////////////////////////////////////////////////////////////////
267#[cfg(test)]
268mod tests {
269    use rstest::rstest;
270
271    use super::*;
272
273    #[rstest]
274    fn test_calculate_from_map() {
275        let mut synth = SyntheticInstrument::default();
276        let mut inputs = HashMap::new();
277        inputs.insert("BTC.BINANCE".to_string(), 100.0);
278        inputs.insert("LTC.BINANCE".to_string(), 200.0);
279        let price = synth.calculate_from_map(&inputs).unwrap();
280
281        assert_eq!(price.as_f64(), 150.0);
282        assert_eq!(
283            synth.formula,
284            "(BTC.BINANCE + LTC.BINANCE) / 2.0".to_string()
285        );
286    }
287
288    #[rstest]
289    fn test_calculate() {
290        let mut synth = SyntheticInstrument::default();
291        let inputs = vec![100.0, 200.0];
292        let price = synth.calculate(&inputs).unwrap();
293        assert_eq!(price.as_f64(), 150.0);
294    }
295
296    #[rstest]
297    fn test_change_formula() {
298        let mut synth = SyntheticInstrument::default();
299        let new_formula = "(BTC.BINANCE + LTC.BINANCE) / 4".to_string();
300        synth.change_formula(new_formula.clone()).unwrap();
301
302        let mut inputs = HashMap::new();
303        inputs.insert("BTC.BINANCE".to_string(), 100.0);
304        inputs.insert("LTC.BINANCE".to_string(), 200.0);
305        let price = synth.calculate_from_map(&inputs).unwrap();
306
307        assert_eq!(price.as_f64(), 75.0);
308        assert_eq!(synth.formula, new_formula);
309    }
310}