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, 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.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    #[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    /// Returns a fluent builder for a [`Commodity`] instance.
174    ///
175    /// Required fields are enforced at compile time; optional fields can be omitted and use the
176    /// same defaults as checked construction. The same correctness checks run on `build`.
177    ///
178    /// # Errors
179    ///
180    /// Returns an error if any input validation fails.
181    #[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        // Spot commodities such as electricity or oil can trade at negative prices
275        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, // mismatch
427            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}