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::{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)]
40#[derive(Clone, Debug, Serialize, Deserialize)]
41#[cfg_attr(
42 feature = "python",
43 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", from_py_object)
44)]
45#[cfg_attr(
46 feature = "python",
47 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
48)]
49pub struct Commodity {
50 pub id: InstrumentId,
52 pub raw_symbol: Symbol,
54 pub asset_class: AssetClass,
56 pub quote_currency: Currency,
58 pub price_precision: u8,
60 pub size_precision: u8,
62 pub price_increment: Price,
64 pub size_increment: Quantity,
66 pub margin_init: Decimal,
68 pub margin_maint: Decimal,
70 pub maker_fee: Decimal,
72 pub taker_fee: Decimal,
74 pub lot_size: Option<Quantity>,
76 pub max_quantity: Option<Quantity>,
78 pub min_quantity: Option<Quantity>,
80 pub max_notional: Option<Money>,
82 pub min_notional: Option<Money>,
84 pub max_price: Option<Price>,
86 pub min_price: Option<Price>,
88 pub tick_scheme: Option<Ustr>,
90 pub info: Option<Params>,
92 pub ts_event: UnixNanos,
94 pub ts_init: UnixNanos,
96}
97
98#[bon::bon]
99impl Commodity {
100 #[expect(clippy::too_many_arguments)]
110 pub fn new_checked(
111 instrument_id: InstrumentId,
112 raw_symbol: Symbol,
113 asset_class: AssetClass,
114 quote_currency: Currency,
115 price_precision: u8,
116 size_precision: u8,
117 price_increment: Price,
118 size_increment: 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 maker_fee: Option<Decimal>,
129 taker_fee: Option<Decimal>,
130 tick_scheme: Option<Ustr>,
131 info: Option<Params>,
132 ts_event: UnixNanos,
133 ts_init: UnixNanos,
134 ) -> CorrectnessResult<Self> {
135 check_equal_u8(
136 price_precision,
137 price_increment.precision,
138 stringify!(price_precision),
139 stringify!(price_increment.precision),
140 )?;
141 check_equal_u8(
142 size_precision,
143 size_increment.precision,
144 stringify!(size_precision),
145 stringify!(size_increment.precision),
146 )?;
147 check_positive_price(price_increment, stringify!(price_increment))?;
148 check_positive_quantity(size_increment, stringify!(size_increment))?;
149 check_tick_scheme(tick_scheme)?;
150
151 if let Some(lot_size) = lot_size {
152 check_positive_quantity(lot_size, stringify!(lot_size))?;
153 }
154
155 Ok(Self {
156 id: instrument_id,
157 raw_symbol,
158 asset_class,
159 quote_currency,
160 price_precision,
161 size_precision,
162 price_increment,
163 size_increment,
164 lot_size,
165 max_quantity,
166 min_quantity,
167 max_notional,
168 min_notional,
169 max_price,
170 min_price,
171 margin_init: margin_init.unwrap_or_default(),
172 margin_maint: margin_maint.unwrap_or_default(),
173 maker_fee: maker_fee.unwrap_or_default(),
174 taker_fee: taker_fee.unwrap_or_default(),
175 tick_scheme,
176 info,
177 ts_event,
178 ts_init,
179 })
180 }
181
182 #[expect(clippy::too_many_arguments)]
188 #[must_use]
189 pub fn new(
190 instrument_id: InstrumentId,
191 raw_symbol: Symbol,
192 asset_class: AssetClass,
193 quote_currency: Currency,
194 price_precision: u8,
195 size_precision: u8,
196 price_increment: Price,
197 size_increment: Quantity,
198 lot_size: Option<Quantity>,
199 max_quantity: Option<Quantity>,
200 min_quantity: Option<Quantity>,
201 max_notional: Option<Money>,
202 min_notional: Option<Money>,
203 max_price: Option<Price>,
204 min_price: Option<Price>,
205 margin_init: Option<Decimal>,
206 margin_maint: Option<Decimal>,
207 maker_fee: Option<Decimal>,
208 taker_fee: Option<Decimal>,
209 tick_scheme: Option<Ustr>,
210 info: Option<Params>,
211 ts_event: UnixNanos,
212 ts_init: UnixNanos,
213 ) -> Self {
214 Self::new_checked(
215 instrument_id,
216 raw_symbol,
217 asset_class,
218 quote_currency,
219 price_precision,
220 size_precision,
221 price_increment,
222 size_increment,
223 lot_size,
224 max_quantity,
225 min_quantity,
226 max_notional,
227 min_notional,
228 max_price,
229 min_price,
230 margin_init,
231 margin_maint,
232 maker_fee,
233 taker_fee,
234 tick_scheme,
235 info,
236 ts_event,
237 ts_init,
238 )
239 .expect_display(FAILED)
240 }
241
242 #[builder(start_fn = builder, finish_fn = build)]
252 pub fn build_checked(
253 instrument_id: InstrumentId,
254 raw_symbol: Symbol,
255 asset_class: AssetClass,
256 quote_currency: Currency,
257 price_precision: u8,
258 size_precision: u8,
259 price_increment: Price,
260 size_increment: Quantity,
261 lot_size: Option<Quantity>,
262 max_quantity: Option<Quantity>,
263 min_quantity: Option<Quantity>,
264 max_notional: Option<Money>,
265 min_notional: Option<Money>,
266 max_price: Option<Price>,
267 min_price: Option<Price>,
268 margin_init: Option<Decimal>,
269 margin_maint: Option<Decimal>,
270 maker_fee: Option<Decimal>,
271 taker_fee: Option<Decimal>,
272 tick_scheme: Option<Ustr>,
273 info: Option<Params>,
274 ts_event: UnixNanos,
275 ts_init: UnixNanos,
276 ) -> CorrectnessResult<Self> {
277 Self::new_checked(
278 instrument_id,
279 raw_symbol,
280 asset_class,
281 quote_currency,
282 price_precision,
283 size_precision,
284 price_increment,
285 size_increment,
286 lot_size,
287 max_quantity,
288 min_quantity,
289 max_notional,
290 min_notional,
291 max_price,
292 min_price,
293 margin_init,
294 margin_maint,
295 maker_fee,
296 taker_fee,
297 tick_scheme,
298 info,
299 ts_event,
300 ts_init,
301 )
302 }
303}
304
305impl PartialEq<Self> for Commodity {
306 fn eq(&self, other: &Self) -> bool {
307 self.id == other.id
308 }
309}
310
311impl Eq for Commodity {}
312
313impl Hash for Commodity {
314 fn hash<H: Hasher>(&self, state: &mut H) {
315 self.id.hash(state);
316 }
317}
318
319impl Instrument for Commodity {
320 fn tick_scheme(&self) -> Option<Ustr> {
321 self.tick_scheme
322 }
323 fn into_any(self) -> InstrumentAny {
324 InstrumentAny::Commodity(self)
325 }
326
327 fn id(&self) -> InstrumentId {
328 self.id
329 }
330
331 fn raw_symbol(&self) -> Symbol {
332 self.raw_symbol
333 }
334
335 fn asset_class(&self) -> AssetClass {
336 self.asset_class
337 }
338
339 fn instrument_class(&self) -> InstrumentClass {
340 InstrumentClass::Spot
341 }
342
343 fn allows_negative_price(&self) -> bool {
344 true
346 }
347
348 fn underlying(&self) -> Option<Ustr> {
349 None
350 }
351
352 fn base_currency(&self) -> Option<Currency> {
353 None
354 }
355
356 fn quote_currency(&self) -> Currency {
357 self.quote_currency
358 }
359
360 fn settlement_currency(&self) -> Currency {
361 self.quote_currency
362 }
363
364 fn isin(&self) -> Option<Ustr> {
365 None
366 }
367
368 fn option_kind(&self) -> Option<OptionKind> {
369 None
370 }
371
372 fn exchange(&self) -> Option<Ustr> {
373 None
374 }
375
376 fn strike_price(&self) -> Option<Price> {
377 None
378 }
379
380 fn activation_ns(&self) -> Option<UnixNanos> {
381 None
382 }
383
384 fn expiration_ns(&self) -> Option<UnixNanos> {
385 None
386 }
387
388 fn is_inverse(&self) -> bool {
389 false
390 }
391
392 fn price_precision(&self) -> u8 {
393 self.price_precision
394 }
395
396 fn size_precision(&self) -> u8 {
397 self.size_precision
398 }
399
400 fn price_increment(&self) -> Price {
401 self.price_increment
402 }
403
404 fn size_increment(&self) -> Quantity {
405 self.size_increment
406 }
407
408 fn multiplier(&self) -> Quantity {
409 Quantity::from(1)
410 }
411
412 fn lot_size(&self) -> Option<Quantity> {
413 self.lot_size
414 }
415
416 fn max_quantity(&self) -> Option<Quantity> {
417 self.max_quantity
418 }
419
420 fn min_quantity(&self) -> Option<Quantity> {
421 self.min_quantity
422 }
423
424 fn max_notional(&self) -> Option<Money> {
425 self.max_notional
426 }
427
428 fn min_notional(&self) -> Option<Money> {
429 self.min_notional
430 }
431
432 fn max_price(&self) -> Option<Price> {
433 self.max_price
434 }
435
436 fn min_price(&self) -> Option<Price> {
437 self.min_price
438 }
439
440 fn margin_init(&self) -> Decimal {
441 self.margin_init
442 }
443
444 fn margin_maint(&self) -> Decimal {
445 self.margin_maint
446 }
447
448 fn maker_fee(&self) -> Decimal {
449 self.maker_fee
450 }
451
452 fn taker_fee(&self) -> Decimal {
453 self.taker_fee
454 }
455
456 fn ts_event(&self) -> UnixNanos {
457 self.ts_event
458 }
459
460 fn ts_init(&self) -> UnixNanos {
461 self.ts_init
462 }
463}
464
465#[cfg(test)]
466mod tests {
467 use rstest::rstest;
468 use rust_decimal_macros::dec;
469
470 use crate::{
471 enums::{AssetClass, InstrumentClass},
472 identifiers::{InstrumentId, Symbol},
473 instruments::{Commodity, Instrument, stubs::*},
474 types::{Currency, Money, Price, Quantity},
475 };
476
477 #[rstest]
478 fn test_trait_accessors(commodity_gold: Commodity) {
479 assert_eq!(commodity_gold.id(), InstrumentId::from("GOLD.COMEX"));
480 assert_eq!(commodity_gold.asset_class(), AssetClass::Commodity);
481 assert_eq!(commodity_gold.instrument_class(), InstrumentClass::Spot);
482 assert_eq!(commodity_gold.quote_currency(), Currency::USD());
483 assert!(!commodity_gold.is_inverse());
484 assert_eq!(commodity_gold.price_precision(), 2);
485 assert_eq!(commodity_gold.size_precision(), 0);
486 assert!(commodity_gold.allows_negative_price());
487 }
488
489 #[rstest]
490 fn test_new_checked_price_precision_mismatch() {
491 let result = Commodity::new_checked(
492 InstrumentId::from("TEST.COMEX"),
493 Symbol::from("TEST"),
494 AssetClass::Commodity,
495 Currency::USD(),
496 4, 0,
498 Price::from("0.01"),
499 Quantity::from("1"),
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 None,
513 0.into(),
514 0.into(),
515 );
516 assert!(result.is_err());
517 }
518
519 #[rstest]
520 fn test_new_checked_rejects_non_positive_lot_size() {
521 let result = Commodity::new_checked(
522 InstrumentId::from("TEST.COMEX"),
523 Symbol::from("TEST"),
524 AssetClass::Commodity,
525 Currency::USD(),
526 2,
527 0,
528 Price::from("0.01"),
529 Quantity::from("1"),
530 Some(Quantity::from("0")),
531 None,
532 None,
533 None,
534 None,
535 None,
536 None,
537 None,
538 None,
539 None,
540 None,
541 None,
542 None,
543 0.into(),
544 0.into(),
545 );
546 let error = result.unwrap_err();
547 assert!(error.to_string().contains("not positive"), "{error}");
548 }
549
550 #[rstest]
551 fn test_serialization_roundtrip(commodity_gold: Commodity) {
552 let json = serde_json::to_string(&commodity_gold).unwrap();
553 let deserialized: Commodity = serde_json::from_str(&json).unwrap();
554 assert_eq!(commodity_gold, deserialized);
555 }
556
557 #[rstest]
558 fn test_builder_matches_new_checked() {
559 let positional = Commodity::new_checked(
560 InstrumentId::from("GOLD.COMEX"),
561 Symbol::from("GOLD"),
562 AssetClass::Commodity,
563 Currency::USD(),
564 2,
565 0,
566 Price::from("0.01"),
567 Quantity::from("1"),
568 Some(Quantity::from("1")),
569 Some(Quantity::from("10000")),
570 Some(Quantity::from("1")),
571 Some(Money::new(5_000_000.0, Currency::USD())),
572 Some(Money::new(10.0, Currency::USD())),
573 Some(Price::from("100000.00")),
574 Some(Price::from("0.01")),
575 Some(dec!(0.01)),
576 Some(dec!(0.02)),
577 Some(dec!(0.0002)),
578 Some(dec!(0.0004)),
579 None,
580 None,
581 1.into(),
582 2.into(),
583 )
584 .unwrap();
585
586 let built = Commodity::builder()
587 .instrument_id(InstrumentId::from("GOLD.COMEX"))
588 .raw_symbol(Symbol::from("GOLD"))
589 .asset_class(AssetClass::Commodity)
590 .quote_currency(Currency::USD())
591 .price_precision(2)
592 .size_precision(0)
593 .price_increment(Price::from("0.01"))
594 .size_increment(Quantity::from("1"))
595 .lot_size(Quantity::from("1"))
596 .max_quantity(Quantity::from("10000"))
597 .min_quantity(Quantity::from("1"))
598 .max_notional(Money::new(5_000_000.0, Currency::USD()))
599 .min_notional(Money::new(10.0, Currency::USD()))
600 .max_price(Price::from("100000.00"))
601 .min_price(Price::from("0.01"))
602 .margin_init(dec!(0.01))
603 .margin_maint(dec!(0.02))
604 .maker_fee(dec!(0.0002))
605 .taker_fee(dec!(0.0004))
606 .ts_event(1.into())
607 .ts_init(2.into())
608 .build()
609 .unwrap();
610
611 assert_eq!(
612 serde_json::to_value(&positional).unwrap(),
613 serde_json::to_value(&built).unwrap(),
614 );
615 }
616}