Skip to main content

nautilus_model/instruments/
index_instrument.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::hash::{Hash, Hasher};
17
18use nautilus_core::{
19    Params, UnixNanos,
20    correctness::{CorrectnessResult, CorrectnessResultExt, FAILED, check_equal_u8},
21};
22use serde::{Deserialize, Serialize};
23use ustr::Ustr;
24
25use super::{Instrument, any::InstrumentAny, tick_scheme::check_tick_scheme};
26use crate::{
27    enums::{AssetClass, InstrumentClass, OptionKind},
28    identifiers::{InstrumentId, Symbol},
29    types::{
30        currency::Currency,
31        money::Money,
32        price::{Price, check_positive_price},
33        quantity::{Quantity, check_positive_quantity},
34    },
35};
36
37/// Represents a generic index instrument.
38///
39/// An index is typically not directly tradable.
40#[repr(C)]
41#[derive(Clone, Debug, Serialize, Deserialize)]
42#[cfg_attr(
43    feature = "python",
44    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", from_py_object)
45)]
46#[cfg_attr(
47    feature = "python",
48    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
49)]
50pub struct IndexInstrument {
51    /// The instrument ID.
52    pub id: InstrumentId,
53    /// The raw/local/native symbol for the instrument, assigned by the venue.
54    pub raw_symbol: Symbol,
55    /// The index currency.
56    pub currency: Currency,
57    /// The price decimal precision.
58    pub price_precision: u8,
59    /// The trading size decimal precision.
60    pub size_precision: u8,
61    /// The minimum price increment (tick size).
62    pub price_increment: Price,
63    /// The minimum size increment.
64    pub size_increment: Quantity,
65    /// The registered variable tick scheme name.
66    pub tick_scheme: Option<Ustr>,
67    /// Additional instrument metadata as a JSON-serializable dictionary.
68    pub info: Option<Params>,
69    /// UNIX timestamp (nanoseconds) when the data event occurred.
70    pub ts_event: UnixNanos,
71    /// UNIX timestamp (nanoseconds) when the data object was initialized.
72    pub ts_init: UnixNanos,
73}
74
75#[bon::bon]
76impl IndexInstrument {
77    /// Creates a new [`IndexInstrument`] instance with correctness checking.
78    ///
79    /// # Errors
80    ///
81    /// Returns an error if any input validation fails.
82    ///
83    /// # Notes
84    ///
85    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
86    #[expect(clippy::too_many_arguments)]
87    pub fn new_checked(
88        instrument_id: InstrumentId,
89        raw_symbol: Symbol,
90        currency: Currency,
91        price_precision: u8,
92        size_precision: u8,
93        price_increment: Price,
94        size_increment: Quantity,
95        tick_scheme: Option<Ustr>,
96        info: Option<Params>,
97        ts_event: UnixNanos,
98        ts_init: UnixNanos,
99    ) -> CorrectnessResult<Self> {
100        check_equal_u8(
101            price_precision,
102            price_increment.precision,
103            stringify!(price_precision),
104            stringify!(price_increment.precision),
105        )?;
106        check_equal_u8(
107            size_precision,
108            size_increment.precision,
109            stringify!(size_precision),
110            stringify!(size_increment.precision),
111        )?;
112        check_positive_price(price_increment, stringify!(price_increment))?;
113        check_positive_quantity(size_increment, stringify!(size_increment))?;
114        check_tick_scheme(tick_scheme)?;
115
116        Ok(Self {
117            id: instrument_id,
118            raw_symbol,
119            currency,
120            price_precision,
121            size_precision,
122            price_increment,
123            size_increment,
124            tick_scheme,
125            info,
126            ts_event,
127            ts_init,
128        })
129    }
130
131    /// Creates a new [`IndexInstrument`] instance.
132    ///
133    /// # Panics
134    ///
135    /// Panics if any parameter is invalid (see `new_checked`).
136    #[expect(clippy::too_many_arguments)]
137    #[must_use]
138    pub fn new(
139        instrument_id: InstrumentId,
140        raw_symbol: Symbol,
141        currency: Currency,
142        price_precision: u8,
143        size_precision: u8,
144        price_increment: Price,
145        size_increment: Quantity,
146        tick_scheme: Option<Ustr>,
147        info: Option<Params>,
148        ts_event: UnixNanos,
149        ts_init: UnixNanos,
150    ) -> Self {
151        Self::new_checked(
152            instrument_id,
153            raw_symbol,
154            currency,
155            price_precision,
156            size_precision,
157            price_increment,
158            size_increment,
159            tick_scheme,
160            info,
161            ts_event,
162            ts_init,
163        )
164        .expect_display(FAILED)
165    }
166
167    /// Returns a fluent builder for a [`IndexInstrument`] instance.
168    ///
169    /// Required fields are enforced at compile time; optional fields can be omitted and default
170    /// the same way they do in [`IndexInstrument::new_checked`], which the builder calls so the same
171    /// correctness checks run on `build`.
172    ///
173    /// # Errors
174    ///
175    /// Returns an error if any input validation fails (see [`IndexInstrument::new_checked`]).
176    #[builder(start_fn = builder, finish_fn = build)]
177    pub fn build_checked(
178        instrument_id: InstrumentId,
179        raw_symbol: Symbol,
180        currency: Currency,
181        price_precision: u8,
182        size_precision: u8,
183        price_increment: Price,
184        size_increment: Quantity,
185        tick_scheme: Option<Ustr>,
186        info: Option<Params>,
187        ts_event: UnixNanos,
188        ts_init: UnixNanos,
189    ) -> CorrectnessResult<Self> {
190        Self::new_checked(
191            instrument_id,
192            raw_symbol,
193            currency,
194            price_precision,
195            size_precision,
196            price_increment,
197            size_increment,
198            tick_scheme,
199            info,
200            ts_event,
201            ts_init,
202        )
203    }
204}
205
206impl PartialEq<Self> for IndexInstrument {
207    fn eq(&self, other: &Self) -> bool {
208        self.id == other.id
209    }
210}
211
212impl Eq for IndexInstrument {}
213
214impl Hash for IndexInstrument {
215    fn hash<H: Hasher>(&self, state: &mut H) {
216        self.id.hash(state);
217    }
218}
219
220impl Instrument for IndexInstrument {
221    fn tick_scheme(&self) -> Option<Ustr> {
222        self.tick_scheme
223    }
224    fn into_any(self) -> InstrumentAny {
225        InstrumentAny::IndexInstrument(self)
226    }
227
228    fn id(&self) -> InstrumentId {
229        self.id
230    }
231
232    fn raw_symbol(&self) -> Symbol {
233        self.raw_symbol
234    }
235
236    fn asset_class(&self) -> AssetClass {
237        AssetClass::Index
238    }
239
240    fn instrument_class(&self) -> InstrumentClass {
241        InstrumentClass::Spot
242    }
243
244    fn underlying(&self) -> Option<Ustr> {
245        None
246    }
247
248    fn base_currency(&self) -> Option<Currency> {
249        None
250    }
251
252    fn quote_currency(&self) -> Currency {
253        self.currency
254    }
255
256    fn settlement_currency(&self) -> Currency {
257        self.currency
258    }
259
260    fn isin(&self) -> Option<Ustr> {
261        None
262    }
263
264    fn option_kind(&self) -> Option<OptionKind> {
265        None
266    }
267
268    fn exchange(&self) -> Option<Ustr> {
269        None
270    }
271
272    fn strike_price(&self) -> Option<Price> {
273        None
274    }
275
276    fn activation_ns(&self) -> Option<UnixNanos> {
277        None
278    }
279
280    fn expiration_ns(&self) -> Option<UnixNanos> {
281        None
282    }
283
284    fn is_inverse(&self) -> bool {
285        false
286    }
287
288    fn price_precision(&self) -> u8 {
289        self.price_precision
290    }
291
292    fn size_precision(&self) -> u8 {
293        self.size_precision
294    }
295
296    fn price_increment(&self) -> Price {
297        self.price_increment
298    }
299
300    fn size_increment(&self) -> Quantity {
301        self.size_increment
302    }
303
304    fn multiplier(&self) -> Quantity {
305        Quantity::from(1)
306    }
307
308    fn lot_size(&self) -> Option<Quantity> {
309        None
310    }
311
312    fn max_quantity(&self) -> Option<Quantity> {
313        None
314    }
315
316    fn min_quantity(&self) -> Option<Quantity> {
317        None
318    }
319
320    fn max_notional(&self) -> Option<Money> {
321        None
322    }
323
324    fn min_notional(&self) -> Option<Money> {
325        None
326    }
327
328    fn max_price(&self) -> Option<Price> {
329        None
330    }
331
332    fn min_price(&self) -> Option<Price> {
333        None
334    }
335
336    fn ts_event(&self) -> UnixNanos {
337        self.ts_event
338    }
339
340    fn ts_init(&self) -> UnixNanos {
341        self.ts_init
342    }
343}
344
345#[cfg(test)]
346mod tests {
347    use rstest::rstest;
348
349    use crate::{
350        enums::{AssetClass, InstrumentClass},
351        identifiers::{InstrumentId, Symbol},
352        instruments::{IndexInstrument, Instrument, stubs::*},
353        types::{Currency, Price, Quantity},
354    };
355
356    #[rstest]
357    fn test_trait_accessors(index_instrument_spx: IndexInstrument) {
358        assert_eq!(index_instrument_spx.id(), InstrumentId::from("SPX.INDEX"));
359        assert_eq!(index_instrument_spx.asset_class(), AssetClass::Index);
360        assert_eq!(
361            index_instrument_spx.instrument_class(),
362            InstrumentClass::Spot
363        );
364        assert_eq!(index_instrument_spx.quote_currency(), Currency::USD());
365        assert!(!index_instrument_spx.is_inverse());
366        assert_eq!(index_instrument_spx.price_precision(), 2);
367        assert_eq!(index_instrument_spx.size_precision(), 0);
368    }
369
370    #[rstest]
371    fn test_new_checked_price_precision_mismatch() {
372        let result = IndexInstrument::new_checked(
373            InstrumentId::from("SPX.INDEX"),
374            Symbol::from("SPX"),
375            Currency::USD(),
376            4, // mismatch
377            0,
378            Price::from("0.01"),
379            Quantity::from("1"),
380            None,
381            None,
382            0.into(),
383            0.into(),
384        );
385        assert!(result.is_err());
386    }
387
388    #[rstest]
389    fn test_serialization_roundtrip(index_instrument_spx: IndexInstrument) {
390        let json = serde_json::to_string(&index_instrument_spx).unwrap();
391        let deserialized: IndexInstrument = serde_json::from_str(&json).unwrap();
392        assert_eq!(index_instrument_spx, deserialized);
393    }
394
395    #[rstest]
396    fn test_builder_matches_new_checked() {
397        let positional = IndexInstrument::new_checked(
398            InstrumentId::from("SPX.INDEX"),
399            Symbol::from("SPX"),
400            Currency::USD(),
401            2,
402            0,
403            Price::from("0.01"),
404            Quantity::from("1"),
405            None,
406            None,
407            1.into(),
408            2.into(),
409        )
410        .unwrap();
411
412        let built = IndexInstrument::builder()
413            .instrument_id(InstrumentId::from("SPX.INDEX"))
414            .raw_symbol(Symbol::from("SPX"))
415            .currency(Currency::USD())
416            .price_precision(2)
417            .size_precision(0)
418            .price_increment(Price::from("0.01"))
419            .size_increment(Quantity::from("1"))
420            .ts_event(1.into())
421            .ts_init(2.into())
422            .build()
423            .unwrap();
424
425        assert_eq!(
426            serde_json::to_value(&positional).unwrap(),
427            serde_json::to_value(&built).unwrap(),
428        );
429    }
430}