Skip to main content

nautilus_model/types/
currency.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Represents a medium of exchange in a specified denomination with a fixed decimal precision.
17//!
18//! Handles up to 16 decimals of precision.
19
20use 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/// Error returned when a currency cannot be resolved from the model currency map.
39#[derive(Clone, Debug, Error, Eq, PartialEq)]
40pub enum CurrencyLookupError {
41    /// The currency map lock could not be acquired.
42    #[error("Failed to acquire lock on `CURRENCY_MAP`: {reason}")]
43    LockFailure {
44        /// The lock failure reason.
45        reason: String,
46    },
47    /// The requested currency code is not present in the currency map.
48    #[error("Unknown currency: {code}")]
49    UnknownCode {
50        /// The currency code that was requested.
51        code: String,
52    },
53}
54
55/// Represents a medium of exchange in a specified denomination with a fixed decimal precision.
56///
57/// Handles up to [`FIXED_PRECISION`] decimals of precision.
58#[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    /// The currency code as an alpha-3 string (e.g., "USD", "EUR").
76    pub code: Ustr,
77    /// The currency decimal precision.
78    pub precision: u8,
79    /// The ISO 4217 currency code.
80    pub iso4217: u16,
81    /// The full name of the currency.
82    pub name: Ustr,
83    /// The currency type, indicating its category (e.g. Fiat, Crypto).
84    pub currency_type: CurrencyType,
85}
86
87impl Currency {
88    /// Creates a new [`Currency`] instance with correctness checking.
89    ///
90    /// # Errors
91    ///
92    /// Returns an error if:
93    /// - `code` is not a valid string.
94    /// - `name` is the empty string.
95    /// - `precision` is invalid outside the valid representable range [0, `FIXED_PRECISION`].
96    ///
97    /// # Notes
98    ///
99    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
100    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    /// Creates a new [`Currency`] instance.
122    ///
123    /// # Panics
124    ///
125    /// Panics if a correctness check fails. See [`Currency::new_checked`] for more details.
126    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    /// Register the given `currency` in the internal currency map.
137    ///
138    /// - If `overwrite` is `true`, any existing currency will be replaced.
139    /// - If `overwrite` is `false` and the currency already exists, the operation is a no-op.
140    ///
141    /// # Errors
142    ///
143    /// Returns an error if there is a failure acquiring the lock on the currency map.
144    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            // If overwrite is false and the currency already exists, simply return
153            return Ok(());
154        }
155
156        // Insert or overwrite the currency in the map
157        map.insert(currency.code.to_string(), currency);
158        Ok(())
159    }
160
161    /// Attempts to parse a [`Currency`] from a string, returning `None` if not found.
162    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    /// Checks if the currency identified by the given `code` is a fiat currency.
168    ///
169    /// # Errors
170    ///
171    /// Returns an error if:
172    /// - A currency with the given `code` does not exist.
173    /// - There is a failure acquiring the lock on the currency map.
174    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    /// Checks if the currency identified by the given `code` is a cryptocurrency.
180    ///
181    /// # Errors
182    ///
183    /// Returns an error if:
184    /// - If a currency with the given `code` does not exist.
185    /// - If there is a failure acquiring the lock on the currency map.
186    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    /// Checks if the currency identified by the given `code` is a commodity (such as a precious
192    /// metal).
193    ///
194    /// # Errors
195    ///
196    /// Returns an error if:
197    /// - A currency with the given `code` does not exist.
198    /// - There is a failure acquiring the lock on the currency map.
199    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    /// Returns a currency from the internal map or creates a new crypto currency if not found.
205    ///
206    /// This is a convenience method for adapters that need to handle unknown currencies
207    /// (e.g., newly listed assets on exchanges). If the currency code is not found in the
208    /// internal map, a new cryptocurrency is created with:
209    /// - 8 decimal precision
210    /// - ISO 4217 code of 0
211    /// - `CurrencyType::Crypto`
212    ///
213    /// The newly created currency is automatically registered in the internal map.
214    #[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    /// Gets or creates a cryptocurrency with context logging.
229    ///
230    /// This is a convenience wrapper around [`Currency::get_or_create_crypto`] that:
231    /// - Trims whitespace from the currency code
232    /// - Handles empty strings with a fallback to USDT
233    /// - Provides optional context for logging
234    ///
235    /// Used by exchange adapters for consistent currency handling across parsing operations.
236    ///
237    /// # Arguments
238    ///
239    /// * `code` - The currency code (will be trimmed)
240    /// * `context` - Optional context for logging (e.g., "balance detail", "instrument")
241    #[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        // Precision greater than maximum (use 19 which exceeds even defi precision of 18)
376        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        // Precision greater than maximum (use 19 which exceeds even defi precision of 18)
384        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(&currency).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        // Test with an existing currency (BTC is in the default map)
593        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        // Test with a non-existent currency code
601        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        // Verify it was registered and can be retrieved
609        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        // First call creates and registers
617        let currency1 = Currency::get_or_create_crypto("TESTCOIN");
618
619        // Second call should retrieve the same currency
620        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        // Test that it works with Ustr (via AsRef<str>)
630        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        // Unknown codes should create a new Currency, preserving newly listed assets
657        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}