Skip to main content

nautilus_model/instruments/
commodity.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use 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/// Represents a generic commodity instrument.
39#[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    /// The instrument ID.
51    pub id: InstrumentId,
52    /// The raw/local/native symbol for the instrument, assigned by the venue.
53    pub raw_symbol: Symbol,
54    /// The asset class of the commodity.
55    pub asset_class: AssetClass,
56    /// The quote currency.
57    pub quote_currency: Currency,
58    /// The price decimal precision.
59    pub price_precision: u8,
60    /// The trading size decimal precision.
61    pub size_precision: u8,
62    /// The minimum price increment (tick size).
63    pub price_increment: Price,
64    /// The minimum size increment.
65    pub size_increment: Quantity,
66    /// The initial (order) margin requirement in percentage of order value.
67    pub margin_init: Decimal,
68    /// The maintenance (position) margin in percentage of position value.
69    pub margin_maint: Decimal,
70    /// The fee rate for liquidity makers as a percentage of order value.
71    pub maker_fee: Decimal,
72    /// The fee rate for liquidity takers as a percentage of order value.
73    pub taker_fee: Decimal,
74    /// The rounded lot unit size (standard/board).
75    pub lot_size: Option<Quantity>,
76    /// The maximum allowable order quantity.
77    pub max_quantity: Option<Quantity>,
78    /// The minimum allowable order quantity.
79    pub min_quantity: Option<Quantity>,
80    /// The maximum allowable order notional value.
81    pub max_notional: Option<Money>,
82    /// The minimum allowable order notional value.
83    pub min_notional: Option<Money>,
84    /// The maximum allowable quoted price.
85    pub max_price: Option<Price>,
86    /// The minimum allowable quoted price.
87    pub min_price: Option<Price>,
88    /// The registered variable tick scheme name.
89    pub tick_scheme: Option<Ustr>,
90    /// Additional instrument metadata as a JSON-serializable dictionary.
91    pub info: Option<Params>,
92    /// UNIX timestamp (nanoseconds) when the data event occurred.
93    pub ts_event: UnixNanos,
94    /// UNIX timestamp (nanoseconds) when the data object was initialized.
95    pub ts_init: UnixNanos,
96}
97
98#[bon::bon]
99impl Commodity {
100    /// Creates a new [`Commodity`] instance with correctness checking.
101    ///
102    /// # Errors
103    ///
104    /// Returns an error if any input validation fails.
105    ///
106    /// # Notes
107    ///
108    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
109    #[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    /// Creates a new [`Commodity`] instance.
183    ///
184    /// # Panics
185    ///
186    /// Panics if any parameter is invalid (see `new_checked`).
187    #[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    /// Returns a fluent builder for a [`Commodity`] instance.
243    ///
244    /// Required fields are enforced at compile time; optional fields can be omitted and default
245    /// the same way they do in [`Commodity::new_checked`], which the builder calls so the same
246    /// correctness checks run on `build`.
247    ///
248    /// # Errors
249    ///
250    /// Returns an error if any input validation fails (see [`Commodity::new_checked`]).
251    #[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        // Spot commodities such as electricity or oil can trade at negative prices
345        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, // mismatch
497            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}