Skip to main content

rustyqlib/equity/
forward_start_option.rs

1//! Forward-start options: the strike is fixed at a future date `t_f` as a
2//! fraction `k` of the then-prevailing spot; the payoff at expiry `T` is
3//! `(S_T - k * S_{t_f})^+` (call) or the mirrored put.
4//!
5//! - **Black-Scholes closed form** (Rubinstein 1991) via homogeneity:
6//!   `price = S_0 e^{-q t_f} * BS(1, k, r, q, sigma, T - t_f)`.
7//! - **Monte Carlo** through [`Payoff::path_payoff`]: the payoff reads
8//!   `S_{t_f}` off the simulated path, so the option prices under GBM,
9//!   local vol and — the reason this product exists — **Heston stochastic
10//!   vol**, whose forward smile differs materially from Black-Scholes.
11
12use crate::core::trade::PutOrCall;
13use crate::core::utils::ContractStyle;
14use crate::equity::blackscholes::bs_price;
15use crate::equity::utils::{Payoff, PayoffType};
16
17#[derive(Debug, Clone)]
18pub struct ForwardStartPayoff {
19    pub put_or_call: PutOrCall,
20    pub exercise_style: ContractStyle,
21    /// Strike as a fraction of the spot on the fixing date (1.0 = at the money).
22    pub strike_fraction: f64,
23    /// Fixing time as a fraction of the option life, in (0, 1).
24    pub start_fraction: f64,
25}
26
27impl Payoff for ForwardStartPayoff {
28    /// Degenerate single-point value (strike not yet fixed): zero.
29    fn payoff(&self, _spot: f64, _strike: f64) -> f64 {
30        0.0
31    }
32    fn path_payoff(&self, path: &[f64], _strike: f64) -> f64 {
33        let n = path.len();
34        // step i covers time (i+1) * T/n: the fixing index for t_f
35        let idx = ((self.start_fraction * n as f64).round() as usize).clamp(1, n - 1) - 1;
36        let strike = self.strike_fraction * path[idx];
37        let terminal = path[n - 1];
38        match self.put_or_call {
39            PutOrCall::Call => (terminal - strike).max(0.0),
40            PutOrCall::Put => (strike - terminal).max(0.0),
41        }
42    }
43    fn path_payoff_var<'t>(
44        &self,
45        path: &[crate::core::aad::Var<'t>],
46        _strike: f64,
47    ) -> Option<crate::core::aad::Var<'t>> {
48        let n = path.len();
49        let idx = ((self.start_fraction * n as f64).round() as usize).clamp(1, n - 1) - 1;
50        let strike = path[idx] * self.strike_fraction;
51        let terminal = path[n - 1];
52        Some(match self.put_or_call {
53            PutOrCall::Call => (terminal - strike).maxf(0.0),
54            PutOrCall::Put => (strike - terminal).maxf(0.0),
55        })
56    }
57    fn is_path_dependent(&self) -> bool {
58        true
59    }
60    fn payoff_kind(&self) -> PayoffType {
61        PayoffType::ForwardStart
62    }
63    fn put_or_call(&self) -> &PutOrCall {
64        &self.put_or_call
65    }
66    fn exercise_style(&self) -> &ContractStyle {
67        &self.exercise_style
68    }
69    fn as_any(&self) -> &dyn std::any::Any {
70        self
71    }
72    fn clone_box(&self) -> Box<dyn Payoff> {
73        Box::new(self.clone())
74    }
75}
76
77/// Rubinstein closed form under Black-Scholes: by homogeneity the value at
78/// the fixing date is `S_{t_f} * BS(1, k, r, q, sigma, T - t_f)`, so the
79/// time-0 value replaces `S_{t_f}` by its dividend-discounted spot.
80#[allow(clippy::too_many_arguments)]
81pub fn forward_start_price(
82    s: f64,
83    strike_fraction: f64,
84    r: f64,
85    q: f64,
86    sigma: f64,
87    start_t: f64,
88    t: f64,
89    put_or_call: PutOrCall,
90) -> f64 {
91    assert!(start_t > 0.0 && start_t < t, "fixing must lie inside the option life");
92    s * (-q * start_t).exp() * bs_price(1.0, strike_fraction, r, q, sigma, t - start_t, put_or_call)
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn reduces_to_vanilla_when_fixing_is_immediate() {
101        // t_f -> 0: strike ~= k * S_0, so the price approaches a vanilla
102        // struck at k * S_0
103        let fs = forward_start_price(100.0, 1.0, 0.05, 0.02, 0.3, 1e-6, 1.0, PutOrCall::Call);
104        let vanilla = bs_price(100.0, 100.0, 0.05, 0.02, 0.3, 1.0, PutOrCall::Call);
105        assert!((fs - vanilla).abs() < 1e-3, "{fs} vs {vanilla}");
106    }
107
108    #[test]
109    fn price_is_homogeneous_in_spot() {
110        let p1 = forward_start_price(100.0, 1.0, 0.05, 0.02, 0.3, 0.5, 1.0, PutOrCall::Call);
111        let p2 = forward_start_price(200.0, 1.0, 0.05, 0.02, 0.3, 0.5, 1.0, PutOrCall::Call);
112        assert!((p2 - 2.0 * p1).abs() < 1e-12);
113    }
114
115    #[test]
116    fn path_payoff_reads_fixing_off_the_path() {
117        let payoff = ForwardStartPayoff {
118            put_or_call: PutOrCall::Call,
119            exercise_style: ContractStyle::European,
120            strike_fraction: 1.0,
121            start_fraction: 0.5,
122        };
123        // 4-step path: fixing at step index 1 (time 0.5T), terminal 120
124        let path = [100.0, 90.0, 110.0, 120.0];
125        assert!((payoff.path_payoff(&path, 0.0) - 30.0).abs() < 1e-12); // 120 - 90
126    }
127}