1use std::hash::{Hash, Hasher};
17
18use nautilus_core::{
19 Params, UnixNanos,
20 correctness::{CorrectnessResult, CorrectnessResultExt, FAILED, check_equal_u8},
21};
22use rust_decimal::Decimal;
23use serde::{Deserialize, Serialize};
24use ustr::Ustr;
25
26use super::any::InstrumentAny;
27use crate::{
28 enums::{AssetClass, InstrumentClass, OptionKind},
29 identifiers::{InstrumentId, Symbol},
30 instruments::Instrument,
31 types::{
32 currency::Currency,
33 money::Money,
34 price::{Price, check_positive_price},
35 quantity::{Quantity, check_positive_quantity},
36 },
37};
38
39#[repr(C)]
41#[derive(Clone, Debug, Serialize, Deserialize)]
42#[cfg_attr(
43 feature = "python",
44 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", from_py_object)
45)]
46#[cfg_attr(
47 feature = "python",
48 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
49)]
50pub struct CryptoPerpetual {
51 pub id: InstrumentId,
53 pub raw_symbol: Symbol,
55 pub base_currency: Currency,
57 pub quote_currency: Currency,
59 pub settlement_currency: Currency,
61 pub is_inverse: bool,
63 pub price_precision: u8,
65 pub size_precision: u8,
67 pub price_increment: Price,
69 pub size_increment: Quantity,
71 pub multiplier: Quantity,
73 pub lot_size: Quantity,
75 pub margin_init: Decimal,
77 pub margin_maint: Decimal,
79 pub maker_fee: Decimal,
81 pub taker_fee: Decimal,
83 pub max_quantity: Option<Quantity>,
85 pub min_quantity: Option<Quantity>,
87 pub max_notional: Option<Money>,
89 pub min_notional: Option<Money>,
91 pub max_price: Option<Price>,
93 pub min_price: Option<Price>,
95 pub info: Option<Params>,
97 pub ts_event: UnixNanos,
99 pub ts_init: UnixNanos,
101}
102
103impl CryptoPerpetual {
104 #[expect(clippy::too_many_arguments)]
113 pub fn new_checked(
114 instrument_id: InstrumentId,
115 raw_symbol: Symbol,
116 base_currency: Currency,
117 quote_currency: Currency,
118 settlement_currency: Currency,
119 is_inverse: bool,
120 price_precision: u8,
121 size_precision: u8,
122 price_increment: Price,
123 size_increment: Quantity,
124 multiplier: Option<Quantity>,
125 lot_size: Option<Quantity>,
126 max_quantity: Option<Quantity>,
127 min_quantity: Option<Quantity>,
128 max_notional: Option<Money>,
129 min_notional: Option<Money>,
130 max_price: Option<Price>,
131 min_price: Option<Price>,
132 margin_init: Option<Decimal>,
133 margin_maint: Option<Decimal>,
134 maker_fee: Option<Decimal>,
135 taker_fee: Option<Decimal>,
136 info: Option<Params>,
137 ts_event: UnixNanos,
138 ts_init: UnixNanos,
139 ) -> CorrectnessResult<Self> {
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
155 Ok(Self {
156 id: instrument_id,
157 raw_symbol,
158 base_currency,
159 quote_currency,
160 settlement_currency,
161 is_inverse,
162 price_precision,
163 size_precision,
164 price_increment,
165 size_increment,
166 multiplier: multiplier.unwrap_or(Quantity::from(1)),
167 lot_size: lot_size.unwrap_or(Quantity::from(1)),
168 margin_init: margin_init.unwrap_or_default(),
169 margin_maint: margin_maint.unwrap_or_default(),
170 maker_fee: maker_fee.unwrap_or_default(),
171 taker_fee: taker_fee.unwrap_or_default(),
172 max_quantity,
173 min_quantity,
174 max_notional,
175 min_notional,
176 max_price,
177 min_price,
178 info,
179 ts_event,
180 ts_init,
181 })
182 }
183
184 #[expect(clippy::too_many_arguments)]
190 #[must_use]
191 pub fn new(
192 instrument_id: InstrumentId,
193 raw_symbol: Symbol,
194 base_currency: Currency,
195 quote_currency: Currency,
196 settlement_currency: Currency,
197 is_inverse: bool,
198 price_precision: u8,
199 size_precision: u8,
200 price_increment: Price,
201 size_increment: Quantity,
202 multiplier: Option<Quantity>,
203 lot_size: Option<Quantity>,
204 max_quantity: Option<Quantity>,
205 min_quantity: Option<Quantity>,
206 max_notional: Option<Money>,
207 min_notional: Option<Money>,
208 max_price: Option<Price>,
209 min_price: Option<Price>,
210 margin_init: Option<Decimal>,
211 margin_maint: Option<Decimal>,
212 maker_fee: Option<Decimal>,
213 taker_fee: Option<Decimal>,
214 info: Option<Params>,
215 ts_event: UnixNanos,
216 ts_init: UnixNanos,
217 ) -> Self {
218 Self::new_checked(
219 instrument_id,
220 raw_symbol,
221 base_currency,
222 quote_currency,
223 settlement_currency,
224 is_inverse,
225 price_precision,
226 size_precision,
227 price_increment,
228 size_increment,
229 multiplier,
230 lot_size,
231 max_quantity,
232 min_quantity,
233 max_notional,
234 min_notional,
235 max_price,
236 min_price,
237 margin_init,
238 margin_maint,
239 maker_fee,
240 taker_fee,
241 info,
242 ts_event,
243 ts_init,
244 )
245 .expect_display(FAILED)
246 }
247}
248
249impl PartialEq<Self> for CryptoPerpetual {
250 fn eq(&self, other: &Self) -> bool {
251 self.id == other.id
252 }
253}
254
255impl Eq for CryptoPerpetual {}
256
257impl Hash for CryptoPerpetual {
258 fn hash<H: Hasher>(&self, state: &mut H) {
259 self.id.hash(state);
260 }
261}
262
263impl Instrument for CryptoPerpetual {
264 fn into_any(self) -> InstrumentAny {
265 InstrumentAny::CryptoPerpetual(self)
266 }
267
268 fn id(&self) -> InstrumentId {
269 self.id
270 }
271
272 fn raw_symbol(&self) -> Symbol {
273 self.raw_symbol
274 }
275
276 fn asset_class(&self) -> AssetClass {
277 AssetClass::Cryptocurrency
278 }
279
280 fn instrument_class(&self) -> InstrumentClass {
281 InstrumentClass::Swap
282 }
283 fn underlying(&self) -> Option<Ustr> {
284 None
285 }
286
287 fn base_currency(&self) -> Option<Currency> {
288 Some(self.base_currency)
289 }
290
291 fn quote_currency(&self) -> Currency {
292 self.quote_currency
293 }
294
295 fn settlement_currency(&self) -> Currency {
296 self.settlement_currency
297 }
298
299 fn isin(&self) -> Option<Ustr> {
300 None
301 }
302 fn option_kind(&self) -> Option<OptionKind> {
303 None
304 }
305 fn exchange(&self) -> Option<Ustr> {
306 None
307 }
308 fn strike_price(&self) -> Option<Price> {
309 None
310 }
311
312 fn activation_ns(&self) -> Option<UnixNanos> {
313 None
314 }
315
316 fn expiration_ns(&self) -> Option<UnixNanos> {
317 None
318 }
319
320 fn is_inverse(&self) -> bool {
321 self.is_inverse
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 Some(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_notional(&self) -> Option<Money> {
357 self.max_notional
358 }
359
360 fn min_notional(&self) -> Option<Money> {
361 self.min_notional
362 }
363
364 fn max_price(&self) -> Option<Price> {
365 self.max_price
366 }
367
368 fn min_price(&self) -> Option<Price> {
369 self.min_price
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 maker_fee(&self) -> Decimal {
381 self.maker_fee
382 }
383
384 fn taker_fee(&self) -> Decimal {
385 self.taker_fee
386 }
387
388 fn ts_event(&self) -> UnixNanos {
389 self.ts_event
390 }
391
392 fn ts_init(&self) -> UnixNanos {
393 self.ts_init
394 }
395}
396
397#[cfg(test)]
398mod tests {
399 use rstest::rstest;
400
401 use crate::{
402 enums::{AssetClass, InstrumentClass},
403 identifiers::{InstrumentId, Symbol},
404 instruments::{CryptoPerpetual, Instrument, stubs::*},
405 types::{Currency, Money, Price, Quantity},
406 };
407
408 #[rstest]
409 fn test_trait_accessors(crypto_perpetual_ethusdt: CryptoPerpetual) {
410 assert_eq!(
411 crypto_perpetual_ethusdt.id(),
412 InstrumentId::from("ETHUSDT-PERP.BINANCE"),
413 );
414 assert_eq!(
415 crypto_perpetual_ethusdt.asset_class(),
416 AssetClass::Cryptocurrency
417 );
418 assert_eq!(
419 crypto_perpetual_ethusdt.instrument_class(),
420 InstrumentClass::Swap
421 );
422 assert_eq!(
423 crypto_perpetual_ethusdt.base_currency(),
424 Some(Currency::ETH())
425 );
426 assert_eq!(crypto_perpetual_ethusdt.quote_currency(), Currency::USDT());
427 assert_eq!(
428 crypto_perpetual_ethusdt.settlement_currency(),
429 Currency::USDT()
430 );
431 assert!(!crypto_perpetual_ethusdt.is_inverse());
432 assert_eq!(crypto_perpetual_ethusdt.price_precision(), 2);
433 assert_eq!(crypto_perpetual_ethusdt.size_precision(), 3);
434 assert_eq!(
435 crypto_perpetual_ethusdt.price_increment(),
436 Price::from("0.01")
437 );
438 assert_eq!(
439 crypto_perpetual_ethusdt.size_increment(),
440 Quantity::from("0.001")
441 );
442 assert_eq!(crypto_perpetual_ethusdt.multiplier(), Quantity::from("1"));
443 assert_eq!(
444 crypto_perpetual_ethusdt.lot_size(),
445 Some(Quantity::from("1"))
446 );
447 assert_eq!(
448 crypto_perpetual_ethusdt.max_quantity(),
449 Some(Quantity::from("10000.0")),
450 );
451 assert_eq!(
452 crypto_perpetual_ethusdt.min_quantity(),
453 Some(Quantity::from("0.001")),
454 );
455 assert_eq!(
456 crypto_perpetual_ethusdt.min_notional(),
457 Some(Money::new(10.00, Currency::USDT())),
458 );
459 assert_eq!(crypto_perpetual_ethusdt.underlying(), None);
460 assert_eq!(crypto_perpetual_ethusdt.option_kind(), None);
461 assert_eq!(crypto_perpetual_ethusdt.strike_price(), None);
462 assert_eq!(crypto_perpetual_ethusdt.activation_ns(), None);
463 assert_eq!(crypto_perpetual_ethusdt.expiration_ns(), None);
464 }
465
466 #[rstest]
467 fn test_inverse_perp_accessors(xbtusd_bitmex: CryptoPerpetual) {
468 assert!(xbtusd_bitmex.is_inverse());
469 assert_eq!(xbtusd_bitmex.base_currency(), Some(Currency::BTC()));
470 assert_eq!(xbtusd_bitmex.quote_currency(), Currency::USD());
471 assert_eq!(xbtusd_bitmex.settlement_currency(), Currency::BTC());
472 assert_eq!(xbtusd_bitmex.cost_currency(), Currency::BTC());
473 }
474
475 #[rstest]
476 fn test_new_checked_price_precision_mismatch() {
477 let result = CryptoPerpetual::new_checked(
478 InstrumentId::from("TEST.EXCHANGE"),
479 Symbol::from("TEST"),
480 Currency::BTC(),
481 Currency::USDT(),
482 Currency::USDT(),
483 false,
484 3, 0,
486 Price::from("0.01"),
487 Quantity::from("1"),
488 None,
489 None,
490 None,
491 None,
492 None,
493 None,
494 None,
495 None,
496 None,
497 None,
498 None,
499 None,
500 None,
501 0.into(),
502 0.into(),
503 );
504 assert!(result.is_err());
505 }
506
507 #[rstest]
508 fn test_new_checked_size_precision_mismatch() {
509 let result = CryptoPerpetual::new_checked(
510 InstrumentId::from("TEST.EXCHANGE"),
511 Symbol::from("TEST"),
512 Currency::BTC(),
513 Currency::USDT(),
514 Currency::USDT(),
515 false,
516 2,
517 5, Price::from("0.01"),
519 Quantity::from("1"),
520 None,
521 None,
522 None,
523 None,
524 None,
525 None,
526 None,
527 None,
528 None,
529 None,
530 None,
531 None,
532 None,
533 0.into(),
534 0.into(),
535 );
536 assert!(result.is_err());
537 }
538
539 #[rstest]
540 fn test_serialization_roundtrip(crypto_perpetual_ethusdt: CryptoPerpetual) {
541 let json = serde_json::to_string(&crypto_perpetual_ethusdt).unwrap();
542 let deserialized: CryptoPerpetual = serde_json::from_str(&json).unwrap();
543 assert_eq!(crypto_perpetual_ethusdt, deserialized);
544 }
545}