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, tick_scheme::check_tick_scheme};
27use crate::{
28    enums::{AssetClass, CurrencyType, 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    /// The registered variable tick scheme name.
93    pub tick_scheme: Option<Ustr>,
94    /// Additional instrument metadata as a JSON-serializable dictionary.
95    pub info: Option<Params>,
96    /// UNIX timestamp (nanoseconds) when the data event occurred.
97    pub ts_event: UnixNanos,
98    /// UNIX timestamp (nanoseconds) when the data object was initialized.
99    pub ts_init: UnixNanos,
100}
101
102#[bon::bon]
103impl CurrencyPair {
104    /// Creates a new [`CurrencyPair`] instance with correctness checking.
105    ///
106    /// # Errors
107    ///
108    /// Returns an error if any input validation fails.
109    ///
110    /// # Notes
111    ///
112    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
113    #[expect(clippy::too_many_arguments)]
114    pub fn new_checked(
115        instrument_id: InstrumentId,
116        raw_symbol: Symbol,
117        base_currency: Currency,
118        quote_currency: Currency,
119        price_precision: u8,
120        size_precision: u8,
121        price_increment: Price,
122        size_increment: Quantity,
123        multiplier: Option<Quantity>,
124        lot_size: Option<Quantity>,
125        max_quantity: Option<Quantity>,
126        min_quantity: Option<Quantity>,
127        max_notional: Option<Money>,
128        min_notional: Option<Money>,
129        max_price: Option<Price>,
130        min_price: Option<Price>,
131        margin_init: Option<Decimal>,
132        margin_maint: Option<Decimal>,
133        maker_fee: Option<Decimal>,
134        taker_fee: Option<Decimal>,
135        tick_scheme: Option<Ustr>,
136        info: Option<Params>,
137        ts_event: UnixNanos,
138        ts_init: UnixNanos,
139    ) -> CorrectnessResult<Self> {
140        check_equal_u8(
141            price_precision,
142            price_increment.precision,
143            stringify!(price_precision),
144            stringify!(price_increment.precision),
145        )?;
146        check_equal_u8(
147            size_precision,
148            size_increment.precision,
149            stringify!(size_precision),
150            stringify!(size_increment.precision),
151        )?;
152        check_positive_price(price_increment, stringify!(price_increment))?;
153        check_positive_quantity(size_increment, stringify!(size_increment))?;
154        check_tick_scheme(tick_scheme)?;
155
156        if let Some(multiplier) = multiplier {
157            check_positive_quantity(multiplier, stringify!(multiplier))?;
158        }
159
160        if let Some(lot_size) = lot_size {
161            check_positive_quantity(lot_size, stringify!(lot_size))?;
162        }
163
164        Ok(Self {
165            id: instrument_id,
166            raw_symbol,
167            base_currency,
168            quote_currency,
169            price_precision,
170            size_precision,
171            price_increment,
172            size_increment,
173            multiplier: multiplier.unwrap_or(Quantity::from(1)),
174            lot_size,
175            max_quantity,
176            min_quantity,
177            max_notional,
178            min_notional,
179            max_price,
180            min_price,
181            margin_init: margin_init.unwrap_or_default(),
182            margin_maint: margin_maint.unwrap_or_default(),
183            maker_fee: maker_fee.unwrap_or_default(),
184            taker_fee: taker_fee.unwrap_or_default(),
185            tick_scheme,
186            info,
187            ts_event,
188            ts_init,
189        })
190    }
191
192    /// Creates a new [`CurrencyPair`] instance.
193    ///
194    /// # Panics
195    ///
196    /// Panics if any input parameter is invalid (see `new_checked`).
197    #[expect(clippy::too_many_arguments)]
198    #[must_use]
199    pub fn new(
200        instrument_id: InstrumentId,
201        raw_symbol: Symbol,
202        base_currency: Currency,
203        quote_currency: Currency,
204        price_precision: u8,
205        size_precision: u8,
206        price_increment: Price,
207        size_increment: Quantity,
208        multiplier: Option<Quantity>,
209        lot_size: Option<Quantity>,
210        max_quantity: Option<Quantity>,
211        min_quantity: Option<Quantity>,
212        max_notional: Option<Money>,
213        min_notional: Option<Money>,
214        max_price: Option<Price>,
215        min_price: Option<Price>,
216        margin_init: Option<Decimal>,
217        margin_maint: Option<Decimal>,
218        maker_fee: Option<Decimal>,
219        taker_fee: Option<Decimal>,
220        tick_scheme: Option<Ustr>,
221        info: Option<Params>,
222        ts_event: UnixNanos,
223        ts_init: UnixNanos,
224    ) -> Self {
225        Self::new_checked(
226            instrument_id,
227            raw_symbol,
228            base_currency,
229            quote_currency,
230            price_precision,
231            size_precision,
232            price_increment,
233            size_increment,
234            multiplier,
235            lot_size,
236            max_quantity,
237            min_quantity,
238            max_notional,
239            min_notional,
240            max_price,
241            min_price,
242            margin_init,
243            margin_maint,
244            maker_fee,
245            taker_fee,
246            tick_scheme,
247            info,
248            ts_event,
249            ts_init,
250        )
251        .expect_display(FAILED)
252    }
253
254    /// Returns a fluent builder for a [`CurrencyPair`] instance.
255    ///
256    /// Required fields are enforced at compile time; optional fields can be omitted and default
257    /// the same way they do in [`CurrencyPair::new_checked`], which the builder calls so the same
258    /// correctness checks run on `build`.
259    ///
260    /// # Errors
261    ///
262    /// Returns an error if any input validation fails (see [`CurrencyPair::new_checked`]).
263    #[builder(start_fn = builder, finish_fn = build)]
264    pub fn build_checked(
265        instrument_id: InstrumentId,
266        raw_symbol: Symbol,
267        base_currency: Currency,
268        quote_currency: Currency,
269        price_precision: u8,
270        size_precision: u8,
271        price_increment: Price,
272        size_increment: Quantity,
273        multiplier: Option<Quantity>,
274        lot_size: Option<Quantity>,
275        max_quantity: Option<Quantity>,
276        min_quantity: Option<Quantity>,
277        max_notional: Option<Money>,
278        min_notional: Option<Money>,
279        max_price: Option<Price>,
280        min_price: Option<Price>,
281        margin_init: Option<Decimal>,
282        margin_maint: Option<Decimal>,
283        maker_fee: Option<Decimal>,
284        taker_fee: Option<Decimal>,
285        tick_scheme: Option<Ustr>,
286        info: Option<Params>,
287        ts_event: UnixNanos,
288        ts_init: UnixNanos,
289    ) -> CorrectnessResult<Self> {
290        Self::new_checked(
291            instrument_id,
292            raw_symbol,
293            base_currency,
294            quote_currency,
295            price_precision,
296            size_precision,
297            price_increment,
298            size_increment,
299            multiplier,
300            lot_size,
301            max_quantity,
302            min_quantity,
303            max_notional,
304            min_notional,
305            max_price,
306            min_price,
307            margin_init,
308            margin_maint,
309            maker_fee,
310            taker_fee,
311            tick_scheme,
312            info,
313            ts_event,
314            ts_init,
315        )
316    }
317}
318
319impl PartialEq<Self> for CurrencyPair {
320    fn eq(&self, other: &Self) -> bool {
321        self.id == other.id
322    }
323}
324
325impl Eq for CurrencyPair {}
326
327impl Hash for CurrencyPair {
328    fn hash<H: Hasher>(&self, state: &mut H) {
329        self.id.hash(state);
330    }
331}
332
333impl Instrument for CurrencyPair {
334    fn tick_scheme(&self) -> Option<Ustr> {
335        self.tick_scheme
336    }
337    fn into_any(self) -> InstrumentAny {
338        InstrumentAny::CurrencyPair(self)
339    }
340
341    fn id(&self) -> InstrumentId {
342        self.id
343    }
344
345    fn raw_symbol(&self) -> Symbol {
346        self.raw_symbol
347    }
348
349    fn asset_class(&self) -> AssetClass {
350        if self.base_currency.currency_type == CurrencyType::Crypto
351            || self.quote_currency.currency_type == CurrencyType::Crypto
352        {
353            AssetClass::Cryptocurrency
354        } else {
355            AssetClass::FX
356        }
357    }
358
359    fn instrument_class(&self) -> InstrumentClass {
360        InstrumentClass::Spot
361    }
362
363    fn underlying(&self) -> Option<Ustr> {
364        None
365    }
366
367    fn base_currency(&self) -> Option<Currency> {
368        Some(self.base_currency)
369    }
370
371    fn quote_currency(&self) -> Currency {
372        self.quote_currency
373    }
374
375    fn settlement_currency(&self) -> Currency {
376        self.quote_currency
377    }
378    fn isin(&self) -> Option<Ustr> {
379        None
380    }
381
382    fn is_inverse(&self) -> bool {
383        false
384    }
385
386    fn price_precision(&self) -> u8 {
387        self.price_precision
388    }
389
390    fn size_precision(&self) -> u8 {
391        self.size_precision
392    }
393
394    fn price_increment(&self) -> Price {
395        self.price_increment
396    }
397
398    fn size_increment(&self) -> Quantity {
399        self.size_increment
400    }
401
402    fn multiplier(&self) -> Quantity {
403        self.multiplier
404    }
405
406    fn lot_size(&self) -> Option<Quantity> {
407        self.lot_size
408    }
409
410    fn max_quantity(&self) -> Option<Quantity> {
411        self.max_quantity
412    }
413
414    fn min_quantity(&self) -> Option<Quantity> {
415        self.min_quantity
416    }
417
418    fn max_price(&self) -> Option<Price> {
419        self.max_price
420    }
421
422    fn min_price(&self) -> Option<Price> {
423        self.min_price
424    }
425
426    fn ts_event(&self) -> UnixNanos {
427        self.ts_event
428    }
429
430    fn ts_init(&self) -> UnixNanos {
431        self.ts_init
432    }
433
434    fn margin_init(&self) -> Decimal {
435        self.margin_init
436    }
437
438    fn margin_maint(&self) -> Decimal {
439        self.margin_maint
440    }
441
442    fn taker_fee(&self) -> Decimal {
443        self.taker_fee
444    }
445
446    fn maker_fee(&self) -> Decimal {
447        self.maker_fee
448    }
449
450    fn option_kind(&self) -> Option<OptionKind> {
451        None
452    }
453
454    fn exchange(&self) -> Option<Ustr> {
455        None
456    }
457
458    fn strike_price(&self) -> Option<Price> {
459        None
460    }
461
462    fn activation_ns(&self) -> Option<UnixNanos> {
463        None
464    }
465
466    fn expiration_ns(&self) -> Option<UnixNanos> {
467        None
468    }
469
470    fn max_notional(&self) -> Option<Money> {
471        self.max_notional
472    }
473
474    fn min_notional(&self) -> Option<Money> {
475        self.min_notional
476    }
477}
478
479#[cfg(test)]
480mod tests {
481    use rstest::rstest;
482    use rust_decimal_macros::dec;
483
484    use crate::{
485        enums::{AssetClass, InstrumentClass},
486        identifiers::{InstrumentId, Symbol},
487        instruments::{CurrencyPair, Instrument, stubs::*},
488        types::{Currency, Money, Price, Quantity},
489    };
490
491    #[rstest]
492    fn test_trait_accessors(currency_pair_btcusdt: CurrencyPair) {
493        assert_eq!(
494            currency_pair_btcusdt.id(),
495            InstrumentId::from("BTCUSDT.BINANCE")
496        );
497        assert_eq!(
498            currency_pair_btcusdt.asset_class(),
499            AssetClass::Cryptocurrency
500        );
501        assert_eq!(
502            currency_pair_btcusdt.instrument_class(),
503            InstrumentClass::Spot
504        );
505        assert_eq!(currency_pair_btcusdt.base_currency(), Some(Currency::BTC()));
506        assert_eq!(currency_pair_btcusdt.quote_currency(), Currency::USDT());
507        assert!(!currency_pair_btcusdt.is_inverse());
508        assert_eq!(currency_pair_btcusdt.price_precision(), 2);
509        assert_eq!(currency_pair_btcusdt.size_precision(), 6);
510        assert_eq!(currency_pair_btcusdt.price_increment(), Price::from("0.01"));
511        assert_eq!(
512            currency_pair_btcusdt.size_increment(),
513            Quantity::from("0.000001")
514        );
515    }
516
517    #[rstest]
518    fn test_new_checked_price_precision_mismatch() {
519        let result = CurrencyPair::new_checked(
520            InstrumentId::from("TEST.BINANCE"),
521            Symbol::from("TEST"),
522            Currency::BTC(),
523            Currency::USDT(),
524            4, // mismatch
525            6,
526            Price::from("0.01"),
527            Quantity::from("0.000001"),
528            None,
529            None,
530            None,
531            None,
532            None,
533            None,
534            None,
535            None,
536            None,
537            None,
538            None,
539            None,
540            None,
541            None,
542            0.into(),
543            0.into(),
544        );
545        assert!(result.is_err());
546    }
547
548    #[rstest]
549    #[case::zero_multiplier(Some(Quantity::from("0")), None)]
550    #[case::zero_lot_size(None, Some(Quantity::from("0")))]
551    fn test_new_checked_rejects_non_positive_sizing(
552        #[case] multiplier: Option<Quantity>,
553        #[case] lot_size: Option<Quantity>,
554    ) {
555        let result = CurrencyPair::new_checked(
556            InstrumentId::from("TEST.BINANCE"),
557            Symbol::from("TEST"),
558            Currency::BTC(),
559            Currency::USDT(),
560            2,
561            6,
562            Price::from("0.01"),
563            Quantity::from("0.000001"),
564            multiplier,
565            lot_size,
566            None,
567            None,
568            None,
569            None,
570            None,
571            None,
572            None,
573            None,
574            None,
575            None,
576            None,
577            None,
578            0.into(),
579            0.into(),
580        );
581        let error = result.unwrap_err();
582        assert!(error.to_string().contains("not positive"), "{error}");
583    }
584
585    #[rstest]
586    fn test_serialization_roundtrip(currency_pair_btcusdt: CurrencyPair) {
587        let json = serde_json::to_string(&currency_pair_btcusdt).unwrap();
588        let deserialized: CurrencyPair = serde_json::from_str(&json).unwrap();
589        assert_eq!(currency_pair_btcusdt, deserialized);
590    }
591
592    #[rstest]
593    fn test_builder_matches_new_checked() {
594        let positional = CurrencyPair::new_checked(
595            InstrumentId::from("BTCUSDT.BINANCE"),
596            Symbol::from("BTCUSDT"),
597            Currency::BTC(),
598            Currency::USDT(),
599            2,
600            6,
601            Price::from("0.01"),
602            Quantity::from("0.000001"),
603            Some(Quantity::from("10")),
604            Some(Quantity::from("5")),
605            Some(Quantity::from("9000.0")),
606            Some(Quantity::from("0.000001")),
607            Some(Money::new(1_000_000.0, Currency::USDT())),
608            Some(Money::new(10.0, Currency::USDT())),
609            Some(Price::from("1000000.00")),
610            Some(Price::from("0.01")),
611            Some(dec!(0.01)),
612            Some(dec!(0.02)),
613            Some(dec!(0.0002)),
614            Some(dec!(0.0004)),
615            None,
616            None,
617            1.into(),
618            2.into(),
619        )
620        .unwrap();
621
622        let built = CurrencyPair::builder()
623            .instrument_id(InstrumentId::from("BTCUSDT.BINANCE"))
624            .raw_symbol(Symbol::from("BTCUSDT"))
625            .base_currency(Currency::BTC())
626            .quote_currency(Currency::USDT())
627            .price_precision(2)
628            .size_precision(6)
629            .price_increment(Price::from("0.01"))
630            .size_increment(Quantity::from("0.000001"))
631            .multiplier(Quantity::from("10"))
632            .lot_size(Quantity::from("5"))
633            .max_quantity(Quantity::from("9000.0"))
634            .min_quantity(Quantity::from("0.000001"))
635            .max_notional(Money::new(1_000_000.0, Currency::USDT()))
636            .min_notional(Money::new(10.0, Currency::USDT()))
637            .max_price(Price::from("1000000.00"))
638            .min_price(Price::from("0.01"))
639            .margin_init(dec!(0.01))
640            .margin_maint(dec!(0.02))
641            .maker_fee(dec!(0.0002))
642            .taker_fee(dec!(0.0004))
643            .ts_event(1.into())
644            .ts_init(2.into())
645            .build()
646            .unwrap();
647
648        assert_eq!(
649            serde_json::to_value(&positional).unwrap(),
650            serde_json::to_value(&built).unwrap(),
651        );
652    }
653}