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 lot_size: Option<Quantity>,
68 pub max_quantity: Option<Quantity>,
70 pub min_quantity: Option<Quantity>,
72 pub max_price: Option<Price>,
74 pub min_price: Option<Price>,
76 pub tick_scheme: Option<Ustr>,
78 pub info: Option<Params>,
80 pub ts_event: UnixNanos,
82 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 #[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, 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}