Skip to main content

optionstratlib_core/model/leg/
traits.rs

1/******************************************************************************
2   Author: Joaquín Béjar García
3   Email: jb@taunais.com
4   Date: 24/12/25
5******************************************************************************/
6
7//! # Leg Traits Module
8//!
9//! This module defines common traits for different types of trading legs
10//! (positions in various instruments like spot, futures, perpetuals, etc.).
11//!
12//! The `LegAble` trait provides a unified interface for calculating profit/loss,
13//! retrieving position information, and computing Greeks across different
14//! instrument types.
15
16use crate::error::PositionError;
17use crate::model::decimal::{d_div, d_mul};
18use crate::model::types::Side;
19use positive::Positive;
20use rust_decimal::Decimal;
21
22/// Hours in a 365-day year, the numerator of the funding-period count.
23const HOURS_PER_YEAR: u32 = 24 * 365;
24
25/// Days in the year [`Expirable::time_to_expiration_years`] divides by.
26const DAYS_PER_YEAR: u32 = 365;
27
28/// Common trait for all leg types in a trading strategy.
29///
30/// This trait provides a unified interface for different position types
31/// (spot, futures, perpetuals, options, etc.) enabling polymorphic handling
32/// in multi-leg strategies.
33///
34/// # Implementors
35///
36/// - `SpotPosition` - Direct ownership of underlying asset
37/// - `FuturePosition` - Exchange-traded futures contracts
38/// - `PerpetualPosition` - Crypto perpetual swap contracts
39/// - `Position` - Option positions (via adapter)
40pub trait LegAble {
41    /// Returns the symbol/ticker of the underlying instrument.
42    fn get_symbol(&self) -> &str;
43
44    /// Returns the position quantity in the leg's native unit (#830).
45    ///
46    /// The unit depends on the leg and is never converted: a `SpotPosition`
47    /// counts units of the underlying (shares), a `FuturePosition` counts
48    /// contracts, a `PerpetualPosition` counts base-currency units and an
49    /// option `Position` counts contracts, each `contract_size` underlying
50    /// units. Quantities of different leg types are not comparable without
51    /// that conversion, so do not add them together.
52    fn get_quantity(&self) -> Positive;
53
54    /// Returns the position side (Long or Short).
55    fn get_side(&self) -> Side;
56
57    /// Calculates the profit/loss at a given price.
58    ///
59    /// # Arguments
60    ///
61    /// * `price` - The price at which to calculate P&L
62    ///
63    /// # Returns
64    ///
65    /// The profit (positive) or loss (negative) as a Decimal value.
66    ///
67    /// # Decision (issue #471): the error channel is part of the signature
68    ///
69    /// This method used to return a bare `Decimal`. Every implementor computes
70    /// some form of `(price - basis) * quantity - fees`, and each of those
71    /// operators aborts the process on overflow. The inputs are `pub` fields
72    /// on the leg structs, so no constructor guard can bound them, and a
73    /// `Decimal` has no value that means "this does not fit". The trait
74    /// signature was the thing forbidding the report, so the trait signature
75    /// is what changed.
76    ///
77    /// # Errors
78    ///
79    /// Returns [`PositionError::DecimalError`] when the price difference, the
80    /// quantity scaling or the fee deduction leaves the representable
81    /// `Decimal` range, and [`PositionError::PositiveError`] when a fee total
82    /// leaves the `Positive` range. Option legs additionally propagate
83    /// whatever [`crate::model::position::Position::pnl_at_expiration`]
84    /// reports.
85    fn pnl_at_price(&self, price: Positive) -> Result<Decimal, PositionError>;
86
87    /// Returns the total cost to establish this position.
88    ///
89    /// For long positions, this includes the purchase price plus fees.
90    /// For short positions, this typically includes only fees.
91    ///
92    /// # Decision (issue #471): fallible, alongside `pnl_at_price`
93    ///
94    /// `Position::total_cost` has returned `Result<Positive, PositionError>`
95    /// since the position layer was made panic-free; the leg trait was the
96    /// remaining infallible wrapper over the same sum, and `Leg::Option` had
97    /// to swallow the position's error as `unwrap_or(Positive::ZERO)` to fit
98    /// this signature. A cost of zero is a legitimate answer for a short
99    /// leg, so that fallback was indistinguishable from a real result.
100    ///
101    /// # Errors
102    ///
103    /// Returns [`PositionError`] when the size, premium or fee sum leaves the
104    /// `Positive` range.
105    fn total_cost(&self) -> Result<Positive, PositionError>;
106
107    /// Returns the total fees associated with this position.
108    ///
109    /// # Errors
110    ///
111    /// Returns [`PositionError`] when the open and close fees do not sum
112    /// within the `Positive` range. See [`LegAble::total_cost`] for why this
113    /// gained an error channel.
114    fn fees(&self) -> Result<Positive, PositionError>;
115
116    /// Checks if this is a long position.
117    #[must_use]
118    fn is_long(&self) -> bool {
119        matches!(self.get_side(), Side::Long)
120    }
121
122    /// Checks if this is a short position.
123    #[must_use]
124    fn is_short(&self) -> bool {
125        matches!(self.get_side(), Side::Short)
126    }
127
128    /// Returns the notional value of the position at a given price.
129    ///
130    /// Notional = quantity × price
131    ///
132    /// # Errors
133    ///
134    /// Returns [`PositionError::PositiveError`] when the product leaves the
135    /// `Positive` range. The quantity and the price are both caller data,
136    /// so the product is not bounded; `Positive * Positive` aborted on it
137    /// (#788).
138    fn notional_value(&self, price: Positive) -> Result<Positive, PositionError> {
139        Ok(self.get_quantity().checked_mul(&price)?)
140    }
141}
142
143/// Trait for positions that have margin requirements.
144///
145/// This applies to futures, perpetuals, and CFDs where positions
146/// are leveraged and require margin collateral.
147///
148/// # Decision (issue #788): every method is fallible
149///
150/// Each implementor scales a `pub` margin, price or quantity field by
151/// another, and divides by the quantity for the liquidation price, so the
152/// infallible signatures aborted on an overflow or on a zero quantity
153/// (`PerpetualPosition::default().liquidation_price(..)` divided by zero).
154/// The methods return [`PositionError`] instead, as `pnl_at_price` has since
155/// #471.
156pub trait Marginable: LegAble {
157    /// Returns the initial margin requirement.
158    ///
159    /// # Errors
160    ///
161    /// Returns [`PositionError`] when the margin computation leaves the
162    /// `Positive` range.
163    fn initial_margin(&self) -> Result<Positive, PositionError>;
164
165    /// Returns the maintenance margin requirement.
166    ///
167    /// # Errors
168    ///
169    /// Returns [`PositionError`] when the margin computation leaves the
170    /// `Positive` or `Decimal` range.
171    fn maintenance_margin(&self) -> Result<Positive, PositionError>;
172
173    /// Returns the current leverage applied to the position.
174    ///
175    /// # Errors
176    ///
177    /// Returns [`PositionError`] when the leverage computation leaves the
178    /// `Decimal` range.
179    fn leverage(&self) -> Result<Positive, PositionError>;
180
181    /// Calculates the liquidation price for this position.
182    ///
183    /// # Arguments
184    ///
185    /// * `current_price` - The current market price
186    ///
187    /// # Returns
188    ///
189    /// `Some(price)` with the price at which the position would be
190    /// liquidated, or `None` when no non-negative price reaches liquidation:
191    /// a long whose margin buffer is wider than its entry price survives a
192    /// fall all the way to zero. `None` is not a price of zero; a price of
193    /// zero would be a liquidation level a spot of zero crosses.
194    ///
195    /// A threshold that falls below zero on the short side is reported as
196    /// `Some(Positive::ZERO)`. That is a domain floor, not a substitute: a
197    /// short is liquidated once the price rises to the threshold, every
198    /// non-negative price already sits at or above a negative one, and zero
199    /// is the lowest price where the comparison holds.
200    ///
201    /// # Errors
202    ///
203    /// Returns [`PositionError::DecimalError`] when the quantity is zero, so
204    /// the per-unit margin buffer is undefined, or when the buffer leaves the
205    /// `Decimal` range.
206    fn liquidation_price(&self, current_price: Positive)
207    -> Result<Option<Positive>, PositionError>;
208
209    /// Checks if the position is at risk of liquidation.
210    ///
211    /// # Arguments
212    ///
213    /// * `current_price` - The current market price
214    /// * `margin_ratio` - Current margin ratio (margin / notional)
215    ///
216    /// # Errors
217    ///
218    /// Propagates the error of [`Marginable::liquidation_price`].
219    fn is_liquidation_risk(
220        &self,
221        current_price: Positive,
222        margin_ratio: Decimal,
223    ) -> Result<bool, PositionError>;
224}
225
226/// Turns a computed liquidation threshold into the value
227/// [`Marginable::liquidation_price`] reports.
228///
229/// A non-negative threshold is the liquidation price itself. A negative one
230/// depends on the side: a long is liquidated once the price falls to the
231/// threshold, which no non-negative price does, so there is no liquidation
232/// price (`None`); a short is liquidated once the price rises to it, which
233/// every non-negative price already has, so zero is the floor
234/// (`Some(Positive::ZERO)`).
235#[must_use]
236pub(crate) fn liquidation_threshold(threshold: Decimal, side: Side) -> Option<Positive> {
237    match Positive::new_decimal(threshold) {
238        Ok(price) => Some(price),
239        // `new_decimal` rejects only a negative value here.
240        Err(_) => match side {
241            Side::Long => None,
242            Side::Short => Some(Positive::ZERO),
243        },
244    }
245}
246
247/// Trait for positions that have funding rate payments.
248///
249/// This applies primarily to perpetual swap contracts in crypto markets.
250pub trait Fundable: LegAble {
251    /// Returns the current funding rate (as a decimal, e.g., 0.0001 = 0.01%).
252    fn funding_rate(&self) -> Decimal;
253
254    /// Returns the funding interval in hours (typically 8 for most exchanges).
255    fn funding_interval_hours(&self) -> u32;
256
257    /// Calculates the funding payment for the current period.
258    ///
259    /// Positive value means the position pays funding.
260    /// Negative value means the position receives funding.
261    ///
262    /// # Arguments
263    ///
264    /// * `mark_price` - The current mark price for funding calculation
265    ///
266    /// # Errors
267    ///
268    /// Returns [`PositionError::DecimalError`] when the notional or the
269    /// payment leaves the `Decimal` range (#788).
270    fn funding_payment(&self, mark_price: Positive) -> Result<Decimal, PositionError>;
271
272    /// Calculates the annualized funding cost/income.
273    ///
274    /// # Arguments
275    ///
276    /// * `mark_price` - The current mark price
277    ///
278    /// The number of funding periods in a year is `24 × 365` hours divided
279    /// by [`Fundable::funding_interval_hours`], truncated to a whole number
280    /// of periods.
281    ///
282    /// # Errors
283    ///
284    /// Returns [`PositionError::ValidationError`] when
285    /// [`Fundable::funding_interval_hours`] is zero, so a year has no whole
286    /// number of periods (the integer division aborted on it, #788), and
287    /// propagates [`Fundable::funding_payment`] and the
288    /// [`PositionError::DecimalError`] of an annualised payment outside the
289    /// `Decimal` range.
290    fn annualized_funding(&self, mark_price: Positive) -> Result<Decimal, PositionError> {
291        let payment = self.funding_payment(mark_price)?;
292        let periods_per_year = HOURS_PER_YEAR
293            .checked_div(self.funding_interval_hours())
294            .ok_or_else(|| PositionError::invalid_position("funding interval is zero hours"))?;
295        Ok(d_mul(
296            payment,
297            Decimal::from(periods_per_year),
298            "Fundable::annualized_funding",
299        )?)
300    }
301}
302
303/// Trait for positions that have an expiration date.
304///
305/// This applies to futures, forwards, and options.
306pub trait Expirable: LegAble {
307    /// Returns the expiration date as a Unix timestamp in seconds.
308    ///
309    /// # Errors
310    ///
311    /// Returns a [`PositionError`] when the expiration date cannot be
312    /// resolved to a calendar instant. The timestamp is never replaced by
313    /// `0`, which would read as the Unix epoch.
314    fn expiration_timestamp(&self) -> Result<i64, PositionError>;
315
316    /// Returns the number of days until expiration.
317    ///
318    /// # Errors
319    ///
320    /// Returns a [`PositionError`] when the time to expiration cannot be
321    /// computed. The day count is never replaced by zero, which would read
322    /// as an expired position.
323    fn days_to_expiration(&self) -> Result<Positive, PositionError>;
324
325    /// Checks if the position has expired.
326    ///
327    /// # Errors
328    ///
329    /// Returns a [`PositionError`] when the expiration date cannot be
330    /// resolved to a calendar instant. The answer is never replaced by
331    /// `false`, which would read as a live position.
332    fn is_expired(&self) -> Result<bool, PositionError>;
333
334    /// Returns the time to expiration in years (for pricing calculations).
335    ///
336    /// # Errors
337    ///
338    /// Propagates the [`PositionError`] of
339    /// [`Expirable::days_to_expiration`].
340    fn time_to_expiration_years(&self) -> Result<Decimal, PositionError> {
341        Ok(d_div(
342            self.days_to_expiration()?.to_dec(),
343            Decimal::from(DAYS_PER_YEAR),
344            "Expirable::time_to_expiration_years",
345        )?)
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    /// Mock implementation for testing
354    struct MockLeg {
355        symbol: String,
356        quantity: Positive,
357        side: Side,
358        cost_basis: Positive,
359        fees: Positive,
360    }
361
362    impl LegAble for MockLeg {
363        fn get_symbol(&self) -> &str {
364            &self.symbol
365        }
366
367        fn get_quantity(&self) -> Positive {
368            self.quantity
369        }
370
371        fn get_side(&self) -> Side {
372            self.side
373        }
374
375        fn pnl_at_price(&self, price: Positive) -> Result<Decimal, PositionError> {
376            let value_change = (price.to_dec() - self.cost_basis.to_dec()) * self.quantity.to_dec();
377            Ok(match self.side {
378                Side::Long => value_change,
379                Side::Short => -value_change,
380            })
381        }
382
383        fn total_cost(&self) -> Result<Positive, PositionError> {
384            Ok(self
385                .cost_basis
386                .checked_mul(&self.quantity)?
387                .checked_add(&self.fees)?)
388        }
389
390        fn fees(&self) -> Result<Positive, PositionError> {
391            Ok(self.fees)
392        }
393    }
394
395    #[test]
396    fn test_mock_leg_long_pnl() {
397        let leg = MockLeg {
398            symbol: "BTC".to_string(),
399            quantity: positive::Positive::ONE,
400            side: Side::Long,
401            cost_basis: positive::pos_or_panic!(50000.0),
402            fees: positive::pos_or_panic!(10.0),
403        };
404
405        // Price goes up - profit
406        let pnl = leg.pnl_at_price(positive::pos_or_panic!(55000.0));
407        assert_eq!(pnl.ok(), Some(Decimal::from(5000)));
408
409        // Price goes down - loss
410        let pnl = leg.pnl_at_price(positive::pos_or_panic!(45000.0));
411        assert_eq!(pnl.ok(), Some(Decimal::from(-5000)));
412    }
413
414    #[test]
415    fn test_mock_leg_short_pnl() {
416        let leg = MockLeg {
417            symbol: "BTC".to_string(),
418            quantity: positive::Positive::ONE,
419            side: Side::Short,
420            cost_basis: positive::pos_or_panic!(50000.0),
421            fees: positive::pos_or_panic!(10.0),
422        };
423
424        // Price goes up - loss for short
425        let pnl = leg.pnl_at_price(positive::pos_or_panic!(55000.0));
426        assert_eq!(pnl.ok(), Some(Decimal::from(-5000)));
427
428        // Price goes down - profit for short
429        let pnl = leg.pnl_at_price(positive::pos_or_panic!(45000.0));
430        assert_eq!(pnl.ok(), Some(Decimal::from(5000)));
431    }
432
433    #[test]
434    fn test_is_long_short() {
435        let long_leg = MockLeg {
436            symbol: "BTC".to_string(),
437            quantity: positive::Positive::ONE,
438            side: Side::Long,
439            cost_basis: positive::pos_or_panic!(50000.0),
440            fees: positive::pos_or_panic!(10.0),
441        };
442
443        let short_leg = MockLeg {
444            symbol: "BTC".to_string(),
445            quantity: Positive::ONE,
446            side: Side::Short,
447            cost_basis: positive::pos_or_panic!(50000.0),
448            fees: positive::pos_or_panic!(10.0),
449        };
450
451        assert!(long_leg.is_long());
452        assert!(!long_leg.is_short());
453        assert!(!short_leg.is_long());
454        assert!(short_leg.is_short());
455    }
456
457    #[test]
458    fn test_notional_value() {
459        let leg = MockLeg {
460            symbol: "BTC".to_string(),
461            quantity: positive::Positive::TWO,
462            side: Side::Long,
463            cost_basis: positive::pos_or_panic!(50000.0),
464            fees: positive::pos_or_panic!(10.0),
465        };
466
467        let notional = leg.notional_value(positive::pos_or_panic!(55000.0));
468        assert_eq!(notional.ok(), Some(positive::pos_or_panic!(110000.0)));
469    }
470
471    #[test]
472    fn test_notional_value_overflow_is_error() {
473        let leg = MockLeg {
474            symbol: "BTC".to_string(),
475            quantity: positive::Positive::TWO,
476            side: Side::Long,
477            cost_basis: positive::Positive::ONE,
478            fees: positive::Positive::ZERO,
479        };
480        assert!(leg.notional_value(Positive::MAX).is_err());
481    }
482
483    struct ZeroIntervalLeg(MockLeg);
484
485    impl LegAble for ZeroIntervalLeg {
486        fn get_symbol(&self) -> &str {
487            self.0.get_symbol()
488        }
489
490        fn get_quantity(&self) -> Positive {
491            self.0.get_quantity()
492        }
493
494        fn get_side(&self) -> Side {
495            self.0.get_side()
496        }
497
498        fn pnl_at_price(&self, price: Positive) -> Result<Decimal, PositionError> {
499            self.0.pnl_at_price(price)
500        }
501
502        fn total_cost(&self) -> Result<Positive, PositionError> {
503            self.0.total_cost()
504        }
505
506        fn fees(&self) -> Result<Positive, PositionError> {
507            self.0.fees()
508        }
509    }
510
511    impl Fundable for ZeroIntervalLeg {
512        fn funding_rate(&self) -> Decimal {
513            Decimal::ONE
514        }
515
516        fn funding_interval_hours(&self) -> u32 {
517            0
518        }
519
520        fn funding_payment(&self, _mark_price: Positive) -> Result<Decimal, PositionError> {
521            Ok(Decimal::ONE)
522        }
523    }
524
525    // `24 * 365 / 0` aborted with `attempt to divide by zero` (#788).
526    #[test]
527    fn test_annualized_funding_zero_interval_is_error() {
528        let leg = ZeroIntervalLeg(MockLeg {
529            symbol: "BTC".to_string(),
530            quantity: positive::Positive::ONE,
531            side: Side::Long,
532            cost_basis: positive::Positive::ONE,
533            fees: positive::Positive::ZERO,
534        });
535        assert!(matches!(
536            leg.annualized_funding(Positive::ONE),
537            Err(PositionError::ValidationError(_))
538        ));
539    }
540}