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.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 #[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, 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}