nautilus_model/instruments/
mod.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
16//! Instrument definitions for the trading domain model.
17
18pub mod any;
19pub mod betting;
20pub mod binary_option;
21pub mod crypto_future;
22pub mod crypto_option;
23pub mod crypto_perpetual;
24pub mod currency_pair;
25pub mod equity;
26pub mod futures_contract;
27pub mod futures_spread;
28pub mod option_contract;
29pub mod option_spread;
30pub mod synthetic;
31
32#[cfg(feature = "stubs")]
33pub mod stubs;
34
35use enum_dispatch::enum_dispatch;
36use nautilus_core::UnixNanos;
37use rust_decimal::Decimal;
38use rust_decimal_macros::dec;
39use ustr::Ustr;
40
41// Re-exports
42pub use crate::instruments::{
43    any::InstrumentAny, betting::BettingInstrument, binary_option::BinaryOption,
44    crypto_future::CryptoFuture, crypto_option::CryptoOption, crypto_perpetual::CryptoPerpetual,
45    currency_pair::CurrencyPair, equity::Equity, futures_contract::FuturesContract,
46    futures_spread::FuturesSpread, option_contract::OptionContract, option_spread::OptionSpread,
47    synthetic::SyntheticInstrument,
48};
49use crate::{
50    enums::{AssetClass, InstrumentClass, OptionKind},
51    identifiers::{InstrumentId, Symbol, Venue},
52    types::{Currency, Money, Price, Quantity},
53};
54
55#[enum_dispatch]
56pub trait Instrument: 'static + Send {
57    fn into_any(self) -> InstrumentAny;
58    fn id(&self) -> InstrumentId;
59    fn symbol(&self) -> Symbol {
60        self.id().symbol
61    }
62    fn venue(&self) -> Venue {
63        self.id().venue
64    }
65    fn raw_symbol(&self) -> Symbol;
66    fn asset_class(&self) -> AssetClass;
67    fn instrument_class(&self) -> InstrumentClass;
68    fn underlying(&self) -> Option<Ustr>;
69    fn base_currency(&self) -> Option<Currency>;
70    fn quote_currency(&self) -> Currency;
71    fn settlement_currency(&self) -> Currency;
72    fn cost_currency(&self) -> Currency {
73        if self.is_inverse() {
74            self.base_currency()
75                .expect("Inverse instruments must have a base currency")
76        } else {
77            self.quote_currency()
78        }
79    }
80    fn isin(&self) -> Option<Ustr>;
81    fn option_kind(&self) -> Option<OptionKind>;
82    fn exchange(&self) -> Option<Ustr>;
83    fn strike_price(&self) -> Option<Price>;
84    fn activation_ns(&self) -> Option<UnixNanos>;
85    fn expiration_ns(&self) -> Option<UnixNanos>;
86    fn is_inverse(&self) -> bool;
87    fn is_quanto(&self) -> bool {
88        if let Some(base_currency) = self.base_currency() {
89            self.settlement_currency() != base_currency
90        } else {
91            false
92        }
93    }
94    fn price_precision(&self) -> u8;
95    fn size_precision(&self) -> u8;
96    fn price_increment(&self) -> Price;
97    fn size_increment(&self) -> Quantity;
98    fn multiplier(&self) -> Quantity;
99    fn lot_size(&self) -> Option<Quantity>;
100    fn max_quantity(&self) -> Option<Quantity>;
101    fn min_quantity(&self) -> Option<Quantity>;
102    fn max_notional(&self) -> Option<Money>;
103    fn min_notional(&self) -> Option<Money>;
104    fn max_price(&self) -> Option<Price>;
105    fn min_price(&self) -> Option<Price>;
106    fn margin_init(&self) -> Decimal {
107        dec!(0) // Temporary until separate fee models
108    }
109
110    fn margin_maint(&self) -> Decimal {
111        dec!(0) // Temporary until separate fee models
112    }
113
114    fn maker_fee(&self) -> Decimal {
115        dec!(0) // Temporary until separate fee models
116    }
117
118    fn taker_fee(&self) -> Decimal {
119        dec!(0) // Temporary until separate fee models
120    }
121    fn ts_event(&self) -> UnixNanos;
122    fn ts_init(&self) -> UnixNanos;
123
124    /// Creates a new [`Price`] from the given `value` with the correct price precision for the instrument.
125    fn make_price(&self, value: f64) -> Price {
126        Price::new(value, self.price_precision())
127    }
128
129    /// Creates a new [`Quantity`] from the given `value` with the correct size precision for the instrument.
130    fn make_qty(&self, value: f64, round_down: Option<bool>) -> Quantity {
131        if round_down.unwrap_or(false) {
132            // Round down to the nearest valid increment
133            let increment = 10f64.powi(-i32::from(self.size_precision()));
134            let rounded_value = (value / increment).floor() * increment;
135            Quantity::new(rounded_value, self.size_precision())
136        } else {
137            // Use standard rounding behavior (banker's rounding)
138            Quantity::new(value, self.size_precision())
139        }
140    }
141
142    /// Calculates the notional value from the given parameters.
143    /// The `use_quote_for_inverse` flag is only applicable for inverse instruments.
144    ///
145    /// # Panics
146    ///
147    /// This function panics if instrument is inverse and not `use_quote_for_inverse`, with no base currency.
148    fn calculate_notional_value(
149        &self,
150        quantity: Quantity,
151        price: Price,
152        use_quote_for_inverse: Option<bool>,
153    ) -> Money {
154        let use_quote_for_inverse = use_quote_for_inverse.unwrap_or(false);
155        let (amount, currency) = if self.is_inverse() {
156            if use_quote_for_inverse {
157                (quantity.as_f64(), self.quote_currency())
158            } else {
159                let amount =
160                    quantity.as_f64() * self.multiplier().as_f64() * (1.0 / price.as_f64());
161                let currency = self
162                    .base_currency()
163                    .expect("Error: no base currency for notional calculation");
164                (amount, currency)
165            }
166        } else {
167            let amount = quantity.as_f64() * self.multiplier().as_f64() * price.as_f64();
168            let currency = self.quote_currency();
169            (amount, currency)
170        };
171
172        Money::new(amount, currency)
173    }
174
175    /// Returns the equivalent quantity of the base asset.
176    fn calculate_base_quantity(&self, quantity: Quantity, last_px: Price) -> Quantity {
177        let value = quantity.as_f64() * (1.0 / last_px.as_f64());
178        Quantity::new(value, self.size_precision())
179    }
180}
181
182pub const EXPIRING_INSTRUMENT_TYPES: [InstrumentClass; 4] = [
183    InstrumentClass::Future,
184    InstrumentClass::FuturesSpread,
185    InstrumentClass::Option,
186    InstrumentClass::OptionSpread,
187];
188
189#[cfg(test)]
190mod tests {
191    use rstest::rstest;
192
193    use crate::instruments::{CurrencyPair, Instrument, stubs::*};
194
195    #[rstest]
196    fn test_make_qty_standard_rounding(currency_pair_btcusdt: CurrencyPair) {
197        assert_eq!(
198            currency_pair_btcusdt.make_qty(1.5, None).to_string(),
199            "1.500000"
200        ); // 1.5 -> 1.500000
201        assert_eq!(
202            currency_pair_btcusdt.make_qty(2.5, None).to_string(),
203            "2.500000"
204        ); // 2.5 -> 2.500000 (banker's rounds to even)
205        assert_eq!(
206            currency_pair_btcusdt.make_qty(1.2345678, None).to_string(),
207            "1.234568"
208        ); // 1.2345678 -> 1.234568 (rounds to precision)
209    }
210
211    #[rstest]
212    fn test_make_qty_round_down(currency_pair_btcusdt: CurrencyPair) {
213        assert_eq!(
214            currency_pair_btcusdt.make_qty(1.5, Some(true)).to_string(),
215            "1.500000"
216        ); // 1.5 -> 1.500000
217        assert_eq!(
218            currency_pair_btcusdt.make_qty(2.5, Some(true)).to_string(),
219            "2.500000"
220        ); // 2.5 -> 2.500000
221        assert_eq!(
222            currency_pair_btcusdt
223                .make_qty(1.2345678, Some(true))
224                .to_string(),
225            "1.234567"
226        ); // 1.2345678 -> 1.234567 (rounds down)
227        assert_eq!(
228            currency_pair_btcusdt
229                .make_qty(1.9999999, Some(true))
230                .to_string(),
231            "1.999999"
232        ); // 1.9999999 -> 1.999999 (rounds down)
233    }
234
235    #[rstest]
236    fn test_make_qty_boundary_cases(currency_pair_btcusdt: CurrencyPair) {
237        // The instrument has size_precision=6, so increment = 0.000001
238        let increment = 0.000001;
239
240        // Testing behavior near increment boundaries
241        let value_just_above = 1.0 + (increment * 1.1);
242        assert_eq!(
243            currency_pair_btcusdt
244                .make_qty(value_just_above, Some(true))
245                .to_string(),
246            "1.000001"
247        ); // Should round down to 1.000001
248        assert_eq!(
249            currency_pair_btcusdt
250                .make_qty(value_just_above, None)
251                .to_string(),
252            "1.000001"
253        ); // Standard rounding should be 1.000001
254
255        // Test with a value that should differ between round modes
256        let value_half_increment = 1.0000015;
257        assert_eq!(
258            currency_pair_btcusdt
259                .make_qty(value_half_increment, Some(true))
260                .to_string(),
261            "1.000001"
262        ); // Should round down to 1.000001
263        assert_eq!(
264            currency_pair_btcusdt
265                .make_qty(value_half_increment, None)
266                .to_string(),
267            "1.000002"
268        ); // Standard rounding should be 1.000002
269    }
270
271    #[rstest]
272    fn test_make_qty_zero_value(currency_pair_btcusdt: CurrencyPair) {
273        // Zero should remain zero with both rounding methods
274        assert_eq!(
275            currency_pair_btcusdt.make_qty(0.0, None).to_string(),
276            "0.000000"
277        );
278        assert_eq!(
279            currency_pair_btcusdt.make_qty(0.0, Some(true)).to_string(),
280            "0.000000"
281        );
282    }
283
284    #[rstest]
285    fn test_make_qty_different_precision(currency_pair_ethusdt: CurrencyPair) {
286        // ethusdt has size_precision=5
287        assert_eq!(
288            currency_pair_ethusdt.make_qty(1.2345678, None).to_string(),
289            "1.23457"
290        ); // 1.2345678 -> 1.23457 (standard rounding)
291        assert_eq!(
292            currency_pair_ethusdt
293                .make_qty(1.2345678, Some(true))
294                .to_string(),
295            "1.23456"
296        ); // 1.2345678 -> 1.23456 (rounds down)
297    }
298}