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