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}