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 rounded lot unit size (standard/board).
71    pub lot_size: Option<Quantity>,
72    /// The maximum allowable order quantity.
73    pub max_quantity: Option<Quantity>,
74    /// The minimum allowable order quantity.
75    pub min_quantity: Option<Quantity>,
76    /// The maximum allowable order notional value.
77    pub max_notional: Option<Money>,
78    /// The minimum allowable order notional value.
79    pub min_notional: Option<Money>,
80    /// The maximum allowable quoted price.
81    pub max_price: Option<Price>,
82    /// The minimum allowable quoted price.
83    pub min_price: Option<Price>,
84    /// The registered variable tick scheme name.
85    pub tick_scheme: Option<Ustr>,
86    /// Additional instrument metadata as a JSON-serializable dictionary.
87    pub info: Option<Params>,
88    /// UNIX timestamp (nanoseconds) when the data event occurred.
89    pub ts_event: UnixNanos,
90    /// UNIX timestamp (nanoseconds) when the data object was initialized.
91    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    /// Returns a fluent builder for a [`Commodity`] instance.
166    ///
167    /// Required fields are enforced at compile time; optional fields can be omitted and use the
168    /// same defaults as checked construction. The same correctness checks run on `build`.
169    ///
170    /// # Errors
171    ///
172    /// Returns an error if any input validation fails.
173    #[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        // Spot commodities such as electricity or oil can trade at negative prices
260        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, // mismatch
412            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}