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};
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,
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    /// Additional instrument metadata as a JSON-serializable dictionary.
84    pub info: Option<Params>,
85    /// UNIX timestamp (nanoseconds) when the data event occurred.
86    pub ts_event: UnixNanos,
87    /// UNIX timestamp (nanoseconds) when the data object was initialized.
88    pub ts_init: UnixNanos,
89}
90
91impl Equity {
92    /// Creates a new [`Equity`] instance with correctness checking.
93    ///
94    /// # Notes
95    ///
96    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
97    /// # Errors
98    ///
99    /// Returns an error if any input validation fails.
100    #[expect(clippy::too_many_arguments)]
101    pub fn new_checked(
102        instrument_id: InstrumentId,
103        raw_symbol: Symbol,
104        isin: Option<Ustr>,
105        currency: Currency,
106        price_precision: u8,
107        price_increment: Price,
108        lot_size: Option<Quantity>,
109        max_quantity: Option<Quantity>,
110        min_quantity: Option<Quantity>,
111        max_price: Option<Price>,
112        min_price: Option<Price>,
113        margin_init: Option<Decimal>,
114        margin_maint: Option<Decimal>,
115        maker_fee: Option<Decimal>,
116        taker_fee: Option<Decimal>,
117        info: Option<Params>,
118        ts_event: UnixNanos,
119        ts_init: UnixNanos,
120    ) -> CorrectnessResult<Self> {
121        check_valid_string_ascii_optional(isin.map(|u| u.as_str()), stringify!(isin))?;
122        check_equal_u8(
123            price_precision,
124            price_increment.precision,
125            stringify!(price_precision),
126            stringify!(price_increment.precision),
127        )?;
128        check_positive_price(price_increment, stringify!(price_increment))?;
129
130        Ok(Self {
131            id: instrument_id,
132            raw_symbol,
133            isin,
134            currency,
135            price_precision,
136            price_increment,
137            lot_size,
138            max_quantity,
139            min_quantity,
140            max_price,
141            min_price,
142            margin_init: margin_init.unwrap_or_default(),
143            margin_maint: margin_maint.unwrap_or_default(),
144            maker_fee: maker_fee.unwrap_or_default(),
145            taker_fee: taker_fee.unwrap_or_default(),
146            info,
147            ts_event,
148            ts_init,
149        })
150    }
151
152    /// Creates a new [`Equity`] instance.
153    ///
154    /// # Panics
155    ///
156    /// Panics if any parameter is invalid (see `new_checked`).
157    #[expect(clippy::too_many_arguments)]
158    #[must_use]
159    pub fn new(
160        instrument_id: InstrumentId,
161        raw_symbol: Symbol,
162        isin: Option<Ustr>,
163        currency: Currency,
164        price_precision: u8,
165        price_increment: Price,
166        lot_size: Option<Quantity>,
167        max_quantity: Option<Quantity>,
168        min_quantity: Option<Quantity>,
169        max_price: Option<Price>,
170        min_price: Option<Price>,
171        margin_init: Option<Decimal>,
172        margin_maint: Option<Decimal>,
173        maker_fee: Option<Decimal>,
174        taker_fee: Option<Decimal>,
175        info: Option<Params>,
176        ts_event: UnixNanos,
177        ts_init: UnixNanos,
178    ) -> Self {
179        Self::new_checked(
180            instrument_id,
181            raw_symbol,
182            isin,
183            currency,
184            price_precision,
185            price_increment,
186            lot_size,
187            max_quantity,
188            min_quantity,
189            max_price,
190            min_price,
191            margin_init,
192            margin_maint,
193            maker_fee,
194            taker_fee,
195            info,
196            ts_event,
197            ts_init,
198        )
199        .expect_display(FAILED)
200    }
201}
202
203impl PartialEq<Self> for Equity {
204    fn eq(&self, other: &Self) -> bool {
205        self.id == other.id
206    }
207}
208
209impl Eq for Equity {}
210
211impl Hash for Equity {
212    fn hash<H: Hasher>(&self, state: &mut H) {
213        self.id.hash(state);
214    }
215}
216
217impl Instrument for Equity {
218    fn into_any(self) -> InstrumentAny {
219        InstrumentAny::Equity(self)
220    }
221
222    fn id(&self) -> InstrumentId {
223        self.id
224    }
225
226    fn raw_symbol(&self) -> Symbol {
227        self.raw_symbol
228    }
229
230    fn asset_class(&self) -> AssetClass {
231        AssetClass::Equity
232    }
233
234    fn instrument_class(&self) -> InstrumentClass {
235        InstrumentClass::Spot
236    }
237    fn underlying(&self) -> Option<Ustr> {
238        None
239    }
240
241    fn base_currency(&self) -> Option<Currency> {
242        None
243    }
244
245    fn quote_currency(&self) -> Currency {
246        self.currency
247    }
248
249    fn settlement_currency(&self) -> Currency {
250        self.currency
251    }
252
253    fn isin(&self) -> Option<Ustr> {
254        self.isin
255    }
256
257    fn option_kind(&self) -> Option<OptionKind> {
258        None
259    }
260
261    fn exchange(&self) -> Option<Ustr> {
262        None
263    }
264
265    fn strike_price(&self) -> Option<Price> {
266        None
267    }
268
269    fn activation_ns(&self) -> Option<UnixNanos> {
270        None
271    }
272
273    fn expiration_ns(&self) -> Option<UnixNanos> {
274        None
275    }
276
277    fn is_inverse(&self) -> bool {
278        false
279    }
280
281    fn price_precision(&self) -> u8 {
282        self.price_precision
283    }
284
285    fn size_precision(&self) -> u8 {
286        0
287    }
288
289    fn price_increment(&self) -> Price {
290        self.price_increment
291    }
292
293    fn size_increment(&self) -> Quantity {
294        Quantity::from(1)
295    }
296
297    fn multiplier(&self) -> Quantity {
298        Quantity::from(1)
299    }
300
301    fn lot_size(&self) -> Option<Quantity> {
302        self.lot_size
303    }
304
305    fn max_quantity(&self) -> Option<Quantity> {
306        self.max_quantity
307    }
308
309    fn min_quantity(&self) -> Option<Quantity> {
310        self.min_quantity
311    }
312
313    fn max_notional(&self) -> Option<Money> {
314        None
315    }
316
317    fn min_notional(&self) -> Option<Money> {
318        None
319    }
320
321    fn max_price(&self) -> Option<Price> {
322        self.max_price
323    }
324
325    fn min_price(&self) -> Option<Price> {
326        self.min_price
327    }
328
329    fn ts_event(&self) -> UnixNanos {
330        self.ts_event
331    }
332
333    fn ts_init(&self) -> UnixNanos {
334        self.ts_init
335    }
336
337    fn margin_init(&self) -> Decimal {
338        self.margin_init
339    }
340
341    fn margin_maint(&self) -> Decimal {
342        self.margin_maint
343    }
344
345    fn maker_fee(&self) -> Decimal {
346        self.maker_fee
347    }
348
349    fn taker_fee(&self) -> Decimal {
350        self.taker_fee
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use rstest::rstest;
357
358    use crate::{
359        enums::{AssetClass, InstrumentClass},
360        identifiers::{InstrumentId, Symbol},
361        instruments::{Equity, Instrument, stubs::*},
362        types::{Currency, Price, Quantity},
363    };
364
365    #[rstest]
366    fn test_trait_accessors(equity_aapl: Equity) {
367        assert_eq!(equity_aapl.id(), InstrumentId::from("AAPL.XNAS"));
368        assert_eq!(equity_aapl.raw_symbol(), Symbol::from("AAPL"));
369        assert_eq!(equity_aapl.asset_class(), AssetClass::Equity);
370        assert_eq!(equity_aapl.instrument_class(), InstrumentClass::Spot);
371        assert_eq!(equity_aapl.quote_currency(), Currency::USD());
372        assert_eq!(equity_aapl.settlement_currency(), Currency::USD());
373        assert!(!equity_aapl.is_inverse());
374        assert_eq!(equity_aapl.price_precision(), 2);
375        assert_eq!(equity_aapl.size_precision(), 0);
376        assert_eq!(equity_aapl.price_increment(), Price::from("0.01"));
377        assert_eq!(equity_aapl.size_increment(), Quantity::from("1"));
378        assert_eq!(equity_aapl.multiplier(), Quantity::from("1"));
379        assert_eq!(equity_aapl.base_currency(), None);
380        assert_eq!(equity_aapl.underlying(), None);
381        assert_eq!(equity_aapl.option_kind(), None);
382        assert_eq!(equity_aapl.strike_price(), None);
383        assert_eq!(equity_aapl.activation_ns(), None);
384        assert_eq!(equity_aapl.expiration_ns(), None);
385    }
386
387    #[rstest]
388    fn test_isin(equity_aapl: Equity) {
389        assert_eq!(
390            equity_aapl.isin().map(|u| u.to_string()),
391            Some("US0378331005".to_string()),
392        );
393    }
394
395    #[rstest]
396    fn test_new_checked_price_precision_mismatch() {
397        let result = Equity::new_checked(
398            InstrumentId::from("AAPL.XNAS"),
399            Symbol::from("AAPL"),
400            None,
401            Currency::USD(),
402            3, // mismatch
403            Price::from("0.01"),
404            None,
405            None,
406            None,
407            None,
408            None,
409            None,
410            None,
411            None,
412            None,
413            None,
414            0.into(),
415            0.into(),
416        );
417        assert!(result.is_err());
418    }
419
420    #[rstest]
421    fn test_new_checked_zero_price_increment() {
422        let result = Equity::new_checked(
423            InstrumentId::from("AAPL.XNAS"),
424            Symbol::from("AAPL"),
425            None,
426            Currency::USD(),
427            0,
428            Price::from("0"),
429            None,
430            None,
431            None,
432            None,
433            None,
434            None,
435            None,
436            None,
437            None,
438            None,
439            0.into(),
440            0.into(),
441        );
442        assert!(result.is_err());
443    }
444
445    #[rstest]
446    fn test_new_checked_non_ascii_isin() {
447        let result = Equity::new_checked(
448            InstrumentId::from("AAPL.XNAS"),
449            Symbol::from("AAPL"),
450            Some(ustr::Ustr::from("US\u{00E9}378331005")),
451            Currency::USD(),
452            2,
453            Price::from("0.01"),
454            None,
455            None,
456            None,
457            None,
458            None,
459            None,
460            None,
461            None,
462            None,
463            None,
464            0.into(),
465            0.into(),
466        );
467        assert!(result.is_err());
468        assert!(result.unwrap_err().to_string().contains("non-ASCII"));
469    }
470
471    #[rstest]
472    fn test_serialization_roundtrip(equity_aapl: Equity) {
473        let json = serde_json::to_string(&equity_aapl).unwrap();
474        let deserialized: Equity = serde_json::from_str(&json).unwrap();
475        assert_eq!(equity_aapl, deserialized);
476    }
477}