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