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