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.map(|u| u.as_str()), 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 tick_scheme(&self) -> Option<Ustr> {
278        self.tick_scheme
279    }
280    fn into_any(self) -> InstrumentAny {
281        InstrumentAny::TokenizedAsset(self)
282    }
283
284    fn id(&self) -> InstrumentId {
285        self.id
286    }
287
288    fn raw_symbol(&self) -> Symbol {
289        self.raw_symbol
290    }
291
292    fn asset_class(&self) -> AssetClass {
293        self.asset_class
294    }
295
296    fn instrument_class(&self) -> InstrumentClass {
297        InstrumentClass::Spot
298    }
299
300    fn underlying(&self) -> Option<Ustr> {
301        None
302    }
303
304    fn base_currency(&self) -> Option<Currency> {
305        Some(self.base_currency)
306    }
307
308    fn quote_currency(&self) -> Currency {
309        self.quote_currency
310    }
311
312    fn settlement_currency(&self) -> Currency {
313        self.quote_currency
314    }
315
316    fn isin(&self) -> Option<Ustr> {
317        self.isin
318    }
319
320    fn is_inverse(&self) -> bool {
321        false
322    }
323
324    fn price_precision(&self) -> u8 {
325        self.price_precision
326    }
327
328    fn size_precision(&self) -> u8 {
329        self.size_precision
330    }
331
332    fn price_increment(&self) -> Price {
333        self.price_increment
334    }
335
336    fn size_increment(&self) -> Quantity {
337        self.size_increment
338    }
339
340    fn multiplier(&self) -> Quantity {
341        self.multiplier
342    }
343
344    fn lot_size(&self) -> Option<Quantity> {
345        self.lot_size
346    }
347
348    fn max_quantity(&self) -> Option<Quantity> {
349        self.max_quantity
350    }
351
352    fn min_quantity(&self) -> Option<Quantity> {
353        self.min_quantity
354    }
355
356    fn max_price(&self) -> Option<Price> {
357        self.max_price
358    }
359
360    fn min_price(&self) -> Option<Price> {
361        self.min_price
362    }
363
364    fn ts_event(&self) -> UnixNanos {
365        self.ts_event
366    }
367
368    fn ts_init(&self) -> UnixNanos {
369        self.ts_init
370    }
371
372    fn margin_init(&self) -> Decimal {
373        self.margin_init
374    }
375
376    fn margin_maint(&self) -> Decimal {
377        self.margin_maint
378    }
379
380    fn taker_fee(&self) -> Decimal {
381        self.taker_fee
382    }
383
384    fn maker_fee(&self) -> Decimal {
385        self.maker_fee
386    }
387
388    fn option_kind(&self) -> Option<OptionKind> {
389        None
390    }
391
392    fn exchange(&self) -> Option<Ustr> {
393        None
394    }
395
396    fn strike_price(&self) -> Option<Price> {
397        None
398    }
399
400    fn activation_ns(&self) -> Option<UnixNanos> {
401        None
402    }
403
404    fn expiration_ns(&self) -> Option<UnixNanos> {
405        None
406    }
407
408    fn max_notional(&self) -> Option<Money> {
409        self.max_notional
410    }
411
412    fn min_notional(&self) -> Option<Money> {
413        self.min_notional
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use rstest::rstest;
420    use rust_decimal_macros::dec;
421    use ustr::Ustr;
422
423    use crate::{
424        enums::{AssetClass, InstrumentClass},
425        identifiers::{InstrumentId, Symbol},
426        instruments::{Instrument, TokenizedAsset, stubs::*},
427        types::{Currency, Money, Price, Quantity},
428    };
429
430    #[rstest]
431    fn test_trait_accessors(tokenized_asset_aaplx: TokenizedAsset) {
432        assert_eq!(
433            tokenized_asset_aaplx.id(),
434            InstrumentId::from("AAPLx/USD.KRAKEN")
435        );
436        assert_eq!(tokenized_asset_aaplx.asset_class(), AssetClass::Equity);
437        assert_eq!(
438            tokenized_asset_aaplx.instrument_class(),
439            InstrumentClass::Spot
440        );
441        assert_eq!(tokenized_asset_aaplx.quote_currency(), Currency::USD());
442        assert!(!tokenized_asset_aaplx.is_inverse());
443        assert_eq!(tokenized_asset_aaplx.price_precision(), 2);
444        assert_eq!(tokenized_asset_aaplx.size_precision(), 4);
445    }
446
447    #[rstest]
448    fn test_new_checked_price_precision_mismatch() {
449        let result = TokenizedAsset::new_checked(
450            InstrumentId::from("TEST.KRAKEN"),
451            Symbol::from("TEST"),
452            AssetClass::Equity,
453            Currency::BTC(),
454            Currency::USD(),
455            None,
456            4, // mismatch
457            4,
458            Price::from("0.01"),
459            Quantity::from("0.0001"),
460            None,
461            None,
462            None,
463            None,
464            None,
465            None,
466            None,
467            None,
468            None,
469            None,
470            None,
471            None,
472            None,
473            None,
474            0.into(),
475            0.into(),
476        );
477        assert!(result.is_err());
478    }
479
480    #[rstest]
481    fn test_new_checked_non_ascii_isin() {
482        let result = TokenizedAsset::new_checked(
483            InstrumentId::from("TEST.KRAKEN"),
484            Symbol::from("TEST"),
485            AssetClass::Equity,
486            Currency::BTC(),
487            Currency::USD(),
488            Some(ustr::Ustr::from("US\u{00E9}378331005")),
489            2,
490            4,
491            Price::from("0.01"),
492            Quantity::from("0.0001"),
493            None,
494            None,
495            None,
496            None,
497            None,
498            None,
499            None,
500            None,
501            None,
502            None,
503            None,
504            None,
505            None,
506            None,
507            0.into(),
508            0.into(),
509        );
510        assert!(result.is_err());
511        assert!(result.unwrap_err().to_string().contains("non-ASCII"));
512    }
513
514    #[rstest]
515    #[case::zero_multiplier(Some(Quantity::from("0")), None)]
516    #[case::zero_lot_size(None, Some(Quantity::from("0")))]
517    fn test_new_checked_rejects_non_positive_sizing(
518        #[case] multiplier: Option<Quantity>,
519        #[case] lot_size: Option<Quantity>,
520    ) {
521        let result = TokenizedAsset::new_checked(
522            InstrumentId::from("TEST.KRAKEN"),
523            Symbol::from("TEST"),
524            AssetClass::Equity,
525            Currency::BTC(),
526            Currency::USD(),
527            None,
528            2,
529            4,
530            Price::from("0.01"),
531            Quantity::from("0.0001"),
532            multiplier,
533            lot_size,
534            None,
535            None,
536            None,
537            None,
538            None,
539            None,
540            None,
541            None,
542            None,
543            None,
544            None,
545            None,
546            0.into(),
547            0.into(),
548        );
549        let error = result.unwrap_err();
550        assert!(error.to_string().contains("not positive"), "{error}");
551    }
552
553    #[rstest]
554    fn test_serialization_roundtrip(tokenized_asset_aaplx: TokenizedAsset) {
555        let json = serde_json::to_string(&tokenized_asset_aaplx).unwrap();
556        let deserialized: TokenizedAsset = serde_json::from_str(&json).unwrap();
557        assert_eq!(json, serde_json::to_string(&deserialized).unwrap());
558    }
559
560    #[rstest]
561    fn test_builder_matches_new_checked() {
562        let positional = TokenizedAsset::new_checked(
563            InstrumentId::from("AAPLx/USD.KRAKEN"),
564            Symbol::from("AAPLxUSD"),
565            AssetClass::Equity,
566            Currency::BTC(),
567            Currency::USD(),
568            Some(Ustr::from("US0378331005")),
569            2,
570            4,
571            Price::from("0.01"),
572            Quantity::from("0.0001"),
573            Some(Quantity::from("10")),
574            Some(Quantity::from("5")),
575            Some(Quantity::from("100")),
576            Some(Quantity::from("0.0001")),
577            Some(Money::new(1000.0, Currency::USD())),
578            Some(Money::new(10.0, Currency::USD())),
579            Some(Price::from("999.99")),
580            Some(Price::from("0.01")),
581            Some(dec!(0.01)),
582            Some(dec!(0.02)),
583            Some(dec!(0.0002)),
584            Some(dec!(0.0004)),
585            None,
586            None,
587            1.into(),
588            2.into(),
589        )
590        .unwrap();
591
592        let built = TokenizedAsset::builder()
593            .instrument_id(InstrumentId::from("AAPLx/USD.KRAKEN"))
594            .raw_symbol(Symbol::from("AAPLxUSD"))
595            .asset_class(AssetClass::Equity)
596            .base_currency(Currency::BTC())
597            .quote_currency(Currency::USD())
598            .isin(Ustr::from("US0378331005"))
599            .price_precision(2)
600            .size_precision(4)
601            .price_increment(Price::from("0.01"))
602            .size_increment(Quantity::from("0.0001"))
603            .multiplier(Quantity::from("10"))
604            .lot_size(Quantity::from("5"))
605            .max_quantity(Quantity::from("100"))
606            .min_quantity(Quantity::from("0.0001"))
607            .max_notional(Money::new(1000.0, Currency::USD()))
608            .min_notional(Money::new(10.0, Currency::USD()))
609            .max_price(Price::from("999.99"))
610            .min_price(Price::from("0.01"))
611            .margin_init(dec!(0.01))
612            .margin_maint(dec!(0.02))
613            .maker_fee(dec!(0.0002))
614            .taker_fee(dec!(0.0004))
615            .ts_event(1.into())
616            .ts_init(2.into())
617            .build()
618            .unwrap();
619
620        assert_eq!(
621            serde_json::to_value(&positional).unwrap(),
622            serde_json::to_value(&built).unwrap(),
623        );
624    }
625}