Skip to main content

rustyqlib/equity/
finite_difference.rs

1//! Finite difference pricer for the backward pricing PDE in log-spot.
2//!
3//! Features:
4//! - theta-scheme (Crank-Nicolson with a Rannacher fully-implicit start),
5//!   cell-averaged terminal conditions (kinks and digital jumps), generic
6//!   Dirichlet boundaries.
7//! - **Per-node, per-step coefficient assembly**: supports the Dupire local
8//!   vol model (`mc_model: "local_vol"` applies to this engine too) and
9//!   term-structure-consistent rates (each time step discounts and drifts
10//!   at the curve's forward rate for its own calendar interval). This
11//!   assembly structure is the 1-D basis a stochastic vol (ADI) solver
12//!   will extend.
13//! - **American exercise via Brennan-Schwartz** (projection inside the
14//!   tridiagonal solve, swept from the out-of-the-money side).
15//! - **Barrier options**: knock-out via an absorbing boundary with the grid
16//!   edge placed exactly at the barrier; knock-in by parity (European).
17//! - **Greeks from the grid**: delta/gamma from a local quadratic fit at
18//!   the spot, theta from the last two time layers — one solve yields
19//!   npv/delta/gamma/theta; vega and rho are bump-and-resolve.
20//!
21//! Grid sizes are configurable per contract (`fd_spot_steps`,
22//! `fd_time_steps` in JSON).
23
24use crate::core::curves::Compounding;
25use crate::core::data_models::EquityOptionData;
26use crate::core::trade::PutOrCall;
27use crate::core::utils::ContractStyle;
28use crate::equity::barrier::{BarrierDirection, KnockType};
29use crate::equity::local_vol::LocalVol;
30use crate::equity::montecarlo::McModel;
31use crate::equity::utils::Payoff;
32use crate::equity::vanila_option::{BarrierPayoff, EquityOption};
33
34const RANNACHER_STEPS: usize = 4;
35const CELL_AVG_POINTS: usize = 16;
36
37#[derive(Debug, Clone, Copy)]
38pub struct FdConfig {
39    pub spot_steps: usize,
40    pub time_steps: usize,
41    pub grid_stdevs: f64,
42}
43
44impl Default for FdConfig {
45    fn default() -> Self {
46        FdConfig { spot_steps: 400, time_steps: 400, grid_stdevs: 5.0 }
47    }
48}
49
50impl FdConfig {
51    pub fn from_data(data: &EquityOptionData) -> Self {
52        let defaults = FdConfig::default();
53        FdConfig {
54            spot_steps: data.fd_spot_steps.unwrap_or(defaults.spot_steps).max(10),
55            time_steps: data.fd_time_steps.unwrap_or(defaults.time_steps).max(10),
56            grid_stdevs: defaults.grid_stdevs,
57        }
58    }
59}
60
61/// One solve returns the value and the grid Greeks.
62#[derive(Debug, Clone, Copy)]
63pub struct FdSolution {
64    pub npv: f64,
65    pub delta: f64,
66    pub gamma: f64,
67    pub theta: f64,
68}
69
70impl FdSolution {
71    fn zero() -> Self {
72        FdSolution { npv: 0.0, delta: 0.0, gamma: 0.0, theta: 0.0 }
73    }
74    fn minus(self, other: FdSolution) -> Self {
75        FdSolution {
76            npv: self.npv - other.npv,
77            delta: self.delta - other.delta,
78            gamma: self.gamma - other.gamma,
79            theta: self.theta - other.theta,
80        }
81    }
82}
83
84pub fn npv(option: &EquityOption) -> f64 {
85    solution(option).npv
86}
87pub fn delta(option: &EquityOption) -> f64 {
88    solution(option).delta
89}
90pub fn gamma(option: &EquityOption) -> f64 {
91    solution(option).gamma
92}
93pub fn theta(option: &EquityOption) -> f64 {
94    solution(option).theta
95}
96pub fn vega(option: &EquityOption) -> f64 {
97    // parallel vol bump: constant-vol solves shift sigma, local vol solves
98    // shift the implied surface before the Dupire transform
99    let h = 1e-3;
100    (solve_dispatch(option, h, 0.0).npv - solve_dispatch(option, -h, 0.0).npv) / (2.0 * h)
101}
102pub fn rho(option: &EquityOption) -> f64 {
103    let h = 1e-4;
104    (solve_dispatch(option, 0.0, h).npv - solve_dispatch(option, 0.0, -h).npv) / (2.0 * h)
105}
106
107/// Value and grid Greeks in a single solve (two for knock-ins).
108pub fn solution(option: &EquityOption) -> FdSolution {
109    solve_dispatch(option, 0.0, 0.0)
110}
111
112fn solve_dispatch(option: &EquityOption, sigma_bump: f64, r_bump: f64) -> FdSolution {
113    let t = option.time_to_maturity();
114    assert!(t >= 0.0, "Option is expired or negative time");
115    let s0 = option.base.underlying_price.value();
116    assert!(s0 > 0.0, "underlying price must be positive");
117    if t == 0.0 {
118        let mut sol = FdSolution::zero();
119        sol.npv = option.payoff.payoff(s0, option.base.strike_price);
120        return sol;
121    }
122
123    if let Some(barrier) = option.payoff.as_any().downcast_ref::<BarrierPayoff>() {
124        let down = barrier.direction == BarrierDirection::Down;
125        let knocked = if down { s0 <= barrier.barrier } else { s0 >= barrier.barrier };
126        return match barrier.knock {
127            KnockType::Out => {
128                if knocked {
129                    FdSolution::zero()
130                } else {
131                    solve(option, sigma_bump, r_bump, Some(barrier))
132                }
133            }
134            KnockType::In => {
135                // knock-in by parity (European only; guarded upstream):
136                // KI = vanilla leg - KO, which is linear in all Greeks
137                let vanilla = solve(option, sigma_bump, r_bump, None);
138                if knocked {
139                    vanilla
140                } else {
141                    vanilla.minus(solve(option, sigma_bump, r_bump, Some(barrier)))
142                }
143            }
144        };
145    }
146    solve(option, sigma_bump, r_bump, None)
147}
148
149/// Volatility field used to assemble the PDE coefficients.
150enum FdVol<'a> {
151    Const(f64),
152    Local(LocalVol<'a>),
153}
154
155impl FdVol<'_> {
156    fn vol(&self, s: f64, calendar_t: f64) -> f64 {
157        match self {
158            FdVol::Const(v) => *v,
159            FdVol::Local(lv) => lv.vol(s, calendar_t),
160        }
161    }
162}
163
164fn solve(
165    option: &EquityOption,
166    sigma_bump: f64,
167    r_bump: f64,
168    knock_out: Option<&BarrierPayoff>,
169) -> FdSolution {
170    let cfg = &option.fd;
171    let payoff = option.payoff.as_ref();
172    let strike = option.base.strike_price;
173    let s0 = option.base.underlying_price.value();
174    let q = option.base.carry_yield();
175    let t = option.time_to_maturity();
176    let sigma_ref = option.base.volatility() + sigma_bump;
177    assert!(sigma_ref > 0.0, "volatility must be positive");
178    let american = matches!(payoff.exercise_style(), ContractStyle::American);
179    let put = matches!(payoff.put_or_call(), PutOrCall::Put);
180
181    let vol_field = match option.mc.model {
182        McModel::Gbm => FdVol::Const(sigma_ref),
183        McModel::LocalVol => FdVol::Local(LocalVol::new(
184            &option.base.vol_surface,
185            &option.base.discount_curve,
186            s0,
187            q,
188            sigma_bump,
189        )),
190        McModel::Heston => panic!(
191            "The Heston model needs a 2-D (ADI) FD solver, which is future \
192             work; use the Analytical or MonteCarlo engines"
193        ),
194    };
195
196    // ── Grid geometry (log-spot). A knock-out barrier becomes the exact
197    // grid edge (absorbing boundary); otherwise the grid centers on x0.
198    let x0 = s0.ln();
199    let r_flat = option.base.risk_free_rate() + r_bump;
200    let drift_width = ((r_flat - q - 0.5 * sigma_ref * sigma_ref) * t).abs();
201    let half_width =
202        cfg.grid_stdevs * sigma_ref * t.sqrt() + drift_width + (strike / s0).ln().abs().max(1e-2);
203    let (x_min, x_max, barrier_low, barrier_high) = match knock_out {
204        Some(b) if b.direction == BarrierDirection::Down => {
205            (b.barrier.ln(), x0 + half_width, true, false)
206        }
207        Some(b) => (x0 - half_width, b.barrier.ln(), false, true),
208        None => (x0 - half_width, x0 + half_width, false, false),
209    };
210    let n = cfg.spot_steps;
211    let dx = (x_max - x_min) / n as f64;
212    let x_at = |i: usize| x_min + i as f64 * dx;
213    let s_grid: Vec<f64> = (0..=n).map(|i| x_at(i).exp()).collect();
214    let exercise: Vec<f64> = s_grid.iter().map(|&s| payoff.payoff(s, strike)).collect();
215
216    // ── Per-step forward rates from the discount curve (term-structure
217    // consistent drift and discounting), plus any rho bump.
218    let steps = cfg.time_steps;
219    let dt = t / steps as f64;
220    let curve = &option.base.discount_curve;
221    let step_rates: Vec<f64> = (0..steps)
222        .map(|k| {
223            // step k advances time-to-expiry tau from k*dt to (k+1)*dt,
224            // i.e. calendar time from t - k*dt back to t - (k+1)*dt
225            let t2 = t - k as f64 * dt;
226            let t1 = t - (k + 1) as f64 * dt;
227            let fwd = if t1 <= 0.0 {
228                curve.zero_rate_with(t2.max(1e-8), Compounding::Continuous)
229            } else {
230                curve
231                    .forward_rate_with(t1, t2, Compounding::Continuous)
232                    .unwrap_or_else(|_| curve.zero_rate_with(t2, Compounding::Continuous))
233            };
234            fwd + r_bump
235        })
236        .collect();
237
238    // cash dividend ex-dates as year fractions inside the option's life
239    let cash_divs: Vec<(f64, f64)> = option
240        .base
241        .cash_dividends
242        .iter()
243        .filter_map(|(date, amount)| {
244            let td = (*date - option.base.valuation_date).num_days() as f64 / 365.0;
245            (td > 0.0 && td <= t).then_some((td, *amount))
246        })
247        .collect();
248
249    // terminal condition: cell-averaged payoff
250    let mut v: Vec<f64> = (0..=n)
251        .map(|i| cell_average_payoff(payoff, strike, x_at(i), dx))
252        .collect();
253    if barrier_low {
254        v[0] = 0.0;
255    }
256    if barrier_high {
257        v[n] = 0.0;
258    }
259
260    let m = n - 1; // interior unknowns
261    let mut sub = vec![0.0; m - 1];
262    let mut dia = vec![0.0; m];
263    let mut sup = vec![0.0; m - 1];
264    let mut rhs = vec![0.0; m];
265    let mut lower = vec![0.0; n + 1];
266    let mut diag = vec![0.0; n + 1];
267    let mut upper = vec![0.0; n + 1];
268
269    // cumulative discount and forward growth to the current time layer,
270    // for the generic Dirichlet boundary V(S_b, tau) = D * payoff(S_b * G)
271    let mut cum_df = 1.0;
272    let mut cum_growth = 1.0;
273    let mut theta_layer_value = 0.0; // value at spot one step before the end
274
275    for step in 0..steps {
276        let theta_w = if step < RANNACHER_STEPS { 1.0 } else { 0.5 };
277        let r_step = step_rates[step];
278        let calendar_mid = (t - (step as f64 + 0.5) * dt).max(0.0);
279        cum_df *= (-r_step * dt).exp();
280        cum_growth *= ((r_step - q) * dt).exp();
281
282        // per-node coefficients at this time layer
283        for i in 0..=n {
284            let sigma = vol_field.vol(s_grid[i], calendar_mid);
285            let s2 = 0.5 * sigma * sigma;
286            let mu = r_step - q - s2;
287            lower[i] = s2 / (dx * dx) - mu / (2.0 * dx);
288            diag[i] = -2.0 * s2 / (dx * dx) - r_step;
289            upper[i] = s2 / (dx * dx) + mu / (2.0 * dx);
290        }
291
292        // boundary values at the new time layer
293        let boundary = |i: usize, is_barrier: bool| -> f64 {
294            if is_barrier {
295                return 0.0;
296            }
297            let mut val = cum_df * payoff.payoff(s_grid[i] * cum_growth, strike);
298            if american {
299                val = val.max(exercise[i]);
300            }
301            val
302        };
303        let v_low = boundary(0, barrier_low);
304        let v_high = boundary(n, barrier_high);
305
306        for i in 1..n {
307            let av = lower[i] * v[i - 1] + diag[i] * v[i] + upper[i] * v[i + 1];
308            rhs[i - 1] = v[i] + (1.0 - theta_w) * dt * av;
309        }
310        rhs[0] += theta_w * dt * lower[1] * v_low;
311        rhs[m - 1] += theta_w * dt * upper[n - 1] * v_high;
312        for i in 1..n {
313            dia[i - 1] = 1.0 - theta_w * dt * diag[i];
314        }
315        for i in 1..n - 1 {
316            sub[i - 1] = -theta_w * dt * lower[i + 1];
317            sup[i - 1] = -theta_w * dt * upper[i];
318        }
319
320        let interior = if american {
321            // Brennan-Schwartz: apply the exercise constraint inside the
322            // back-substitution, sweeping from the out-of-the-money side
323            // toward the exercise region (low spot for puts, high for calls)
324            brennan_schwartz(&sub, &dia, &sup, &rhs, &exercise[1..n], put)
325        } else {
326            thomas_algorithm(&sub, &dia, &sup, &rhs)
327        };
328        v[0] = v_low;
329        v[n] = v_high;
330        v[1..n].copy_from_slice(&interior);
331
332        // cash dividend jump condition: when the backward induction crosses
333        // an ex-date, V(S, t_ex^-) = V(S - D, t_ex^+)
334        if !cash_divs.is_empty() {
335            let cal_old = t - step as f64 * dt;
336            let cal_new = t - (step + 1) as f64 * dt;
337            let crossing: f64 = cash_divs
338                .iter()
339                .filter(|(td, _)| *td < cal_old && *td >= cal_new)
340                .map(|(_, amount)| *amount)
341                .sum();
342            if crossing > 0.0 {
343                let shifted: Vec<f64> = (0..=n)
344                    .map(|i| {
345                        let s_target = s_grid[i] - crossing;
346                        if s_target <= s_grid[0] {
347                            v[0]
348                        } else {
349                            let x_target = s_target.ln();
350                            let j =
351                                (((x_target - x_min) / dx).floor() as usize).min(n - 1);
352                            let w = ((x_target - x_at(j)) / dx).clamp(0.0, 1.0);
353                            v[j] * (1.0 - w) + v[j + 1] * w
354                        }
355                    })
356                    .collect();
357                v = shifted;
358                if american {
359                    for i in 0..=n {
360                        if v[i] < exercise[i] {
361                            v[i] = exercise[i];
362                        }
363                    }
364                }
365            }
366        }
367
368        if step + 1 == steps.saturating_sub(1) {
369            theta_layer_value = read_grid(&v, x_min, dx, x0).0;
370        }
371    }
372
373    let (npv, delta_x, gamma_x) = read_grid(&v, x_min, dx, x0);
374    // chain rule from log-spot: V_S = V_x / S, V_SS = (V_xx - V_x) / S^2
375    let delta = delta_x / s0;
376    let gamma = (gamma_x - delta_x) / (s0 * s0);
377    let theta = if steps >= 2 { (theta_layer_value - npv) / dt } else { 0.0 };
378    FdSolution { npv, delta, gamma, theta }
379}
380
381/// Quadratic fit through the three nodes nearest `x0`:
382/// returns (value, dV/dx, d2V/dx2) at x0.
383fn read_grid(v: &[f64], x_min: f64, dx: f64, x0: f64) -> (f64, f64, f64) {
384    let n = v.len() - 1;
385    let i = (((x0 - x_min) / dx).round() as usize).clamp(1, n - 1);
386    let e = x0 - (x_min + i as f64 * dx);
387    let b = (v[i + 1] - v[i - 1]) / (2.0 * dx);
388    let c = (v[i + 1] - 2.0 * v[i] + v[i - 1]) / (2.0 * dx * dx);
389    (v[i] + b * e + c * e * e, b + 2.0 * c * e, 2.0 * c)
390}
391
392/// Average of the payoff over the grid cell `[x - dx/2, x + dx/2]`.
393fn cell_average_payoff(payoff: &dyn Payoff, strike: f64, x: f64, dx: f64) -> f64 {
394    let k = CELL_AVG_POINTS;
395    let mut sum = 0.0;
396    for j in 0..k {
397        let xi = x - 0.5 * dx + (j as f64 + 0.5) * dx / k as f64;
398        sum += payoff.payoff(xi.exp(), strike);
399    }
400    sum / k as f64
401}
402
403/// Solves a tridiagonal system `A x = d` where `a` is the sub-diagonal
404/// (`a[i-1]` multiplies `x[i-1]` in row `i`), `b` the diagonal and `c` the
405/// super-diagonal (`c[i]` multiplies `x[i+1]` in row `i`).
406/// https://en.wikipedia.org/wiki/Tridiagonal_matrix_algorithm
407pub fn thomas_algorithm(a: &[f64], b: &[f64], c: &[f64], d: &[f64]) -> Vec<f64> {
408    let n = d.len();
409    assert!(b.len() == n && a.len() == n - 1 && c.len() == n - 1);
410    if n == 1 {
411        return vec![d[0] / b[0]];
412    }
413    let mut c_ = c.to_vec();
414    let mut d_ = d.to_vec();
415    let mut x: Vec<f64> = vec![0.0; n];
416
417    c_[0] = c_[0] / b[0];
418    d_[0] = d_[0] / b[0];
419    for i in 1..n - 1 {
420        let id = 1.0 / (b[i] - a[i - 1] * c_[i - 1]);
421        c_[i] = c_[i] * id;
422        d_[i] = (d_[i] - a[i - 1] * d_[i - 1]) * id;
423    }
424    d_[n - 1] = (d_[n - 1] - a[n - 2] * d_[n - 2]) / (b[n - 1] - a[n - 2] * c_[n - 2]);
425
426    x[n - 1] = d_[n - 1];
427    for i in (0..n - 1).rev() {
428        x[i] = d_[i] - c_[i] * x[i + 1];
429    }
430    x
431}
432
433/// Brennan-Schwartz solve of the linear complementarity problem
434/// `Ax = d, x >= exercise`: standard Thomas elimination with the constraint
435/// applied during back-substitution. The substitution must sweep *toward*
436/// the exercise region, so puts (exercise at low spot) use the natural
437/// high-to-low sweep and calls are solved on the reversed system.
438fn brennan_schwartz(
439    a: &[f64],
440    b: &[f64],
441    c: &[f64],
442    d: &[f64],
443    exercise: &[f64],
444    exercise_at_low_spot: bool,
445) -> Vec<f64> {
446    if exercise_at_low_spot {
447        brennan_schwartz_sweep(a, b, c, d, exercise)
448    } else {
449        // reverse the system: row order flips, sub- and super-diagonals swap
450        let n = d.len();
451        let ar: Vec<f64> = c.iter().rev().copied().collect();
452        let br: Vec<f64> = b.iter().rev().copied().collect();
453        let cr: Vec<f64> = a.iter().rev().copied().collect();
454        let dr: Vec<f64> = d.iter().rev().copied().collect();
455        let er: Vec<f64> = exercise.iter().rev().copied().collect();
456        let mut x = brennan_schwartz_sweep(&ar, &br, &cr, &dr, &er);
457        x.reverse();
458        debug_assert_eq!(x.len(), n);
459        x
460    }
461}
462
463fn brennan_schwartz_sweep(
464    a: &[f64],
465    b: &[f64],
466    c: &[f64],
467    d: &[f64],
468    exercise: &[f64],
469) -> Vec<f64> {
470    let n = d.len();
471    if n == 1 {
472        return vec![(d[0] / b[0]).max(exercise[0])];
473    }
474    let mut c_ = c.to_vec();
475    let mut d_ = d.to_vec();
476    let mut x = vec![0.0; n];
477    c_[0] = c_[0] / b[0];
478    d_[0] = d_[0] / b[0];
479    for i in 1..n - 1 {
480        let id = 1.0 / (b[i] - a[i - 1] * c_[i - 1]);
481        c_[i] = c_[i] * id;
482        d_[i] = (d_[i] - a[i - 1] * d_[i - 1]) * id;
483    }
484    d_[n - 1] = (d_[n - 1] - a[n - 2] * d_[n - 2]) / (b[n - 1] - a[n - 2] * c_[n - 2]);
485
486    x[n - 1] = d_[n - 1].max(exercise[n - 1]);
487    for i in (0..n - 1).rev() {
488        x[i] = (d_[i] - c_[i] * x[i + 1]).max(exercise[i]);
489    }
490    x
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496
497    #[test]
498    fn thomas_solves_small_system() {
499        // [2 1 0; 1 2 1; 0 1 2] x = [4; 8; 8] -> x = [1; 2; 3]
500        let x = thomas_algorithm(&[1.0, 1.0], &[2.0, 2.0, 2.0], &[1.0, 1.0], &[4.0, 8.0, 8.0]);
501        for (got, want) in x.iter().zip(&[1.0, 2.0, 3.0]) {
502            assert!((got - want).abs() < 1e-12, "{x:?}");
503        }
504    }
505
506    #[test]
507    fn brennan_schwartz_reduces_to_thomas_when_unconstrained() {
508        let a = [1.0, 1.0];
509        let b = [3.0, 3.0, 3.0];
510        let c = [1.0, 1.0];
511        let d = [5.0, 10.0, 11.0];
512        let free = thomas_algorithm(&a, &b, &c, &d);
513        let low = [-1e9, -1e9, -1e9];
514        for dir in [true, false] {
515            let constrained = brennan_schwartz(&a, &b, &c, &d, &low, dir);
516            for (x, y) in free.iter().zip(&constrained) {
517                assert!((x - y).abs() < 1e-12);
518            }
519        }
520    }
521
522    #[test]
523    fn brennan_schwartz_enforces_floor() {
524        let a = [1.0, 1.0];
525        let b = [3.0, 3.0, 3.0];
526        let c = [1.0, 1.0];
527        let d = [5.0, 10.0, 11.0];
528        let floor = [10.0, 10.0, 10.0];
529        let x = brennan_schwartz(&a, &b, &c, &d, &floor, true);
530        assert!(x.iter().all(|&v| v >= 10.0 - 1e-12), "{x:?}");
531    }
532}