Skip to main content

optionstratlib_core/model/leg/
future.rs

1/******************************************************************************
2   Author: Joaquín Béjar García
3   Email: jb@taunais.com
4   Date: 24/12/25
5******************************************************************************/
6
7//! # Future Position Module
8//!
9//! This module provides the `FuturePosition` struct for representing standardized
10//! exchange-traded futures contracts.
11//!
12//! ## Characteristics
13//!
14//! - Fixed expiration date
15//! - Margin-based (leveraged)
16//! - Mark-to-market daily settlement
17//! - Delta ≈ ±1.0 per contract (adjusted for contract size)
18//! - Rho sensitivity for interest rate changes
19//!
20//! ## Example
21//!
22//! ```rust
23//! # #[cfg(feature = "clock")] {
24//! use optionstratlib_core::model::leg::FuturePosition;
25//! use optionstratlib_core::model::types::Side;
26//! use optionstratlib_core::model::ExpirationDate;
27//! use positive::{pos_or_panic,Positive};
28//! use chrono::Utc;
29//!
30//! let future = FuturePosition::new(
31//!     "ES".to_string(),        // E-mini S&P 500
32//!     Positive::TWO,               // 2 contracts
33//!     pos_or_panic!(4500.0),            // entry price
34//!     Side::Long,
35//!     ExpirationDate::Days(pos_or_panic!(30.0)),
36//!     pos_or_panic!(50.0),              // contract multiplier
37//!     pos_or_panic!(15000.0),           // initial margin
38//!     pos_or_panic!(12000.0),           // maintenance margin
39//!     Utc::now(),
40//!     pos_or_panic!(5.0),               // fees
41//! );
42//! # }
43//! ```
44
45use crate::error::{DecimalError, PositionError};
46use crate::model::ExpirationDate;
47use crate::model::decimal::{d_add, d_div, d_mul, d_sub};
48use crate::model::expiration::resolve_expiration_date;
49use crate::model::leg::traits::{Expirable, LegAble, Marginable, liquidation_threshold};
50use crate::model::types::Side;
51#[cfg(feature = "clock")]
52use crate::utils::time::wall_clock;
53use crate::utils::time::{evaluation_time, years_to_expiration};
54use chrono::{DateTime, Utc};
55use positive::Positive;
56use rust_decimal::Decimal;
57#[cfg(feature = "clock")]
58use rust_decimal_macros::dec;
59use serde::{Deserialize, Serialize};
60
61/// Represents a futures contract position.
62///
63/// Futures are standardized contracts traded on exchanges that obligate the buyer
64/// to purchase (or seller to sell) an asset at a predetermined price on a specific
65/// future date.
66///
67/// # Fields
68///
69/// * `symbol` - Contract symbol (e.g., "ES", "CL", "BTC-QUARTERLY")
70/// * `quantity` - Number of contracts
71/// * `entry_price` - Average entry price
72/// * `side` - Long or Short position
73/// * `expiration_date` - Contract expiry date
74/// * `contract_size` - Multiplier (e.g., 50 for ES, 1000 for CL)
75/// * `initial_margin` - Initial margin requirement
76/// * `maintenance_margin` - Maintenance margin level
77/// * `date` - Position open timestamp
78/// * `fees` - Commission and exchange fees
79#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
80#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
81pub struct FuturePosition {
82    /// Contract symbol (e.g., "ES", "CL", "GC").
83    pub symbol: String,
84
85    /// Number of contracts held.
86    pub quantity: Positive,
87
88    /// Average entry price per contract.
89    pub entry_price: Positive,
90
91    /// Position direction (Long or Short).
92    pub side: Side,
93
94    /// Contract expiration date.
95    pub expiration_date: ExpirationDate,
96
97    /// Contract size multiplier (notional per point).
98    pub contract_size: Positive,
99
100    /// Initial margin requirement per contract.
101    pub initial_margin_req: Positive,
102
103    /// Maintenance margin requirement per contract.
104    pub maintenance_margin_req: Positive,
105
106    /// Timestamp when the position was opened.
107    pub date: DateTime<Utc>,
108
109    /// Total trading fees paid.
110    pub fees: Positive,
111}
112
113impl FuturePosition {
114    /// Creates a new futures position.
115    ///
116    /// # Arguments
117    ///
118    /// * `symbol` - Contract symbol
119    /// * `quantity` - Number of contracts
120    /// * `entry_price` - Average entry price
121    /// * `side` - Long or Short
122    /// * `expiration_date` - Contract expiry
123    /// * `contract_size` - Contract multiplier
124    /// * `initial_margin_req` - Initial margin per contract
125    /// * `maintenance_margin_req` - Maintenance margin per contract
126    /// * `date` - Position open timestamp
127    /// * `fees` - Trading fees
128    #[must_use]
129    #[allow(clippy::too_many_arguments)]
130    pub fn new(
131        symbol: String,
132        quantity: Positive,
133        entry_price: Positive,
134        side: Side,
135        expiration_date: ExpirationDate,
136        contract_size: Positive,
137        initial_margin_req: Positive,
138        maintenance_margin_req: Positive,
139        date: DateTime<Utc>,
140        fees: Positive,
141    ) -> Self {
142        Self {
143            symbol,
144            quantity,
145            entry_price,
146            side,
147            expiration_date,
148            contract_size,
149            initial_margin_req,
150            maintenance_margin_req,
151            date,
152            fees,
153        }
154    }
155
156    /// Creates a long futures position with default settings.
157    ///
158    /// # Arguments
159    ///
160    /// * `symbol` - Contract symbol
161    /// * `quantity` - Number of contracts
162    /// * `entry_price` - Entry price
163    /// * `expiration_date` - Contract expiry
164    /// * `contract_size` - Contract multiplier
165    /// * `margin` - Margin requirement
166    #[cfg(feature = "clock")]
167    #[must_use]
168    pub fn long(
169        symbol: String,
170        quantity: Positive,
171        entry_price: Positive,
172        expiration_date: ExpirationDate,
173        contract_size: Positive,
174        margin: Positive,
175    ) -> Self {
176        Self::new(
177            symbol,
178            quantity,
179            entry_price,
180            Side::Long,
181            expiration_date,
182            contract_size,
183            margin,
184            maintenance_from_initial(margin), // 80% of initial margin
185            wall_clock(),
186            Positive::ZERO,
187        )
188    }
189
190    /// Creates a short futures position with default settings.
191    ///
192    /// # Arguments
193    ///
194    /// * `symbol` - Contract symbol
195    /// * `quantity` - Number of contracts
196    /// * `entry_price` - Entry price
197    /// * `expiration_date` - Contract expiry
198    /// * `contract_size` - Contract multiplier
199    /// * `margin` - Margin requirement
200    #[cfg(feature = "clock")]
201    #[must_use]
202    pub fn short(
203        symbol: String,
204        quantity: Positive,
205        entry_price: Positive,
206        expiration_date: ExpirationDate,
207        contract_size: Positive,
208        margin: Positive,
209    ) -> Self {
210        Self::new(
211            symbol,
212            quantity,
213            entry_price,
214            Side::Short,
215            expiration_date,
216            contract_size,
217            margin,
218            maintenance_from_initial(margin),
219            wall_clock(),
220            Positive::ZERO,
221        )
222    }
223
224    /// Returns the notional value of the position at entry.
225    ///
226    /// Notional = quantity × entry_price × contract_size
227    ///
228    /// # Errors
229    ///
230    /// Returns [`PositionError::PositiveError`] when the product leaves the
231    /// `Positive` range. The three factors are `pub` fields, so nothing
232    /// bounds it; `Positive * Positive` aborted on it (#788).
233    pub fn notional_value_at_entry(&self) -> Result<Positive, PositionError> {
234        Ok(self
235            .quantity
236            .checked_mul(&self.entry_price)?
237            .checked_mul(&self.contract_size)?)
238    }
239
240    /// Returns the notional value at a given price.
241    ///
242    /// # Arguments
243    ///
244    /// * `current_price` - Current market price
245    ///
246    /// # Errors
247    ///
248    /// Returns [`PositionError::PositiveError`] when the product leaves the
249    /// `Positive` range (#788).
250    pub fn notional_value_at_price(
251        &self,
252        current_price: Positive,
253    ) -> Result<Positive, PositionError> {
254        Ok(self
255            .quantity
256            .checked_mul(&current_price)?
257            .checked_mul(&self.contract_size)?)
258    }
259
260    /// Calculates the unrealized P&L at a given price.
261    ///
262    /// # Arguments
263    ///
264    /// * `current_price` - Current market price
265    ///
266    /// # Decision (issue #471): fallible because `pnl_at_price` is
267    ///
268    /// This is the whole body of [`LegAble::pnl_at_price`] for a future, so
269    /// leaving it infallible would have moved that method's abort one frame
270    /// down rather than removing it. `entry_price`, `quantity` and
271    /// `contract_size` are `pub`, so no constructor guard bounds the product.
272    ///
273    /// # Errors
274    ///
275    /// Returns [`PositionError::DecimalError`] when the price difference, the
276    /// quantity scaling or the contract-size scaling leaves the representable
277    /// `Decimal` range.
278    pub fn unrealized_pnl(&self, current_price: Positive) -> Result<Decimal, PositionError> {
279        let price_change = d_sub(
280            current_price.to_dec(),
281            self.entry_price.to_dec(),
282            "FuturePosition::unrealized_pnl/price_change",
283        )?;
284        let pnl = d_mul(
285            d_mul(
286                price_change,
287                self.quantity.to_dec(),
288                "FuturePosition::unrealized_pnl/quantity",
289            )?,
290            self.contract_size.to_dec(),
291            "FuturePosition::unrealized_pnl/contract_size",
292        )?;
293
294        Ok(match self.side {
295            Side::Long => pnl,
296            // Negating a representable `Decimal` is always representable.
297            Side::Short => -pnl,
298        })
299    }
300
301    /// Calculates the tick value (value of one minimum price movement).
302    ///
303    /// # Arguments
304    ///
305    /// * `tick_size` - Minimum price increment
306    ///
307    /// # Errors
308    ///
309    /// Returns [`PositionError::PositiveError`] when the product leaves the
310    /// `Positive` range (#788).
311    pub fn tick_value(&self, tick_size: Positive) -> Result<Positive, PositionError> {
312        Ok(tick_size.checked_mul(&self.contract_size)?)
313    }
314
315    /// Returns the total margin required for this position.
316    ///
317    /// # Errors
318    ///
319    /// Returns [`PositionError::PositiveError`] when the product leaves the
320    /// `Positive` range (#788).
321    pub fn total_margin_required(&self) -> Result<Positive, PositionError> {
322        Ok(self.initial_margin_req.checked_mul(&self.quantity)?)
323    }
324
325    /// Calculates the basis (futures price - spot price).
326    ///
327    /// # Arguments
328    ///
329    /// * `spot_price` - Current spot price of the underlying
330    #[must_use]
331    // Both operands lie in `[0, Decimal::MAX]`, so the difference lies in
332    // `[-Decimal::MAX, Decimal::MAX]` and is always representable.
333    #[expect(
334        clippy::arithmetic_side_effects,
335        reason = "the difference of two values in [0, Decimal::MAX] is representable"
336    )]
337    pub fn basis(&self, spot_price: Positive) -> Decimal {
338        self.entry_price.to_dec() - spot_price.to_dec()
339    }
340
341    /// Calculates the implied leverage of the position.
342    ///
343    /// Leverage = Notional Value / Margin Required, and zero when no margin
344    /// is required.
345    ///
346    /// # Errors
347    ///
348    /// Returns [`PositionError`] when the notional, the margin or their
349    /// ratio leaves the representable range (#788).
350    pub fn implied_leverage(&self) -> Result<Decimal, PositionError> {
351        let margin = self.total_margin_required()?;
352        if margin == Positive::ZERO {
353            return Ok(Decimal::ZERO);
354        }
355
356        Ok(d_div(
357            self.notional_value_at_entry()?.to_dec(),
358            margin.to_dec(),
359            "FuturePosition::implied_leverage",
360        )?)
361    }
362}
363
364/// Maintenance margin set by [`FuturePosition::long`] and
365/// [`FuturePosition::short`]: 80% of the initial margin.
366// Scaling a value in `[0, Decimal::MAX]` by `0.8` stays in that range, so the
367// product is representable and keeps the `Positive` invariant.
368#[cfg(feature = "clock")]
369#[expect(
370    clippy::arithmetic_side_effects,
371    reason = "0.8 times a value in [0, Decimal::MAX] stays in that range"
372)]
373fn maintenance_from_initial(margin: Positive) -> Positive {
374    margin * Decimal::new(8, 1)
375}
376
377impl LegAble for FuturePosition {
378    fn get_symbol(&self) -> &str {
379        &self.symbol
380    }
381
382    fn get_quantity(&self) -> Positive {
383        self.quantity
384    }
385
386    fn get_side(&self) -> Side {
387        self.side
388    }
389
390    fn pnl_at_price(&self, price: Positive) -> Result<Decimal, PositionError> {
391        Ok(d_sub(
392            self.unrealized_pnl(price)?,
393            self.fees.to_dec(),
394            "FuturePosition::pnl_at_price/net",
395        )?)
396    }
397
398    fn total_cost(&self) -> Result<Positive, PositionError> {
399        // The product is formed here rather than read from
400        // `total_margin_required`, which multiplies raw and would abort on
401        // `initial_margin_req = Positive::MAX` before this `Result` could
402        // carry the failure. The infallible helper keeps its signature; what
403        // this fixes is the error channel promising something it could not
404        // deliver.
405        let margin = self
406            .initial_margin_req
407            .checked_mul(&self.quantity)
408            .map_err(PositionError::from)?;
409        Ok(margin.checked_add(&self.fees)?)
410    }
411
412    fn fees(&self) -> Result<Positive, PositionError> {
413        Ok(self.fees)
414    }
415}
416
417impl Marginable for FuturePosition {
418    fn initial_margin(&self) -> Result<Positive, PositionError> {
419        Ok(self.initial_margin_req.checked_mul(&self.quantity)?)
420    }
421
422    fn maintenance_margin(&self) -> Result<Positive, PositionError> {
423        Ok(self.maintenance_margin_req.checked_mul(&self.quantity)?)
424    }
425
426    fn leverage(&self) -> Result<Positive, PositionError> {
427        let lev = self.implied_leverage()?;
428        Ok(Positive::new_decimal(lev).unwrap_or(positive::Positive::ONE))
429    }
430
431    /// `None` for a long whose margin buffer per unit exceeds the entry
432    /// price: no non-negative price liquidates it. A short threshold below
433    /// zero is floored at zero, the documented domain floor of
434    /// [`Marginable::liquidation_price`].
435    ///
436    /// # Errors
437    ///
438    /// Returns [`PositionError::DecimalError`] for a zero quantity or
439    /// contract size, where the buffer per unit is undefined (the division
440    /// aborted with `Division by zero`, #788), and when a margin, the buffer
441    /// or the shifted price leaves the representable range.
442    fn liquidation_price(
443        &self,
444        _current_price: Positive,
445    ) -> Result<Option<Positive>, PositionError> {
446        let margin_buffer = d_sub(
447            self.initial_margin()?.to_dec(),
448            self.maintenance_margin()?.to_dec(),
449            "FuturePosition::liquidation_price/margin_buffer",
450        )?;
451        let units = d_mul(
452            self.quantity.to_dec(),
453            self.contract_size.to_dec(),
454            "FuturePosition::liquidation_price/units",
455        )?;
456        let price_buffer = d_div(
457            margin_buffer,
458            units,
459            "FuturePosition::liquidation_price/price_buffer",
460        )?;
461
462        let threshold = match self.side {
463            Side::Long => d_sub(
464                self.entry_price.to_dec(),
465                price_buffer,
466                "FuturePosition::liquidation_price/long",
467            )?,
468            Side::Short => d_add(
469                self.entry_price.to_dec(),
470                price_buffer,
471                "FuturePosition::liquidation_price/short",
472            )?,
473        };
474        Ok(liquidation_threshold(threshold, self.side))
475    }
476
477    fn is_liquidation_risk(
478        &self,
479        current_price: Positive,
480        _margin_ratio: Decimal,
481    ) -> Result<bool, PositionError> {
482        // No reachable liquidation price means no price is at risk.
483        let Some(liq_price) = self.liquidation_price(current_price)? else {
484            return Ok(false);
485        };
486
487        Ok(match self.side {
488            Side::Long => current_price <= liq_price,
489            Side::Short => current_price >= liq_price,
490        })
491    }
492}
493
494impl Expirable for FuturePosition {
495    fn expiration_timestamp(&self) -> Result<i64, PositionError> {
496        // The resolver rather than `get_date`, which aborts on a day count no
497        // calendar instant can hold instead of reporting it. An unresolvable
498        // date used to read as `0`, the Unix epoch (#810).
499        let date = resolve_expiration_date(&self.expiration_date).map_err(DecimalError::from)?;
500        Ok(date.timestamp())
501    }
502
503    fn days_to_expiration(&self) -> Result<Positive, PositionError> {
504        let years = years_to_expiration(&self.expiration_date).map_err(DecimalError::from)?;
505        let days = d_mul(
506            years.to_dec(),
507            Decimal::from(365),
508            "FuturePosition::days_to_expiration",
509        )?;
510        Ok(Positive::new_decimal(days)?)
511    }
512
513    fn is_expired(&self) -> Result<bool, PositionError> {
514        // An unresolvable date used to read as "not expired" (#810).
515        let date = resolve_expiration_date(&self.expiration_date).map_err(DecimalError::from)?;
516        Ok(date < evaluation_time()?)
517    }
518}
519
520impl std::fmt::Display for FuturePosition {
521    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
522        write!(
523            f,
524            "{} {} {} @ {} (size: {}, exp: {})",
525            self.side,
526            self.quantity,
527            self.symbol,
528            self.entry_price,
529            self.contract_size,
530            self.expiration_date
531        )
532    }
533}
534
535#[cfg(feature = "clock")]
536impl Default for FuturePosition {
537    fn default() -> Self {
538        Self {
539            symbol: String::new(),
540            quantity: Positive::ZERO,
541            entry_price: Positive::ZERO,
542            side: Side::Long,
543            // `dec!(30.0)` is a compile-time positive literal; the checked
544            // constructor never fails, so the `Positive::ZERO` fallback is
545            // unreachable and only exists to keep the call site free of
546            // `.unwrap()`/`.expect()`.
547            expiration_date: ExpirationDate::Days(
548                Positive::new_decimal(dec!(30.0)).unwrap_or(Positive::ZERO),
549            ),
550            contract_size: positive::Positive::ONE,
551            initial_margin_req: Positive::ZERO,
552            maintenance_margin_req: Positive::ZERO,
553            date: wall_clock(),
554            fees: Positive::ZERO,
555        }
556    }
557}
558
559#[cfg(all(test, feature = "clock"))]
560mod tests {
561    use super::*;
562    use positive::pos_or_panic;
563
564    /// A margin requirement at the top of the range with more than one
565    /// contract overflows the product `total_cost` needs. The signature
566    /// promises a `Result`, so it has to arrive as one rather than as an
567    /// abort inside the raw multiplication `total_margin_required` performs.
568    #[cfg(feature = "clock")]
569    #[test]
570    fn test_future_total_cost_reports_a_margin_product_that_overflows() {
571        let future = FuturePosition::new(
572            "ES".to_string(),
573            Positive::TWO,
574            pos_or_panic!(4500.0),
575            Side::Long,
576            ExpirationDate::Days(pos_or_panic!(30.0)),
577            pos_or_panic!(50.0),
578            Positive::MAX,
579            pos_or_panic!(12000.0),
580            wall_clock(),
581            pos_or_panic!(5.0),
582        );
583
584        assert!(future.total_cost().is_err());
585    }
586
587    #[cfg(feature = "clock")]
588    #[test]
589    fn test_future_position_new() {
590        let future = FuturePosition::new(
591            "ES".to_string(),
592            Positive::TWO,
593            pos_or_panic!(4500.0),
594            Side::Long,
595            ExpirationDate::Days(pos_or_panic!(30.0)),
596            pos_or_panic!(50.0),
597            pos_or_panic!(15000.0),
598            pos_or_panic!(12000.0),
599            wall_clock(),
600            pos_or_panic!(5.0),
601        );
602
603        assert_eq!(future.symbol, "ES");
604        assert_eq!(future.quantity, Positive::TWO);
605        assert_eq!(future.entry_price, pos_or_panic!(4500.0));
606        assert_eq!(future.side, Side::Long);
607        assert_eq!(future.contract_size, pos_or_panic!(50.0));
608    }
609
610    #[cfg(feature = "clock")]
611    #[test]
612    fn test_future_long_convenience() {
613        let future = FuturePosition::long(
614            "ES".to_string(),
615            Positive::ONE,
616            pos_or_panic!(4500.0),
617            ExpirationDate::Days(pos_or_panic!(30.0)),
618            pos_or_panic!(50.0),
619            pos_or_panic!(15000.0),
620        );
621
622        assert_eq!(future.side, Side::Long);
623        assert_eq!(future.initial_margin_req, pos_or_panic!(15000.0));
624    }
625
626    #[cfg(feature = "clock")]
627    #[test]
628    fn test_future_short_convenience() {
629        let future = FuturePosition::short(
630            "ES".to_string(),
631            Positive::ONE,
632            pos_or_panic!(4500.0),
633            ExpirationDate::Days(pos_or_panic!(30.0)),
634            pos_or_panic!(50.0),
635            pos_or_panic!(15000.0),
636        );
637
638        assert_eq!(future.side, Side::Short);
639    }
640
641    #[cfg(feature = "clock")]
642    #[test]
643    fn test_notional_value() -> Result<(), Box<dyn std::error::Error>> {
644        let future = FuturePosition::long(
645            "ES".to_string(),
646            Positive::TWO,
647            pos_or_panic!(4500.0),
648            ExpirationDate::Days(pos_or_panic!(30.0)),
649            pos_or_panic!(50.0),
650            pos_or_panic!(15000.0),
651        );
652
653        assert_eq!(future.notional_value_at_entry()?, pos_or_panic!(450000.0));
654        Ok(())
655    }
656
657    #[cfg(feature = "clock")]
658    #[test]
659    fn test_unrealized_pnl_long() {
660        let future = FuturePosition::long(
661            "ES".to_string(),
662            Positive::ONE,
663            pos_or_panic!(4500.0),
664            ExpirationDate::Days(pos_or_panic!(30.0)),
665            pos_or_panic!(50.0),
666            pos_or_panic!(15000.0),
667        );
668
669        let pnl = future.unrealized_pnl(pos_or_panic!(4510.0));
670        assert_eq!(pnl.ok(), Some(Decimal::from(500)));
671
672        let pnl_loss = future.unrealized_pnl(pos_or_panic!(4490.0));
673        assert_eq!(pnl_loss.ok(), Some(Decimal::from(-500)));
674    }
675
676    #[cfg(feature = "clock")]
677    #[test]
678    fn test_unrealized_pnl_short() {
679        let future = FuturePosition::short(
680            "ES".to_string(),
681            Positive::ONE,
682            pos_or_panic!(4500.0),
683            ExpirationDate::Days(pos_or_panic!(30.0)),
684            pos_or_panic!(50.0),
685            pos_or_panic!(15000.0),
686        );
687
688        let pnl = future.unrealized_pnl(pos_or_panic!(4490.0));
689        assert_eq!(pnl.ok(), Some(Decimal::from(500)));
690
691        let pnl_loss = future.unrealized_pnl(pos_or_panic!(4510.0));
692        assert_eq!(pnl_loss.ok(), Some(Decimal::from(-500)));
693    }
694
695    #[cfg(feature = "clock")]
696    #[test]
697    fn test_implied_leverage() -> Result<(), Box<dyn std::error::Error>> {
698        let future = FuturePosition::long(
699            "ES".to_string(),
700            Positive::ONE,
701            pos_or_panic!(4500.0),
702            ExpirationDate::Days(pos_or_panic!(30.0)),
703            pos_or_panic!(50.0),
704            pos_or_panic!(15000.0),
705        );
706
707        let leverage = future.implied_leverage()?;
708        assert_eq!(leverage, Decimal::from(15));
709        Ok(())
710    }
711
712    #[cfg(feature = "clock")]
713    #[test]
714    fn test_basis() {
715        let future = FuturePosition::long(
716            "ES".to_string(),
717            Positive::ONE,
718            pos_or_panic!(4510.0),
719            ExpirationDate::Days(pos_or_panic!(30.0)),
720            pos_or_panic!(50.0),
721            pos_or_panic!(15000.0),
722        );
723
724        let basis = future.basis(pos_or_panic!(4500.0));
725        assert_eq!(basis, Decimal::from(10));
726    }
727
728    #[cfg(feature = "clock")]
729    #[test]
730    fn test_tick_value() -> Result<(), Box<dyn std::error::Error>> {
731        let future = FuturePosition::long(
732            "ES".to_string(),
733            Positive::ONE,
734            pos_or_panic!(4500.0),
735            ExpirationDate::Days(pos_or_panic!(30.0)),
736            pos_or_panic!(50.0),
737            pos_or_panic!(15000.0),
738        );
739
740        let tick_val = future.tick_value(pos_or_panic!(0.25))?;
741        assert_eq!(tick_val, pos_or_panic!(12.5));
742        Ok(())
743    }
744
745    #[cfg(feature = "clock")]
746    #[test]
747    fn test_total_margin_required() -> Result<(), Box<dyn std::error::Error>> {
748        let future = FuturePosition::long(
749            "ES".to_string(),
750            Positive::TWO,
751            pos_or_panic!(4500.0),
752            ExpirationDate::Days(pos_or_panic!(30.0)),
753            pos_or_panic!(50.0),
754            pos_or_panic!(15000.0),
755        );
756
757        assert_eq!(future.total_margin_required()?, pos_or_panic!(30000.0));
758        Ok(())
759    }
760
761    #[cfg(feature = "clock")]
762    #[test]
763    fn test_display() {
764        let future = FuturePosition::long(
765            "ES".to_string(),
766            Positive::ONE,
767            pos_or_panic!(4500.0),
768            ExpirationDate::Days(pos_or_panic!(30.0)),
769            pos_or_panic!(50.0),
770            pos_or_panic!(15000.0),
771        );
772
773        let display = format!("{}", future);
774        assert!(display.contains("Long"));
775        assert!(display.contains("ES"));
776        assert!(display.contains("4500"));
777    }
778
779    fn future_with_margins(
780        side: Side,
781        entry_price: Positive,
782        initial_margin_req: Positive,
783        maintenance_margin_req: Positive,
784    ) -> FuturePosition {
785        FuturePosition::new(
786            "ES".to_string(),
787            Positive::ONE,
788            entry_price,
789            side,
790            ExpirationDate::Days(pos_or_panic!(30.0)),
791            Positive::ONE,
792            initial_margin_req,
793            maintenance_margin_req,
794            wall_clock(),
795            Positive::ZERO,
796        )
797    }
798
799    #[cfg(feature = "clock")]
800    #[test]
801    fn test_future_liquidation_price_reachable_is_unchanged()
802    -> Result<(), Box<dyn std::error::Error>> {
803        let long = FuturePosition::long(
804            "ES".to_string(),
805            Positive::ONE,
806            pos_or_panic!(4500.0),
807            ExpirationDate::Days(pos_or_panic!(30.0)),
808            pos_or_panic!(50.0),
809            pos_or_panic!(15000.0),
810        );
811        let short = FuturePosition::short(
812            "ES".to_string(),
813            Positive::ONE,
814            pos_or_panic!(4500.0),
815            ExpirationDate::Days(pos_or_panic!(30.0)),
816            pos_or_panic!(50.0),
817            pos_or_panic!(15000.0),
818        );
819
820        // (15000 - 12000) / 50 = 60 of price buffer either side of 4500.
821        assert_eq!(
822            long.liquidation_price(pos_or_panic!(4500.0))?,
823            Some(pos_or_panic!(4440.0))
824        );
825        assert_eq!(
826            short.liquidation_price(pos_or_panic!(4500.0))?,
827            Some(pos_or_panic!(4560.0))
828        );
829        assert!(long.is_liquidation_risk(pos_or_panic!(4440.0), Decimal::ZERO)?);
830        assert!(!long.is_liquidation_risk(pos_or_panic!(4441.0), Decimal::ZERO)?);
831        assert!(short.is_liquidation_risk(pos_or_panic!(4560.0), Decimal::ZERO)?);
832        assert!(!short.is_liquidation_risk(pos_or_panic!(4559.0), Decimal::ZERO)?);
833        Ok(())
834    }
835
836    #[test]
837    fn test_future_long_liquidation_price_below_zero_is_unreachable()
838    -> Result<(), Box<dyn std::error::Error>> {
839        // A buffer of 90 per unit under an entry of 10 puts the threshold at
840        // -80: the long survives a fall all the way to zero.
841        let long = future_with_margins(
842            Side::Long,
843            pos_or_panic!(10.0),
844            pos_or_panic!(100.0),
845            pos_or_panic!(10.0),
846        );
847
848        assert_eq!(long.liquidation_price(pos_or_panic!(10.0))?, None);
849        assert!(!long.is_liquidation_risk(Positive::ZERO, Decimal::ZERO)?);
850        assert!(!long.is_liquidation_risk(pos_or_panic!(10.0), Decimal::ZERO)?);
851        Ok(())
852    }
853
854    #[test]
855    fn test_future_short_liquidation_price_below_zero_floors_at_zero()
856    -> Result<(), Box<dyn std::error::Error>> {
857        // A maintenance requirement 90 above the initial one puts the short
858        // threshold at -80: every non-negative price already crosses it.
859        let short = future_with_margins(
860            Side::Short,
861            pos_or_panic!(10.0),
862            pos_or_panic!(10.0),
863            pos_or_panic!(100.0),
864        );
865
866        assert_eq!(
867            short.liquidation_price(pos_or_panic!(10.0))?,
868            Some(Positive::ZERO)
869        );
870        assert!(short.is_liquidation_risk(Positive::ZERO, Decimal::ZERO)?);
871        assert!(short.is_liquidation_risk(pos_or_panic!(10.0), Decimal::ZERO)?);
872        Ok(())
873    }
874
875    #[cfg(feature = "clock")]
876    #[test]
877    fn test_future_days_to_expiration_is_unchanged() {
878        let future = future_with_margins(
879            Side::Long,
880            pos_or_panic!(4500.0),
881            pos_or_panic!(15000.0),
882            pos_or_panic!(12000.0),
883        );
884        let expected = ExpirationDate::Days(pos_or_panic!(30.0))
885            .get_years()
886            .expect("30 days")
887            * Decimal::from(365);
888
889        let days = future.days_to_expiration().expect("30 days");
890        assert_eq!(days, expected);
891        assert_eq!(
892            future.time_to_expiration_years().expect("30 days"),
893            expected.to_dec() / Decimal::from(365)
894        );
895    }
896
897    #[test]
898    fn test_future_days_to_expiration_overflow_is_an_error_not_zero() {
899        let mut future = future_with_margins(
900            Side::Long,
901            pos_or_panic!(4500.0),
902            pos_or_panic!(15000.0),
903            pos_or_panic!(12000.0),
904        );
905        future.expiration_date = ExpirationDate::Days(Positive::MAX);
906
907        // `get_years` resolves, its product with 365 does not fit `Decimal`.
908        let result = future.days_to_expiration();
909        assert!(
910            matches!(
911                result,
912                Err(PositionError::DecimalError(DecimalError::Overflow { .. }))
913            ),
914            "{result:?}"
915        );
916        assert!(future.time_to_expiration_years().is_err());
917    }
918
919    #[cfg(feature = "clock")]
920    #[test]
921    fn test_future_expiration_timestamp_and_is_expired_are_unchanged() {
922        let expiry = Utc::now() + chrono::Duration::days(30);
923        let mut future = future_with_margins(
924            Side::Long,
925            pos_or_panic!(4500.0),
926            pos_or_panic!(15000.0),
927            pos_or_panic!(12000.0),
928        );
929        future.expiration_date = ExpirationDate::DateTime(expiry);
930        assert_eq!(future.expiration_timestamp().ok(), Some(expiry.timestamp()));
931        assert_eq!(future.is_expired().ok(), Some(false));
932
933        let past = Utc::now() - chrono::Duration::days(1);
934        future.expiration_date = ExpirationDate::DateTime(past);
935        assert_eq!(future.expiration_timestamp().ok(), Some(past.timestamp()));
936        assert_eq!(future.is_expired().ok(), Some(true));
937    }
938
939    #[test]
940    fn test_future_unresolvable_expiration_is_an_error_not_epoch_or_live() {
941        let mut future = future_with_margins(
942            Side::Long,
943            pos_or_panic!(4500.0),
944            pos_or_panic!(15000.0),
945            pos_or_panic!(12000.0),
946        );
947        // No calendar instant holds this many days: the timestamp read as
948        // `0` (the Unix epoch) and the position as not expired (#810).
949        future.expiration_date = ExpirationDate::Days(Positive::MAX);
950
951        let timestamp = future.expiration_timestamp();
952        assert!(
953            matches!(
954                timestamp,
955                Err(PositionError::DecimalError(DecimalError::ExpirationDate(_)))
956            ),
957            "{timestamp:?}"
958        );
959        let expired = future.is_expired();
960        assert!(
961            matches!(
962                expired,
963                Err(PositionError::DecimalError(DecimalError::ExpirationDate(_)))
964            ),
965            "{expired:?}"
966        );
967    }
968
969    fn future_with_quantity(quantity: Positive) -> FuturePosition {
970        FuturePosition::long(
971            "ES".to_string(),
972            quantity,
973            pos_or_panic!(4500.0),
974            ExpirationDate::Days(pos_or_panic!(30.0)),
975            pos_or_panic!(50.0),
976            pos_or_panic!(15000.0),
977        )
978    }
979
980    // A zero quantity left the per-unit margin buffer undefined, and the
981    // division aborted with `Division by zero` (#788).
982    #[test]
983    fn test_liquidation_price_zero_quantity_is_error() {
984        let future = future_with_quantity(Positive::ZERO);
985        assert!(matches!(
986            future.liquidation_price(pos_or_panic!(4500.0)),
987            Err(PositionError::DecimalError(_))
988        ));
989        assert!(
990            future
991                .is_liquidation_risk(pos_or_panic!(4500.0), Decimal::ZERO)
992                .is_err()
993        );
994    }
995
996    // `Positive * Positive` aborted with `Positive arithmetic overflow in
997    // mul` (#788).
998    #[test]
999    fn test_notional_and_margins_overflow_is_error() {
1000        let future = future_with_quantity(Positive::MAX);
1001        assert!(future.notional_value_at_entry().is_err());
1002        assert!(future.notional_value_at_price(Positive::TWO).is_err());
1003        assert!(future.total_margin_required().is_err());
1004        assert!(future.implied_leverage().is_err());
1005        assert!(future.initial_margin().is_err());
1006        assert!(future.maintenance_margin().is_err());
1007        assert!(future.leverage().is_err());
1008        let mut wide = future_with_quantity(Positive::ONE);
1009        wide.contract_size = Positive::MAX;
1010        assert!(wide.tick_value(Positive::TWO).is_err());
1011    }
1012}