Skip to main content

rustyqlib/equity/
cos.rs

1//! COS method: Fourier-cosine series pricing of European vanillas from a
2//! characteristic function (Fang & Oosterlee, 2008).
3//!
4//! The risk-neutral density of `y = ln S_T` is unknown for Heston/Bates
5//! models but its Fourier transform — the characteristic function `phi` —
6//! is closed-form. The density's cosine-series coefficients on a
7//! truncated interval `[a, b]` are values of `phi`, and the payoff's
8//! cosine coefficients have closed forms, so the price collapses to a
9//! dot product with **exponential** convergence in the number of terms:
10//!
11//! ```text
12//! V ~ e^{-rT} sum_k' Re{ phi(u_k) e^{-i u_k a} } * V_k(K),   u_k = k pi/(b-a)
13//! ```
14//!
15//! The `phi(u_k)` sweep depends only on the model and expiry — not the
16//! strike — so one [`CosPricer`] prices an entire smile for the cost of
17//! one option. That is what makes characteristic-function **calibration**
18//! fast: the Levenberg-Marquardt objective revalues the whole quote grid
19//! thousands of times, and with COS each revaluation is one CF sweep per
20//! expiry instead of two 4000-point integrations per quote.
21//!
22//! The truncation range is set from the distribution's cumulants,
23//! estimated numerically from `ln phi` near zero — so any model with a CF
24//! (Heston, both Bates variants, GBM) works without per-model formulas.
25//! The legacy P1/P2 integration ([`heston_price`]/[`bates_price`]) is kept
26//! as the independent cross-check oracle in the tests.
27//!
28//! [`heston_price`]: crate::equity::heston::heston_price
29//! [`bates_price`]: crate::equity::bates::bates_price
30
31use std::f64::consts::PI;
32
33use crate::core::trade::PutOrCall;
34use crate::equity::heston::Cpx;
35
36/// Series terms used by the calibration objectives: enough for ~1e-8
37/// vanilla accuracy on market-typical parameters.
38pub const CALIBRATION_TERMS: usize = 160;
39
40/// Series terms for full-accuracy pricing and cross-checks. Exponential
41/// convergence makes the sweep cheap; 2048 converges even the slowly
42/// decaying CFs of high vol-of-vol (Feller-violated) parameter sets.
43pub const DEFAULT_TERMS: usize = 2048;
44
45/// A COS pricer for one (model, expiry): the CF sweep is done once at
46/// construction, every strike after that is an `O(N)` dot product with
47/// closed-form payoff coefficients.
48pub(crate) struct CosPricer {
49    a: f64,
50    b: f64,
51    /// `Re{ phi(u_k) e^{-i u_k a} }`, `k = 0..N`, `k = 0` term halved.
52    weights: Vec<f64>,
53    df: f64,
54    /// Forward from the martingale property `phi(-i) = E[S_T]`.
55    forward: f64,
56}
57
58impl CosPricer {
59    /// Build from the characteristic function of `y = ln S_T` (drift
60    /// included, as the Heston/Bates CFs are written).
61    pub(crate) fn new(cf: &dyn Fn(Cpx) -> Cpx, r: f64, t: f64, n: usize) -> Self {
62        // cumulants of y from psi(u) = ln phi(u):
63        //   Im psi(h) =  c1 h + O(h^3),   Re psi(h) = -c2 h^2/2 + c4 h^4/24
64        // two passes: a rough c2 fixes the step to the distribution scale,
65        // then psi at h and 2h separate c2 from c4
66        let psi = |h: f64| cf(Cpx::real(h)).ln();
67        let rough = psi(1e-2);
68        let c2_rough = (-2.0 * rough.re / 1e-4).abs().max(1e-10);
69        let h = (0.05 / c2_rough.sqrt()).clamp(1e-4, 1e-1);
70        let p1 = psi(h);
71        let p2 = psi(2.0 * h);
72        let c1 = p1.im / h;
73        let c4 = (2.0 * (p2.re - 4.0 * p1.re) / h.powi(4)).max(0.0);
74        let c2 = (-2.0 * p1.re / (h * h) + c4 * h * h / 12.0).abs().max(1e-10);
75
76        // Fang-Oosterlee range with the kurtosis term: L = 10 covers the
77        // fat tails of short-dated / high vol-of-vol Heston-class models
78        let width = 10.0 * (c2 + c4.sqrt()).sqrt();
79        let (a, b) = (c1 - width, c1 + width);
80
81        let bma = b - a;
82        let weights = (0..n)
83            .map(|k| {
84                let u = k as f64 * PI / bma;
85                // Re{ phi(u) e^{-i u a} }
86                let e = Cpx::new((u * a).cos(), -(u * a).sin());
87                let w = cf(Cpx::real(u)).mul(e).re;
88                if k == 0 { 0.5 * w } else { w }
89            })
90            .collect();
91
92        let forward = cf(Cpx::new(0.0, -1.0)).re;
93        CosPricer { a, b, weights, df: (-r * t).exp(), forward }
94    }
95
96    /// European call price at `strike`. The series is evaluated on the
97    /// out-of-the-money side (small payoff coefficients, best relative
98    /// precision) and in-the-money prices recover through put-call
99    /// parity with the CF-implied forward.
100    pub(crate) fn call(&self, strike: f64) -> f64 {
101        if strike < self.forward {
102            return self.raw_put(strike) + self.df * (self.forward - strike);
103        }
104        self.raw_call(strike)
105    }
106
107    /// European put price at `strike` (OTM series + parity, as for calls).
108    pub(crate) fn put(&self, strike: f64) -> f64 {
109        if strike > self.forward {
110            return self.raw_call(strike) + self.df * (strike - self.forward);
111        }
112        self.raw_put(strike)
113    }
114
115    fn raw_call(&self, strike: f64) -> f64 {
116        let lnk = strike.ln();
117        if lnk >= self.b {
118            return 0.0; // beyond the truncation range the tail mass is ~0
119        }
120        self.sum(strike, lnk.max(self.a), self.b, true)
121    }
122
123    fn raw_put(&self, strike: f64) -> f64 {
124        let lnk = strike.ln();
125        if lnk <= self.a {
126            return 0.0;
127        }
128        self.sum(strike, self.a, lnk.min(self.b), false)
129    }
130
131    pub(crate) fn price(&self, strike: f64, put_or_call: PutOrCall) -> f64 {
132        match put_or_call {
133            PutOrCall::Call => self.call(strike),
134            PutOrCall::Put => self.put(strike),
135        }
136    }
137
138    /// `e^{-rT} sum_k' w_k V_k` with the closed-form cosine coefficients
139    /// of the vanilla payoff over `[c, d]`:
140    ///   chi_k = int e^y cos(k pi (y-a)/(b-a)) dy,
141    ///   psi_k = int     cos(k pi (y-a)/(b-a)) dy.
142    fn sum(&self, strike: f64, c: f64, d: f64, call: bool) -> f64 {
143        let bma = self.b - self.a;
144        let (ec, ed) = (c.exp(), d.exp());
145        let (yc, yd) = (c - self.a, d - self.a);
146        let mut total = 0.0;
147        for (k, w) in self.weights.iter().enumerate() {
148            let omega = k as f64 * PI / bma;
149            let (sin_c, cos_c) = (omega * yc).sin_cos();
150            let (sin_d, cos_d) = (omega * yd).sin_cos();
151            let chi =
152                (cos_d * ed - cos_c * ec + omega * (sin_d * ed - sin_c * ec)) / (1.0 + omega * omega);
153            let psi = if k == 0 { d - c } else { (sin_d - sin_c) / omega };
154            let payoff_coeff = if call { chi - strike * psi } else { strike * psi - chi };
155            total += w * payoff_coeff;
156        }
157        self.df * total * 2.0 / bma
158    }
159}
160
161/// Group quote indices by (exact) maturity, preserving first-seen order —
162/// the shape the calibration objectives iterate over so each expiry pays
163/// for one CF sweep regardless of its strike count.
164pub(crate) fn group_by_maturity(maturities: impl Iterator<Item = f64>) -> Vec<(f64, Vec<usize>)> {
165    let mut groups: Vec<(f64, Vec<usize>)> = Vec::new();
166    for (i, t) in maturities.enumerate() {
167        match groups.iter_mut().find(|(gt, _)| *gt == t) {
168            Some((_, idxs)) => idxs.push(i),
169            None => groups.push((t, vec![i])),
170        }
171    }
172    groups
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use crate::equity::bates::{
179        bates_double_exp_price, bates_price, BatesDoubleExpParams, BatesParams, KouJumps,
180        MertonJumps,
181    };
182    use crate::equity::blackscholes::bs_price;
183    use crate::equity::heston::{heston_price, HestonParams};
184
185    const S: f64 = 100.0;
186    const R: f64 = 0.03;
187    const Q: f64 = 0.01;
188
189    /// CF of ln S_T under GBM: the exact analytic benchmark.
190    fn gbm_cf(u: Cpx, sigma: f64, t: f64) -> Cpx {
191        let drift = S.ln() + (R - Q - 0.5 * sigma * sigma) * t;
192        crate::equity::heston::I
193            .mul(u)
194            .scale(drift)
195            .sub(u.mul(u).scale(0.5 * sigma * sigma * t))
196            .exp()
197    }
198
199    fn heston() -> HestonParams {
200        HestonParams { v0: 0.09, kappa: 2.0, theta: 0.09, vol_of_vol: 0.4, rho: -0.7 }
201    }
202
203    #[test]
204    fn cos_reproduces_black_scholes() {
205        for t in [0.1, 1.0, 3.0] {
206            for sigma in [0.1, 0.3, 0.6] {
207                let pricer = CosPricer::new(&|u| gbm_cf(u, sigma, t), R, t, DEFAULT_TERMS);
208                for k in [60.0, 90.0, 100.0, 110.0, 150.0] {
209                    let call = bs_price(S, k, R, Q, sigma, t, PutOrCall::Call);
210                    let put = bs_price(S, k, R, Q, sigma, t, PutOrCall::Put);
211                    assert!(
212                        (pricer.call(k) - call).abs() < 1e-8,
213                        "call K={k} t={t} sigma={sigma}: cos {} bs {call}",
214                        pricer.call(k)
215                    );
216                    assert!(
217                        (pricer.put(k) - put).abs() < 1e-8,
218                        "put K={k} t={t} sigma={sigma}: cos {} bs {put}",
219                        pricer.put(k)
220                    );
221                }
222            }
223        }
224    }
225
226    #[test]
227    fn cos_agrees_with_the_heston_integration_oracle() {
228        let params = [
229            heston(),
230            // high vol-of-vol, Feller violated: the stress case
231            HestonParams { v0: 0.04, kappa: 1.0, theta: 0.04, vol_of_vol: 0.9, rho: -0.9 },
232            HestonParams { v0: 0.16, kappa: 3.0, theta: 0.09, vol_of_vol: 0.2, rho: 0.3 },
233        ];
234        for hp in &params {
235            for t in [0.25, 1.0, 2.0] {
236                let pricer = CosPricer::new(
237                    &|u| crate::equity::heston::characteristic_fn(u, S, R, Q, t, hp),
238                    R,
239                    t,
240                    DEFAULT_TERMS,
241                );
242                for k in [70.0, 90.0, 100.0, 110.0, 140.0] {
243                    let oracle = heston_price(S, k, R, Q, t, hp, PutOrCall::Call);
244                    let cos = pricer.call(k);
245                    assert!(
246                        (cos - oracle).abs() < 5e-6,
247                        "K={k} t={t} hp={hp:?}: cos {cos} oracle {oracle}"
248                    );
249                }
250            }
251        }
252    }
253
254    #[test]
255    fn cos_agrees_with_both_bates_oracles() {
256        let merton = BatesParams {
257            heston: heston(),
258            jumps: MertonJumps { intensity: 0.5, mean_jump: -0.1, jump_vol: 0.2 },
259        };
260        let kou = BatesDoubleExpParams {
261            heston: heston(),
262            jumps: KouJumps { intensity: 0.7, p_up: 0.4, eta_up: 12.0, eta_down: 8.0 },
263        };
264        let t = 1.0;
265        let merton_pricer =
266            CosPricer::new(&|u| crate::equity::bates::ln_price_cf(u, S, R, Q, t, &merton), R, t, DEFAULT_TERMS);
267        let kou_pricer = CosPricer::new(
268            &|u| crate::equity::bates::ln_price_cf_double_exp(u, S, R, Q, t, &kou),
269            R,
270            t,
271            DEFAULT_TERMS,
272        );
273        for k in [80.0, 100.0, 120.0] {
274            let m_oracle = bates_price(S, k, R, Q, t, &merton, PutOrCall::Call);
275            let k_oracle = bates_double_exp_price(S, k, R, Q, t, &kou, PutOrCall::Call);
276            assert!(
277                (merton_pricer.call(k) - m_oracle).abs() < 5e-6,
278                "merton K={k}: cos {} oracle {m_oracle}",
279                merton_pricer.call(k)
280            );
281            assert!(
282                (kou_pricer.call(k) - k_oracle).abs() < 5e-6,
283                "kou K={k}: cos {} oracle {k_oracle}",
284                kou_pricer.call(k)
285            );
286        }
287    }
288
289    #[test]
290    fn put_call_parity_holds_to_high_precision() {
291        let t = 1.0;
292        let hp = heston();
293        let pricer = CosPricer::new(
294            &|u| crate::equity::heston::characteristic_fn(u, S, R, Q, t, &hp),
295            R,
296            t,
297            DEFAULT_TERMS,
298        );
299        let df = (-R * t).exp();
300        let forward = S * ((R - Q) * t).exp();
301        for k in [80.0, 100.0, 125.0] {
302            let parity = pricer.call(k) - pricer.put(k) - df * (forward - k);
303            assert!(parity.abs() < 1e-9, "parity violation {parity} at K={k}");
304        }
305    }
306
307
308
309    #[test]
310    fn maturity_grouping_preserves_indices() {
311        let groups = group_by_maturity([1.0, 0.5, 1.0, 2.0, 0.5].into_iter());
312        assert_eq!(
313            groups,
314            vec![(1.0, vec![0, 2]), (0.5, vec![1, 4]), (2.0, vec![3])]
315        );
316    }
317}