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    /// # Errors
137    ///
138    /// This function does not currently return an error.
139    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            // If overwrite is false and the currency already exists, simply return
144            return Ok(());
145        }
146
147        // Insert or overwrite the currency in the map
148        map.insert(currency.code.to_string(), currency);
149        Ok(())
150    }
151
152    /// Attempts to parse a [`Currency`] from a string, returning `None` if not found.
153    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    /// Checks if the currency identified by the given `code` is a fiat currency.
159    ///
160    /// # Errors
161    ///
162    /// Returns an error if a currency with the given `code` does not exist.
163    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    /// Checks if the currency identified by the given `code` is a cryptocurrency.
169    ///
170    /// # Errors
171    ///
172    /// Returns an error if a currency with the given `code` does not exist.
173    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    /// Checks if the currency identified by the given `code` is a commodity (such as a precious
179    /// metal).
180    ///
181    /// # Errors
182    ///
183    /// Returns an error if a currency with the given `code` does not exist.
184    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    /// Returns a currency from the internal map or creates a new crypto currency if not found.
190    ///
191    /// This is a convenience method for adapters that need to handle unknown currencies
192    /// (e.g., newly listed assets on exchanges). If the currency code is not found in the
193    /// internal map, a new cryptocurrency is created with:
194    /// - 8 decimal precision
195    /// - ISO 4217 code of 0
196    /// - `CurrencyType::Crypto`
197    ///
198    /// The newly created currency is automatically registered in the internal map.
199    #[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    /// Gets or creates a cryptocurrency with context logging.
214    ///
215    /// This is a convenience wrapper around [`Currency::get_or_create_crypto`] that:
216    /// - Trims whitespace from the currency code
217    /// - Handles empty strings with a fallback to USDT
218    /// - Provides optional context for logging
219    ///
220    /// Used by exchange adapters for consistent currency handling across parsing operations.
221    ///
222    /// # Arguments
223    ///
224    /// * `code` - The currency code (will be trimmed)
225    /// * `context` - Optional context for logging (e.g., "balance detail", "instrument")
226    #[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        // Precision greater than maximum (use 19 which exceeds even defi precision of 18)
357        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        // Precision greater than maximum (use 19 which exceeds even defi precision of 18)
365        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(&currency).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        // Test with an existing currency (BTC is in the default map)
574        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        // Test with a non-existent currency code
582        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        // Verify it was registered and can be retrieved
590        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        // First call creates and registers
598        let currency1 = Currency::get_or_create_crypto("TESTCOIN");
599
600        // Second call should retrieve the same currency
601        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        // Test that it works with Ustr (via AsRef<str>)
611        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        // Unknown codes should create a new Currency, preserving newly listed assets
638        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}