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