1use 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#[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 pub id: InstrumentId,
54 pub raw_symbol: Symbol,
56 pub base_currency: Currency,
58 pub quote_currency: Currency,
60 pub price_precision: u8,
62 pub size_precision: u8,
64 pub price_increment: Price,
66 pub size_increment: Quantity,
68 pub multiplier: Quantity,
70 pub lot_size: Option<Quantity>,
72 pub margin_init: Decimal,
74 pub margin_maint: Decimal,
76 pub maker_fee: Decimal,
78 pub taker_fee: Decimal,
80 pub max_quantity: Option<Quantity>,
82 pub min_quantity: Option<Quantity>,
84 pub max_notional: Option<Money>,
86 pub min_notional: Option<Money>,
88 pub max_price: Option<Price>,
90 pub min_price: Option<Price>,
92 pub tick_scheme: Option<Ustr>,
94 pub info: Option<Params>,
96 pub ts_event: UnixNanos,
98 pub ts_init: UnixNanos,
100}
101
102#[bon::bon]
103impl CurrencyPair {
104 #[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 #[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 #[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, 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(¤cy_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}