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