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 tick_scheme(&self) -> Option<Ustr> {
251 self.tick_scheme
252 }
253 fn into_any(self) -> InstrumentAny {
254 InstrumentAny::Commodity(self)
255 }
256
257 fn id(&self) -> InstrumentId {
258 self.id
259 }
260
261 fn raw_symbol(&self) -> Symbol {
262 self.raw_symbol
263 }
264
265 fn asset_class(&self) -> AssetClass {
266 self.asset_class
267 }
268
269 fn instrument_class(&self) -> InstrumentClass {
270 InstrumentClass::Spot
271 }
272
273 fn allows_negative_price(&self) -> bool {
274 true
276 }
277
278 fn underlying(&self) -> Option<Ustr> {
279 None
280 }
281
282 fn base_currency(&self) -> Option<Currency> {
283 None
284 }
285
286 fn quote_currency(&self) -> Currency {
287 self.quote_currency
288 }
289
290 fn settlement_currency(&self) -> Currency {
291 self.quote_currency
292 }
293
294 fn isin(&self) -> Option<Ustr> {
295 None
296 }
297
298 fn option_kind(&self) -> Option<OptionKind> {
299 None
300 }
301
302 fn exchange(&self) -> Option<Ustr> {
303 None
304 }
305
306 fn strike_price(&self) -> Option<Price> {
307 None
308 }
309
310 fn activation_ns(&self) -> Option<UnixNanos> {
311 None
312 }
313
314 fn expiration_ns(&self) -> Option<UnixNanos> {
315 None
316 }
317
318 fn is_inverse(&self) -> bool {
319 false
320 }
321
322 fn price_precision(&self) -> u8 {
323 self.price_precision
324 }
325
326 fn size_precision(&self) -> u8 {
327 self.size_precision
328 }
329
330 fn price_increment(&self) -> Price {
331 self.price_increment
332 }
333
334 fn size_increment(&self) -> Quantity {
335 self.size_increment
336 }
337
338 fn multiplier(&self) -> Quantity {
339 Quantity::from(1)
340 }
341
342 fn lot_size(&self) -> Option<Quantity> {
343 self.lot_size
344 }
345
346 fn max_quantity(&self) -> Option<Quantity> {
347 self.max_quantity
348 }
349
350 fn min_quantity(&self) -> Option<Quantity> {
351 self.min_quantity
352 }
353
354 fn max_notional(&self) -> Option<Money> {
355 self.max_notional
356 }
357
358 fn min_notional(&self) -> Option<Money> {
359 self.min_notional
360 }
361
362 fn max_price(&self) -> Option<Price> {
363 self.max_price
364 }
365
366 fn min_price(&self) -> Option<Price> {
367 self.min_price
368 }
369
370 fn margin_init(&self) -> Decimal {
371 self.margin_init
372 }
373
374 fn margin_maint(&self) -> Decimal {
375 self.margin_maint
376 }
377
378 fn maker_fee(&self) -> Decimal {
379 self.maker_fee
380 }
381
382 fn taker_fee(&self) -> Decimal {
383 self.taker_fee
384 }
385
386 fn ts_event(&self) -> UnixNanos {
387 self.ts_event
388 }
389
390 fn ts_init(&self) -> UnixNanos {
391 self.ts_init
392 }
393}
394
395#[cfg(test)]
396mod tests {
397 use rstest::rstest;
398 use rust_decimal_macros::dec;
399
400 use crate::{
401 enums::{AssetClass, InstrumentClass},
402 identifiers::{InstrumentId, Symbol},
403 instruments::{Commodity, Instrument, stubs::*},
404 types::{Currency, Money, Price, Quantity},
405 };
406
407 #[rstest]
408 fn test_trait_accessors(commodity_gold: Commodity) {
409 assert_eq!(commodity_gold.id(), InstrumentId::from("GOLD.COMEX"));
410 assert_eq!(commodity_gold.asset_class(), AssetClass::Commodity);
411 assert_eq!(commodity_gold.instrument_class(), InstrumentClass::Spot);
412 assert_eq!(commodity_gold.quote_currency(), Currency::USD());
413 assert!(!commodity_gold.is_inverse());
414 assert_eq!(commodity_gold.price_precision(), 2);
415 assert_eq!(commodity_gold.size_precision(), 0);
416 assert!(commodity_gold.allows_negative_price());
417 }
418
419 #[rstest]
420 fn test_new_checked_price_precision_mismatch() {
421 let result = Commodity::new_checked(
422 InstrumentId::from("TEST.COMEX"),
423 Symbol::from("TEST"),
424 AssetClass::Commodity,
425 Currency::USD(),
426 4, 0,
428 Price::from("0.01"),
429 Quantity::from("1"),
430 None,
431 None,
432 None,
433 None,
434 None,
435 None,
436 None,
437 None,
438 None,
439 None,
440 None,
441 None,
442 None,
443 0.into(),
444 0.into(),
445 );
446 assert!(result.is_err());
447 }
448
449 #[rstest]
450 fn test_new_checked_rejects_non_positive_lot_size() {
451 let result = Commodity::new_checked(
452 InstrumentId::from("TEST.COMEX"),
453 Symbol::from("TEST"),
454 AssetClass::Commodity,
455 Currency::USD(),
456 2,
457 0,
458 Price::from("0.01"),
459 Quantity::from("1"),
460 Some(Quantity::from("0")),
461 None,
462 None,
463 None,
464 None,
465 None,
466 None,
467 None,
468 None,
469 None,
470 None,
471 None,
472 None,
473 0.into(),
474 0.into(),
475 );
476 let error = result.unwrap_err();
477 assert!(error.to_string().contains("not positive"), "{error}");
478 }
479
480 #[rstest]
481 fn test_serialization_roundtrip(commodity_gold: Commodity) {
482 let json = serde_json::to_string(&commodity_gold).unwrap();
483 let deserialized: Commodity = serde_json::from_str(&json).unwrap();
484 assert_eq!(json, serde_json::to_string(&deserialized).unwrap());
485 }
486
487 #[rstest]
488 fn test_builder_matches_new_checked() {
489 let positional = Commodity::new_checked(
490 InstrumentId::from("GOLD.COMEX"),
491 Symbol::from("GOLD"),
492 AssetClass::Commodity,
493 Currency::USD(),
494 2,
495 0,
496 Price::from("0.01"),
497 Quantity::from("1"),
498 Some(Quantity::from("1")),
499 Some(Quantity::from("10000")),
500 Some(Quantity::from("1")),
501 Some(Money::new(5_000_000.0, Currency::USD())),
502 Some(Money::new(10.0, Currency::USD())),
503 Some(Price::from("100000.00")),
504 Some(Price::from("0.01")),
505 Some(dec!(0.01)),
506 Some(dec!(0.02)),
507 Some(dec!(0.0002)),
508 Some(dec!(0.0004)),
509 None,
510 None,
511 1.into(),
512 2.into(),
513 )
514 .unwrap();
515
516 let built = Commodity::builder()
517 .instrument_id(InstrumentId::from("GOLD.COMEX"))
518 .raw_symbol(Symbol::from("GOLD"))
519 .asset_class(AssetClass::Commodity)
520 .quote_currency(Currency::USD())
521 .price_precision(2)
522 .size_precision(0)
523 .price_increment(Price::from("0.01"))
524 .size_increment(Quantity::from("1"))
525 .lot_size(Quantity::from("1"))
526 .max_quantity(Quantity::from("10000"))
527 .min_quantity(Quantity::from("1"))
528 .max_notional(Money::new(5_000_000.0, Currency::USD()))
529 .min_notional(Money::new(10.0, Currency::USD()))
530 .max_price(Price::from("100000.00"))
531 .min_price(Price::from("0.01"))
532 .margin_init(dec!(0.01))
533 .margin_maint(dec!(0.02))
534 .maker_fee(dec!(0.0002))
535 .taker_fee(dec!(0.0004))
536 .ts_event(1.into())
537 .ts_init(2.into())
538 .build()
539 .unwrap();
540
541 assert_eq!(
542 serde_json::to_value(&positional).unwrap(),
543 serde_json::to_value(&built).unwrap(),
544 );
545 }
546}