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