Skip to main content

nautilus_model/instruments/
equity.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::{
21        CorrectnessResult, CorrectnessResultExt, FAILED, check_equal_u8,
22        check_valid_string_ascii_optional,
23    },
24};
25use rust_decimal::Decimal;
26use serde::{Deserialize, Serialize};
27use ustr::Ustr;
28
29use super::{Instrument, any::InstrumentAny, tick_scheme::check_tick_scheme};
30use crate::{
31    enums::{AssetClass, InstrumentClass, OptionKind},
32    identifiers::{InstrumentId, Symbol},
33    types::{
34        currency::Currency,
35        money::Money,
36        price::{Price, check_positive_price},
37        quantity::{Quantity, check_positive_quantity},
38    },
39};
40
41/// Represents a generic equity instrument.
42#[repr(C)]
43#[derive(Clone, Debug, Serialize, Deserialize)]
44#[cfg_attr(
45    feature = "python",
46    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", from_py_object)
47)]
48#[cfg_attr(
49    feature = "python",
50    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
51)]
52pub struct Equity {
53    /// The instrument ID.
54    pub id: InstrumentId,
55    /// The raw/local/native symbol for the instrument, assigned by the venue.
56    pub raw_symbol: Symbol,
57    /// The instruments International Securities Identification Number (ISIN).
58    pub isin: Option<Ustr>,
59    /// The futures contract currency.
60    pub currency: Currency,
61    /// The price decimal precision.
62    pub price_precision: u8,
63    /// The minimum price increment (tick size).
64    pub price_increment: Price,
65    /// The initial (order) margin requirement in percentage of order value.
66    pub margin_init: Decimal,
67    /// The maintenance (position) margin in percentage of position value.
68    pub margin_maint: Decimal,
69    /// The fee rate for liquidity makers as a percentage of order value.
70    pub maker_fee: Decimal,
71    /// The fee rate for liquidity takers as a percentage of order value.
72    pub taker_fee: Decimal,
73    /// The rounded lot unit size (standard/board).
74    pub lot_size: Option<Quantity>,
75    /// The maximum allowable order quantity.
76    pub max_quantity: Option<Quantity>,
77    /// The minimum allowable order quantity.
78    pub min_quantity: Option<Quantity>,
79    /// The maximum allowable quoted price.
80    pub max_price: Option<Price>,
81    /// The minimum allowable quoted price.
82    pub min_price: Option<Price>,
83    /// The registered variable tick scheme name.
84    pub tick_scheme: Option<Ustr>,
85    /// Additional instrument metadata as a JSON-serializable dictionary.
86    pub info: Option<Params>,
87    /// UNIX timestamp (nanoseconds) when the data event occurred.
88    pub ts_event: UnixNanos,
89    /// UNIX timestamp (nanoseconds) when the data object was initialized.
90    pub ts_init: UnixNanos,
91}
92
93#[bon::bon]
94impl Equity {
95    /// Creates a new [`Equity`] instance with correctness checking.
96    ///
97    /// # Errors
98    ///
99    /// Returns an error if any input validation fails.
100    ///
101    /// # Notes
102    ///
103    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
104    #[expect(clippy::too_many_arguments)]
105    pub fn new_checked(
106        instrument_id: InstrumentId,
107        raw_symbol: Symbol,
108        isin: Option<Ustr>,
109        currency: Currency,
110        price_precision: u8,
111        price_increment: Price,
112        lot_size: Option<Quantity>,
113        max_quantity: Option<Quantity>,
114        min_quantity: Option<Quantity>,
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_valid_string_ascii_optional(isin.map(|u| u.as_str()), stringify!(isin))?;
127        check_equal_u8(
128            price_precision,
129            price_increment.precision,
130            stringify!(price_precision),
131            stringify!(price_increment.precision),
132        )?;
133        check_positive_price(price_increment, stringify!(price_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            isin,
144            currency,
145            price_precision,
146            price_increment,
147            lot_size,
148            max_quantity,
149            min_quantity,
150            max_price,
151            min_price,
152            margin_init: margin_init.unwrap_or_default(),
153            margin_maint: margin_maint.unwrap_or_default(),
154            maker_fee: maker_fee.unwrap_or_default(),
155            taker_fee: taker_fee.unwrap_or_default(),
156            tick_scheme,
157            info,
158            ts_event,
159            ts_init,
160        })
161    }
162
163    /// Creates a new [`Equity`] instance.
164    ///
165    /// # Panics
166    ///
167    /// Panics if any parameter is invalid (see `new_checked`).
168    #[expect(clippy::too_many_arguments)]
169    #[must_use]
170    pub fn new(
171        instrument_id: InstrumentId,
172        raw_symbol: Symbol,
173        isin: Option<Ustr>,
174        currency: Currency,
175        price_precision: u8,
176        price_increment: Price,
177        lot_size: Option<Quantity>,
178        max_quantity: Option<Quantity>,
179        min_quantity: Option<Quantity>,
180        max_price: Option<Price>,
181        min_price: Option<Price>,
182        margin_init: Option<Decimal>,
183        margin_maint: Option<Decimal>,
184        maker_fee: Option<Decimal>,
185        taker_fee: Option<Decimal>,
186        tick_scheme: Option<Ustr>,
187        info: Option<Params>,
188        ts_event: UnixNanos,
189        ts_init: UnixNanos,
190    ) -> Self {
191        Self::new_checked(
192            instrument_id,
193            raw_symbol,
194            isin,
195            currency,
196            price_precision,
197            price_increment,
198            lot_size,
199            max_quantity,
200            min_quantity,
201            max_price,
202            min_price,
203            margin_init,
204            margin_maint,
205            maker_fee,
206            taker_fee,
207            tick_scheme,
208            info,
209            ts_event,
210            ts_init,
211        )
212        .expect_display(FAILED)
213    }
214
215    /// Returns a fluent builder for a [`Equity`] instance.
216    ///
217    /// Required fields are enforced at compile time; optional fields can be omitted and default
218    /// the same way they do in [`Equity::new_checked`], which the builder calls so the same
219    /// correctness checks run on `build`.
220    ///
221    /// # Errors
222    ///
223    /// Returns an error if any input validation fails (see [`Equity::new_checked`]).
224    #[builder(start_fn = builder, finish_fn = build)]
225    pub fn build_checked(
226        instrument_id: InstrumentId,
227        raw_symbol: Symbol,
228        isin: Option<Ustr>,
229        currency: Currency,
230        price_precision: u8,
231        price_increment: Price,
232        lot_size: Option<Quantity>,
233        max_quantity: Option<Quantity>,
234        min_quantity: Option<Quantity>,
235        max_price: Option<Price>,
236        min_price: Option<Price>,
237        margin_init: Option<Decimal>,
238        margin_maint: Option<Decimal>,
239        maker_fee: Option<Decimal>,
240        taker_fee: Option<Decimal>,
241        tick_scheme: Option<Ustr>,
242        info: Option<Params>,
243        ts_event: UnixNanos,
244        ts_init: UnixNanos,
245    ) -> CorrectnessResult<Self> {
246        Self::new_checked(
247            instrument_id,
248            raw_symbol,
249            isin,
250            currency,
251            price_precision,
252            price_increment,
253            lot_size,
254            max_quantity,
255            min_quantity,
256            max_price,
257            min_price,
258            margin_init,
259            margin_maint,
260            maker_fee,
261            taker_fee,
262            tick_scheme,
263            info,
264            ts_event,
265            ts_init,
266        )
267    }
268}
269
270impl PartialEq<Self> for Equity {
271    fn eq(&self, other: &Self) -> bool {
272        self.id == other.id
273    }
274}
275
276impl Eq for Equity {}
277
278impl Hash for Equity {
279    fn hash<H: Hasher>(&self, state: &mut H) {
280        self.id.hash(state);
281    }
282}
283
284impl Instrument for Equity {
285    fn tick_scheme(&self) -> Option<Ustr> {
286        self.tick_scheme
287    }
288    fn into_any(self) -> InstrumentAny {
289        InstrumentAny::Equity(self)
290    }
291
292    fn id(&self) -> InstrumentId {
293        self.id
294    }
295
296    fn raw_symbol(&self) -> Symbol {
297        self.raw_symbol
298    }
299
300    fn asset_class(&self) -> AssetClass {
301        AssetClass::Equity
302    }
303
304    fn instrument_class(&self) -> InstrumentClass {
305        InstrumentClass::Spot
306    }
307    fn underlying(&self) -> Option<Ustr> {
308        None
309    }
310
311    fn base_currency(&self) -> Option<Currency> {
312        None
313    }
314
315    fn quote_currency(&self) -> Currency {
316        self.currency
317    }
318
319    fn settlement_currency(&self) -> Currency {
320        self.currency
321    }
322
323    fn isin(&self) -> Option<Ustr> {
324        self.isin
325    }
326
327    fn option_kind(&self) -> Option<OptionKind> {
328        None
329    }
330
331    fn exchange(&self) -> Option<Ustr> {
332        None
333    }
334
335    fn strike_price(&self) -> Option<Price> {
336        None
337    }
338
339    fn activation_ns(&self) -> Option<UnixNanos> {
340        None
341    }
342
343    fn expiration_ns(&self) -> Option<UnixNanos> {
344        None
345    }
346
347    fn is_inverse(&self) -> bool {
348        false
349    }
350
351    fn price_precision(&self) -> u8 {
352        self.price_precision
353    }
354
355    fn size_precision(&self) -> u8 {
356        0
357    }
358
359    fn price_increment(&self) -> Price {
360        self.price_increment
361    }
362
363    fn size_increment(&self) -> Quantity {
364        Quantity::from(1)
365    }
366
367    fn multiplier(&self) -> Quantity {
368        Quantity::from(1)
369    }
370
371    fn lot_size(&self) -> Option<Quantity> {
372        self.lot_size
373    }
374
375    fn max_quantity(&self) -> Option<Quantity> {
376        self.max_quantity
377    }
378
379    fn min_quantity(&self) -> Option<Quantity> {
380        self.min_quantity
381    }
382
383    fn max_notional(&self) -> Option<Money> {
384        None
385    }
386
387    fn min_notional(&self) -> Option<Money> {
388        None
389    }
390
391    fn max_price(&self) -> Option<Price> {
392        self.max_price
393    }
394
395    fn min_price(&self) -> Option<Price> {
396        self.min_price
397    }
398
399    fn ts_event(&self) -> UnixNanos {
400        self.ts_event
401    }
402
403    fn ts_init(&self) -> UnixNanos {
404        self.ts_init
405    }
406
407    fn margin_init(&self) -> Decimal {
408        self.margin_init
409    }
410
411    fn margin_maint(&self) -> Decimal {
412        self.margin_maint
413    }
414
415    fn maker_fee(&self) -> Decimal {
416        self.maker_fee
417    }
418
419    fn taker_fee(&self) -> Decimal {
420        self.taker_fee
421    }
422}
423
424#[cfg(test)]
425mod tests {
426    use rstest::rstest;
427    use rust_decimal_macros::dec;
428    use ustr::Ustr;
429
430    use crate::{
431        enums::{AssetClass, InstrumentClass},
432        identifiers::{InstrumentId, Symbol},
433        instruments::{Equity, Instrument, stubs::*},
434        types::{Currency, Price, Quantity},
435    };
436
437    #[rstest]
438    fn test_trait_accessors(equity_aapl: Equity) {
439        assert_eq!(equity_aapl.id(), InstrumentId::from("AAPL.XNAS"));
440        assert_eq!(equity_aapl.raw_symbol(), Symbol::from("AAPL"));
441        assert_eq!(equity_aapl.asset_class(), AssetClass::Equity);
442        assert_eq!(equity_aapl.instrument_class(), InstrumentClass::Spot);
443        assert_eq!(equity_aapl.quote_currency(), Currency::USD());
444        assert_eq!(equity_aapl.settlement_currency(), Currency::USD());
445        assert!(!equity_aapl.is_inverse());
446        assert_eq!(equity_aapl.price_precision(), 2);
447        assert_eq!(equity_aapl.size_precision(), 0);
448        assert_eq!(equity_aapl.price_increment(), Price::from("0.01"));
449        assert_eq!(equity_aapl.size_increment(), Quantity::from("1"));
450        assert_eq!(equity_aapl.multiplier(), Quantity::from("1"));
451        assert_eq!(equity_aapl.base_currency(), None);
452        assert_eq!(equity_aapl.underlying(), None);
453        assert_eq!(equity_aapl.option_kind(), None);
454        assert_eq!(equity_aapl.strike_price(), None);
455        assert_eq!(equity_aapl.activation_ns(), None);
456        assert_eq!(equity_aapl.expiration_ns(), None);
457    }
458
459    #[rstest]
460    fn test_isin(equity_aapl: Equity) {
461        assert_eq!(
462            equity_aapl.isin().map(|u| u.to_string()),
463            Some("US0378331005".to_string()),
464        );
465    }
466
467    #[rstest]
468    fn test_new_checked_price_precision_mismatch() {
469        let result = Equity::new_checked(
470            InstrumentId::from("AAPL.XNAS"),
471            Symbol::from("AAPL"),
472            None,
473            Currency::USD(),
474            3, // mismatch
475            Price::from("0.01"),
476            None,
477            None,
478            None,
479            None,
480            None,
481            None,
482            None,
483            None,
484            None,
485            None,
486            None,
487            0.into(),
488            0.into(),
489        );
490        assert!(result.is_err());
491    }
492
493    #[rstest]
494    fn test_new_checked_zero_price_increment() {
495        let result = Equity::new_checked(
496            InstrumentId::from("AAPL.XNAS"),
497            Symbol::from("AAPL"),
498            None,
499            Currency::USD(),
500            0,
501            Price::from("0"),
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_non_ascii_isin() {
521        let result = Equity::new_checked(
522            InstrumentId::from("AAPL.XNAS"),
523            Symbol::from("AAPL"),
524            Some(ustr::Ustr::from("US\u{00E9}378331005")),
525            Currency::USD(),
526            2,
527            Price::from("0.01"),
528            None,
529            None,
530            None,
531            None,
532            None,
533            None,
534            None,
535            None,
536            None,
537            None,
538            None,
539            0.into(),
540            0.into(),
541        );
542        assert!(result.is_err());
543        assert!(result.unwrap_err().to_string().contains("non-ASCII"));
544    }
545
546    #[rstest]
547    fn test_new_checked_rejects_non_positive_lot_size() {
548        let result = Equity::new_checked(
549            InstrumentId::from("AAPL.XNAS"),
550            Symbol::from("AAPL"),
551            None,
552            Currency::USD(),
553            2,
554            Price::from("0.01"),
555            Some(Quantity::from("0")),
556            None,
557            None,
558            None,
559            None,
560            None,
561            None,
562            None,
563            None,
564            None,
565            None,
566            0.into(),
567            0.into(),
568        );
569        let error = result.unwrap_err();
570        assert!(error.to_string().contains("not positive"), "{error}");
571    }
572
573    #[rstest]
574    fn test_serialization_roundtrip(equity_aapl: Equity) {
575        let json = serde_json::to_string(&equity_aapl).unwrap();
576        let deserialized: Equity = serde_json::from_str(&json).unwrap();
577        assert_eq!(equity_aapl, deserialized);
578    }
579
580    #[rstest]
581    fn test_builder_matches_new_checked() {
582        let positional = Equity::new_checked(
583            InstrumentId::from("AAPL.XNAS"),
584            Symbol::from("AAPL"),
585            Some(Ustr::from("US0378331005")),
586            Currency::USD(),
587            2,
588            Price::from("0.01"),
589            Some(Quantity::from("100")),
590            Some(Quantity::from("10000.0")),
591            Some(Quantity::from("0.001")),
592            Some(Price::from("9999.99")),
593            Some(Price::from("0.01")),
594            Some(dec!(0.01)),
595            Some(dec!(0.02)),
596            Some(dec!(0.0002)),
597            Some(dec!(0.0004)),
598            None,
599            None,
600            1.into(),
601            2.into(),
602        )
603        .unwrap();
604
605        let built = Equity::builder()
606            .instrument_id(InstrumentId::from("AAPL.XNAS"))
607            .raw_symbol(Symbol::from("AAPL"))
608            .isin(Ustr::from("US0378331005"))
609            .currency(Currency::USD())
610            .price_precision(2)
611            .price_increment(Price::from("0.01"))
612            .lot_size(Quantity::from("100"))
613            .max_quantity(Quantity::from("10000.0"))
614            .min_quantity(Quantity::from("0.001"))
615            .max_price(Price::from("9999.99"))
616            .min_price(Price::from("0.01"))
617            .margin_init(dec!(0.01))
618            .margin_maint(dec!(0.02))
619            .maker_fee(dec!(0.0002))
620            .taker_fee(dec!(0.0004))
621            .ts_event(1.into())
622            .ts_init(2.into())
623            .build()
624            .unwrap();
625
626        assert_eq!(
627            serde_json::to_value(&positional).unwrap(),
628            serde_json::to_value(&built).unwrap(),
629        );
630    }
631}