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 max_quantity: Option<Quantity>,
78 pub min_quantity: Option<Quantity>,
80 pub max_notional: Option<Money>,
82 pub min_notional: Option<Money>,
84 pub max_price: Option<Price>,
86 pub min_price: Option<Price>,
88 pub tick_scheme: Option<Ustr>,
90 pub info: Option<Params>,
92 pub ts_event: UnixNanos,
94 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 #[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, 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(¤cy_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}