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