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, tick_scheme::check_tick_scheme};
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    /// The registered variable tick scheme name.
102    pub tick_scheme: Option<Ustr>,
103    /// Additional instrument metadata as a JSON-serializable dictionary.
104    pub info: Option<Params>,
105    /// UNIX timestamp (nanoseconds) when the data event occurred.
106    pub ts_event: UnixNanos,
107    /// UNIX timestamp (nanoseconds) when the data object was initialized.
108    pub ts_init: UnixNanos,
109}
110
111#[bon::bon]
112impl TokenizedAsset {
113    /// Creates a new [`TokenizedAsset`] instance with correctness checking.
114    ///
115    /// # Notes
116    ///
117    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
118    ///
119    /// # Errors
120    ///
121    /// Returns an error if any input validation fails.
122    #[expect(clippy::too_many_arguments)]
123    pub fn new_checked(
124        instrument_id: InstrumentId,
125        raw_symbol: Symbol,
126        asset_class: AssetClass,
127        base_currency: Currency,
128        quote_currency: Currency,
129        isin: Option<Ustr>,
130        price_precision: u8,
131        size_precision: u8,
132        price_increment: Price,
133        size_increment: Quantity,
134        multiplier: Option<Quantity>,
135        lot_size: Option<Quantity>,
136        max_quantity: Option<Quantity>,
137        min_quantity: Option<Quantity>,
138        max_notional: Option<Money>,
139        min_notional: Option<Money>,
140        max_price: Option<Price>,
141        min_price: Option<Price>,
142        margin_init: Option<Decimal>,
143        margin_maint: Option<Decimal>,
144        maker_fee: Option<Decimal>,
145        taker_fee: Option<Decimal>,
146        tick_scheme: Option<Ustr>,
147        info: Option<Params>,
148        ts_event: UnixNanos,
149        ts_init: UnixNanos,
150    ) -> CorrectnessResult<Self> {
151        check_valid_string_ascii_optional(isin.map(|u| u.as_str()), stringify!(isin))?;
152        check_equal_u8(
153            price_precision,
154            price_increment.precision,
155            stringify!(price_precision),
156            stringify!(price_increment.precision),
157        )?;
158        check_equal_u8(
159            size_precision,
160            size_increment.precision,
161            stringify!(size_precision),
162            stringify!(size_increment.precision),
163        )?;
164        check_positive_price(price_increment, stringify!(price_increment))?;
165        check_positive_quantity(size_increment, stringify!(size_increment))?;
166        check_tick_scheme(tick_scheme)?;
167
168        if let Some(multiplier) = multiplier {
169            check_positive_quantity(multiplier, stringify!(multiplier))?;
170        }
171
172        if let Some(lot_size) = lot_size {
173            check_positive_quantity(lot_size, stringify!(lot_size))?;
174        }
175
176        Ok(Self {
177            id: instrument_id,
178            raw_symbol,
179            asset_class,
180            base_currency,
181            quote_currency,
182            isin,
183            price_precision,
184            size_precision,
185            price_increment,
186            size_increment,
187            multiplier: multiplier.unwrap_or(Quantity::from(1)),
188            lot_size,
189            max_quantity,
190            min_quantity,
191            max_notional,
192            min_notional,
193            max_price,
194            min_price,
195            margin_init: margin_init.unwrap_or_default(),
196            margin_maint: margin_maint.unwrap_or_default(),
197            maker_fee: maker_fee.unwrap_or_default(),
198            taker_fee: taker_fee.unwrap_or_default(),
199            tick_scheme,
200            info,
201            ts_event,
202            ts_init,
203        })
204    }
205
206    /// Creates a new [`TokenizedAsset`] instance.
207    ///
208    /// # Panics
209    ///
210    /// Panics if any input parameter is invalid (see `new_checked`).
211    #[expect(clippy::too_many_arguments)]
212    #[must_use]
213    pub fn new(
214        instrument_id: InstrumentId,
215        raw_symbol: Symbol,
216        asset_class: AssetClass,
217        base_currency: Currency,
218        quote_currency: Currency,
219        isin: Option<Ustr>,
220        price_precision: u8,
221        size_precision: u8,
222        price_increment: Price,
223        size_increment: Quantity,
224        multiplier: Option<Quantity>,
225        lot_size: Option<Quantity>,
226        max_quantity: Option<Quantity>,
227        min_quantity: Option<Quantity>,
228        max_notional: Option<Money>,
229        min_notional: Option<Money>,
230        max_price: Option<Price>,
231        min_price: Option<Price>,
232        margin_init: Option<Decimal>,
233        margin_maint: Option<Decimal>,
234        maker_fee: Option<Decimal>,
235        taker_fee: Option<Decimal>,
236        tick_scheme: Option<Ustr>,
237        info: Option<Params>,
238        ts_event: UnixNanos,
239        ts_init: UnixNanos,
240    ) -> Self {
241        Self::new_checked(
242            instrument_id,
243            raw_symbol,
244            asset_class,
245            base_currency,
246            quote_currency,
247            isin,
248            price_precision,
249            size_precision,
250            price_increment,
251            size_increment,
252            multiplier,
253            lot_size,
254            max_quantity,
255            min_quantity,
256            max_notional,
257            min_notional,
258            max_price,
259            min_price,
260            margin_init,
261            margin_maint,
262            maker_fee,
263            taker_fee,
264            tick_scheme,
265            info,
266            ts_event,
267            ts_init,
268        )
269        .expect_display(FAILED)
270    }
271
272    /// Returns a fluent builder for a [`TokenizedAsset`] instance.
273    ///
274    /// Required fields are enforced at compile time; optional fields can be omitted and default
275    /// the same way they do in [`TokenizedAsset::new_checked`], which the builder calls so the same
276    /// correctness checks run on `build`.
277    ///
278    /// # Errors
279    ///
280    /// Returns an error if any input validation fails (see [`TokenizedAsset::new_checked`]).
281    #[builder(start_fn = builder, finish_fn = build)]
282    pub fn build_checked(
283        instrument_id: InstrumentId,
284        raw_symbol: Symbol,
285        asset_class: AssetClass,
286        base_currency: Currency,
287        quote_currency: Currency,
288        isin: Option<Ustr>,
289        price_precision: u8,
290        size_precision: u8,
291        price_increment: Price,
292        size_increment: Quantity,
293        multiplier: Option<Quantity>,
294        lot_size: Option<Quantity>,
295        max_quantity: Option<Quantity>,
296        min_quantity: Option<Quantity>,
297        max_notional: Option<Money>,
298        min_notional: Option<Money>,
299        max_price: Option<Price>,
300        min_price: Option<Price>,
301        margin_init: Option<Decimal>,
302        margin_maint: Option<Decimal>,
303        maker_fee: Option<Decimal>,
304        taker_fee: Option<Decimal>,
305        tick_scheme: Option<Ustr>,
306        info: Option<Params>,
307        ts_event: UnixNanos,
308        ts_init: UnixNanos,
309    ) -> CorrectnessResult<Self> {
310        Self::new_checked(
311            instrument_id,
312            raw_symbol,
313            asset_class,
314            base_currency,
315            quote_currency,
316            isin,
317            price_precision,
318            size_precision,
319            price_increment,
320            size_increment,
321            multiplier,
322            lot_size,
323            max_quantity,
324            min_quantity,
325            max_notional,
326            min_notional,
327            max_price,
328            min_price,
329            margin_init,
330            margin_maint,
331            maker_fee,
332            taker_fee,
333            tick_scheme,
334            info,
335            ts_event,
336            ts_init,
337        )
338    }
339}
340
341impl PartialEq<Self> for TokenizedAsset {
342    fn eq(&self, other: &Self) -> bool {
343        self.id == other.id
344    }
345}
346
347impl Eq for TokenizedAsset {}
348
349impl Hash for TokenizedAsset {
350    fn hash<H: Hasher>(&self, state: &mut H) {
351        self.id.hash(state);
352    }
353}
354
355impl Instrument for TokenizedAsset {
356    fn tick_scheme(&self) -> Option<Ustr> {
357        self.tick_scheme
358    }
359    fn into_any(self) -> InstrumentAny {
360        InstrumentAny::TokenizedAsset(self)
361    }
362
363    fn id(&self) -> InstrumentId {
364        self.id
365    }
366
367    fn raw_symbol(&self) -> Symbol {
368        self.raw_symbol
369    }
370
371    fn asset_class(&self) -> AssetClass {
372        self.asset_class
373    }
374
375    fn instrument_class(&self) -> InstrumentClass {
376        InstrumentClass::Spot
377    }
378
379    fn underlying(&self) -> Option<Ustr> {
380        None
381    }
382
383    fn base_currency(&self) -> Option<Currency> {
384        Some(self.base_currency)
385    }
386
387    fn quote_currency(&self) -> Currency {
388        self.quote_currency
389    }
390
391    fn settlement_currency(&self) -> Currency {
392        self.quote_currency
393    }
394
395    fn isin(&self) -> Option<Ustr> {
396        self.isin
397    }
398
399    fn is_inverse(&self) -> bool {
400        false
401    }
402
403    fn price_precision(&self) -> u8 {
404        self.price_precision
405    }
406
407    fn size_precision(&self) -> u8 {
408        self.size_precision
409    }
410
411    fn price_increment(&self) -> Price {
412        self.price_increment
413    }
414
415    fn size_increment(&self) -> Quantity {
416        self.size_increment
417    }
418
419    fn multiplier(&self) -> Quantity {
420        self.multiplier
421    }
422
423    fn lot_size(&self) -> Option<Quantity> {
424        self.lot_size
425    }
426
427    fn max_quantity(&self) -> Option<Quantity> {
428        self.max_quantity
429    }
430
431    fn min_quantity(&self) -> Option<Quantity> {
432        self.min_quantity
433    }
434
435    fn max_price(&self) -> Option<Price> {
436        self.max_price
437    }
438
439    fn min_price(&self) -> Option<Price> {
440        self.min_price
441    }
442
443    fn ts_event(&self) -> UnixNanos {
444        self.ts_event
445    }
446
447    fn ts_init(&self) -> UnixNanos {
448        self.ts_init
449    }
450
451    fn margin_init(&self) -> Decimal {
452        self.margin_init
453    }
454
455    fn margin_maint(&self) -> Decimal {
456        self.margin_maint
457    }
458
459    fn taker_fee(&self) -> Decimal {
460        self.taker_fee
461    }
462
463    fn maker_fee(&self) -> Decimal {
464        self.maker_fee
465    }
466
467    fn option_kind(&self) -> Option<OptionKind> {
468        None
469    }
470
471    fn exchange(&self) -> Option<Ustr> {
472        None
473    }
474
475    fn strike_price(&self) -> Option<Price> {
476        None
477    }
478
479    fn activation_ns(&self) -> Option<UnixNanos> {
480        None
481    }
482
483    fn expiration_ns(&self) -> Option<UnixNanos> {
484        None
485    }
486
487    fn max_notional(&self) -> Option<Money> {
488        self.max_notional
489    }
490
491    fn min_notional(&self) -> Option<Money> {
492        self.min_notional
493    }
494}
495
496#[cfg(test)]
497mod tests {
498    use rstest::rstest;
499    use rust_decimal_macros::dec;
500    use ustr::Ustr;
501
502    use crate::{
503        enums::{AssetClass, InstrumentClass},
504        identifiers::{InstrumentId, Symbol},
505        instruments::{Instrument, TokenizedAsset, stubs::*},
506        types::{Currency, Money, Price, Quantity},
507    };
508
509    #[rstest]
510    fn test_trait_accessors(tokenized_asset_aaplx: TokenizedAsset) {
511        assert_eq!(
512            tokenized_asset_aaplx.id(),
513            InstrumentId::from("AAPLx/USD.KRAKEN")
514        );
515        assert_eq!(tokenized_asset_aaplx.asset_class(), AssetClass::Equity);
516        assert_eq!(
517            tokenized_asset_aaplx.instrument_class(),
518            InstrumentClass::Spot
519        );
520        assert_eq!(tokenized_asset_aaplx.quote_currency(), Currency::USD());
521        assert!(!tokenized_asset_aaplx.is_inverse());
522        assert_eq!(tokenized_asset_aaplx.price_precision(), 2);
523        assert_eq!(tokenized_asset_aaplx.size_precision(), 4);
524    }
525
526    #[rstest]
527    fn test_new_checked_price_precision_mismatch() {
528        let result = TokenizedAsset::new_checked(
529            InstrumentId::from("TEST.KRAKEN"),
530            Symbol::from("TEST"),
531            AssetClass::Equity,
532            Currency::BTC(),
533            Currency::USD(),
534            None,
535            4, // mismatch
536            4,
537            Price::from("0.01"),
538            Quantity::from("0.0001"),
539            None,
540            None,
541            None,
542            None,
543            None,
544            None,
545            None,
546            None,
547            None,
548            None,
549            None,
550            None,
551            None,
552            None,
553            0.into(),
554            0.into(),
555        );
556        assert!(result.is_err());
557    }
558
559    #[rstest]
560    fn test_new_checked_non_ascii_isin() {
561        let result = TokenizedAsset::new_checked(
562            InstrumentId::from("TEST.KRAKEN"),
563            Symbol::from("TEST"),
564            AssetClass::Equity,
565            Currency::BTC(),
566            Currency::USD(),
567            Some(ustr::Ustr::from("US\u{00E9}378331005")),
568            2,
569            4,
570            Price::from("0.01"),
571            Quantity::from("0.0001"),
572            None,
573            None,
574            None,
575            None,
576            None,
577            None,
578            None,
579            None,
580            None,
581            None,
582            None,
583            None,
584            None,
585            None,
586            0.into(),
587            0.into(),
588        );
589        assert!(result.is_err());
590        assert!(result.unwrap_err().to_string().contains("non-ASCII"));
591    }
592
593    #[rstest]
594    #[case::zero_multiplier(Some(Quantity::from("0")), None)]
595    #[case::zero_lot_size(None, Some(Quantity::from("0")))]
596    fn test_new_checked_rejects_non_positive_sizing(
597        #[case] multiplier: Option<Quantity>,
598        #[case] lot_size: Option<Quantity>,
599    ) {
600        let result = TokenizedAsset::new_checked(
601            InstrumentId::from("TEST.KRAKEN"),
602            Symbol::from("TEST"),
603            AssetClass::Equity,
604            Currency::BTC(),
605            Currency::USD(),
606            None,
607            2,
608            4,
609            Price::from("0.01"),
610            Quantity::from("0.0001"),
611            multiplier,
612            lot_size,
613            None,
614            None,
615            None,
616            None,
617            None,
618            None,
619            None,
620            None,
621            None,
622            None,
623            None,
624            None,
625            0.into(),
626            0.into(),
627        );
628        let error = result.unwrap_err();
629        assert!(error.to_string().contains("not positive"), "{error}");
630    }
631
632    #[rstest]
633    fn test_serialization_roundtrip(tokenized_asset_aaplx: TokenizedAsset) {
634        let json = serde_json::to_string(&tokenized_asset_aaplx).unwrap();
635        let deserialized: TokenizedAsset = serde_json::from_str(&json).unwrap();
636        assert_eq!(tokenized_asset_aaplx, deserialized);
637    }
638
639    #[rstest]
640    fn test_builder_matches_new_checked() {
641        let positional = TokenizedAsset::new_checked(
642            InstrumentId::from("AAPLx/USD.KRAKEN"),
643            Symbol::from("AAPLxUSD"),
644            AssetClass::Equity,
645            Currency::BTC(),
646            Currency::USD(),
647            Some(Ustr::from("US0378331005")),
648            2,
649            4,
650            Price::from("0.01"),
651            Quantity::from("0.0001"),
652            Some(Quantity::from("10")),
653            Some(Quantity::from("5")),
654            Some(Quantity::from("100")),
655            Some(Quantity::from("0.0001")),
656            Some(Money::new(1000.0, Currency::USD())),
657            Some(Money::new(10.0, Currency::USD())),
658            Some(Price::from("999.99")),
659            Some(Price::from("0.01")),
660            Some(dec!(0.01)),
661            Some(dec!(0.02)),
662            Some(dec!(0.0002)),
663            Some(dec!(0.0004)),
664            None,
665            None,
666            1.into(),
667            2.into(),
668        )
669        .unwrap();
670
671        let built = TokenizedAsset::builder()
672            .instrument_id(InstrumentId::from("AAPLx/USD.KRAKEN"))
673            .raw_symbol(Symbol::from("AAPLxUSD"))
674            .asset_class(AssetClass::Equity)
675            .base_currency(Currency::BTC())
676            .quote_currency(Currency::USD())
677            .isin(Ustr::from("US0378331005"))
678            .price_precision(2)
679            .size_precision(4)
680            .price_increment(Price::from("0.01"))
681            .size_increment(Quantity::from("0.0001"))
682            .multiplier(Quantity::from("10"))
683            .lot_size(Quantity::from("5"))
684            .max_quantity(Quantity::from("100"))
685            .min_quantity(Quantity::from("0.0001"))
686            .max_notional(Money::new(1000.0, Currency::USD()))
687            .min_notional(Money::new(10.0, Currency::USD()))
688            .max_price(Price::from("999.99"))
689            .min_price(Price::from("0.01"))
690            .margin_init(dec!(0.01))
691            .margin_maint(dec!(0.02))
692            .maker_fee(dec!(0.0002))
693            .taker_fee(dec!(0.0004))
694            .ts_event(1.into())
695            .ts_init(2.into())
696            .build()
697            .unwrap();
698
699        assert_eq!(
700            serde_json::to_value(&positional).unwrap(),
701            serde_json::to_value(&built).unwrap(),
702        );
703    }
704}