Skip to main content

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