Skip to main content

optionstratlib_core/model/leg/
leg_enum.rs

1/******************************************************************************
2   Author: Joaquín Béjar García
3   Email: jb@taunais.com
4   Date: 24/12/25
5******************************************************************************/
6
7//! # Leg Enum Module
8//!
9//! This module provides the `Leg` enum that unifies different position types
10//! (options, spot, futures, perpetuals) into a single type for use in
11//! multi-instrument strategies.
12//!
13//! ## Supported Leg Types
14//!
15//! - `Option` - Standard option positions (Call/Put)
16//! - `Spot` - Direct ownership of underlying assets
17//! - `Future` - Exchange-traded futures contracts
18//! - `Perpetual` - Crypto perpetual swap contracts
19//!
20//! ## Example
21//!
22//! ```rust
23//! # #[cfg(feature = "clock")] {
24//! use optionstratlib_core::model::leg::{Leg, SpotPosition};
25//! use optionstratlib_core::model::Position;
26//! use optionstratlib_core::model::types::Side;
27//! use positive::{pos_or_panic,Positive};
28//!
29//! // Create a spot leg
30//! let spot = SpotPosition::long("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
31//! let spot_leg = Leg::Spot(spot);
32//!
33//! // Check leg type
34//! assert!(spot_leg.is_spot());
35//! # }
36//! ```
37
38use crate::error::PositionError;
39use crate::model::leg::future::FuturePosition;
40use crate::model::leg::perpetual::PerpetualPosition;
41use crate::model::leg::spot::SpotPosition;
42use crate::model::leg::traits::LegAble;
43use crate::model::position::Position;
44use crate::model::types::Side;
45use positive::Positive;
46use rust_decimal::Decimal;
47use serde::{Deserialize, Serialize};
48
49/// Represents different types of legs in a trading strategy.
50///
51/// This enum provides a unified interface for handling various instrument types
52/// in multi-leg strategies, enabling strategies like Covered Call (spot + option),
53/// Cash & Carry (spot + perpetual), and other combinations.
54///
55/// # Variants
56///
57/// * `Option` - Standard option position (Call or Put)
58/// * `Spot` - Direct ownership of underlying asset
59/// * `Future` - Exchange-traded futures contract
60/// * `Perpetual` - Crypto perpetual swap contract
61#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
62#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
63#[serde(rename_all = "snake_case")]
64pub enum Leg {
65    /// Standard option position (Call/Put).
66    Option(Box<Position>),
67
68    /// Spot/underlying asset position.
69    Spot(SpotPosition),
70
71    /// Exchange-traded futures contract.
72    Future(FuturePosition),
73
74    /// Crypto perpetual swap contract.
75    Perpetual(PerpetualPosition),
76}
77
78impl Leg {
79    /// Creates a new option leg from a Position.
80    #[must_use]
81    pub fn option(position: Position) -> Self {
82        Self::Option(Box::new(position))
83    }
84
85    /// Creates a new spot leg from a SpotPosition.
86    #[must_use]
87    pub fn spot(position: SpotPosition) -> Self {
88        Self::Spot(position)
89    }
90
91    /// Creates a new future leg from a FuturePosition.
92    #[must_use]
93    pub fn future(position: FuturePosition) -> Self {
94        Self::Future(position)
95    }
96
97    /// Creates a new perpetual leg from a PerpetualPosition.
98    #[must_use]
99    pub fn perpetual(position: PerpetualPosition) -> Self {
100        Self::Perpetual(position)
101    }
102
103    /// Returns true if this is an option leg.
104    #[must_use]
105    pub fn is_option(&self) -> bool {
106        matches!(self, Self::Option(_))
107    }
108
109    /// Returns true if this is a spot leg.
110    #[must_use]
111    pub fn is_spot(&self) -> bool {
112        matches!(self, Self::Spot(_))
113    }
114
115    /// Returns true if this is a future leg.
116    #[must_use]
117    pub fn is_future(&self) -> bool {
118        matches!(self, Self::Future(_))
119    }
120
121    /// Returns true if this is a perpetual leg.
122    #[must_use]
123    pub fn is_perpetual(&self) -> bool {
124        matches!(self, Self::Perpetual(_))
125    }
126
127    /// Returns true if this is a linear instrument (spot, future, perpetual).
128    ///
129    /// Linear instruments have constant delta and zero gamma.
130    #[must_use]
131    pub fn is_linear(&self) -> bool {
132        !self.is_option()
133    }
134
135    /// Returns true if this is a derivative (option, future, perpetual).
136    #[must_use]
137    pub fn is_derivative(&self) -> bool {
138        !self.is_spot()
139    }
140
141    /// Returns true if this leg has margin requirements.
142    #[must_use]
143    pub fn is_margined(&self) -> bool {
144        matches!(self, Self::Future(_) | Self::Perpetual(_))
145    }
146
147    /// Returns true if this leg has an expiration date.
148    #[must_use]
149    pub fn has_expiration(&self) -> bool {
150        matches!(self, Self::Option(_) | Self::Future(_))
151    }
152
153    /// Returns the underlying option position if this is an Option leg.
154    #[must_use]
155    pub fn as_option(&self) -> Option<&Position> {
156        match self {
157            Self::Option(pos) => Some(pos),
158            _ => None,
159        }
160    }
161
162    /// Returns the underlying spot position if this is a Spot leg.
163    #[must_use]
164    pub fn as_spot(&self) -> Option<&SpotPosition> {
165        match self {
166            Self::Spot(pos) => Some(pos),
167            _ => None,
168        }
169    }
170
171    /// Returns the underlying future position if this is a Future leg.
172    #[must_use]
173    pub fn as_future(&self) -> Option<&FuturePosition> {
174        match self {
175            Self::Future(pos) => Some(pos),
176            _ => None,
177        }
178    }
179
180    /// Returns the underlying perpetual position if this is a Perpetual leg.
181    #[must_use]
182    pub fn as_perpetual(&self) -> Option<&PerpetualPosition> {
183        match self {
184            Self::Perpetual(pos) => Some(pos),
185            _ => None,
186        }
187    }
188
189    /// Returns a mutable reference to the underlying option position.
190    #[must_use]
191    pub fn as_option_mut(&mut self) -> Option<&mut Position> {
192        match self {
193            Self::Option(pos) => Some(pos),
194            _ => None,
195        }
196    }
197
198    /// Returns a mutable reference to the underlying spot position.
199    #[must_use]
200    pub fn as_spot_mut(&mut self) -> Option<&mut SpotPosition> {
201        match self {
202            Self::Spot(pos) => Some(pos),
203            _ => None,
204        }
205    }
206
207    /// Returns a mutable reference to the underlying future position.
208    #[must_use]
209    pub fn as_future_mut(&mut self) -> Option<&mut FuturePosition> {
210        match self {
211            Self::Future(pos) => Some(pos),
212            _ => None,
213        }
214    }
215
216    /// Returns a mutable reference to the underlying perpetual position.
217    #[must_use]
218    pub fn as_perpetual_mut(&mut self) -> Option<&mut PerpetualPosition> {
219        match self {
220            Self::Perpetual(pos) => Some(pos),
221            _ => None,
222        }
223    }
224
225    /// Returns a string describing the leg type.
226    #[must_use]
227    pub fn leg_type_name(&self) -> &'static str {
228        match self {
229            Self::Option(_) => "Option",
230            Self::Spot(_) => "Spot",
231            Self::Future(_) => "Future",
232            Self::Perpetual(_) => "Perpetual",
233        }
234    }
235}
236
237impl LegAble for Leg {
238    fn get_symbol(&self) -> &str {
239        match self {
240            Self::Option(pos) => &pos.option.underlying_symbol,
241            Self::Spot(pos) => pos.get_symbol(),
242            Self::Future(pos) => pos.get_symbol(),
243            Self::Perpetual(pos) => pos.get_symbol(),
244        }
245    }
246
247    fn get_quantity(&self) -> Positive {
248        match self {
249            Self::Option(pos) => pos.option.quantity,
250            Self::Spot(pos) => pos.get_quantity(),
251            Self::Future(pos) => pos.get_quantity(),
252            Self::Perpetual(pos) => pos.get_quantity(),
253        }
254    }
255
256    fn get_side(&self) -> Side {
257        match self {
258            Self::Option(pos) => pos.option.side,
259            Self::Spot(pos) => pos.get_side(),
260            Self::Future(pos) => pos.get_side(),
261            Self::Perpetual(pos) => pos.get_side(),
262        }
263    }
264
265    fn pnl_at_price(&self, price: Positive) -> Result<Decimal, PositionError> {
266        match self {
267            // The option arm used to swallow the failure as
268            // `unwrap_or(Decimal::ZERO)`, which reported a break-even where
269            // the payoff had in fact not been computed. Now that the trait
270            // has an error channel it reports the failure instead.
271            Self::Option(pos) => pos.pnl_at_expiration(&Some(&price)),
272            Self::Spot(pos) => pos.pnl_at_price(price),
273            Self::Future(pos) => pos.pnl_at_price(price),
274            Self::Perpetual(pos) => pos.pnl_at_price(price),
275        }
276    }
277
278    fn total_cost(&self) -> Result<Positive, PositionError> {
279        match self {
280            // The option arm used to swallow the failure as
281            // `unwrap_or(Positive::ZERO)`, which is also the honest cost of a
282            // short leg and so could not be told apart from a real answer.
283            Self::Option(pos) => pos.total_cost(),
284            Self::Spot(pos) => pos.total_cost(),
285            Self::Future(pos) => pos.total_cost(),
286            Self::Perpetual(pos) => pos.total_cost(),
287        }
288    }
289
290    fn fees(&self) -> Result<Positive, PositionError> {
291        match self {
292            // Deliberately not `Position::fees()`, which scales the sum by
293            // the contract quantity. This arm has always returned the two
294            // fees unscaled, and #471 is about giving the method an error
295            // channel, not about changing what it reports. The discrepancy
296            // between the two conventions predates this change.
297            Self::Option(pos) => Ok(pos.open_fee.checked_add(&pos.close_fee)?),
298            Self::Spot(pos) => pos.fees(),
299            Self::Future(pos) => pos.fees(),
300            Self::Perpetual(pos) => pos.fees(),
301        }
302    }
303
304    /// Notional value at `price`. An option leg covers
305    /// `quantity × contract_size` units of the underlying, so its notional is
306    /// that size times `price`; the other legs keep `quantity × price`.
307    ///
308    /// # Errors
309    ///
310    /// Returns [`PositionError::PositiveError`] when the product leaves the
311    /// `Positive` range (#788).
312    fn notional_value(&self, price: Positive) -> Result<Positive, PositionError> {
313        match self {
314            Self::Option(pos) => Ok(pos
315                .option
316                .quantity
317                .checked_mul(&pos.option.contract_size)?
318                .checked_mul(&price)?),
319            Self::Spot(pos) => pos.notional_value(price),
320            Self::Future(pos) => pos.notional_value(price),
321            Self::Perpetual(pos) => pos.notional_value(price),
322        }
323    }
324}
325
326impl std::fmt::Display for Leg {
327    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
328        match self {
329            Self::Option(pos) => write!(f, "[Option] {}", pos),
330            Self::Spot(pos) => write!(f, "[Spot] {}", pos),
331            Self::Future(pos) => write!(f, "[Future] {}", pos),
332            Self::Perpetual(pos) => write!(f, "[Perpetual] {}", pos),
333        }
334    }
335}
336
337impl From<Position> for Leg {
338    fn from(position: Position) -> Self {
339        Self::Option(Box::new(position))
340    }
341}
342
343impl From<SpotPosition> for Leg {
344    fn from(position: SpotPosition) -> Self {
345        Self::Spot(position)
346    }
347}
348
349impl From<FuturePosition> for Leg {
350    fn from(position: FuturePosition) -> Self {
351        Self::Future(position)
352    }
353}
354
355impl From<PerpetualPosition> for Leg {
356    fn from(position: PerpetualPosition) -> Self {
357        Self::Perpetual(position)
358    }
359}
360
361#[cfg(all(test, feature = "clock"))]
362mod tests {
363    use super::*;
364    use crate::model::ExpirationDate;
365    use crate::model::types::OptionStyle;
366    use crate::model::utils::create_sample_option_simplest;
367
368    use positive::pos_or_panic;
369
370    fn create_test_option_position() -> Position {
371        let option = create_sample_option_simplest(OptionStyle::Call, Side::Long);
372        Position::new(
373            option,
374            pos_or_panic!(5.0),
375            chrono::DateTime::UNIX_EPOCH,
376            pos_or_panic!(0.5),
377            pos_or_panic!(0.5),
378            None,
379            None,
380        )
381    }
382
383    fn create_test_spot_position() -> SpotPosition {
384        SpotPosition::long("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0))
385    }
386
387    fn create_test_future_position() -> FuturePosition {
388        FuturePosition::long(
389            "ES".to_string(),
390            Positive::ONE,
391            pos_or_panic!(4500.0),
392            ExpirationDate::Days(pos_or_panic!(30.0)),
393            pos_or_panic!(50.0),
394            pos_or_panic!(15000.0),
395        )
396    }
397
398    fn create_test_perpetual_position() -> PerpetualPosition {
399        PerpetualPosition::long(
400            "BTC-USDT-PERP".to_string(),
401            Positive::ONE,
402            pos_or_panic!(50000.0),
403            pos_or_panic!(10.0),
404            pos_or_panic!(5000.0),
405        )
406    }
407
408    #[test]
409    fn test_leg_option_creation() {
410        let pos = create_test_option_position();
411        let leg = Leg::option(pos.clone());
412
413        assert!(leg.is_option());
414        assert!(!leg.is_spot());
415        assert!(!leg.is_future());
416        assert!(!leg.is_perpetual());
417        assert!(!leg.is_linear());
418        assert!(leg.is_derivative());
419        assert!(leg.has_expiration());
420    }
421
422    #[test]
423    fn test_leg_spot_creation() {
424        let pos = create_test_spot_position();
425        let leg = Leg::spot(pos);
426
427        assert!(!leg.is_option());
428        assert!(leg.is_spot());
429        assert!(!leg.is_future());
430        assert!(!leg.is_perpetual());
431        assert!(leg.is_linear());
432        assert!(!leg.is_derivative());
433        assert!(!leg.has_expiration());
434        assert!(!leg.is_margined());
435    }
436
437    #[test]
438    fn test_leg_future_creation() {
439        let pos = create_test_future_position();
440        let leg = Leg::future(pos);
441
442        assert!(!leg.is_option());
443        assert!(!leg.is_spot());
444        assert!(leg.is_future());
445        assert!(!leg.is_perpetual());
446        assert!(leg.is_linear());
447        assert!(leg.is_derivative());
448        assert!(leg.has_expiration());
449        assert!(leg.is_margined());
450    }
451
452    #[test]
453    fn test_leg_perpetual_creation() {
454        let pos = create_test_perpetual_position();
455        let leg = Leg::perpetual(pos);
456
457        assert!(!leg.is_option());
458        assert!(!leg.is_spot());
459        assert!(!leg.is_future());
460        assert!(leg.is_perpetual());
461        assert!(leg.is_linear());
462        assert!(leg.is_derivative());
463        assert!(!leg.has_expiration());
464        assert!(leg.is_margined());
465    }
466
467    #[test]
468    fn test_leg_from_position() {
469        let pos = create_test_option_position();
470        let leg: Leg = pos.into();
471        assert!(leg.is_option());
472    }
473
474    #[test]
475    fn test_leg_from_spot() {
476        let pos = create_test_spot_position();
477        let leg: Leg = pos.into();
478        assert!(leg.is_spot());
479    }
480
481    #[test]
482    fn test_leg_from_future() {
483        let pos = create_test_future_position();
484        let leg: Leg = pos.into();
485        assert!(leg.is_future());
486    }
487
488    #[test]
489    fn test_leg_from_perpetual() {
490        let pos = create_test_perpetual_position();
491        let leg: Leg = pos.into();
492        assert!(leg.is_perpetual());
493    }
494
495    #[test]
496    fn test_leg_as_option() {
497        let pos = create_test_option_position();
498        let leg = Leg::option(pos);
499
500        assert!(leg.as_option().is_some());
501        assert!(leg.as_spot().is_none());
502        assert!(leg.as_future().is_none());
503        assert!(leg.as_perpetual().is_none());
504    }
505
506    #[test]
507    fn test_leg_as_spot() {
508        let pos = create_test_spot_position();
509        let leg = Leg::spot(pos);
510
511        assert!(leg.as_option().is_none());
512        assert!(leg.as_spot().is_some());
513        assert!(leg.as_future().is_none());
514        assert!(leg.as_perpetual().is_none());
515    }
516
517    #[test]
518    fn test_leg_get_symbol() {
519        let spot = create_test_spot_position();
520        let leg = Leg::spot(spot);
521        assert_eq!(leg.get_symbol(), "AAPL");
522
523        let future = create_test_future_position();
524        let leg = Leg::future(future);
525        assert_eq!(leg.get_symbol(), "ES");
526
527        let perp = create_test_perpetual_position();
528        let leg = Leg::perpetual(perp);
529        assert_eq!(leg.get_symbol(), "BTC-USDT-PERP");
530    }
531
532    #[cfg(feature = "clock")]
533    #[test]
534    fn test_leg_get_quantity() {
535        let spot = SpotPosition::long("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
536        let leg = Leg::spot(spot);
537        assert_eq!(leg.get_quantity(), Positive::HUNDRED);
538    }
539
540    #[cfg(feature = "clock")]
541    #[test]
542    fn test_leg_get_side() {
543        let long_spot =
544            SpotPosition::long("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
545        let leg = Leg::spot(long_spot);
546        assert_eq!(leg.get_side(), Side::Long);
547
548        let short_spot =
549            SpotPosition::short("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
550        let leg = Leg::spot(short_spot);
551        assert_eq!(leg.get_side(), Side::Short);
552    }
553
554    #[cfg(feature = "clock")]
555    #[test]
556    fn test_leg_pnl_spot() {
557        let spot = SpotPosition::long("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
558        let leg = Leg::spot(spot);
559
560        let pnl = leg.pnl_at_price(pos_or_panic!(160.0));
561        assert_eq!(pnl.ok(), Some(Decimal::from(1000)));
562    }
563
564    #[test]
565    fn test_leg_type_name() {
566        let option_leg = Leg::option(create_test_option_position());
567        assert_eq!(option_leg.leg_type_name(), "Option");
568
569        let spot_leg = Leg::spot(create_test_spot_position());
570        assert_eq!(spot_leg.leg_type_name(), "Spot");
571
572        let future_leg = Leg::future(create_test_future_position());
573        assert_eq!(future_leg.leg_type_name(), "Future");
574
575        let perp_leg = Leg::perpetual(create_test_perpetual_position());
576        assert_eq!(perp_leg.leg_type_name(), "Perpetual");
577    }
578
579    #[test]
580    fn test_leg_display() {
581        let spot = create_test_spot_position();
582        let leg = Leg::spot(spot);
583        let display = format!("{}", leg);
584        assert!(display.contains("[Spot]"));
585        assert!(display.contains("AAPL"));
586    }
587
588    #[test]
589    fn test_leg_total_cost() {
590        let spot = SpotPosition::new(
591            "AAPL".to_string(),
592            Positive::HUNDRED,
593            pos_or_panic!(150.0),
594            Side::Long,
595            chrono::DateTime::UNIX_EPOCH,
596            pos_or_panic!(10.0),
597            pos_or_panic!(10.0),
598        );
599        let leg = Leg::spot(spot);
600        assert_eq!(leg.total_cost().ok(), Some(pos_or_panic!(15020.0)));
601    }
602
603    #[test]
604    fn test_leg_fees() {
605        let spot = SpotPosition::new(
606            "AAPL".to_string(),
607            Positive::HUNDRED,
608            pos_or_panic!(150.0),
609            Side::Long,
610            chrono::DateTime::UNIX_EPOCH,
611            pos_or_panic!(10.0),
612            pos_or_panic!(15.0),
613        );
614        let leg = Leg::spot(spot);
615        assert_eq!(leg.fees().ok(), Some(pos_or_panic!(25.0)));
616    }
617
618    // `quantity * contract_size * price` aborted with `Positive arithmetic
619    // overflow in mul` on an option leg (#788).
620    #[test]
621    fn test_leg_notional_value_option_overflow_is_error() {
622        let mut position = create_test_option_position();
623        position.option.quantity = Positive::TWO;
624        let leg = Leg::option(position);
625        assert!(leg.notional_value(Positive::MAX).is_err());
626        assert_eq!(
627            leg.notional_value(Positive::HUNDRED).ok(),
628            Some(pos_or_panic!(200.0))
629        );
630    }
631}