1use 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#[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 pub id: InstrumentId,
59 pub raw_symbol: Symbol,
61 pub asset_class: AssetClass,
63 pub base_currency: Currency,
65 pub quote_currency: Currency,
67 pub isin: Option<Ustr>,
69 pub price_precision: u8,
71 pub size_precision: u8,
73 pub price_increment: Price,
75 pub size_increment: Quantity,
77 pub multiplier: Quantity,
79 pub lot_size: Option<Quantity>,
81 pub margin_init: Decimal,
83 pub margin_maint: Decimal,
85 pub maker_fee: Decimal,
87 pub taker_fee: Decimal,
89 pub max_quantity: Option<Quantity>,
91 pub min_quantity: Option<Quantity>,
93 pub max_notional: Option<Money>,
95 pub min_notional: Option<Money>,
97 pub max_price: Option<Price>,
99 pub min_price: Option<Price>,
101 pub tick_scheme: Option<Ustr>,
103 pub info: Option<Params>,
105 pub ts_event: UnixNanos,
107 pub ts_init: UnixNanos,
109}
110
111#[bon::bon]
112impl TokenizedAsset {
113 #[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 #[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 #[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, 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}