1use std::{
21 fmt::{Debug, Display},
22 hash::{Hash, Hasher},
23 str::FromStr,
24};
25
26use nautilus_core::correctness::{
27 CorrectnessResult, CorrectnessResultExt, FAILED, check_nonempty_string, check_valid_string_utf8,
28};
29use serde::{Deserialize, Serialize, Serializer};
30use thiserror::Error;
31use ustr::Ustr;
32
33#[allow(unused_imports)]
34use super::fixed::{FIXED_PRECISION, check_fixed_precision};
35use crate::{currencies::CURRENCY_MAP, enums::CurrencyType};
36
37#[derive(Clone, Debug, Error, Eq, PartialEq)]
39pub enum CurrencyLookupError {
40 #[error("Failed to acquire lock on `CURRENCY_MAP`: {reason}")]
44 LockFailure {
45 reason: String,
47 },
48 #[error("Unknown currency: {code}")]
50 UnknownCode {
51 code: String,
53 },
54}
55
56#[repr(C)]
60#[derive(Clone, Copy, Eq)]
61#[cfg_attr(
62 feature = "python",
63 pyo3::pyclass(module = "nautilus_trader.model", frozen, eq, hash, from_py_object)
64)]
65#[cfg_attr(
66 feature = "python",
67 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
68)]
69pub struct Currency {
70 pub code: Ustr,
72 pub precision: u8,
74 pub iso4217: u16,
76 pub name: Ustr,
78 pub currency_type: CurrencyType,
80}
81
82impl Currency {
83 pub fn new_checked<T: AsRef<str>>(
96 code: T,
97 precision: u8,
98 iso4217: u16,
99 name: T,
100 currency_type: CurrencyType,
101 ) -> CorrectnessResult<Self> {
102 let code = code.as_ref();
103 let name = name.as_ref();
104 check_valid_string_utf8(code, "code")?;
105 check_nonempty_string(name, "name")?;
106 check_fixed_precision(precision)?;
107 Ok(Self {
108 code: Ustr::from(code),
109 precision,
110 iso4217,
111 name: Ustr::from(name),
112 currency_type,
113 })
114 }
115
116 pub fn new<T: AsRef<str>>(
122 code: T,
123 precision: u8,
124 iso4217: u16,
125 name: T,
126 currency_type: CurrencyType,
127 ) -> Self {
128 Self::new_checked(code, precision, iso4217, name, currency_type).expect_display(FAILED)
129 }
130
131 #[allow(
138 clippy::missing_errors_doc,
139 reason = "currency registration only inserts into the registry"
140 )]
141 pub fn register(currency: Self, overwrite: bool) -> CorrectnessResult<()> {
142 let mut map = CURRENCY_MAP.lock();
143
144 if !overwrite && map.contains_key(currency.code.as_str()) {
145 return Ok(());
147 }
148
149 map.insert(currency.code.to_string(), currency);
151 Ok(())
152 }
153
154 pub fn try_from_str(s: &str) -> Option<Self> {
156 let map_guard = CURRENCY_MAP.lock();
157 map_guard.get(s).copied()
158 }
159
160 pub fn is_fiat(code: &str) -> Result<bool, CurrencyLookupError> {
166 let currency = Self::from_str(code)?;
167 Ok(currency.currency_type == CurrencyType::Fiat)
168 }
169
170 pub fn is_crypto(code: &str) -> Result<bool, CurrencyLookupError> {
176 let currency = Self::from_str(code)?;
177 Ok(currency.currency_type == CurrencyType::Crypto)
178 }
179
180 pub fn is_commodity_backed(code: &str) -> Result<bool, CurrencyLookupError> {
187 let currency = Self::from_str(code)?;
188 Ok(currency.currency_type == CurrencyType::CommodityBacked)
189 }
190
191 #[must_use]
202 pub fn get_or_create_crypto<T: AsRef<str>>(code: T) -> Self {
203 let code_str = code.as_ref();
204 Self::try_from_str(code_str).unwrap_or_else(|| {
205 let currency = Self::new(code_str, 8, 0, code_str, CurrencyType::Crypto);
206
207 if let Err(e) = Self::register(currency, false) {
208 log::error!("Failed to register currency '{code_str}': {e}");
209 }
210
211 currency
212 })
213 }
214
215 #[must_use]
229 pub fn get_or_create_crypto_with_context<T: AsRef<str>>(
230 code: T,
231 context: Option<&str>,
232 ) -> Self {
233 let trimmed = code.as_ref().trim();
234 let ctx = context.unwrap_or("unknown");
235
236 if trimmed.is_empty() {
237 log::warn!(
238 "get_or_create_crypto_with_context called with empty code (context: {ctx}), using USDT as fallback"
239 );
240 return Self::USDT();
241 }
242
243 Self::get_or_create_crypto(trimmed)
244 }
245}
246
247impl PartialEq for Currency {
248 fn eq(&self, other: &Self) -> bool {
249 self.code == other.code
250 }
251}
252
253impl Hash for Currency {
254 fn hash<H: Hasher>(&self, state: &mut H) {
255 self.code.hash(state);
256 }
257}
258
259impl Debug for Currency {
260 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
261 write!(
262 f,
263 "{}(code='{}', precision={}, iso4217={}, name='{}', currency_type={})",
264 stringify!(Currency),
265 self.code,
266 self.precision,
267 self.iso4217,
268 self.name,
269 self.currency_type,
270 )
271 }
272}
273
274impl Display for Currency {
275 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
276 write!(f, "{}", self.code)
277 }
278}
279
280impl FromStr for Currency {
281 type Err = CurrencyLookupError;
282
283 fn from_str(s: &str) -> Result<Self, Self::Err> {
284 let map_guard = CURRENCY_MAP.lock();
285 map_guard
286 .get(s)
287 .copied()
288 .ok_or_else(|| CurrencyLookupError::UnknownCode {
289 code: s.to_string(),
290 })
291 }
292}
293
294impl<T: AsRef<str>> From<T> for Currency {
295 fn from(value: T) -> Self {
296 match Self::from_str(value.as_ref()) {
297 Ok(currency) => currency,
298 Err(e) => panic!("{FAILED}: {e}"),
299 }
300 }
301}
302
303impl Serialize for Currency {
304 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
305 where
306 S: Serializer,
307 {
308 self.code.serialize(serializer)
309 }
310}
311
312impl<'de> Deserialize<'de> for Currency {
313 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
314 where
315 D: serde::Deserializer<'de>,
316 {
317 let currency_str: std::borrow::Cow<'de, str> = Deserialize::deserialize(deserializer)?;
318 Self::from_str(currency_str.as_ref()).map_err(serde::de::Error::custom)
319 }
320}
321
322#[cfg(test)]
323mod tests {
324 use std::str::FromStr;
325
326 use rstest::rstest;
327
328 use crate::{
329 enums::CurrencyType,
330 types::{Currency, CurrencyLookupError},
331 };
332
333 #[rstest]
334 fn test_debug() {
335 let currency = Currency::AUD();
336 assert_eq!(
337 format!("{currency:?}"),
338 "Currency(code='AUD', precision=2, iso4217=36, name='Australian dollar', currency_type=FIAT)".to_string()
339 );
340 }
341
342 #[rstest]
343 fn test_display() {
344 let currency = Currency::AUD();
345 assert_eq!(format!("{currency}"), "AUD");
346 }
347
348 #[rstest]
349 #[should_panic(expected = "code")]
350 fn test_invalid_currency_code() {
351 let _ = Currency::new("", 2, 840, "United States dollar", CurrencyType::Fiat);
352 }
353
354 #[cfg(not(feature = "defi"))]
355 #[rstest]
356 #[should_panic(expected = "Condition failed: `precision` exceeded maximum `FIXED_PRECISION`")]
357 fn test_invalid_precision() {
358 let _ = Currency::new("USD", 19, 840, "United States dollar", CurrencyType::Fiat);
360 }
361
362 #[cfg(feature = "defi")]
363 #[rstest]
364 #[should_panic(expected = "Condition failed: `precision` exceeded maximum `WEI_PRECISION`")]
365 fn test_invalid_precision() {
366 let _ = Currency::new("ETH", 19, 0, "Ethereum", CurrencyType::Crypto);
368 }
369
370 #[rstest]
371 fn test_register_no_overwrite() {
372 let currency1 = Currency::new("TEST1", 2, 999, "Test Currency 1", CurrencyType::Fiat);
373 Currency::register(currency1, false).unwrap();
374
375 let currency2 = Currency::new(
376 "TEST1",
377 2,
378 999,
379 "Test Currency 2 Updated",
380 CurrencyType::Fiat,
381 );
382 Currency::register(currency2, false).unwrap();
383
384 let found = Currency::try_from_str("TEST1").unwrap();
385 assert_eq!(found.name.as_str(), "Test Currency 1");
386 }
387
388 #[rstest]
389 fn test_register_with_overwrite() {
390 let currency1 = Currency::new("TEST2", 2, 998, "Test Currency 2", CurrencyType::Fiat);
391 Currency::register(currency1, false).unwrap();
392
393 let currency2 = Currency::new(
394 "TEST2",
395 2,
396 998,
397 "Test Currency 2 Overwritten",
398 CurrencyType::Fiat,
399 );
400 Currency::register(currency2, true).unwrap();
401
402 let found = Currency::try_from_str("TEST2").unwrap();
403 assert_eq!(found.name.as_str(), "Test Currency 2 Overwritten");
404 }
405
406 #[rstest]
407 fn test_new_for_fiat() {
408 let currency = Currency::new("AUD", 2, 36, "Australian dollar", CurrencyType::Fiat);
409 assert_eq!(currency, currency);
410 assert_eq!(currency.code.as_str(), "AUD");
411 assert_eq!(currency.precision, 2);
412 assert_eq!(currency.iso4217, 36);
413 assert_eq!(currency.name.as_str(), "Australian dollar");
414 assert_eq!(currency.currency_type, CurrencyType::Fiat);
415 }
416
417 #[rstest]
418 fn test_new_for_crypto() {
419 let currency = Currency::new("ETH", 8, 0, "Ether", CurrencyType::Crypto);
420 assert_eq!(currency, currency);
421 assert_eq!(currency.code.as_str(), "ETH");
422 assert_eq!(currency.precision, 8);
423 assert_eq!(currency.iso4217, 0);
424 assert_eq!(currency.name.as_str(), "Ether");
425 assert_eq!(currency.currency_type, CurrencyType::Crypto);
426 }
427
428 #[rstest]
429 fn test_try_from_str_valid() {
430 let test_currency = Currency::new("TEST", 2, 999, "Test Currency", CurrencyType::Fiat);
431 Currency::register(test_currency, true).unwrap();
432
433 let currency = Currency::try_from_str("TEST");
434 assert!(currency.is_some());
435 assert_eq!(currency.unwrap(), test_currency);
436 }
437
438 #[rstest]
439 fn test_try_from_str_invalid() {
440 let invalid_currency = Currency::try_from_str("INVALID");
441 assert!(invalid_currency.is_none());
442 }
443
444 #[rstest]
445 fn test_equality() {
446 let currency1 = Currency::new("USD", 2, 840, "United States dollar", CurrencyType::Fiat);
447 let currency2 = Currency::new("USD", 2, 840, "United States dollar", CurrencyType::Fiat);
448 assert_eq!(currency1, currency2);
449 }
450
451 #[rstest]
452 fn test_currency_partial_eq_only_checks_code() {
453 let c1 = Currency::new("ABC", 2, 999, "Currency ABC", CurrencyType::Fiat);
454 let c2 = Currency::new("ABC", 8, 100, "Completely Different", CurrencyType::Crypto);
455
456 assert_eq!(c1, c2, "Should be equal if 'code' is the same");
457 }
458
459 #[rstest]
460 fn test_is_fiat() {
461 let currency = Currency::new("TESTFIAT", 2, 840, "Test Fiat", CurrencyType::Fiat);
462 Currency::register(currency, true).unwrap();
463
464 let result = Currency::is_fiat("TESTFIAT");
465 assert!(result.is_ok());
466 assert!(
467 result.unwrap(),
468 "Expected TESTFIAT to be recognized as fiat"
469 );
470 }
471
472 #[rstest]
473 fn test_is_crypto() {
474 let currency = Currency::new("TESTCRYPTO", 8, 0, "Test Crypto", CurrencyType::Crypto);
475 Currency::register(currency, true).unwrap();
476
477 let result = Currency::is_crypto("TESTCRYPTO");
478 assert!(result.is_ok());
479 assert!(
480 result.unwrap(),
481 "Expected TESTCRYPTO to be recognized as crypto"
482 );
483 }
484
485 #[rstest]
486 fn test_is_commodity_backed() {
487 let currency = Currency::new("TESTGOLD", 5, 0, "Test Gold", CurrencyType::CommodityBacked);
488 Currency::register(currency, true).unwrap();
489
490 let result = Currency::is_commodity_backed("TESTGOLD");
491 assert!(result.is_ok());
492 assert!(
493 result.unwrap(),
494 "Expected TESTGOLD to be recognized as commodity-backed"
495 );
496 }
497
498 #[rstest]
499 fn test_is_fiat_unknown_currency() {
500 let err = Currency::is_fiat("NON_EXISTENT").unwrap_err();
501 assert_eq!(
502 err,
503 CurrencyLookupError::UnknownCode {
504 code: "NON_EXISTENT".to_string()
505 }
506 );
507 assert_eq!(err.to_string(), "Unknown currency: NON_EXISTENT");
508 }
509
510 #[rstest]
511 #[case(Currency::is_fiat)]
512 #[case(Currency::is_crypto)]
513 #[case(Currency::is_commodity_backed)]
514 fn test_currency_classification_unknown_code_returns_typed_error(
515 #[case] classify: fn(&str) -> Result<bool, CurrencyLookupError>,
516 ) {
517 let err = classify("UNKNOWN_CLASSIFICATION").unwrap_err();
518
519 assert_eq!(
520 err,
521 CurrencyLookupError::UnknownCode {
522 code: "UNKNOWN_CLASSIFICATION".to_string()
523 }
524 );
525 assert_eq!(err.to_string(), "Unknown currency: UNKNOWN_CLASSIFICATION");
526 }
527
528 #[rstest]
529 fn test_from_str_unknown_code_returns_typed_error() {
530 let err = Currency::from_str("UNKNOWN_FROM_STR").unwrap_err();
531
532 assert_eq!(
533 err,
534 CurrencyLookupError::UnknownCode {
535 code: "UNKNOWN_FROM_STR".to_string()
536 }
537 );
538 assert_eq!(err.to_string(), "Unknown currency: UNKNOWN_FROM_STR");
539 }
540
541 #[rstest]
542 fn test_currency_lookup_error_legacy_lock_failure_display() {
543 let err = CurrencyLookupError::LockFailure {
544 reason: "legacy lock failure".to_string(),
545 };
546
547 assert_eq!(
548 err,
549 CurrencyLookupError::LockFailure {
550 reason: "legacy lock failure".to_string()
551 }
552 );
553 assert_eq!(
554 err.to_string(),
555 "Failed to acquire lock on `CURRENCY_MAP`: legacy lock failure"
556 );
557 }
558
559 #[rstest]
560 #[should_panic(expected = "Unknown currency: UNKNOWN_FROM_PANIC")]
561 fn test_from_unknown_code_panics_with_display_error() {
562 let _: Currency = Currency::from("UNKNOWN_FROM_PANIC");
563 }
564
565 #[rstest]
566 fn test_serialization_deserialization() {
567 let currency = Currency::USD();
568 let serialized = serde_json::to_string(¤cy).unwrap();
569 let deserialized: Currency = serde_json::from_str(&serialized).unwrap();
570 assert_eq!(currency, deserialized);
571 }
572
573 #[rstest]
574 fn test_get_or_create_crypto_existing() {
575 let currency = Currency::get_or_create_crypto("BTC");
577 assert_eq!(currency.code.as_str(), "BTC");
578 assert_eq!(currency.currency_type, CurrencyType::Crypto);
579 }
580
581 #[rstest]
582 fn test_get_or_create_crypto_new() {
583 let currency = Currency::get_or_create_crypto("NEWCOIN");
585 assert_eq!(currency.code.as_str(), "NEWCOIN");
586 assert_eq!(currency.precision, 8);
587 assert_eq!(currency.iso4217, 0);
588 assert_eq!(currency.name.as_str(), "NEWCOIN");
589 assert_eq!(currency.currency_type, CurrencyType::Crypto);
590
591 let retrieved = Currency::try_from_str("NEWCOIN");
593 assert!(retrieved.is_some());
594 assert_eq!(retrieved.unwrap(), currency);
595 }
596
597 #[rstest]
598 fn test_get_or_create_crypto_idempotent() {
599 let currency1 = Currency::get_or_create_crypto("TESTCOIN");
601
602 let currency2 = Currency::get_or_create_crypto("TESTCOIN");
604
605 assert_eq!(currency1, currency2);
606 }
607
608 #[rstest]
609 fn test_get_or_create_crypto_with_ustr() {
610 use ustr::Ustr;
611
612 let code = Ustr::from("USTRCOIN");
614 let currency = Currency::get_or_create_crypto(code);
615 assert_eq!(currency.code.as_str(), "USTRCOIN");
616 assert_eq!(currency.currency_type, CurrencyType::Crypto);
617 }
618
619 #[rstest]
620 fn test_get_or_create_crypto_with_context_valid() {
621 let result = Currency::get_or_create_crypto_with_context("BTC", Some("test context"));
622 assert_eq!(result, Currency::BTC());
623 }
624
625 #[rstest]
626 fn test_get_or_create_crypto_with_context_empty() {
627 let result = Currency::get_or_create_crypto_with_context("", Some("test context"));
628 assert_eq!(result, Currency::USDT());
629 }
630
631 #[rstest]
632 fn test_get_or_create_crypto_with_context_whitespace() {
633 let result = Currency::get_or_create_crypto_with_context(" ", Some("test context"));
634 assert_eq!(result, Currency::USDT());
635 }
636
637 #[rstest]
638 fn test_get_or_create_crypto_with_context_unknown() {
639 let result = Currency::get_or_create_crypto_with_context("NEWCOIN", Some("test context"));
641 assert_eq!(result.code.as_str(), "NEWCOIN");
642 assert_eq!(result.precision, 8);
643 }
644}