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 ustr::Ustr;
32
33#[allow(unused_imports)]
34use super::fixed::{FIXED_PRECISION, check_fixed_precision};
35use crate::{currencies::CURRENCY_MAP, enums::CurrencyType};
36
37/// Represents a medium of exchange in a specified denomination with a fixed decimal precision.
38///
39/// Handles up to [`FIXED_PRECISION`] decimals of precision.
40#[repr(C)]
41#[derive(Clone, Copy, Eq)]
42#[cfg_attr(
43    feature = "python",
44    pyo3::pyclass(
45        module = "nautilus_trader.core.nautilus_pyo3.model",
46        frozen,
47        eq,
48        hash,
49        from_py_object
50    )
51)]
52#[cfg_attr(
53    feature = "python",
54    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
55)]
56pub struct Currency {
57    /// The currency code as an alpha-3 string (e.g., "USD", "EUR").
58    pub code: Ustr,
59    /// The currency decimal precision.
60    pub precision: u8,
61    /// The ISO 4217 currency code.
62    pub iso4217: u16,
63    /// The full name of the currency.
64    pub name: Ustr,
65    /// The currency type, indicating its category (e.g. Fiat, Crypto).
66    pub currency_type: CurrencyType,
67}
68
69impl Currency {
70    /// Creates a new [`Currency`] instance with correctness checking.
71    ///
72    /// # Errors
73    ///
74    /// Returns an error if:
75    /// - `code` is not a valid string.
76    /// - `name` is the empty string.
77    /// - `precision` is invalid outside the valid representable range [0, `FIXED_PRECISION`].
78    ///
79    /// # Notes
80    ///
81    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
82    pub fn new_checked<T: AsRef<str>>(
83        code: T,
84        precision: u8,
85        iso4217: u16,
86        name: T,
87        currency_type: CurrencyType,
88    ) -> CorrectnessResult<Self> {
89        let code = code.as_ref();
90        let name = name.as_ref();
91        check_valid_string_utf8(code, "code")?;
92        check_nonempty_string(name, "name")?;
93        check_fixed_precision(precision)?;
94        Ok(Self {
95            code: Ustr::from(code),
96            precision,
97            iso4217,
98            name: Ustr::from(name),
99            currency_type,
100        })
101    }
102
103    /// Creates a new [`Currency`] instance.
104    ///
105    /// # Panics
106    ///
107    /// Panics if a correctness check fails. See [`Currency::new_checked`] for more details.
108    pub fn new<T: AsRef<str>>(
109        code: T,
110        precision: u8,
111        iso4217: u16,
112        name: T,
113        currency_type: CurrencyType,
114    ) -> Self {
115        Self::new_checked(code, precision, iso4217, name, currency_type).expect_display(FAILED)
116    }
117
118    /// Register the given `currency` in the internal currency map.
119    ///
120    /// - If `overwrite` is `true`, any existing currency will be replaced.
121    /// - If `overwrite` is `false` and the currency already exists, the operation is a no-op.
122    ///
123    /// # Errors
124    ///
125    /// Returns an error if there is a failure acquiring the lock on the currency map.
126    pub fn register(currency: Self, overwrite: bool) -> CorrectnessResult<()> {
127        let mut map = CURRENCY_MAP
128            .lock()
129            .map_err(|e| CorrectnessError::PredicateViolation {
130                message: format!("Failed to acquire lock on `CURRENCY_MAP`: {e}"),
131            })?;
132
133        if !overwrite && map.contains_key(currency.code.as_str()) {
134            // If overwrite is false and the currency already exists, simply return
135            return Ok(());
136        }
137
138        // Insert or overwrite the currency in the map
139        map.insert(currency.code.to_string(), currency);
140        Ok(())
141    }
142
143    /// Attempts to parse a [`Currency`] from a string, returning `None` if not found.
144    pub fn try_from_str(s: &str) -> Option<Self> {
145        let map_guard = CURRENCY_MAP.lock().ok()?;
146        map_guard.get(s).copied()
147    }
148
149    /// Checks if the currency identified by the given `code` is a fiat currency.
150    ///
151    /// # Errors
152    ///
153    /// Returns an error if:
154    /// - A currency with the given `code` does not exist.
155    /// - There is a failure acquiring the lock on the currency map.
156    pub fn is_fiat(code: &str) -> anyhow::Result<bool> {
157        let currency = Self::from_str(code)?;
158        Ok(currency.currency_type == CurrencyType::Fiat)
159    }
160
161    /// Checks if the currency identified by the given `code` is a cryptocurrency.
162    ///
163    /// # Errors
164    ///
165    /// Returns an error if:
166    /// - If a currency with the given `code` does not exist.
167    /// - If there is a failure acquiring the lock on the currency map.
168    pub fn is_crypto(code: &str) -> anyhow::Result<bool> {
169        let currency = Self::from_str(code)?;
170        Ok(currency.currency_type == CurrencyType::Crypto)
171    }
172
173    /// Checks if the currency identified by the given `code` is a commodity (such as a precious
174    /// metal).
175    ///
176    /// # Errors
177    ///
178    /// Returns an error if:
179    /// - A currency with the given `code` does not exist.
180    /// - There is a failure acquiring the lock on the currency map.
181    pub fn is_commodity_backed(code: &str) -> anyhow::Result<bool> {
182        let currency = Self::from_str(code)?;
183        Ok(currency.currency_type == CurrencyType::CommodityBacked)
184    }
185
186    /// Returns a currency from the internal map or creates a new crypto currency if not found.
187    ///
188    /// This is a convenience method for adapters that need to handle unknown currencies
189    /// (e.g., newly listed assets on exchanges). If the currency code is not found in the
190    /// internal map, a new cryptocurrency is created with:
191    /// - 8 decimal precision
192    /// - ISO 4217 code of 0
193    /// - `CurrencyType::Crypto`
194    ///
195    /// The newly created currency is automatically registered in the internal map.
196    #[must_use]
197    pub fn get_or_create_crypto<T: AsRef<str>>(code: T) -> Self {
198        let code_str = code.as_ref();
199        Self::try_from_str(code_str).unwrap_or_else(|| {
200            let currency = Self::new(code_str, 8, 0, code_str, CurrencyType::Crypto);
201
202            if let Err(e) = Self::register(currency, false) {
203                log::error!("Failed to register currency '{code_str}': {e}");
204            }
205
206            currency
207        })
208    }
209
210    /// Gets or creates a cryptocurrency with context logging.
211    ///
212    /// This is a convenience wrapper around [`Currency::get_or_create_crypto`] that:
213    /// - Trims whitespace from the currency code
214    /// - Handles empty strings with a fallback to USDT
215    /// - Provides optional context for logging
216    ///
217    /// Used by exchange adapters for consistent currency handling across parsing operations.
218    ///
219    /// # Arguments
220    ///
221    /// * `code` - The currency code (will be trimmed)
222    /// * `context` - Optional context for logging (e.g., "balance detail", "instrument")
223    #[must_use]
224    pub fn get_or_create_crypto_with_context<T: AsRef<str>>(
225        code: T,
226        context: Option<&str>,
227    ) -> Self {
228        let trimmed = code.as_ref().trim();
229        let ctx = context.unwrap_or("unknown");
230
231        if trimmed.is_empty() {
232            log::warn!(
233                "get_or_create_crypto_with_context called with empty code (context: {ctx}), using USDT as fallback"
234            );
235            return Self::USDT();
236        }
237
238        Self::get_or_create_crypto(trimmed)
239    }
240}
241
242impl PartialEq for Currency {
243    fn eq(&self, other: &Self) -> bool {
244        self.code == other.code
245    }
246}
247
248impl Hash for Currency {
249    fn hash<H: Hasher>(&self, state: &mut H) {
250        self.code.hash(state);
251    }
252}
253
254impl Debug for Currency {
255    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
256        write!(
257            f,
258            "{}(code='{}', precision={}, iso4217={}, name='{}', currency_type={})",
259            stringify!(Currency),
260            self.code,
261            self.precision,
262            self.iso4217,
263            self.name,
264            self.currency_type,
265        )
266    }
267}
268
269impl Display for Currency {
270    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
271        write!(f, "{}", self.code)
272    }
273}
274
275impl FromStr for Currency {
276    type Err = anyhow::Error;
277
278    fn from_str(s: &str) -> anyhow::Result<Self> {
279        let map_guard = CURRENCY_MAP
280            .lock()
281            .map_err(|e| anyhow::anyhow!("Failed to acquire lock on `CURRENCY_MAP`: {e}"))?;
282        map_guard
283            .get(s)
284            .copied()
285            .ok_or_else(|| anyhow::anyhow!("Unknown currency: {s}"))
286    }
287}
288
289impl<T: AsRef<str>> From<T> for Currency {
290    fn from(value: T) -> Self {
291        Self::from_str(value.as_ref()).expect(FAILED)
292    }
293}
294
295impl Serialize for Currency {
296    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
297    where
298        S: Serializer,
299    {
300        self.code.serialize(serializer)
301    }
302}
303
304impl<'de> Deserialize<'de> for Currency {
305    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
306    where
307        D: serde::Deserializer<'de>,
308    {
309        let currency_str: String = Deserialize::deserialize(deserializer)?;
310        Self::from_str(&currency_str).map_err(serde::de::Error::custom)
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use rstest::rstest;
317
318    use crate::{enums::CurrencyType, types::Currency};
319
320    #[rstest]
321    fn test_debug() {
322        let currency = Currency::AUD();
323        assert_eq!(
324            format!("{currency:?}"),
325            format!(
326                "Currency(code='AUD', precision=2, iso4217=36, name='Australian dollar', currency_type=FIAT)"
327            )
328        );
329    }
330
331    #[rstest]
332    fn test_display() {
333        let currency = Currency::AUD();
334        assert_eq!(format!("{currency}"), "AUD");
335    }
336
337    #[rstest]
338    #[should_panic(expected = "code")]
339    fn test_invalid_currency_code() {
340        let _ = Currency::new("", 2, 840, "United States dollar", CurrencyType::Fiat);
341    }
342
343    #[cfg(not(feature = "defi"))]
344    #[rstest]
345    #[should_panic(expected = "Condition failed: `precision` exceeded maximum `FIXED_PRECISION`")]
346    fn test_invalid_precision() {
347        // Precision greater than maximum (use 19 which exceeds even defi precision of 18)
348        let _ = Currency::new("USD", 19, 840, "United States dollar", CurrencyType::Fiat);
349    }
350
351    #[cfg(feature = "defi")]
352    #[rstest]
353    #[should_panic(expected = "Condition failed: `precision` exceeded maximum `WEI_PRECISION`")]
354    fn test_invalid_precision() {
355        // Precision greater than maximum (use 19 which exceeds even defi precision of 18)
356        let _ = Currency::new("ETH", 19, 0, "Ethereum", CurrencyType::Crypto);
357    }
358
359    #[rstest]
360    fn test_register_no_overwrite() {
361        let currency1 = Currency::new("TEST1", 2, 999, "Test Currency 1", CurrencyType::Fiat);
362        Currency::register(currency1, false).unwrap();
363
364        let currency2 = Currency::new(
365            "TEST1",
366            2,
367            999,
368            "Test Currency 2 Updated",
369            CurrencyType::Fiat,
370        );
371        Currency::register(currency2, false).unwrap();
372
373        let found = Currency::try_from_str("TEST1").unwrap();
374        assert_eq!(found.name.as_str(), "Test Currency 1");
375    }
376
377    #[rstest]
378    fn test_register_with_overwrite() {
379        let currency1 = Currency::new("TEST2", 2, 998, "Test Currency 2", CurrencyType::Fiat);
380        Currency::register(currency1, false).unwrap();
381
382        let currency2 = Currency::new(
383            "TEST2",
384            2,
385            998,
386            "Test Currency 2 Overwritten",
387            CurrencyType::Fiat,
388        );
389        Currency::register(currency2, true).unwrap();
390
391        let found = Currency::try_from_str("TEST2").unwrap();
392        assert_eq!(found.name.as_str(), "Test Currency 2 Overwritten");
393    }
394
395    #[rstest]
396    fn test_new_for_fiat() {
397        let currency = Currency::new("AUD", 2, 36, "Australian dollar", CurrencyType::Fiat);
398        assert_eq!(currency, currency);
399        assert_eq!(currency.code.as_str(), "AUD");
400        assert_eq!(currency.precision, 2);
401        assert_eq!(currency.iso4217, 36);
402        assert_eq!(currency.name.as_str(), "Australian dollar");
403        assert_eq!(currency.currency_type, CurrencyType::Fiat);
404    }
405
406    #[rstest]
407    fn test_new_for_crypto() {
408        let currency = Currency::new("ETH", 8, 0, "Ether", CurrencyType::Crypto);
409        assert_eq!(currency, currency);
410        assert_eq!(currency.code.as_str(), "ETH");
411        assert_eq!(currency.precision, 8);
412        assert_eq!(currency.iso4217, 0);
413        assert_eq!(currency.name.as_str(), "Ether");
414        assert_eq!(currency.currency_type, CurrencyType::Crypto);
415    }
416
417    #[rstest]
418    fn test_try_from_str_valid() {
419        let test_currency = Currency::new("TEST", 2, 999, "Test Currency", CurrencyType::Fiat);
420        Currency::register(test_currency, true).unwrap();
421
422        let currency = Currency::try_from_str("TEST");
423        assert!(currency.is_some());
424        assert_eq!(currency.unwrap(), test_currency);
425    }
426
427    #[rstest]
428    fn test_try_from_str_invalid() {
429        let invalid_currency = Currency::try_from_str("INVALID");
430        assert!(invalid_currency.is_none());
431    }
432
433    #[rstest]
434    fn test_equality() {
435        let currency1 = Currency::new("USD", 2, 840, "United States dollar", CurrencyType::Fiat);
436        let currency2 = Currency::new("USD", 2, 840, "United States dollar", CurrencyType::Fiat);
437        assert_eq!(currency1, currency2);
438    }
439
440    #[rstest]
441    fn test_currency_partial_eq_only_checks_code() {
442        let c1 = Currency::new("ABC", 2, 999, "Currency ABC", CurrencyType::Fiat);
443        let c2 = Currency::new("ABC", 8, 100, "Completely Different", CurrencyType::Crypto);
444
445        assert_eq!(c1, c2, "Should be equal if 'code' is the same");
446    }
447
448    #[rstest]
449    fn test_is_fiat() {
450        let currency = Currency::new("TESTFIAT", 2, 840, "Test Fiat", CurrencyType::Fiat);
451        Currency::register(currency, true).unwrap();
452
453        let result = Currency::is_fiat("TESTFIAT");
454        assert!(result.is_ok());
455        assert!(
456            result.unwrap(),
457            "Expected TESTFIAT to be recognized as fiat"
458        );
459    }
460
461    #[rstest]
462    fn test_is_crypto() {
463        let currency = Currency::new("TESTCRYPTO", 8, 0, "Test Crypto", CurrencyType::Crypto);
464        Currency::register(currency, true).unwrap();
465
466        let result = Currency::is_crypto("TESTCRYPTO");
467        assert!(result.is_ok());
468        assert!(
469            result.unwrap(),
470            "Expected TESTCRYPTO to be recognized as crypto"
471        );
472    }
473
474    #[rstest]
475    fn test_is_commodity_backed() {
476        let currency = Currency::new("TESTGOLD", 5, 0, "Test Gold", CurrencyType::CommodityBacked);
477        Currency::register(currency, true).unwrap();
478
479        let result = Currency::is_commodity_backed("TESTGOLD");
480        assert!(result.is_ok());
481        assert!(
482            result.unwrap(),
483            "Expected TESTGOLD to be recognized as commodity-backed"
484        );
485    }
486
487    #[rstest]
488    fn test_is_fiat_unknown_currency() {
489        let result = Currency::is_fiat("NON_EXISTENT");
490        assert!(result.is_err(), "Should fail for unknown currency code");
491    }
492
493    #[rstest]
494    fn test_serialization_deserialization() {
495        let currency = Currency::USD();
496        let serialized = serde_json::to_string(&currency).unwrap();
497        let deserialized: Currency = serde_json::from_str(&serialized).unwrap();
498        assert_eq!(currency, deserialized);
499    }
500
501    #[rstest]
502    fn test_get_or_create_crypto_existing() {
503        // Test with an existing currency (BTC is in the default map)
504        let currency = Currency::get_or_create_crypto("BTC");
505        assert_eq!(currency.code.as_str(), "BTC");
506        assert_eq!(currency.currency_type, CurrencyType::Crypto);
507    }
508
509    #[rstest]
510    fn test_get_or_create_crypto_new() {
511        // Test with a non-existent currency code
512        let currency = Currency::get_or_create_crypto("NEWCOIN");
513        assert_eq!(currency.code.as_str(), "NEWCOIN");
514        assert_eq!(currency.precision, 8);
515        assert_eq!(currency.iso4217, 0);
516        assert_eq!(currency.name.as_str(), "NEWCOIN");
517        assert_eq!(currency.currency_type, CurrencyType::Crypto);
518
519        // Verify it was registered and can be retrieved
520        let retrieved = Currency::try_from_str("NEWCOIN");
521        assert!(retrieved.is_some());
522        assert_eq!(retrieved.unwrap(), currency);
523    }
524
525    #[rstest]
526    fn test_get_or_create_crypto_idempotent() {
527        // First call creates and registers
528        let currency1 = Currency::get_or_create_crypto("TESTCOIN");
529
530        // Second call should retrieve the same currency
531        let currency2 = Currency::get_or_create_crypto("TESTCOIN");
532
533        assert_eq!(currency1, currency2);
534    }
535
536    #[rstest]
537    fn test_get_or_create_crypto_with_ustr() {
538        use ustr::Ustr;
539
540        // Test that it works with Ustr (via AsRef<str>)
541        let code = Ustr::from("USTRCOIN");
542        let currency = Currency::get_or_create_crypto(code);
543        assert_eq!(currency.code.as_str(), "USTRCOIN");
544        assert_eq!(currency.currency_type, CurrencyType::Crypto);
545    }
546
547    #[rstest]
548    fn test_get_or_create_crypto_with_context_valid() {
549        let result = Currency::get_or_create_crypto_with_context("BTC", Some("test context"));
550        assert_eq!(result, Currency::BTC());
551    }
552
553    #[rstest]
554    fn test_get_or_create_crypto_with_context_empty() {
555        let result = Currency::get_or_create_crypto_with_context("", Some("test context"));
556        assert_eq!(result, Currency::USDT());
557    }
558
559    #[rstest]
560    fn test_get_or_create_crypto_with_context_whitespace() {
561        let result = Currency::get_or_create_crypto_with_context("  ", Some("test context"));
562        assert_eq!(result, Currency::USDT());
563    }
564
565    #[rstest]
566    fn test_get_or_create_crypto_with_context_unknown() {
567        // Unknown codes should create a new Currency, preserving newly listed assets
568        let result = Currency::get_or_create_crypto_with_context("NEWCOIN", Some("test context"));
569        assert_eq!(result.code.as_str(), "NEWCOIN");
570        assert_eq!(result.precision, 8);
571    }
572}