nautilus_model/types/
currency.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 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, Formatter},
22    hash::{Hash, Hasher},
23    str::FromStr,
24};
25
26use nautilus_core::correctness::{FAILED, check_nonempty_string, check_valid_string_utf8};
27use serde::{Deserialize, Serialize, Serializer};
28use ustr::Ustr;
29
30#[allow(unused_imports, reason = "FIXED_PRECISION used in docs")]
31use super::fixed::{FIXED_PRECISION, check_fixed_precision};
32use crate::{currencies::CURRENCY_MAP, enums::CurrencyType};
33
34/// Represents a medium of exchange in a specified denomination with a fixed decimal precision.
35///
36/// Handles up to [`FIXED_PRECISION`] decimals of precision.
37#[repr(C)]
38#[derive(Clone, Copy, Eq)]
39#[cfg_attr(
40    feature = "python",
41    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", frozen, eq, hash)
42)]
43pub struct Currency {
44    /// The currency code as an alpha-3 string (e.g., "USD", "EUR").
45    pub code: Ustr,
46    /// The currency decimal precision.
47    pub precision: u8,
48    /// The ISO 4217 currency code.
49    pub iso4217: u16,
50    /// The full name of the currency.
51    pub name: Ustr,
52    /// The currency type, indicating its category (e.g. Fiat, Crypto).
53    pub currency_type: CurrencyType,
54}
55
56impl Currency {
57    /// Creates a new [`Currency`] instance with correctness checking.
58    ///
59    /// # Errors
60    ///
61    /// Returns an error if:
62    /// - `code` is not a valid string.
63    /// - `name` is the empty string.
64    /// - `precision` is invalid outside the valid representable range [0, FIXED_PRECISION].
65    ///
66    /// # Notes
67    ///
68    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
69    pub fn new_checked<T: AsRef<str>>(
70        code: T,
71        precision: u8,
72        iso4217: u16,
73        name: T,
74        currency_type: CurrencyType,
75    ) -> anyhow::Result<Self> {
76        let code = code.as_ref();
77        let name = name.as_ref();
78        check_valid_string_utf8(code, "code")?;
79        check_nonempty_string(name, "name")?;
80        check_fixed_precision(precision)?;
81        Ok(Self {
82            code: Ustr::from(code),
83            precision,
84            iso4217,
85            name: Ustr::from(name),
86            currency_type,
87        })
88    }
89
90    /// Creates a new [`Currency`] instance.
91    ///
92    /// # Panics
93    ///
94    /// Panics if a correctness check fails. See [`Currency::new_checked`] for more details.
95    pub fn new<T: AsRef<str>>(
96        code: T,
97        precision: u8,
98        iso4217: u16,
99        name: T,
100        currency_type: CurrencyType,
101    ) -> Self {
102        Self::new_checked(code, precision, iso4217, name, currency_type).expect(FAILED)
103    }
104
105    /// Register the given `currency` in the internal currency map.
106    ///
107    /// - If `overwrite` is `true`, any existing currency will be replaced.
108    /// - If `overwrite` is `false` and the currency already exists, the operation is a no-op.
109    ///
110    /// # Errors
111    ///
112    /// Returns an error if there is a failure acquiring the lock on the currency map.
113    pub fn register(currency: Self, overwrite: bool) -> anyhow::Result<()> {
114        let mut map = CURRENCY_MAP
115            .lock()
116            .map_err(|e| anyhow::anyhow!(e.to_string()))?;
117
118        if !overwrite && map.contains_key(currency.code.as_str()) {
119            // If overwrite is false and the currency already exists, simply return
120            return Ok(());
121        }
122
123        // Insert or overwrite the currency in the map
124        map.insert(currency.code.to_string(), currency);
125        Ok(())
126    }
127
128    /// Attempts to parse a [`Currency`] from a string, returning `None` if not found.
129    pub fn try_from_str(s: &str) -> Option<Self> {
130        let map_guard = CURRENCY_MAP.lock().ok()?;
131        map_guard.get(s).copied()
132    }
133
134    /// Checks if the currency identified by the given `code` is a fiat currency.
135    ///
136    /// # Errors
137    ///
138    /// Returns an error if:
139    /// - A currency with the given `code` does not exist.
140    /// - There is a failure acquiring the lock on the currency map.
141    pub fn is_fiat(code: &str) -> anyhow::Result<bool> {
142        let currency = Self::from_str(code)?;
143        Ok(currency.currency_type == CurrencyType::Fiat)
144    }
145
146    /// Checks if the currency identified by the given `code` is a cryptocurrency.
147    ///
148    /// # Errors
149    ///
150    /// Returns an error if:
151    /// - If a currency with the given `code` does not exist.
152    /// - If there is a failure acquiring the lock on the currency map.
153    pub fn is_crypto(code: &str) -> anyhow::Result<bool> {
154        let currency = Self::from_str(code)?;
155        Ok(currency.currency_type == CurrencyType::Crypto)
156    }
157
158    /// Checks if the currency identified by the given `code` is a commodity (such as a precious
159    /// metal).
160    ///
161    /// # Errors
162    ///
163    /// Returns an error if:
164    /// - A currency with the given `code` does not exist.
165    /// - There is a failure acquiring the lock on the currency map.
166    pub fn is_commodity_backed(code: &str) -> anyhow::Result<bool> {
167        let currency = Self::from_str(code)?;
168        Ok(currency.currency_type == CurrencyType::CommodityBacked)
169    }
170}
171
172impl PartialEq for Currency {
173    fn eq(&self, other: &Self) -> bool {
174        self.code == other.code
175    }
176}
177
178impl Hash for Currency {
179    fn hash<H: Hasher>(&self, state: &mut H) {
180        self.code.hash(state);
181    }
182}
183
184impl Debug for Currency {
185    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
186        write!(
187            f,
188            "{}(code='{}', precision={}, iso4217={}, name='{}', currency_type={})",
189            stringify!(Currency),
190            self.code,
191            self.precision,
192            self.iso4217,
193            self.name,
194            self.currency_type,
195        )
196    }
197}
198
199impl Display for Currency {
200    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
201        write!(f, "{}", self.code)
202    }
203}
204
205impl FromStr for Currency {
206    type Err = anyhow::Error;
207
208    fn from_str(s: &str) -> anyhow::Result<Self> {
209        let map_guard = CURRENCY_MAP
210            .lock()
211            .map_err(|e| anyhow::anyhow!("Failed to acquire lock on `CURRENCY_MAP`: {e}"))?;
212        map_guard
213            .get(s)
214            .copied()
215            .ok_or_else(|| anyhow::anyhow!("Unknown currency: {s}"))
216    }
217}
218
219impl<T: AsRef<str>> From<T> for Currency {
220    fn from(value: T) -> Self {
221        Self::from_str(value.as_ref()).expect(FAILED)
222    }
223}
224
225impl Serialize for Currency {
226    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
227    where
228        S: Serializer,
229    {
230        self.code.serialize(serializer)
231    }
232}
233
234impl<'de> Deserialize<'de> for Currency {
235    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
236    where
237        D: serde::Deserializer<'de>,
238    {
239        let currency_str: String = Deserialize::deserialize(deserializer)?;
240        Self::from_str(&currency_str).map_err(serde::de::Error::custom)
241    }
242}
243
244////////////////////////////////////////////////////////////////////////////////
245// Tests
246////////////////////////////////////////////////////////////////////////////////
247#[cfg(test)]
248mod tests {
249    use rstest::rstest;
250
251    use crate::{enums::CurrencyType, types::Currency};
252
253    #[rstest]
254    fn test_debug() {
255        let currency = Currency::AUD();
256        assert_eq!(
257            format!("{currency:?}"),
258            format!(
259                "Currency(code='AUD', precision=2, iso4217=36, name='Australian dollar', currency_type=FIAT)"
260            )
261        );
262    }
263
264    #[rstest]
265    fn test_display() {
266        let currency = Currency::AUD();
267        assert_eq!(format!("{currency}"), "AUD");
268    }
269
270    #[rstest]
271    #[should_panic(expected = "code")]
272    fn test_invalid_currency_code() {
273        let _ = Currency::new("", 2, 840, "United States dollar", CurrencyType::Fiat);
274    }
275
276    #[cfg(not(feature = "defi"))]
277    #[rstest]
278    #[should_panic(expected = "Condition failed: `precision` exceeded maximum `FIXED_PRECISION`")]
279    fn test_invalid_precision() {
280        // Precision greater than maximum (use 19 which exceeds even defi precision of 18)
281        let _ = Currency::new("USD", 19, 840, "United States dollar", CurrencyType::Fiat);
282    }
283
284    #[cfg(feature = "defi")]
285    #[rstest]
286    #[should_panic(expected = "Condition failed: `precision` exceeded maximum `WEI_PRECISION`")]
287    fn test_invalid_precision() {
288        // Precision greater than maximum (use 19 which exceeds even defi precision of 18)
289        let _ = Currency::new("ETH", 19, 0, "Ethereum", CurrencyType::Crypto);
290    }
291
292    #[rstest]
293    fn test_register_no_overwrite() {
294        let currency1 = Currency::new("TEST1", 2, 999, "Test Currency 1", CurrencyType::Fiat);
295        Currency::register(currency1, false).unwrap();
296
297        let currency2 = Currency::new(
298            "TEST1",
299            2,
300            999,
301            "Test Currency 2 Updated",
302            CurrencyType::Fiat,
303        );
304        Currency::register(currency2, false).unwrap();
305
306        let found = Currency::try_from_str("TEST1").unwrap();
307        assert_eq!(found.name.as_str(), "Test Currency 1");
308    }
309
310    #[rstest]
311    fn test_register_with_overwrite() {
312        let currency1 = Currency::new("TEST2", 2, 998, "Test Currency 2", CurrencyType::Fiat);
313        Currency::register(currency1, false).unwrap();
314
315        let currency2 = Currency::new(
316            "TEST2",
317            2,
318            998,
319            "Test Currency 2 Overwritten",
320            CurrencyType::Fiat,
321        );
322        Currency::register(currency2, true).unwrap();
323
324        let found = Currency::try_from_str("TEST2").unwrap();
325        assert_eq!(found.name.as_str(), "Test Currency 2 Overwritten");
326    }
327
328    #[rstest]
329    fn test_new_for_fiat() {
330        let currency = Currency::new("AUD", 2, 36, "Australian dollar", CurrencyType::Fiat);
331        assert_eq!(currency, currency);
332        assert_eq!(currency.code.as_str(), "AUD");
333        assert_eq!(currency.precision, 2);
334        assert_eq!(currency.iso4217, 36);
335        assert_eq!(currency.name.as_str(), "Australian dollar");
336        assert_eq!(currency.currency_type, CurrencyType::Fiat);
337    }
338
339    #[rstest]
340    fn test_new_for_crypto() {
341        let currency = Currency::new("ETH", 8, 0, "Ether", CurrencyType::Crypto);
342        assert_eq!(currency, currency);
343        assert_eq!(currency.code.as_str(), "ETH");
344        assert_eq!(currency.precision, 8);
345        assert_eq!(currency.iso4217, 0);
346        assert_eq!(currency.name.as_str(), "Ether");
347        assert_eq!(currency.currency_type, CurrencyType::Crypto);
348    }
349
350    #[rstest]
351    fn test_try_from_str_valid() {
352        let test_currency = Currency::new("TEST", 2, 999, "Test Currency", CurrencyType::Fiat);
353        Currency::register(test_currency, true).unwrap();
354
355        let currency = Currency::try_from_str("TEST");
356        assert!(currency.is_some());
357        assert_eq!(currency.unwrap(), test_currency);
358    }
359
360    #[rstest]
361    fn test_try_from_str_invalid() {
362        let invalid_currency = Currency::try_from_str("INVALID");
363        assert!(invalid_currency.is_none());
364    }
365
366    #[rstest]
367    fn test_equality() {
368        let currency1 = Currency::new("USD", 2, 840, "United States dollar", CurrencyType::Fiat);
369        let currency2 = Currency::new("USD", 2, 840, "United States dollar", CurrencyType::Fiat);
370        assert_eq!(currency1, currency2);
371    }
372
373    #[rstest]
374    fn test_currency_partial_eq_only_checks_code() {
375        let c1 = Currency::new("ABC", 2, 999, "Currency ABC", CurrencyType::Fiat);
376        let c2 = Currency::new("ABC", 8, 100, "Completely Different", CurrencyType::Crypto);
377
378        assert_eq!(c1, c2, "Should be equal if 'code' is the same");
379    }
380
381    #[rstest]
382    fn test_is_fiat() {
383        let currency = Currency::new("TESTFIAT", 2, 840, "Test Fiat", CurrencyType::Fiat);
384        Currency::register(currency, true).unwrap();
385
386        let result = Currency::is_fiat("TESTFIAT");
387        assert!(result.is_ok());
388        assert!(
389            result.unwrap(),
390            "Expected TESTFIAT to be recognized as fiat"
391        );
392    }
393
394    #[rstest]
395    fn test_is_crypto() {
396        let currency = Currency::new("TESTCRYPTO", 8, 0, "Test Crypto", CurrencyType::Crypto);
397        Currency::register(currency, true).unwrap();
398
399        let result = Currency::is_crypto("TESTCRYPTO");
400        assert!(result.is_ok());
401        assert!(
402            result.unwrap(),
403            "Expected TESTCRYPTO to be recognized as crypto"
404        );
405    }
406
407    #[rstest]
408    fn test_is_commodity_backed() {
409        let currency = Currency::new("TESTGOLD", 5, 0, "Test Gold", CurrencyType::CommodityBacked);
410        Currency::register(currency, true).unwrap();
411
412        let result = Currency::is_commodity_backed("TESTGOLD");
413        assert!(result.is_ok());
414        assert!(
415            result.unwrap(),
416            "Expected TESTGOLD to be recognized as commodity-backed"
417        );
418    }
419
420    #[rstest]
421    fn test_is_fiat_unknown_currency() {
422        let result = Currency::is_fiat("NON_EXISTENT");
423        assert!(result.is_err(), "Should fail for unknown currency code");
424    }
425
426    #[rstest]
427    fn test_serialization_deserialization() {
428        let currency = Currency::USD();
429        let serialized = serde_json::to_string(&currency).unwrap();
430        let deserialized: Currency = serde_json::from_str(&serialized).unwrap();
431        assert_eq!(currency, deserialized);
432    }
433}