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