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::{CorrectnessResult, check_equal_u8, check_valid_string_ascii_optional},
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 equity 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 Equity {
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 instruments International Securities Identification Number (ISIN).
55    pub isin: Option<Ustr>,
56    /// The futures contract currency.
57    pub currency: Currency,
58    /// The price decimal precision.
59    pub price_precision: u8,
60    /// The minimum price increment (tick size).
61    pub price_increment: Price,
62    /// The initial (order) margin requirement in percentage of order value.
63    pub margin_init: Decimal,
64    /// The maintenance (position) margin in percentage of position value.
65    pub margin_maint: Decimal,
66    /// The rounded lot unit size (standard/board).
67    pub lot_size: Option<Quantity>,
68    /// The maximum allowable order quantity.
69    pub max_quantity: Option<Quantity>,
70    /// The minimum allowable order quantity.
71    pub min_quantity: Option<Quantity>,
72    /// The maximum allowable quoted price.
73    pub max_price: Option<Price>,
74    /// The minimum allowable quoted price.
75    pub min_price: Option<Price>,
76    /// The registered variable tick scheme name.
77    pub tick_scheme: Option<Ustr>,
78    /// Additional instrument metadata as a JSON-serializable dictionary.
79    pub info: Option<Params>,
80    /// UNIX timestamp (nanoseconds) when the data event occurred.
81    pub ts_event: UnixNanos,
82    /// UNIX timestamp (nanoseconds) when the data object was initialized.
83    pub ts_init: UnixNanos,
84}
85
86#[bon::bon]
87impl Equity {
88    #[expect(clippy::too_many_arguments)]
89    fn new_checked(
90        instrument_id: InstrumentId,
91        raw_symbol: Symbol,
92        isin: Option<Ustr>,
93        currency: Currency,
94        price_precision: u8,
95        price_increment: Price,
96        lot_size: Option<Quantity>,
97        max_quantity: Option<Quantity>,
98        min_quantity: Option<Quantity>,
99        max_price: Option<Price>,
100        min_price: Option<Price>,
101        margin_init: Option<Decimal>,
102        margin_maint: Option<Decimal>,
103        tick_scheme: Option<Ustr>,
104        info: Option<Params>,
105        ts_event: UnixNanos,
106        ts_init: UnixNanos,
107    ) -> CorrectnessResult<Self> {
108        check_valid_string_ascii_optional(isin, stringify!(isin))?;
109        check_equal_u8(
110            price_precision,
111            price_increment.precision,
112            stringify!(price_precision),
113            stringify!(price_increment.precision),
114        )?;
115        check_positive_price(price_increment, stringify!(price_increment))?;
116        check_tick_scheme(tick_scheme)?;
117
118        if let Some(lot_size) = lot_size {
119            check_positive_quantity(lot_size, stringify!(lot_size))?;
120        }
121
122        Ok(Self {
123            id: instrument_id,
124            raw_symbol,
125            isin,
126            currency,
127            price_precision,
128            price_increment,
129            lot_size,
130            max_quantity,
131            min_quantity,
132            max_price,
133            min_price,
134            margin_init: margin_init.unwrap_or_default(),
135            margin_maint: margin_maint.unwrap_or_default(),
136            tick_scheme,
137            info,
138            ts_event,
139            ts_init,
140        })
141    }
142
143    /// Returns a fluent builder for a [`Equity`] instance.
144    ///
145    /// Required fields are enforced at compile time; optional fields can be omitted and use the
146    /// same defaults as checked construction. The same correctness checks run on `build`.
147    ///
148    /// # Errors
149    ///
150    /// Returns an error if any input validation fails.
151    #[builder(start_fn = builder, finish_fn = build)]
152    pub fn build_checked(
153        instrument_id: InstrumentId,
154        raw_symbol: Symbol,
155        isin: Option<Ustr>,
156        currency: Currency,
157        price_precision: u8,
158        price_increment: Price,
159        lot_size: Option<Quantity>,
160        max_quantity: Option<Quantity>,
161        min_quantity: Option<Quantity>,
162        max_price: Option<Price>,
163        min_price: Option<Price>,
164        margin_init: Option<Decimal>,
165        margin_maint: Option<Decimal>,
166        tick_scheme: Option<Ustr>,
167        info: Option<Params>,
168        ts_event: UnixNanos,
169        ts_init: UnixNanos,
170    ) -> CorrectnessResult<Self> {
171        Self::new_checked(
172            instrument_id,
173            raw_symbol,
174            isin,
175            currency,
176            price_precision,
177            price_increment,
178            lot_size,
179            max_quantity,
180            min_quantity,
181            max_price,
182            min_price,
183            margin_init,
184            margin_maint,
185            tick_scheme,
186            info,
187            ts_event,
188            ts_init,
189        )
190    }
191}
192
193impl PartialEq<Self> for Equity {
194    fn eq(&self, other: &Self) -> bool {
195        self.id == other.id
196    }
197}
198
199impl Eq for Equity {}
200
201impl Hash for Equity {
202    fn hash<H: Hasher>(&self, state: &mut H) {
203        self.id.hash(state);
204    }
205}
206
207impl Instrument for Equity {
208    fn into_any(self) -> InstrumentAny {
209        InstrumentAny::Equity(self)
210    }
211
212    fn id(&self) -> InstrumentId {
213        self.id
214    }
215
216    fn raw_symbol(&self) -> Symbol {
217        self.raw_symbol
218    }
219
220    fn asset_class(&self) -> AssetClass {
221        AssetClass::Equity
222    }
223
224    fn instrument_class(&self) -> InstrumentClass {
225        InstrumentClass::Spot
226    }
227    fn underlying(&self) -> Option<Ustr> {
228        None
229    }
230
231    fn base_currency(&self) -> Option<Currency> {
232        None
233    }
234
235    fn quote_currency(&self) -> Currency {
236        self.currency
237    }
238
239    fn settlement_currency(&self) -> Currency {
240        self.currency
241    }
242
243    fn isin(&self) -> Option<Ustr> {
244        self.isin
245    }
246
247    fn option_kind(&self) -> Option<OptionKind> {
248        None
249    }
250
251    fn exchange(&self) -> Option<Ustr> {
252        None
253    }
254
255    fn strike_price(&self) -> Option<Price> {
256        None
257    }
258
259    fn activation_ns(&self) -> Option<UnixNanos> {
260        None
261    }
262
263    fn expiration_ns(&self) -> Option<UnixNanos> {
264        None
265    }
266
267    fn is_inverse(&self) -> bool {
268        false
269    }
270
271    fn price_precision(&self) -> u8 {
272        self.price_precision
273    }
274
275    fn size_precision(&self) -> u8 {
276        0
277    }
278
279    fn price_increment(&self) -> Price {
280        self.price_increment
281    }
282
283    fn size_increment(&self) -> Quantity {
284        Quantity::from(1)
285    }
286
287    fn multiplier(&self) -> Quantity {
288        Quantity::from(1)
289    }
290
291    fn lot_size(&self) -> Option<Quantity> {
292        self.lot_size
293    }
294
295    fn max_quantity(&self) -> Option<Quantity> {
296        self.max_quantity
297    }
298
299    fn min_quantity(&self) -> Option<Quantity> {
300        self.min_quantity
301    }
302
303    fn max_notional(&self) -> Option<Money> {
304        None
305    }
306
307    fn min_notional(&self) -> Option<Money> {
308        None
309    }
310
311    fn max_price(&self) -> Option<Price> {
312        self.max_price
313    }
314
315    fn min_price(&self) -> Option<Price> {
316        self.min_price
317    }
318
319    fn tick_scheme(&self) -> Option<Ustr> {
320        self.tick_scheme
321    }
322
323    fn info(&self) -> Option<&Params> {
324        self.info.as_ref()
325    }
326
327    fn ts_event(&self) -> UnixNanos {
328        self.ts_event
329    }
330
331    fn ts_init(&self) -> UnixNanos {
332        self.ts_init
333    }
334
335    fn margin_init(&self) -> Decimal {
336        self.margin_init
337    }
338
339    fn margin_maint(&self) -> Decimal {
340        self.margin_maint
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use rstest::rstest;
347    use rust_decimal_macros::dec;
348    use ustr::Ustr;
349
350    use crate::{
351        enums::{AssetClass, InstrumentClass},
352        identifiers::{InstrumentId, Symbol},
353        instruments::{Equity, Instrument, stubs::*},
354        types::{Currency, Price, Quantity},
355    };
356
357    #[rstest]
358    fn test_trait_accessors(equity_aapl: Equity) {
359        assert_eq!(equity_aapl.id(), InstrumentId::from("AAPL.XNAS"));
360        assert_eq!(equity_aapl.raw_symbol(), Symbol::from("AAPL"));
361        assert_eq!(equity_aapl.asset_class(), AssetClass::Equity);
362        assert_eq!(equity_aapl.instrument_class(), InstrumentClass::Spot);
363        assert_eq!(equity_aapl.quote_currency(), Currency::USD());
364        assert_eq!(equity_aapl.settlement_currency(), Currency::USD());
365        assert!(!equity_aapl.is_inverse());
366        assert_eq!(equity_aapl.price_precision(), 2);
367        assert_eq!(equity_aapl.size_precision(), 0);
368        assert_eq!(equity_aapl.price_increment(), Price::from("0.01"));
369        assert_eq!(equity_aapl.size_increment(), Quantity::from("1"));
370        assert_eq!(equity_aapl.multiplier(), Quantity::from("1"));
371        assert_eq!(equity_aapl.base_currency(), None);
372        assert_eq!(equity_aapl.underlying(), None);
373        assert_eq!(equity_aapl.option_kind(), None);
374        assert_eq!(equity_aapl.strike_price(), None);
375        assert_eq!(equity_aapl.activation_ns(), None);
376        assert_eq!(equity_aapl.expiration_ns(), None);
377    }
378
379    #[rstest]
380    fn test_isin(equity_aapl: Equity) {
381        assert_eq!(
382            equity_aapl.isin().map(|u| u.to_string()),
383            Some("US0378331005".to_string()),
384        );
385    }
386
387    #[rstest]
388    fn test_new_checked_price_precision_mismatch() {
389        let result = Equity::new_checked(
390            InstrumentId::from("AAPL.XNAS"),
391            Symbol::from("AAPL"),
392            None,
393            Currency::USD(),
394            3, // mismatch
395            Price::from("0.01"),
396            None,
397            None,
398            None,
399            None,
400            None,
401            None,
402            None,
403            None,
404            None,
405            0.into(),
406            0.into(),
407        );
408        assert!(result.is_err());
409    }
410
411    #[rstest]
412    fn test_new_checked_zero_price_increment() {
413        let result = Equity::new_checked(
414            InstrumentId::from("AAPL.XNAS"),
415            Symbol::from("AAPL"),
416            None,
417            Currency::USD(),
418            0,
419            Price::from("0"),
420            None,
421            None,
422            None,
423            None,
424            None,
425            None,
426            None,
427            None,
428            None,
429            0.into(),
430            0.into(),
431        );
432        assert!(result.is_err());
433    }
434
435    #[rstest]
436    fn test_new_checked_non_ascii_isin() {
437        let result = Equity::new_checked(
438            InstrumentId::from("AAPL.XNAS"),
439            Symbol::from("AAPL"),
440            Some(ustr::Ustr::from("US\u{00E9}378331005")),
441            Currency::USD(),
442            2,
443            Price::from("0.01"),
444            None,
445            None,
446            None,
447            None,
448            None,
449            None,
450            None,
451            None,
452            None,
453            0.into(),
454            0.into(),
455        );
456        assert!(result.is_err());
457        assert!(result.unwrap_err().to_string().contains("non-ASCII"));
458    }
459
460    #[rstest]
461    fn test_new_checked_rejects_non_positive_lot_size() {
462        let result = Equity::new_checked(
463            InstrumentId::from("AAPL.XNAS"),
464            Symbol::from("AAPL"),
465            None,
466            Currency::USD(),
467            2,
468            Price::from("0.01"),
469            Some(Quantity::from("0")),
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(equity_aapl: Equity) {
487        let json = serde_json::to_string(&equity_aapl).unwrap();
488        let deserialized: Equity = 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 = Equity::new_checked(
495            InstrumentId::from("AAPL.XNAS"),
496            Symbol::from("AAPL"),
497            Some(Ustr::from("US0378331005")),
498            Currency::USD(),
499            2,
500            Price::from("0.01"),
501            Some(Quantity::from("100")),
502            Some(Quantity::from("10000.0")),
503            Some(Quantity::from("0.001")),
504            Some(Price::from("9999.99")),
505            Some(Price::from("0.01")),
506            Some(dec!(0.01)),
507            Some(dec!(0.02)),
508            None,
509            None,
510            1.into(),
511            2.into(),
512        )
513        .unwrap();
514
515        let built = Equity::builder()
516            .instrument_id(InstrumentId::from("AAPL.XNAS"))
517            .raw_symbol(Symbol::from("AAPL"))
518            .isin(Ustr::from("US0378331005"))
519            .currency(Currency::USD())
520            .price_precision(2)
521            .price_increment(Price::from("0.01"))
522            .lot_size(Quantity::from("100"))
523            .max_quantity(Quantity::from("10000.0"))
524            .min_quantity(Quantity::from("0.001"))
525            .max_price(Price::from("9999.99"))
526            .min_price(Price::from("0.01"))
527            .margin_init(dec!(0.01))
528            .margin_maint(dec!(0.02))
529            .ts_event(1.into())
530            .ts_init(2.into())
531            .build()
532            .unwrap();
533
534        assert_eq!(
535            serde_json::to_value(&positional).unwrap(),
536            serde_json::to_value(&built).unwrap(),
537        );
538    }
539}