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