Skip to main content

rithmic_rs/util/
instrument.rs

1//! Instrument reference data types.
2
3use crate::rti::ResponseReferenceData;
4
5/// Parsed instrument information from Rithmic reference data.
6///
7/// # Example
8/// ```ignore
9/// let info = InstrumentInfo::try_from(&response)?;
10/// println!("{} on {} - tick size {:?}", info.symbol, info.exchange, info.tick_size);
11/// ```
12#[derive(Debug, Clone, Default)]
13pub struct InstrumentInfo {
14    /// Trading symbol (e.g., "ESH4")
15    pub symbol: String,
16    /// Exchange code (e.g., "CME")
17    pub exchange: String,
18    /// Exchange-specific symbol if different from symbol
19    pub exchange_symbol: Option<String>,
20    /// Human-readable name (e.g., "E-mini S&P 500")
21    pub name: Option<String>,
22    /// Product code for the instrument family (e.g., "ES")
23    pub product_code: Option<String>,
24    /// Instrument type (e.g., "Future", "Option")
25    pub instrument_type: Option<String>,
26    /// Underlying symbol for derivatives
27    pub underlying: Option<String>,
28    /// Currency code (e.g., "USD")
29    pub currency: Option<String>,
30    /// Expiration date string (format varies by exchange)
31    pub expiration_date: Option<String>,
32    /// Minimum price increment
33    pub tick_size: Option<f64>,
34    /// Dollar value of one point move
35    pub point_value: Option<f64>,
36    /// Whether the instrument can be traded
37    pub is_tradable: bool,
38}
39
40impl InstrumentInfo {
41    /// Calculate decimal places for price display based on tick size.
42    ///
43    /// Returns 2 as default if tick_size is not available.
44    ///
45    /// # Example
46    /// ```
47    /// use rithmic_rs::InstrumentInfo;
48    ///
49    /// let mut info = InstrumentInfo::default();
50    /// info.tick_size = Some(0.25);  // ES
51    /// assert_eq!(info.price_precision(), 2);
52    ///
53    /// info.tick_size = Some(0.03125);  // ZB (1/32)
54    /// assert_eq!(info.price_precision(), 5);
55    /// ```
56    pub fn price_precision(&self) -> u8 {
57        match self.tick_size {
58            Some(tick) if tick > 0.0 => {
59                let mut precision = 0u8;
60                let mut value = tick;
61
62                while value < 1.0 && precision < 10 {
63                    value *= 10.0;
64                    precision += 1;
65                }
66
67                let fractional = value - value.floor();
68
69                if fractional > 0.0001 && precision < 10 {
70                    let mut frac = fractional;
71
72                    while frac > 0.0001 && precision < 10 {
73                        frac *= 10.0;
74                        frac -= frac.floor();
75                        precision += 1;
76                    }
77                }
78                precision
79            }
80            _ => 2,
81        }
82    }
83
84    /// Size precision (always 0 for futures since they trade in whole contracts).
85    pub fn size_precision(&self) -> u8 {
86        0
87    }
88}
89
90/// Error returned when constructing an [`InstrumentInfo`] from reference data.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct InstrumentInfoError {
93    /// Human-readable description of what went wrong.
94    pub message: String,
95}
96
97impl std::fmt::Display for InstrumentInfoError {
98    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        write!(f, "{}", self.message)
100    }
101}
102
103impl std::error::Error for InstrumentInfoError {}
104
105impl TryFrom<&ResponseReferenceData> for InstrumentInfo {
106    type Error = InstrumentInfoError;
107
108    fn try_from(data: &ResponseReferenceData) -> Result<Self, Self::Error> {
109        let symbol = data.symbol.clone().ok_or_else(|| InstrumentInfoError {
110            message: "missing symbol".to_string(),
111        })?;
112
113        let exchange = data.exchange.clone().ok_or_else(|| InstrumentInfoError {
114            message: "missing exchange".to_string(),
115        })?;
116
117        let is_tradable = data
118            .is_tradable
119            .as_ref()
120            .map(|s| s.eq_ignore_ascii_case("true") || s == "1")
121            .unwrap_or(false);
122
123        Ok(InstrumentInfo {
124            symbol,
125            exchange,
126            exchange_symbol: data.exchange_symbol.clone(),
127            name: data.symbol_name.clone(),
128            product_code: data.product_code.clone(),
129            instrument_type: data.instrument_type.clone(),
130            underlying: data.underlying_symbol.clone(),
131            currency: data.currency.clone(),
132            expiration_date: data.expiration_date.clone(),
133            tick_size: data.min_qprice_change,
134            point_value: data.single_point_value,
135            is_tradable,
136        })
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    #[allow(clippy::field_reassign_with_default)]
146    fn test_price_precision() {
147        let mut info = InstrumentInfo::default();
148
149        info.tick_size = Some(0.25); // ES
150        assert_eq!(info.price_precision(), 2);
151
152        info.tick_size = Some(0.01); // CL
153        assert_eq!(info.price_precision(), 2);
154
155        info.tick_size = Some(0.03125); // ZB (1/32)
156        assert_eq!(info.price_precision(), 5);
157
158        info.tick_size = Some(1.0); // whole number tick
159        assert_eq!(info.price_precision(), 0);
160
161        info.tick_size = None; // default
162        assert_eq!(info.price_precision(), 2);
163    }
164
165    #[test]
166    fn test_try_from_missing_symbol() {
167        let data = ResponseReferenceData {
168            template_id: 15,
169            symbol: None,
170            exchange: Some("CME".to_string()),
171            ..Default::default()
172        };
173
174        let result = InstrumentInfo::try_from(&data);
175        assert!(result.is_err());
176        assert_eq!(result.unwrap_err().message, "missing symbol");
177    }
178
179    #[test]
180    fn test_try_from_missing_exchange() {
181        let data = ResponseReferenceData {
182            template_id: 15,
183            symbol: Some("ESH4".to_string()),
184            exchange: None,
185            ..Default::default()
186        };
187
188        let result = InstrumentInfo::try_from(&data);
189        assert!(result.is_err());
190        assert_eq!(result.unwrap_err().message, "missing exchange");
191    }
192
193    #[test]
194    fn test_try_from_success() {
195        let data = ResponseReferenceData {
196            template_id: 15,
197            symbol: Some("ESH4".to_string()),
198            exchange: Some("CME".to_string()),
199            symbol_name: Some("E-mini S&P 500".to_string()),
200            product_code: Some("ES".to_string()),
201            instrument_type: Some("Future".to_string()),
202            currency: Some("USD".to_string()),
203            min_qprice_change: Some(0.25),
204            single_point_value: Some(50.0),
205            is_tradable: Some("true".to_string()),
206            ..Default::default()
207        };
208
209        let info = InstrumentInfo::try_from(&data).unwrap();
210        assert_eq!(info.symbol, "ESH4");
211        assert_eq!(info.exchange, "CME");
212        assert_eq!(info.name, Some("E-mini S&P 500".to_string()));
213        assert_eq!(info.product_code, Some("ES".to_string()));
214        assert_eq!(info.tick_size, Some(0.25));
215        assert_eq!(info.point_value, Some(50.0));
216        assert!(info.is_tradable);
217    }
218}