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