1use 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#[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 pub id: InstrumentId,
55 pub raw_symbol: Symbol,
57 pub isin: Option<Ustr>,
59 pub currency: Currency,
61 pub price_precision: u8,
63 pub price_increment: Price,
65 pub margin_init: Decimal,
67 pub margin_maint: Decimal,
69 pub maker_fee: Decimal,
71 pub taker_fee: Decimal,
73 pub lot_size: Option<Quantity>,
75 pub max_quantity: Option<Quantity>,
77 pub min_quantity: Option<Quantity>,
79 pub max_price: Option<Price>,
81 pub min_price: Option<Price>,
83 pub tick_scheme: Option<Ustr>,
85 pub info: Option<Params>,
87 pub ts_event: UnixNanos,
89 pub ts_init: UnixNanos,
91}
92
93#[bon::bon]
94impl Equity {
95 #[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 #[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 #[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, 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}