1use 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#[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 pub id: InstrumentId,
56 pub raw_symbol: Symbol,
58 pub asset_class: AssetClass,
60 pub base_currency: Currency,
62 pub quote_currency: Currency,
64 pub isin: Option<Ustr>,
66 pub price_precision: u8,
68 pub size_precision: u8,
70 pub price_increment: Price,
72 pub size_increment: Quantity,
74 pub multiplier: Quantity,
76 pub lot_size: Option<Quantity>,
78 pub margin_init: Decimal,
80 pub margin_maint: Decimal,
82 pub maker_fee: Decimal,
84 pub taker_fee: Decimal,
86 pub max_quantity: Option<Quantity>,
88 pub min_quantity: Option<Quantity>,
90 pub max_notional: Option<Money>,
92 pub min_notional: Option<Money>,
94 pub max_price: Option<Price>,
96 pub min_price: Option<Price>,
98 pub tick_scheme: Option<Ustr>,
100 pub info: Option<Params>,
102 pub ts_event: UnixNanos,
104 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 #[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, 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}