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 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, 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(¤cy_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}