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