Skip to main content

rustyqlib/equity/
lookback.rs

1//! Lookback options with closed forms (continuous monitoring, GBM).
2//!
3//! - **Floating strike** (Goldman-Sosin-Gatto 1979): the call pays
4//!   `S_T - min S`, the put `max S - S_T` — "buy at the low, sell at
5//!   the high", never out of the money;
6//! - **Fixed strike** (Conze-Viswanathan 1991): the call pays
7//!   `(max S - K)+`, the put `(K - min S)+`.
8//!
9//! Both support seasoned contracts through the running extremum
10//! argument (pass the spot for a fresh option). The formulas take the
11//! usual carry `b = r - q`; the `b = 0` singularity (the `sigma^2/2b`
12//! factor) is handled by nudging `b` by 1e-7, accurate to ~1e-6 in
13//! price and validated against Monte Carlo in the tests.
14//!
15//! Monte Carlo pricing of the same payoffs monitors **discretely** on
16//! the simulation grid, so it sits *below* these continuous forms for
17//! max-based payoffs (above for min-based) by the O(sigma sqrt(dt))
18//! extremum gap — the tests assert direction and convergence rather
19//! than pretending the two conventions coincide.
20
21use crate::core::trade::PutOrCall;
22use crate::core::utils::norm_cdf;
23
24/// Floating-strike lookback (Goldman-Sosin-Gatto). `extremum` is the
25/// running minimum for a call, the running maximum for a put; pass the
26/// spot for a freshly issued option.
27pub fn floating_strike_lookback_price(
28    s: f64,
29    extremum: f64,
30    r: f64,
31    q: f64,
32    sigma: f64,
33    t: f64,
34    put_or_call: PutOrCall,
35) -> f64 {
36    assert!(s > 0.0 && extremum > 0.0 && sigma > 0.0 && t > 0.0);
37    let mut b = r - q;
38    if b.abs() < 1e-7 {
39        b = if b >= 0.0 { 1e-7 } else { -1e-7 };
40    }
41    let sq = sigma * t.sqrt();
42    let two_b_over_v2 = 2.0 * b / (sigma * sigma);
43    let carry_df = ((b - r) * t).exp();
44    let df = (-r * t).exp();
45    match put_or_call {
46        PutOrCall::Call => {
47            let m = extremum; // running minimum <= s
48            assert!(m <= s * (1.0 + 1e-12), "call extremum must be the running minimum");
49            let a1 = ((s / m).ln() + (b + 0.5 * sigma * sigma) * t) / sq;
50            let a2 = a1 - sq;
51            s * carry_df * norm_cdf(a1) - m * df * norm_cdf(a2)
52                + s * df * (1.0 / two_b_over_v2)
53                    * ((s / m).powf(-two_b_over_v2) * norm_cdf(-a1 + (2.0 * b / sigma) * t.sqrt())
54                        - (b * t).exp() * norm_cdf(-a1))
55        }
56        PutOrCall::Put => {
57            let m = extremum; // running maximum >= s
58            assert!(m >= s * (1.0 - 1e-12), "put extremum must be the running maximum");
59            let b1 = ((s / m).ln() + (b + 0.5 * sigma * sigma) * t) / sq;
60            let b2 = b1 - sq;
61            m * df * norm_cdf(-b2) - s * carry_df * norm_cdf(-b1)
62                + s * df * (1.0 / two_b_over_v2)
63                    * (-(s / m).powf(-two_b_over_v2) * norm_cdf(b1 - (2.0 * b / sigma) * t.sqrt())
64                        + (b * t).exp() * norm_cdf(b1))
65        }
66    }
67}
68
69/// Fixed-strike lookback (Conze-Viswanathan). `extremum` is the running
70/// maximum for a call, the running minimum for a put; pass the spot for
71/// a freshly issued option.
72pub fn fixed_strike_lookback_price(
73    s: f64,
74    k: f64,
75    extremum: f64,
76    r: f64,
77    q: f64,
78    sigma: f64,
79    t: f64,
80    put_or_call: PutOrCall,
81) -> f64 {
82    assert!(s > 0.0 && k > 0.0 && extremum > 0.0 && sigma > 0.0 && t > 0.0);
83    let mut b = r - q;
84    if b.abs() < 1e-7 {
85        b = if b >= 0.0 { 1e-7 } else { -1e-7 };
86    }
87    let sq = sigma * t.sqrt();
88    let two_b_over_v2 = 2.0 * b / (sigma * sigma);
89    let carry_df = ((b - r) * t).exp();
90    let df = (-r * t).exp();
91    let two_b_sq = (2.0 * b / sigma) * t.sqrt();
92    match put_or_call {
93        PutOrCall::Call => {
94            let m = extremum; // running maximum
95            assert!(m >= s * (1.0 - 1e-12), "call extremum must be the running maximum");
96            if k > m {
97                let d1 = ((s / k).ln() + (b + 0.5 * sigma * sigma) * t) / sq;
98                let d2 = d1 - sq;
99                s * carry_df * norm_cdf(d1) - k * df * norm_cdf(d2)
100                    + s * df * (1.0 / two_b_over_v2)
101                        * (-(s / k).powf(-two_b_over_v2) * norm_cdf(d1 - two_b_sq)
102                            + (b * t).exp() * norm_cdf(d1))
103            } else {
104                let e1 = ((s / m).ln() + (b + 0.5 * sigma * sigma) * t) / sq;
105                let e2 = e1 - sq;
106                df * (m - k) + s * carry_df * norm_cdf(e1) - m * df * norm_cdf(e2)
107                    + s * df * (1.0 / two_b_over_v2)
108                        * (-(s / m).powf(-two_b_over_v2) * norm_cdf(e1 - two_b_sq)
109                            + (b * t).exp() * norm_cdf(e1))
110            }
111        }
112        PutOrCall::Put => {
113            let m = extremum; // running minimum
114            assert!(m <= s * (1.0 + 1e-12), "put extremum must be the running minimum");
115            if k < m {
116                let d1 = ((s / k).ln() + (b + 0.5 * sigma * sigma) * t) / sq;
117                let d2 = d1 - sq;
118                k * df * norm_cdf(-d2) - s * carry_df * norm_cdf(-d1)
119                    + s * df * (1.0 / two_b_over_v2)
120                        * ((s / k).powf(-two_b_over_v2) * norm_cdf(-d1 + two_b_sq)
121                            - (b * t).exp() * norm_cdf(-d1))
122            } else {
123                let f1 = ((s / m).ln() + (b + 0.5 * sigma * sigma) * t) / sq;
124                let f2 = f1 - sq;
125                df * (k - m) - s * carry_df * norm_cdf(-f1) + m * df * norm_cdf(-f2)
126                    + s * df * (1.0 / two_b_over_v2)
127                        * ((s / m).powf(-two_b_over_v2) * norm_cdf(-f1 + two_b_sq)
128                            - (b * t).exp() * norm_cdf(-f1))
129            }
130        }
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use crate::equity::blackscholes::bs_price;
138
139    const S: f64 = 100.0;
140    const R: f64 = 0.05;
141    const Q: f64 = 0.02;
142    const SIG: f64 = 0.3;
143    const T: f64 = 1.0;
144
145    #[test]
146    fn floating_lookbacks_dominate_atm_vanillas() {
147        // S_T - min >= (S_T - S_0)+ pathwise for fresh options, so the
148        // lookback must cost more; same for the put side
149        let call = floating_strike_lookback_price(S, S, R, Q, SIG, T, PutOrCall::Call);
150        let put = floating_strike_lookback_price(S, S, R, Q, SIG, T, PutOrCall::Put);
151        assert!(call > bs_price(S, S, R, Q, SIG, T, PutOrCall::Call), "{call}");
152        assert!(put > bs_price(S, S, R, Q, SIG, T, PutOrCall::Put), "{put}");
153        // and both are worth more than intrinsic zero by a wide margin
154        assert!(call > 10.0 && put > 10.0);
155    }
156
157    #[test]
158    fn fixed_strike_branches_are_continuous_at_the_extremum() {
159        // the K > M and K <= M branches must agree at K = M
160        let eps = 1e-9;
161        let call_above =
162            fixed_strike_lookback_price(S, S + eps, S, R, Q, SIG, T, PutOrCall::Call);
163        let call_below =
164            fixed_strike_lookback_price(S, S - eps, S, R, Q, SIG, T, PutOrCall::Call);
165        assert!((call_above - call_below).abs() < 1e-6, "{call_above} vs {call_below}");
166        let put_above =
167            fixed_strike_lookback_price(S, S + eps, S, R, Q, SIG, T, PutOrCall::Put);
168        let put_below =
169            fixed_strike_lookback_price(S, S - eps, S, R, Q, SIG, T, PutOrCall::Put);
170        assert!((put_above - put_below).abs() < 1e-6, "{put_above} vs {put_below}");
171    }
172
173    #[test]
174    fn fixed_and_floating_forms_are_linked_by_exact_identities() {
175        // (max - K)+ with K -> 0 is max - K, and (max - S_T) + S_T = max:
176        // fixed_call(K~0) - floating_put = e^{-rT} E[S_T] - K e^{-rT}
177        //                                = S e^{-qT} - K e^{-rT}
178        let k_small = 1e-7;
179        let fixed_call =
180            fixed_strike_lookback_price(S, k_small, S, R, Q, SIG, T, PutOrCall::Call);
181        let floating_put = floating_strike_lookback_price(S, S, R, Q, SIG, T, PutOrCall::Put);
182        let expect = S * (-Q * T).exp() - k_small * (-R * T).exp();
183        assert!(
184            (fixed_call - floating_put - expect).abs() < 1e-4,
185            "{} vs {}",
186            fixed_call - floating_put,
187            expect
188        );
189        // mirrored: (K - min)+ with huge K is K - min = (K - S_T) + (S_T - min)
190        let k_big = 100_000.0;
191        let fixed_put =
192            fixed_strike_lookback_price(S, k_big, S, R, Q, SIG, T, PutOrCall::Put);
193        let floating_call = floating_strike_lookback_price(S, S, R, Q, SIG, T, PutOrCall::Call);
194        let expect2 = k_big * (-R * T).exp() - S * (-Q * T).exp();
195        assert!(
196            (fixed_put - floating_call - expect2).abs() < 1e-4,
197            "{} vs {}",
198            fixed_put - floating_call,
199            expect2
200        );
201    }
202
203    #[test]
204    fn zero_carry_nudge_is_smooth() {
205        // b = 0 exactly vs b = 1e-5 either side: no discontinuity
206        let at = floating_strike_lookback_price(S, S, 0.03, 0.03, SIG, T, PutOrCall::Call);
207        let up = floating_strike_lookback_price(S, S, 0.03 + 1e-5, 0.03, SIG, T, PutOrCall::Call);
208        let dn = floating_strike_lookback_price(S, S, 0.03 - 1e-5, 0.03, SIG, T, PutOrCall::Call);
209        assert!(dn < at && at < up, "{dn} {at} {up}");
210        assert!((up - dn).abs() < 0.02, "smooth through b = 0");
211    }
212
213    #[test]
214    fn seasoned_extrema_move_prices_the_right_way() {
215        // a lower observed minimum makes the floating call strictly richer
216        // (the gain is modest: the fresh option already expects a low min)
217        let fresh = floating_strike_lookback_price(S, S, R, Q, SIG, T, PutOrCall::Call);
218        let seasoned = floating_strike_lookback_price(S, 80.0, R, Q, SIG, T, PutOrCall::Call);
219        assert!(seasoned > fresh + 1.0, "{seasoned} vs {fresh}");
220        // lower bound: the locked-in extremum pays at least e^{-rT}(E[S_T] - 80)
221        assert!(seasoned > S * (-Q * T).exp() - 80.0 * (-R * T).exp());
222        // a higher observed maximum makes the fixed call richer, and it is
223        // floored by the locked-in intrinsic e^{-rT}(130 - 110)
224        let fresh_fix = fixed_strike_lookback_price(S, 110.0, S, R, Q, SIG, T, PutOrCall::Call);
225        let seasoned_fix =
226            fixed_strike_lookback_price(S, 110.0, 130.0, R, Q, SIG, T, PutOrCall::Call);
227        assert!(seasoned_fix > fresh_fix + 1.0, "{seasoned_fix} vs {fresh_fix}");
228        assert!(seasoned_fix >= (130.0_f64 - 110.0) * (-R * T).exp());
229    }
230}