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    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/// Error returned when a currency cannot be resolved from the model currency map.
38#[derive(Clone, Debug, Error, Eq, PartialEq)]
39pub enum CurrencyLookupError {
40    /// A legacy currency-map lock failure.
41    ///
42    /// Current lookup methods use infallible locks and do not return this variant.
43    #[error("Failed to acquire lock on `CURRENCY_MAP`: {reason}")]
44    LockFailure {
45        /// The legacy lock failure reason.
46        reason: String,
47    },
48    /// The requested currency code is not present in the currency map.
49    #[error("Unknown currency: {code}")]
50    UnknownCode {
51        /// The currency code that was requested.
52        code: String,
53    },
54}
55
56/// Represents a medium of exchange in a specified denomination with a fixed decimal precision.
57///
58/// Handles up to [`FIXED_PRECISION`] decimals of precision.
59#[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    /// The currency code as an alpha-3 string (e.g., "USD", "EUR").
71    pub code: Ustr,
72    /// The currency decimal precision.
73    pub precision: u8,
74    /// The ISO 4217 currency code.
75    pub iso4217: u16,
76    /// The full name of the currency.
77    pub name: Ustr,
78    /// The currency type, indicating its category (e.g. Fiat, Crypto).
79    pub currency_type: CurrencyType,
80}
81
82impl Currency {
83    /// Creates a new [`Currency`] instance with correctness checking.
84    ///
85    /// # Errors
86    ///
87    /// Returns an error if:
88    /// - `code` is not a valid string.
89    /// - `name` is the empty string.
90    /// - `precision` is invalid outside the valid representable range [0, `FIXED_PRECISION`].
91    ///
92    /// # Notes
93    ///
94    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
95    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    /// Creates a new [`Currency`] instance.
117    ///
118    /// # Panics
119    ///
120    /// Panics if a correctness check fails. See [`Currency::new_checked`] for more details.
121    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    /// Register the given `currency` in the internal currency map.
132    ///
133    /// - If `overwrite` is `true`, any existing currency will be replaced.
134    /// - If `overwrite` is `false` and the currency already exists, the operation is a no-op.
135    ///
136    /// This function does not return an error.
137    #[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            // If overwrite is false and the currency already exists, simply return
146            return Ok(());
147        }
148
149        // Insert or overwrite the currency in the map
150        map.insert(currency.code.to_string(), currency);
151        Ok(())
152    }
153
154    /// Attempts to parse a [`Currency`] from a string, returning `None` if not found.
155    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    /// Checks if the currency identified by the given `code` is a fiat currency.
161    ///
162    /// # Errors
163    ///
164    /// Returns an error if a currency with the given `code` does not exist.
165    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    /// Checks if the currency identified by the given `code` is a cryptocurrency.
171    ///
172    /// # Errors
173    ///
174    /// Returns an error if a currency with the given `code` does not exist.
175    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    /// Checks if the currency identified by the given `code` is a commodity (such as a precious
181    /// metal).
182    ///
183    /// # Errors
184    ///
185    /// Returns an error if a currency with the given `code` does not exist.
186    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    /// Returns a currency from the internal map or creates a new crypto currency if not found.
192    ///
193    /// This is a convenience method for adapters that need to handle unknown currencies
194    /// (e.g., newly listed assets on exchanges). If the currency code is not found in the
195    /// internal map, a new cryptocurrency is created with:
196    /// - 8 decimal precision
197    /// - ISO 4217 code of 0
198    /// - `CurrencyType::Crypto`
199    ///
200    /// The newly created currency is automatically registered in the internal map.
201    #[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    /// Gets or creates a cryptocurrency with context logging.
216    ///
217    /// This is a convenience wrapper around [`Currency::get_or_create_crypto`] that:
218    /// - Trims whitespace from the currency code
219    /// - Handles empty strings with a fallback to USDT
220    /// - Provides optional context for logging
221    ///
222    /// Used by exchange adapters for consistent currency handling across parsing operations.
223    ///
224    /// # Arguments
225    ///
226    /// * `code` - The currency code (will be trimmed)
227    /// * `context` - Optional context for logging (e.g., "balance detail", "instrument")
228    #[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        // Precision greater than maximum (use 19 which exceeds even defi precision of 18)
359        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        // Precision greater than maximum (use 19 which exceeds even defi precision of 18)
367        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(&currency).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        // Test with an existing currency (BTC is in the default map)
576        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        // Test with a non-existent currency code
584        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        // Verify it was registered and can be retrieved
592        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        // First call creates and registers
600        let currency1 = Currency::get_or_create_crypto("TESTCOIN");
601
602        // Second call should retrieve the same currency
603        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        // Test that it works with Ustr (via AsRef<str>)
613        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        // Unknown codes should create a new Currency, preserving newly listed assets
640        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}