Skip to main content

nautilus_model/instruments/
tokenized_asset.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
16use std::hash::{Hash, Hasher};
17
18use nautilus_core::{
19    Params, UnixNanos,
20    correctness::{
21        CorrectnessResult, CorrectnessResultExt, FAILED, check_equal_u8,
22        check_valid_string_ascii_optional,
23    },
24};
25use rust_decimal::Decimal;
26use serde::{Deserialize, Serialize};
27use ustr::Ustr;
28
29use super::{Instrument, any::InstrumentAny};
30use crate::{
31    enums::{AssetClass, InstrumentClass, OptionKind},
32    identifiers::{InstrumentId, Symbol},
33    types::{
34        currency::Currency,
35        money::Money,
36        price::{Price, check_positive_price},
37        quantity::{Quantity, check_positive_quantity},
38    },
39};
40
41/// Represents a tokenized real-world asset traded as a pair on a crypto venue.
42///
43/// Covers tokenized equities, ETFs, commodities, and other asset classes where the
44/// underlying is represented as a base token traded against a quote currency.
45/// The `asset_class` field identifies the underlying asset type.
46#[repr(C)]
47#[derive(Clone, Debug, Serialize, Deserialize)]
48#[cfg_attr(
49    feature = "python",
50    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", from_py_object)
51)]
52#[cfg_attr(
53    feature = "python",
54    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
55)]
56pub struct TokenizedAsset {
57    /// The instrument ID.
58    pub id: InstrumentId,
59    /// The raw/local/native symbol for the instrument, assigned by the venue.
60    pub raw_symbol: Symbol,
61    /// The asset class of the underlying (e.g. Equity, Commodity, Index).
62    pub asset_class: AssetClass,
63    /// The base currency (the tokenized asset).
64    pub base_currency: Currency,
65    /// The quote currency.
66    pub quote_currency: Currency,
67    /// The International Securities Identification Number (ISIN) of the underlying.
68    pub isin: Option<Ustr>,
69    /// The price decimal precision.
70    pub price_precision: u8,
71    /// The trading size decimal precision.
72    pub size_precision: u8,
73    /// The minimum price increment (tick size).
74    pub price_increment: Price,
75    /// The minimum size increment.
76    pub size_increment: Quantity,
77    /// The contract multiplier.
78    pub multiplier: Quantity,
79    /// The rounded lot unit size.
80    pub lot_size: Option<Quantity>,
81    /// The initial (order) margin requirement in percentage of order value.
82    pub margin_init: Decimal,
83    /// The maintenance (position) margin in percentage of position value.
84    pub margin_maint: Decimal,
85    /// The fee rate for liquidity makers as a percentage of order value.
86    pub maker_fee: Decimal,
87    /// The fee rate for liquidity takers as a percentage of order value.
88    pub taker_fee: Decimal,
89    /// The maximum allowable order quantity.
90    pub max_quantity: Option<Quantity>,
91    /// The minimum allowable order quantity.
92    pub min_quantity: Option<Quantity>,
93    /// The maximum allowable order notional value.
94    pub max_notional: Option<Money>,
95    /// The minimum allowable order notional value.
96    pub min_notional: Option<Money>,
97    /// The maximum allowable quoted price.
98    pub max_price: Option<Price>,
99    /// The minimum allowable quoted price.
100    pub min_price: Option<Price>,
101    /// Additional instrument metadata as a JSON-serializable dictionary.
102    pub info: Option<Params>,
103    /// UNIX timestamp (nanoseconds) when the data event occurred.
104    pub ts_event: UnixNanos,
105    /// UNIX timestamp (nanoseconds) when the data object was initialized.
106    pub ts_init: UnixNanos,
107}
108
109impl TokenizedAsset {
110    /// Creates a new [`TokenizedAsset`] instance with correctness checking.
111    ///
112    /// # Notes
113    ///
114    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
115    ///
116    /// # Errors
117    ///
118    /// Returns an error if any input validation fails.
119    #[expect(clippy::too_many_arguments)]
120    pub fn new_checked(
121        instrument_id: InstrumentId,
122        raw_symbol: Symbol,
123        asset_class: AssetClass,
124        base_currency: Currency,
125        quote_currency: Currency,
126        isin: Option<Ustr>,
127        price_precision: u8,
128        size_precision: u8,
129        price_increment: Price,
130        size_increment: Quantity,
131        multiplier: Option<Quantity>,
132        lot_size: Option<Quantity>,
133        max_quantity: Option<Quantity>,
134        min_quantity: Option<Quantity>,
135        max_notional: Option<Money>,
136        min_notional: Option<Money>,
137        max_price: Option<Price>,
138        min_price: Option<Price>,
139        margin_init: Option<Decimal>,
140        margin_maint: Option<Decimal>,
141        maker_fee: Option<Decimal>,
142        taker_fee: Option<Decimal>,
143        info: Option<Params>,
144        ts_event: UnixNanos,
145        ts_init: UnixNanos,
146    ) -> CorrectnessResult<Self> {
147        check_valid_string_ascii_optional(isin.map(|u| u.as_str()), stringify!(isin))?;
148        check_equal_u8(
149            price_precision,
150            price_increment.precision,
151            stringify!(price_precision),
152            stringify!(price_increment.precision),
153        )?;
154        check_equal_u8(
155            size_precision,
156            size_increment.precision,
157            stringify!(size_precision),
158            stringify!(size_increment.precision),
159        )?;
160        check_positive_price(price_increment, stringify!(price_increment))?;
161        check_positive_quantity(size_increment, stringify!(size_increment))?;
162
163        Ok(Self {
164            id: instrument_id,
165            raw_symbol,
166            asset_class,
167            base_currency,
168            quote_currency,
169            isin,
170            price_precision,
171            size_precision,
172            price_increment,
173            size_increment,
174            multiplier: multiplier.unwrap_or(Quantity::from(1)),
175            lot_size,
176            max_quantity,
177            min_quantity,
178            max_notional,
179            min_notional,
180            max_price,
181            min_price,
182            margin_init: margin_init.unwrap_or_default(),
183            margin_maint: margin_maint.unwrap_or_default(),
184            maker_fee: maker_fee.unwrap_or_default(),
185            taker_fee: taker_fee.unwrap_or_default(),
186            info,
187            ts_event,
188            ts_init,
189        })
190    }
191
192    /// Creates a new [`TokenizedAsset`] instance.
193    ///
194    /// # Panics
195    ///
196    /// Panics if any input parameter is invalid (see `new_checked`).
197    #[expect(clippy::too_many_arguments)]
198    #[must_use]
199    pub fn new(
200        instrument_id: InstrumentId,
201        raw_symbol: Symbol,
202        asset_class: AssetClass,
203        base_currency: Currency,
204        quote_currency: Currency,
205        isin: Option<Ustr>,
206        price_precision: u8,
207        size_precision: u8,
208        price_increment: Price,
209        size_increment: Quantity,
210        multiplier: Option<Quantity>,
211        lot_size: Option<Quantity>,
212        max_quantity: Option<Quantity>,
213        min_quantity: Option<Quantity>,
214        max_notional: Option<Money>,
215        min_notional: Option<Money>,
216        max_price: Option<Price>,
217        min_price: Option<Price>,
218        margin_init: Option<Decimal>,
219        margin_maint: Option<Decimal>,
220        maker_fee: Option<Decimal>,
221        taker_fee: Option<Decimal>,
222        info: Option<Params>,
223        ts_event: UnixNanos,
224        ts_init: UnixNanos,
225    ) -> Self {
226        Self::new_checked(
227            instrument_id,
228            raw_symbol,
229            asset_class,
230            base_currency,
231            quote_currency,
232            isin,
233            price_precision,
234            size_precision,
235            price_increment,
236            size_increment,
237            multiplier,
238            lot_size,
239            max_quantity,
240            min_quantity,
241            max_notional,
242            min_notional,
243            max_price,
244            min_price,
245            margin_init,
246            margin_maint,
247            maker_fee,
248            taker_fee,
249            info,
250            ts_event,
251            ts_init,
252        )
253        .expect_display(FAILED)
254    }
255}
256
257impl PartialEq<Self> for TokenizedAsset {
258    fn eq(&self, other: &Self) -> bool {
259        self.id == other.id
260    }
261}
262
263impl Eq for TokenizedAsset {}
264
265impl Hash for TokenizedAsset {
266    fn hash<H: Hasher>(&self, state: &mut H) {
267        self.id.hash(state);
268    }
269}
270
271impl Instrument for TokenizedAsset {
272    fn into_any(self) -> InstrumentAny {
273        InstrumentAny::TokenizedAsset(self)
274    }
275
276    fn id(&self) -> InstrumentId {
277        self.id
278    }
279
280    fn raw_symbol(&self) -> Symbol {
281        self.raw_symbol
282    }
283
284    fn asset_class(&self) -> AssetClass {
285        self.asset_class
286    }
287
288    fn instrument_class(&self) -> InstrumentClass {
289        InstrumentClass::Spot
290    }
291
292    fn underlying(&self) -> Option<Ustr> {
293        None
294    }
295
296    fn base_currency(&self) -> Option<Currency> {
297        Some(self.base_currency)
298    }
299
300    fn quote_currency(&self) -> Currency {
301        self.quote_currency
302    }
303
304    fn settlement_currency(&self) -> Currency {
305        self.quote_currency
306    }
307
308    fn isin(&self) -> Option<Ustr> {
309        self.isin
310    }
311
312    fn is_inverse(&self) -> bool {
313        false
314    }
315
316    fn price_precision(&self) -> u8 {
317        self.price_precision
318    }
319
320    fn size_precision(&self) -> u8 {
321        self.size_precision
322    }
323
324    fn price_increment(&self) -> Price {
325        self.price_increment
326    }
327
328    fn size_increment(&self) -> Quantity {
329        self.size_increment
330    }
331
332    fn multiplier(&self) -> Quantity {
333        self.multiplier
334    }
335
336    fn lot_size(&self) -> Option<Quantity> {
337        self.lot_size
338    }
339
340    fn max_quantity(&self) -> Option<Quantity> {
341        self.max_quantity
342    }
343
344    fn min_quantity(&self) -> Option<Quantity> {
345        self.min_quantity
346    }
347
348    fn max_price(&self) -> Option<Price> {
349        self.max_price
350    }
351
352    fn min_price(&self) -> Option<Price> {
353        self.min_price
354    }
355
356    fn ts_event(&self) -> UnixNanos {
357        self.ts_event
358    }
359
360    fn ts_init(&self) -> UnixNanos {
361        self.ts_init
362    }
363
364    fn margin_init(&self) -> Decimal {
365        self.margin_init
366    }
367
368    fn margin_maint(&self) -> Decimal {
369        self.margin_maint
370    }
371
372    fn taker_fee(&self) -> Decimal {
373        self.taker_fee
374    }
375
376    fn maker_fee(&self) -> Decimal {
377        self.maker_fee
378    }
379
380    fn option_kind(&self) -> Option<OptionKind> {
381        None
382    }
383
384    fn exchange(&self) -> Option<Ustr> {
385        None
386    }
387
388    fn strike_price(&self) -> Option<Price> {
389        None
390    }
391
392    fn activation_ns(&self) -> Option<UnixNanos> {
393        None
394    }
395
396    fn expiration_ns(&self) -> Option<UnixNanos> {
397        None
398    }
399
400    fn max_notional(&self) -> Option<Money> {
401        self.max_notional
402    }
403
404    fn min_notional(&self) -> Option<Money> {
405        self.min_notional
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use rstest::rstest;
412
413    use crate::{
414        enums::{AssetClass, InstrumentClass},
415        identifiers::{InstrumentId, Symbol},
416        instruments::{Instrument, TokenizedAsset, stubs::*},
417        types::{Currency, Price, Quantity},
418    };
419
420    #[rstest]
421    fn test_trait_accessors(tokenized_asset_aaplx: TokenizedAsset) {
422        assert_eq!(
423            tokenized_asset_aaplx.id(),
424            InstrumentId::from("AAPLx/USD.KRAKEN")
425        );
426        assert_eq!(tokenized_asset_aaplx.asset_class(), AssetClass::Equity);
427        assert_eq!(
428            tokenized_asset_aaplx.instrument_class(),
429            InstrumentClass::Spot
430        );
431        assert_eq!(tokenized_asset_aaplx.quote_currency(), Currency::USD());
432        assert!(!tokenized_asset_aaplx.is_inverse());
433        assert_eq!(tokenized_asset_aaplx.price_precision(), 2);
434        assert_eq!(tokenized_asset_aaplx.size_precision(), 4);
435    }
436
437    #[rstest]
438    fn test_new_checked_price_precision_mismatch() {
439        let result = TokenizedAsset::new_checked(
440            InstrumentId::from("TEST.KRAKEN"),
441            Symbol::from("TEST"),
442            AssetClass::Equity,
443            Currency::BTC(),
444            Currency::USD(),
445            None,
446            4, // mismatch
447            4,
448            Price::from("0.01"),
449            Quantity::from("0.0001"),
450            None,
451            None,
452            None,
453            None,
454            None,
455            None,
456            None,
457            None,
458            None,
459            None,
460            None,
461            None,
462            None,
463            0.into(),
464            0.into(),
465        );
466        assert!(result.is_err());
467    }
468
469    #[rstest]
470    fn test_new_checked_non_ascii_isin() {
471        let result = TokenizedAsset::new_checked(
472            InstrumentId::from("TEST.KRAKEN"),
473            Symbol::from("TEST"),
474            AssetClass::Equity,
475            Currency::BTC(),
476            Currency::USD(),
477            Some(ustr::Ustr::from("US\u{00E9}378331005")),
478            2,
479            4,
480            Price::from("0.01"),
481            Quantity::from("0.0001"),
482            None,
483            None,
484            None,
485            None,
486            None,
487            None,
488            None,
489            None,
490            None,
491            None,
492            None,
493            None,
494            None,
495            0.into(),
496            0.into(),
497        );
498        assert!(result.is_err());
499        assert!(result.unwrap_err().to_string().contains("non-ASCII"));
500    }
501
502    #[rstest]
503    fn test_serialization_roundtrip(tokenized_asset_aaplx: TokenizedAsset) {
504        let json = serde_json::to_string(&tokenized_asset_aaplx).unwrap();
505        let deserialized: TokenizedAsset = serde_json::from_str(&json).unwrap();
506        assert_eq!(tokenized_asset_aaplx, deserialized);
507    }
508}