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