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 into_any(self) -> InstrumentAny {
263        InstrumentAny::CurrencyPair(self)
264    }
265
266    fn id(&self) -> InstrumentId {
267        self.id
268    }
269
270    fn raw_symbol(&self) -> Symbol {
271        self.raw_symbol
272    }
273
274    fn asset_class(&self) -> AssetClass {
275        if self.base_currency.currency_type == CurrencyType::Crypto
276            || self.quote_currency.currency_type == CurrencyType::Crypto
277        {
278            AssetClass::Cryptocurrency
279        } else {
280            AssetClass::FX
281        }
282    }
283
284    fn instrument_class(&self) -> InstrumentClass {
285        InstrumentClass::Spot
286    }
287
288    fn underlying(&self) -> Option<Ustr> {
289        None
290    }
291
292    fn base_currency(&self) -> Option<Currency> {
293        Some(self.base_currency)
294    }
295
296    fn quote_currency(&self) -> Currency {
297        self.quote_currency
298    }
299
300    fn settlement_currency(&self) -> Currency {
301        self.quote_currency
302    }
303    fn isin(&self) -> Option<Ustr> {
304        None
305    }
306
307    fn is_inverse(&self) -> bool {
308        false
309    }
310
311    fn price_precision(&self) -> u8 {
312        self.price_precision
313    }
314
315    fn size_precision(&self) -> u8 {
316        self.size_precision
317    }
318
319    fn price_increment(&self) -> Price {
320        self.price_increment
321    }
322
323    fn size_increment(&self) -> Quantity {
324        self.size_increment
325    }
326
327    fn multiplier(&self) -> Quantity {
328        self.multiplier
329    }
330
331    fn lot_size(&self) -> Option<Quantity> {
332        self.lot_size
333    }
334
335    fn max_quantity(&self) -> Option<Quantity> {
336        self.max_quantity
337    }
338
339    fn min_quantity(&self) -> Option<Quantity> {
340        self.min_quantity
341    }
342
343    fn max_price(&self) -> Option<Price> {
344        self.max_price
345    }
346
347    fn min_price(&self) -> Option<Price> {
348        self.min_price
349    }
350
351    fn tick_scheme(&self) -> Option<Ustr> {
352        self.tick_scheme
353    }
354
355    fn info(&self) -> Option<&Params> {
356        self.info.as_ref()
357    }
358
359    fn ts_event(&self) -> UnixNanos {
360        self.ts_event
361    }
362
363    fn ts_init(&self) -> UnixNanos {
364        self.ts_init
365    }
366
367    fn margin_init(&self) -> Decimal {
368        self.margin_init
369    }
370
371    fn margin_maint(&self) -> Decimal {
372        self.margin_maint
373    }
374
375    fn taker_fee(&self) -> Decimal {
376        self.taker_fee
377    }
378
379    fn maker_fee(&self) -> Decimal {
380        self.maker_fee
381    }
382
383    fn option_kind(&self) -> Option<OptionKind> {
384        None
385    }
386
387    fn exchange(&self) -> Option<Ustr> {
388        None
389    }
390
391    fn strike_price(&self) -> Option<Price> {
392        None
393    }
394
395    fn activation_ns(&self) -> Option<UnixNanos> {
396        None
397    }
398
399    fn expiration_ns(&self) -> Option<UnixNanos> {
400        None
401    }
402
403    fn max_notional(&self) -> Option<Money> {
404        self.max_notional
405    }
406
407    fn min_notional(&self) -> Option<Money> {
408        self.min_notional
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use rstest::rstest;
415    use rust_decimal_macros::dec;
416
417    use crate::{
418        enums::{AssetClass, InstrumentClass},
419        identifiers::{InstrumentId, Symbol},
420        instruments::{CurrencyPair, Instrument, stubs::*},
421        types::{Currency, Money, Price, Quantity},
422    };
423
424    #[rstest]
425    fn test_trait_accessors(currency_pair_btcusdt: CurrencyPair) {
426        assert_eq!(
427            currency_pair_btcusdt.id(),
428            InstrumentId::from("BTCUSDT.BINANCE")
429        );
430        assert_eq!(
431            currency_pair_btcusdt.asset_class(),
432            AssetClass::Cryptocurrency
433        );
434        assert_eq!(
435            currency_pair_btcusdt.instrument_class(),
436            InstrumentClass::Spot
437        );
438        assert_eq!(currency_pair_btcusdt.base_currency(), Some(Currency::BTC()));
439        assert_eq!(currency_pair_btcusdt.quote_currency(), Currency::USDT());
440        assert!(!currency_pair_btcusdt.is_inverse());
441        assert_eq!(currency_pair_btcusdt.price_precision(), 2);
442        assert_eq!(currency_pair_btcusdt.size_precision(), 6);
443        assert_eq!(currency_pair_btcusdt.price_increment(), Price::from("0.01"));
444        assert_eq!(
445            currency_pair_btcusdt.size_increment(),
446            Quantity::from("0.000001")
447        );
448    }
449
450    #[rstest]
451    fn test_new_checked_price_precision_mismatch() {
452        let result = CurrencyPair::new_checked(
453            InstrumentId::from("TEST.BINANCE"),
454            Symbol::from("TEST"),
455            Currency::BTC(),
456            Currency::USDT(),
457            4, // mismatch
458            6,
459            Price::from("0.01"),
460            Quantity::from("0.000001"),
461            None,
462            None,
463            None,
464            None,
465            None,
466            None,
467            None,
468            None,
469            None,
470            None,
471            None,
472            None,
473            None,
474            None,
475            0.into(),
476            0.into(),
477        );
478        assert!(result.is_err());
479    }
480
481    #[rstest]
482    #[case::zero_multiplier(Some(Quantity::from("0")), None)]
483    #[case::zero_lot_size(None, Some(Quantity::from("0")))]
484    fn test_new_checked_rejects_non_positive_sizing(
485        #[case] multiplier: Option<Quantity>,
486        #[case] lot_size: Option<Quantity>,
487    ) {
488        let result = CurrencyPair::new_checked(
489            InstrumentId::from("TEST.BINANCE"),
490            Symbol::from("TEST"),
491            Currency::BTC(),
492            Currency::USDT(),
493            2,
494            6,
495            Price::from("0.01"),
496            Quantity::from("0.000001"),
497            multiplier,
498            lot_size,
499            None,
500            None,
501            None,
502            None,
503            None,
504            None,
505            None,
506            None,
507            None,
508            None,
509            None,
510            None,
511            0.into(),
512            0.into(),
513        );
514        let error = result.unwrap_err();
515        assert!(error.to_string().contains("not positive"), "{error}");
516    }
517
518    #[rstest]
519    fn test_serialization_roundtrip(currency_pair_btcusdt: CurrencyPair) {
520        let json = serde_json::to_string(&currency_pair_btcusdt).unwrap();
521        let deserialized: CurrencyPair = serde_json::from_str(&json).unwrap();
522        assert_eq!(json, serde_json::to_string(&deserialized).unwrap());
523    }
524
525    #[rstest]
526    fn test_builder_matches_new_checked() {
527        let positional = CurrencyPair::new_checked(
528            InstrumentId::from("BTCUSDT.BINANCE"),
529            Symbol::from("BTCUSDT"),
530            Currency::BTC(),
531            Currency::USDT(),
532            2,
533            6,
534            Price::from("0.01"),
535            Quantity::from("0.000001"),
536            Some(Quantity::from("10")),
537            Some(Quantity::from("5")),
538            Some(Quantity::from("9000.0")),
539            Some(Quantity::from("0.000001")),
540            Some(Money::new(1_000_000.0, Currency::USDT())),
541            Some(Money::new(10.0, Currency::USDT())),
542            Some(Price::from("1000000.00")),
543            Some(Price::from("0.01")),
544            Some(dec!(0.01)),
545            Some(dec!(0.02)),
546            Some(dec!(0.0002)),
547            Some(dec!(0.0004)),
548            None,
549            None,
550            1.into(),
551            2.into(),
552        )
553        .unwrap();
554
555        let built = CurrencyPair::builder()
556            .instrument_id(InstrumentId::from("BTCUSDT.BINANCE"))
557            .raw_symbol(Symbol::from("BTCUSDT"))
558            .base_currency(Currency::BTC())
559            .quote_currency(Currency::USDT())
560            .price_precision(2)
561            .size_precision(6)
562            .price_increment(Price::from("0.01"))
563            .size_increment(Quantity::from("0.000001"))
564            .multiplier(Quantity::from("10"))
565            .lot_size(Quantity::from("5"))
566            .max_quantity(Quantity::from("9000.0"))
567            .min_quantity(Quantity::from("0.000001"))
568            .max_notional(Money::new(1_000_000.0, Currency::USDT()))
569            .min_notional(Money::new(10.0, Currency::USDT()))
570            .max_price(Price::from("1000000.00"))
571            .min_price(Price::from("0.01"))
572            .margin_init(dec!(0.01))
573            .margin_maint(dec!(0.02))
574            .maker_fee(dec!(0.0002))
575            .taker_fee(dec!(0.0004))
576            .ts_event(1.into())
577            .ts_init(2.into())
578            .build()
579            .unwrap();
580
581        assert_eq!(
582            serde_json::to_value(&positional).unwrap(),
583            serde_json::to_value(&built).unwrap(),
584        );
585    }
586}