1use std::hash::{Hash, Hasher};
17
18use nautilus_core::{
19 Params, UnixNanos,
20 correctness::{
21 CorrectnessError, CorrectnessResult, CorrectnessResultExt, FAILED, check_equal_u8,
22 },
23};
24use rust_decimal::Decimal;
25use serde::{Deserialize, Serialize};
26use ustr::Ustr;
27
28use super::{Instrument, any::InstrumentAny};
29use crate::{
30 enums::{AssetClass, InstrumentClass, OptionKind},
31 identifiers::{InstrumentId, Symbol},
32 types::{
33 currency::Currency,
34 money::Money,
35 price::{Price, check_positive_price},
36 quantity::{Quantity, check_positive_quantity},
37 },
38};
39
40#[repr(C)]
45#[derive(Clone, Debug, Serialize, Deserialize)]
46#[cfg_attr(
47 feature = "python",
48 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", from_py_object)
49)]
50#[cfg_attr(
51 feature = "python",
52 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
53)]
54pub struct PerpetualContract {
55 pub id: InstrumentId,
57 pub raw_symbol: Symbol,
59 pub underlying: Ustr,
61 pub asset_class: AssetClass,
63 pub base_currency: Option<Currency>,
65 pub quote_currency: Currency,
67 pub settlement_currency: Currency,
69 pub is_inverse: bool,
71 pub price_precision: u8,
73 pub size_precision: u8,
75 pub price_increment: Price,
77 pub size_increment: Quantity,
79 pub multiplier: Quantity,
81 pub lot_size: Quantity,
83 pub margin_init: Decimal,
85 pub margin_maint: Decimal,
87 pub maker_fee: Decimal,
89 pub taker_fee: Decimal,
91 pub max_quantity: Option<Quantity>,
93 pub min_quantity: Option<Quantity>,
95 pub max_notional: Option<Money>,
97 pub min_notional: Option<Money>,
99 pub max_price: Option<Price>,
101 pub min_price: Option<Price>,
103 pub info: Option<Params>,
105 pub ts_event: UnixNanos,
107 pub ts_init: UnixNanos,
109}
110
111impl PerpetualContract {
112 #[expect(clippy::too_many_arguments)]
121 pub fn new_checked(
122 instrument_id: InstrumentId,
123 raw_symbol: Symbol,
124 underlying: Ustr,
125 asset_class: AssetClass,
126 base_currency: Option<Currency>,
127 quote_currency: Currency,
128 settlement_currency: Currency,
129 is_inverse: bool,
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 info: Option<Params>,
147 ts_event: UnixNanos,
148 ts_init: UnixNanos,
149 ) -> CorrectnessResult<Self> {
150 check_equal_u8(
151 price_precision,
152 price_increment.precision,
153 stringify!(price_precision),
154 stringify!(price_increment.precision),
155 )?;
156 check_equal_u8(
157 size_precision,
158 size_increment.precision,
159 stringify!(size_precision),
160 stringify!(size_increment.precision),
161 )?;
162 check_positive_price(price_increment, stringify!(price_increment))?;
163 check_positive_quantity(size_increment, stringify!(size_increment))?;
164
165 if is_inverse && base_currency.is_none() {
166 return Err(CorrectnessError::PredicateViolation {
167 message: "Inverse perpetual contract requires a `base_currency`".to_string(),
168 });
169 }
170
171 Ok(Self {
172 id: instrument_id,
173 raw_symbol,
174 underlying,
175 asset_class,
176 base_currency,
177 quote_currency,
178 settlement_currency,
179 is_inverse,
180 price_precision,
181 size_precision,
182 price_increment,
183 size_increment,
184 multiplier: multiplier.unwrap_or(Quantity::from(1)),
185 lot_size: lot_size.unwrap_or(Quantity::from(1)),
186 margin_init: margin_init.unwrap_or_default(),
187 margin_maint: margin_maint.unwrap_or_default(),
188 maker_fee: maker_fee.unwrap_or_default(),
189 taker_fee: taker_fee.unwrap_or_default(),
190 max_quantity,
191 min_quantity,
192 max_notional,
193 min_notional,
194 max_price,
195 min_price,
196 info,
197 ts_event,
198 ts_init,
199 })
200 }
201
202 #[expect(clippy::too_many_arguments)]
208 #[must_use]
209 pub fn new(
210 instrument_id: InstrumentId,
211 raw_symbol: Symbol,
212 underlying: Ustr,
213 asset_class: AssetClass,
214 base_currency: Option<Currency>,
215 quote_currency: Currency,
216 settlement_currency: Currency,
217 is_inverse: bool,
218 price_precision: u8,
219 size_precision: u8,
220 price_increment: Price,
221 size_increment: Quantity,
222 multiplier: Option<Quantity>,
223 lot_size: Option<Quantity>,
224 max_quantity: Option<Quantity>,
225 min_quantity: Option<Quantity>,
226 max_notional: Option<Money>,
227 min_notional: Option<Money>,
228 max_price: Option<Price>,
229 min_price: Option<Price>,
230 margin_init: Option<Decimal>,
231 margin_maint: Option<Decimal>,
232 maker_fee: Option<Decimal>,
233 taker_fee: Option<Decimal>,
234 info: Option<Params>,
235 ts_event: UnixNanos,
236 ts_init: UnixNanos,
237 ) -> Self {
238 Self::new_checked(
239 instrument_id,
240 raw_symbol,
241 underlying,
242 asset_class,
243 base_currency,
244 quote_currency,
245 settlement_currency,
246 is_inverse,
247 price_precision,
248 size_precision,
249 price_increment,
250 size_increment,
251 multiplier,
252 lot_size,
253 max_quantity,
254 min_quantity,
255 max_notional,
256 min_notional,
257 max_price,
258 min_price,
259 margin_init,
260 margin_maint,
261 maker_fee,
262 taker_fee,
263 info,
264 ts_event,
265 ts_init,
266 )
267 .expect_display(FAILED)
268 }
269}
270
271impl PartialEq<Self> for PerpetualContract {
272 fn eq(&self, other: &Self) -> bool {
273 self.id == other.id
274 }
275}
276
277impl Eq for PerpetualContract {}
278
279impl Hash for PerpetualContract {
280 fn hash<H: Hasher>(&self, state: &mut H) {
281 self.id.hash(state);
282 }
283}
284
285impl Instrument for PerpetualContract {
286 fn into_any(self) -> InstrumentAny {
287 InstrumentAny::PerpetualContract(self)
288 }
289
290 fn id(&self) -> InstrumentId {
291 self.id
292 }
293
294 fn raw_symbol(&self) -> Symbol {
295 self.raw_symbol
296 }
297
298 fn asset_class(&self) -> AssetClass {
299 self.asset_class
300 }
301
302 fn instrument_class(&self) -> InstrumentClass {
303 InstrumentClass::Swap
304 }
305
306 fn underlying(&self) -> Option<Ustr> {
307 Some(self.underlying)
308 }
309
310 fn base_currency(&self) -> Option<Currency> {
311 self.base_currency
312 }
313
314 fn quote_currency(&self) -> Currency {
315 self.quote_currency
316 }
317
318 fn settlement_currency(&self) -> Currency {
319 self.settlement_currency
320 }
321
322 fn isin(&self) -> Option<Ustr> {
323 None
324 }
325
326 fn option_kind(&self) -> Option<OptionKind> {
327 None
328 }
329
330 fn exchange(&self) -> Option<Ustr> {
331 None
332 }
333
334 fn strike_price(&self) -> Option<Price> {
335 None
336 }
337
338 fn activation_ns(&self) -> Option<UnixNanos> {
339 None
340 }
341
342 fn expiration_ns(&self) -> Option<UnixNanos> {
343 None
344 }
345
346 fn is_inverse(&self) -> bool {
347 self.is_inverse
348 }
349
350 fn price_precision(&self) -> u8 {
351 self.price_precision
352 }
353
354 fn size_precision(&self) -> u8 {
355 self.size_precision
356 }
357
358 fn price_increment(&self) -> Price {
359 self.price_increment
360 }
361
362 fn size_increment(&self) -> Quantity {
363 self.size_increment
364 }
365
366 fn multiplier(&self) -> Quantity {
367 self.multiplier
368 }
369
370 fn lot_size(&self) -> Option<Quantity> {
371 Some(self.lot_size)
372 }
373
374 fn max_quantity(&self) -> Option<Quantity> {
375 self.max_quantity
376 }
377
378 fn min_quantity(&self) -> Option<Quantity> {
379 self.min_quantity
380 }
381
382 fn max_notional(&self) -> Option<Money> {
383 self.max_notional
384 }
385
386 fn min_notional(&self) -> Option<Money> {
387 self.min_notional
388 }
389
390 fn max_price(&self) -> Option<Price> {
391 self.max_price
392 }
393
394 fn min_price(&self) -> Option<Price> {
395 self.min_price
396 }
397
398 fn margin_init(&self) -> Decimal {
399 self.margin_init
400 }
401
402 fn margin_maint(&self) -> Decimal {
403 self.margin_maint
404 }
405
406 fn maker_fee(&self) -> Decimal {
407 self.maker_fee
408 }
409
410 fn taker_fee(&self) -> Decimal {
411 self.taker_fee
412 }
413
414 fn ts_event(&self) -> UnixNanos {
415 self.ts_event
416 }
417
418 fn ts_init(&self) -> UnixNanos {
419 self.ts_init
420 }
421}
422
423#[cfg(test)]
424mod tests {
425 use rstest::rstest;
426 use ustr::Ustr;
427
428 use crate::{
429 enums::{AssetClass, InstrumentClass},
430 identifiers::{InstrumentId, Symbol},
431 instruments::{Instrument, PerpetualContract, stubs::*},
432 types::{Currency, Price, Quantity},
433 };
434
435 #[rstest]
436 fn test_trait_accessors(perpetual_contract_eurusd: PerpetualContract) {
437 assert_eq!(
438 perpetual_contract_eurusd.id(),
439 InstrumentId::from("EURUSD-PERP.AX"),
440 );
441 assert_eq!(perpetual_contract_eurusd.asset_class(), AssetClass::FX);
442 assert_eq!(
443 perpetual_contract_eurusd.instrument_class(),
444 InstrumentClass::Swap
445 );
446 assert_eq!(
447 perpetual_contract_eurusd.base_currency(),
448 Some(Currency::EUR())
449 );
450 assert_eq!(perpetual_contract_eurusd.quote_currency(), Currency::USD());
451 assert_eq!(
452 perpetual_contract_eurusd.settlement_currency(),
453 Currency::USD()
454 );
455 assert!(!perpetual_contract_eurusd.is_inverse());
456 assert_eq!(perpetual_contract_eurusd.price_precision(), 5);
457 assert_eq!(perpetual_contract_eurusd.size_precision(), 0);
458 assert_eq!(
459 perpetual_contract_eurusd.price_increment(),
460 Price::from("0.00001")
461 );
462 assert_eq!(
463 perpetual_contract_eurusd.size_increment(),
464 Quantity::from("1")
465 );
466 assert_eq!(
467 perpetual_contract_eurusd.underlying(),
468 Some(Ustr::from("EURUSD")),
469 );
470 }
471
472 #[rstest]
473 fn test_new_checked_inverse_without_base_currency() {
474 let result = PerpetualContract::new_checked(
475 InstrumentId::from("TEST.EXCHANGE"),
476 Symbol::from("TEST"),
477 Ustr::from("TEST"),
478 AssetClass::FX,
479 None, Currency::USD(),
481 Currency::USD(),
482 true, 5,
484 0,
485 Price::from("0.00001"),
486 Quantity::from("1"),
487 None,
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 0.into(),
501 0.into(),
502 );
503 assert!(result.is_err());
504 assert!(result.unwrap_err().to_string().contains("base_currency"),);
505 }
506
507 #[rstest]
508 fn test_new_checked_price_precision_mismatch() {
509 let result = PerpetualContract::new_checked(
510 InstrumentId::from("TEST.EXCHANGE"),
511 Symbol::from("TEST"),
512 Ustr::from("TEST"),
513 AssetClass::FX,
514 Some(Currency::EUR()),
515 Currency::USD(),
516 Currency::USD(),
517 false,
518 3, 0,
520 Price::from("0.00001"),
521 Quantity::from("1"),
522 None,
523 None,
524 None,
525 None,
526 None,
527 None,
528 None,
529 None,
530 None,
531 None,
532 None,
533 None,
534 None,
535 0.into(),
536 0.into(),
537 );
538 assert!(result.is_err());
539 }
540
541 #[rstest]
542 fn test_serialization_roundtrip(perpetual_contract_eurusd: PerpetualContract) {
543 let json = serde_json::to_string(&perpetual_contract_eurusd).unwrap();
544 let deserialized: PerpetualContract = serde_json::from_str(&json).unwrap();
545 assert_eq!(perpetual_contract_eurusd, deserialized);
546 }
547}