1use 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#[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 pub id: InstrumentId,
52 pub raw_symbol: Symbol,
54 pub isin: Option<Ustr>,
56 pub currency: Currency,
58 pub price_precision: u8,
60 pub price_increment: Price,
62 pub margin_init: Decimal,
64 pub margin_maint: Decimal,
66 pub maker_fee: Decimal,
68 pub taker_fee: Decimal,
70 pub lot_size: Option<Quantity>,
72 pub max_quantity: Option<Quantity>,
74 pub min_quantity: Option<Quantity>,
76 pub max_price: Option<Price>,
78 pub min_price: Option<Price>,
80 pub tick_scheme: Option<Ustr>,
82 pub info: Option<Params>,
84 pub ts_event: UnixNanos,
86 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 #[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, 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}