1use 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#[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 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)]
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 #[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, 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(¤cy_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}