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