1use std::hash::{Hash, Hasher};
17
18use nautilus_core::{
19 Params, UnixNanos,
20 correctness::{
21 CorrectnessResult, CorrectnessResultExt, FAILED, check_equal_u8, check_valid_string_ascii,
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, 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 OptionSpread {
53 pub id: InstrumentId,
55 pub raw_symbol: Symbol,
57 pub asset_class: AssetClass,
59 pub exchange: Option<Ustr>,
61 pub underlying: Ustr,
63 pub strategy_type: Ustr,
65 pub activation_ns: UnixNanos,
67 pub expiration_ns: UnixNanos,
69 pub currency: Currency,
71 pub price_precision: u8,
73 pub price_increment: Price,
75 pub size_increment: Quantity,
77 pub size_precision: u8,
79 pub multiplier: Quantity,
81 pub lot_size: Quantity,
83 pub margin_init: Decimal,
85 pub margin_maint: Decimal,
87 pub maker_fee: Decimal,
89 pub taker_fee: Decimal,
91 pub max_quantity: Option<Quantity>,
93 pub min_quantity: Option<Quantity>,
95 pub max_price: Option<Price>,
97 pub min_price: Option<Price>,
99 pub info: Option<Params>,
101 pub ts_event: UnixNanos,
103 pub ts_init: UnixNanos,
105}
106
107impl OptionSpread {
108 #[expect(clippy::too_many_arguments)]
117 pub fn new_checked(
118 instrument_id: InstrumentId,
119 raw_symbol: Symbol,
120 asset_class: AssetClass,
121 exchange: Option<Ustr>,
122 underlying: Ustr,
123 strategy_type: Ustr,
124 activation_ns: UnixNanos,
125 expiration_ns: UnixNanos,
126 currency: Currency,
127 price_precision: u8,
128 price_increment: Price,
129 multiplier: Quantity,
130 lot_size: Quantity,
131 max_quantity: Option<Quantity>,
132 min_quantity: Option<Quantity>,
133 max_price: Option<Price>,
134 min_price: Option<Price>,
135 margin_init: Option<Decimal>,
136 margin_maint: Option<Decimal>,
137 maker_fee: Option<Decimal>,
138 taker_fee: Option<Decimal>,
139 info: Option<Params>,
140 ts_event: UnixNanos,
141 ts_init: UnixNanos,
142 ) -> CorrectnessResult<Self> {
143 check_valid_string_ascii_optional(exchange.map(|u| u.as_str()), stringify!(exchange))?;
144 check_valid_string_ascii(strategy_type.as_str(), stringify!(strategy_type))?;
145 check_equal_u8(
146 price_precision,
147 price_increment.precision,
148 stringify!(price_precision),
149 stringify!(price_increment.precision),
150 )?;
151 check_positive_price(price_increment, stringify!(price_increment))?;
152 check_positive_quantity(multiplier, stringify!(multiplier))?;
153 check_positive_quantity(lot_size, stringify!(lot_size))?;
154
155 Ok(Self {
156 id: instrument_id,
157 raw_symbol,
158 asset_class,
159 exchange,
160 underlying,
161 strategy_type,
162 activation_ns,
163 expiration_ns,
164 currency,
165 price_precision,
166 price_increment,
167 size_precision: 0,
168 size_increment: Quantity::from("1"),
169 multiplier,
170 lot_size,
171 margin_init: margin_init.unwrap_or_default(),
172 margin_maint: margin_maint.unwrap_or_default(),
173 maker_fee: maker_fee.unwrap_or_default(),
174 taker_fee: taker_fee.unwrap_or_default(),
175 max_quantity,
176 min_quantity: Some(min_quantity.unwrap_or(1.into())),
177 max_price,
178 min_price,
179 info,
180 ts_event,
181 ts_init,
182 })
183 }
184
185 #[expect(clippy::too_many_arguments)]
191 #[must_use]
192 pub fn new(
193 instrument_id: InstrumentId,
194 raw_symbol: Symbol,
195 asset_class: AssetClass,
196 exchange: Option<Ustr>,
197 underlying: Ustr,
198 strategy_type: Ustr,
199 activation_ns: UnixNanos,
200 expiration_ns: UnixNanos,
201 currency: Currency,
202 price_precision: u8,
203 price_increment: Price,
204 multiplier: Quantity,
205 lot_size: Quantity,
206 max_quantity: Option<Quantity>,
207 min_quantity: Option<Quantity>,
208 max_price: Option<Price>,
209 min_price: Option<Price>,
210 margin_init: Option<Decimal>,
211 margin_maint: Option<Decimal>,
212 maker_fee: Option<Decimal>,
213 taker_fee: Option<Decimal>,
214 info: Option<Params>,
215 ts_event: UnixNanos,
216 ts_init: UnixNanos,
217 ) -> Self {
218 Self::new_checked(
219 instrument_id,
220 raw_symbol,
221 asset_class,
222 exchange,
223 underlying,
224 strategy_type,
225 activation_ns,
226 expiration_ns,
227 currency,
228 price_precision,
229 price_increment,
230 multiplier,
231 lot_size,
232 max_quantity,
233 min_quantity,
234 max_price,
235 min_price,
236 margin_init,
237 margin_maint,
238 maker_fee,
239 taker_fee,
240 info,
241 ts_event,
242 ts_init,
243 )
244 .expect_display(FAILED)
245 }
246}
247
248impl PartialEq<Self> for OptionSpread {
249 fn eq(&self, other: &Self) -> bool {
250 self.id == other.id
251 }
252}
253
254impl Eq for OptionSpread {}
255
256impl Hash for OptionSpread {
257 fn hash<H: Hasher>(&self, state: &mut H) {
258 self.id.hash(state);
259 }
260}
261
262impl Instrument for OptionSpread {
263 fn into_any(self) -> InstrumentAny {
264 InstrumentAny::OptionSpread(self)
265 }
266
267 fn id(&self) -> InstrumentId {
268 self.id
269 }
270
271 fn raw_symbol(&self) -> Symbol {
272 self.raw_symbol
273 }
274
275 fn asset_class(&self) -> AssetClass {
276 self.asset_class
277 }
278
279 fn instrument_class(&self) -> InstrumentClass {
280 InstrumentClass::OptionSpread
281 }
282 fn underlying(&self) -> Option<Ustr> {
283 Some(self.underlying)
284 }
285
286 fn base_currency(&self) -> Option<Currency> {
287 None
288 }
289
290 fn quote_currency(&self) -> Currency {
291 self.currency
292 }
293
294 fn settlement_currency(&self) -> Currency {
295 self.currency
296 }
297
298 fn isin(&self) -> Option<Ustr> {
299 None
300 }
301
302 fn option_kind(&self) -> Option<OptionKind> {
303 None
304 }
305
306 fn exchange(&self) -> Option<Ustr> {
307 self.exchange
308 }
309
310 fn strike_price(&self) -> Option<Price> {
311 None
312 }
313
314 fn activation_ns(&self) -> Option<UnixNanos> {
315 Some(self.activation_ns)
316 }
317
318 fn expiration_ns(&self) -> Option<UnixNanos> {
319 Some(self.expiration_ns)
320 }
321
322 fn is_inverse(&self) -> bool {
323 false
324 }
325
326 fn price_precision(&self) -> u8 {
327 self.price_precision
328 }
329
330 fn size_precision(&self) -> u8 {
331 0
332 }
333
334 fn price_increment(&self) -> Price {
335 self.price_increment
336 }
337
338 fn size_increment(&self) -> Quantity {
339 Quantity::from(1)
340 }
341
342 fn multiplier(&self) -> Quantity {
343 self.multiplier
344 }
345
346 fn lot_size(&self) -> Option<Quantity> {
347 Some(self.lot_size)
348 }
349
350 fn max_quantity(&self) -> Option<Quantity> {
351 self.max_quantity
352 }
353
354 fn min_quantity(&self) -> Option<Quantity> {
355 self.min_quantity
356 }
357
358 fn max_notional(&self) -> Option<Money> {
359 None
360 }
361
362 fn min_notional(&self) -> Option<Money> {
363 None
364 }
365
366 fn max_price(&self) -> Option<Price> {
367 self.max_price
368 }
369
370 fn min_price(&self) -> Option<Price> {
371 self.min_price
372 }
373
374 fn ts_event(&self) -> UnixNanos {
375 self.ts_event
376 }
377
378 fn ts_init(&self) -> UnixNanos {
379 self.ts_init
380 }
381
382 fn margin_init(&self) -> Decimal {
383 self.margin_init
384 }
385
386 fn margin_maint(&self) -> Decimal {
387 self.margin_maint
388 }
389
390 fn maker_fee(&self) -> Decimal {
391 self.maker_fee
392 }
393
394 fn taker_fee(&self) -> Decimal {
395 self.taker_fee
396 }
397}
398
399#[cfg(test)]
400mod tests {
401 use rstest::rstest;
402 use ustr::Ustr;
403
404 use crate::{
405 enums::{AssetClass, InstrumentClass},
406 identifiers::{InstrumentId, Symbol},
407 instruments::{Instrument, OptionSpread, stubs::*},
408 types::{Currency, Price, Quantity},
409 };
410
411 #[rstest]
412 fn test_trait_accessors(option_spread: OptionSpread) {
413 assert_eq!(
414 option_spread.id(),
415 InstrumentId::from("UD:U$: GN 2534559.GLBX")
416 );
417 assert_eq!(option_spread.asset_class(), AssetClass::FX);
418 assert_eq!(
419 option_spread.instrument_class(),
420 InstrumentClass::OptionSpread
421 );
422 assert_eq!(option_spread.quote_currency(), Currency::USD());
423 assert!(!option_spread.is_inverse());
424 assert_eq!(option_spread.exchange(), Some(Ustr::from("XCME")));
425 assert_eq!(option_spread.size_precision(), 0);
426 assert_eq!(option_spread.size_increment(), Quantity::from("1"));
427 assert_eq!(option_spread.min_quantity(), Some(Quantity::from("1")));
428 }
429
430 #[rstest]
431 fn test_new_checked_price_precision_mismatch() {
432 let result = OptionSpread::new_checked(
433 InstrumentId::from("TEST.GLBX"),
434 Symbol::from("TEST"),
435 AssetClass::FX,
436 Some(Ustr::from("XCME")),
437 Ustr::from("SR3"),
438 Ustr::from("GN"),
439 0.into(),
440 0.into(),
441 Currency::USD(),
442 4, Price::from("0.01"),
444 Quantity::from(1),
445 Quantity::from(1),
446 None,
447 None,
448 None,
449 None,
450 None,
451 None,
452 None,
453 None,
454 None,
455 0.into(),
456 0.into(),
457 );
458 assert!(result.is_err());
459 }
460
461 #[rstest]
462 fn test_serialization_roundtrip(option_spread: OptionSpread) {
463 let json = serde_json::to_string(&option_spread).unwrap();
464 let deserialized: OptionSpread = serde_json::from_str(&json).unwrap();
465 assert_eq!(option_spread, deserialized);
466 }
467}