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::errors::RustyQLibError;
27// linear kernels live in core::fd_solvers; thomas_algorithm is re-exported
28// because it was previously public from this module
29use crate::core::fd_solvers::brennan_schwartz;
30pub use crate::core::fd_solvers::thomas_algorithm;
31use crate::core::trade::PutOrCall;
32use crate::core::utils::ContractStyle;
33use crate::equity::barrier::{BarrierDirection, KnockType};
34use crate::equity::local_vol::LocalVol;
35use crate::equity::utils::Model;
36use crate::equity::utils::Payoff;
37use crate::equity::vanilla_option::{BarrierPayoff, EquityOption};
38
39const RANNACHER_STEPS: usize = 4;
40const CELL_AVG_POINTS: usize = 16;
41
42#[derive(Debug, Clone, Copy, PartialEq)]
43pub struct FdConfig {
44    pub spot_steps: usize,
45    pub time_steps: usize,
46    pub grid_stdevs: f64,
47}
48
49impl Default for FdConfig {
50    fn default() -> Self {
51        FdConfig { spot_steps: 400, time_steps: 400, grid_stdevs: 5.0 }
52    }
53}
54
55impl FdConfig {
56    pub fn from_data(data: &EquityOptionData) -> Self {
57        let defaults = FdConfig::default();
58        FdConfig {
59            spot_steps: data.fd_spot_steps.unwrap_or(defaults.spot_steps).max(10),
60            time_steps: data.fd_time_steps.unwrap_or(defaults.time_steps).max(10),
61            grid_stdevs: defaults.grid_stdevs,
62        }
63    }
64
65    /// Domain checks on the grid dimensions.
66    pub fn validate(&self) -> Result<(), RustyQLibError> {
67        if self.spot_steps < 3 || self.time_steps < 1 {
68            return Err(RustyQLibError::invalid_input(
69                "fd_grid",
70                format!(
71                    "the FD grid needs at least 3 spot steps and 1 time step, got {} x {}",
72                    self.spot_steps, self.time_steps
73                ),
74            ));
75        }
76        Ok(())
77    }
78}
79
80/// One solve returns the value and the grid Greeks.
81#[derive(Debug, Clone, Copy)]
82pub struct FdSolution {
83    pub npv: f64,
84    pub delta: f64,
85    pub gamma: f64,
86    pub theta: f64,
87}
88
89impl FdSolution {
90    fn zero() -> Self {
91        FdSolution { npv: 0.0, delta: 0.0, gamma: 0.0, theta: 0.0 }
92    }
93    fn minus(self, other: FdSolution) -> Self {
94        FdSolution {
95            npv: self.npv - other.npv,
96            delta: self.delta - other.delta,
97            gamma: self.gamma - other.gamma,
98            theta: self.theta - other.theta,
99        }
100    }
101}
102
103pub fn npv(option: &EquityOption) -> f64 {
104    solution(option).npv
105}
106pub fn delta(option: &EquityOption) -> f64 {
107    solution(option).delta
108}
109pub fn gamma(option: &EquityOption) -> f64 {
110    solution(option).gamma
111}
112pub fn theta(option: &EquityOption) -> f64 {
113    solution(option).theta
114}
115pub fn vega(option: &EquityOption) -> f64 {
116    // parallel vol bump: constant-vol solves shift sigma, local vol solves
117    // shift the implied surface before the Dupire transform
118    let h = 1e-3;
119    (solve_dispatch(option, h, 0.0, 0.0).npv - solve_dispatch(option, -h, 0.0, 0.0).npv) / (2.0 * h)
120}
121pub fn rho(option: &EquityOption) -> f64 {
122    let h = 1e-4;
123    (solve_dispatch(option, 0.0, h, 0.0).npv - solve_dispatch(option, 0.0, -h, 0.0).npv) / (2.0 * h)
124}
125
126/// Vanna from the change in the grid delta under a parallel vol bump.
127pub fn vanna(option: &EquityOption) -> f64 {
128    let h = 1e-3;
129    (solve_dispatch(option, h, 0.0, 0.0).delta - solve_dispatch(option, -h, 0.0, 0.0).delta)
130        / (2.0 * h)
131}
132
133/// Charm from the spot derivative of the grid's calendar theta.
134pub fn charm(option: &EquityOption) -> f64 {
135    let h = option.market.spot.value() * 1e-3;
136    (solve_dispatch(option, 0.0, 0.0, h).theta - solve_dispatch(option, 0.0, 0.0, -h).theta)
137        / (2.0 * h)
138}
139
140/// Zomma from the change in the grid gamma under a parallel vol bump.
141pub fn zomma(option: &EquityOption) -> f64 {
142    let h = 1e-3;
143    (solve_dispatch(option, h, 0.0, 0.0).gamma - solve_dispatch(option, -h, 0.0, 0.0).gamma)
144        / (2.0 * h)
145}
146
147/// Volga as the second price derivative under a parallel vol bump. A larger
148/// step than the first-order Greeks tempers the roundoff amplification of a
149/// second difference against the grid's own discretization error.
150pub fn volga(option: &EquityOption) -> f64 {
151    let h = 1e-2;
152    (solve_dispatch(option, h, 0.0, 0.0).npv - 2.0 * solve_dispatch(option, 0.0, 0.0, 0.0).npv
153        + solve_dispatch(option, -h, 0.0, 0.0).npv)
154        / (h * h)
155}
156
157/// Value and grid Greeks in a single solve (two for knock-ins).
158pub fn solution(option: &EquityOption) -> FdSolution {
159    solve_dispatch(option, 0.0, 0.0, 0.0)
160}
161
162/// Value and all nine reported Greeks from a **shared** set of grid solves
163/// instead of a re-solve per Greek: the base solve yields the price plus
164/// delta/gamma/theta for free, the two vol-bumped solves yield vega, vanna
165/// and zomma together, two rate bumps yield rho, and two spot-bumped
166/// solves yield charm — nine solves in total. Each number is produced by
167/// exactly the same solves and arithmetic as its accessor above.
168pub fn pricing_result(option: &EquityOption) -> crate::core::results::PricingResult {
169    use crate::core::results::{Greeks, PricingResult};
170    let base = solution(option);
171    let hv = 1e-3;
172    let vol_up = solve_dispatch(option, hv, 0.0, 0.0);
173    let vol_down = solve_dispatch(option, -hv, 0.0, 0.0);
174    let hr = 1e-4;
175    let rho = (solve_dispatch(option, 0.0, hr, 0.0).npv
176        - solve_dispatch(option, 0.0, -hr, 0.0).npv)
177        / (2.0 * hr);
178    let hs = option.market.spot.value() * 1e-3;
179    let charm = (solve_dispatch(option, 0.0, 0.0, hs).theta
180        - solve_dispatch(option, 0.0, 0.0, -hs).theta)
181        / (2.0 * hs);
182    let gamma_p = if base.delta == 0.0 {
183        f64::NAN
184    } else {
185        option.market.spot.value() * base.gamma / base.delta
186    };
187    PricingResult {
188        pv: base.npv,
189        greeks: Greeks {
190            delta: base.delta,
191            gamma: base.gamma,
192            vega: (vol_up.npv - vol_down.npv) / (2.0 * hv),
193            theta: base.theta,
194            rho,
195            vanna: (vol_up.delta - vol_down.delta) / (2.0 * hv),
196            charm,
197            gamma_p,
198            zomma: (vol_up.gamma - vol_down.gamma) / (2.0 * hv),
199        },
200        std_err: None,
201    }
202}
203
204/// Reprice under a shifted market for PnL attribution. The grid has no
205/// calendar-time shift, so the elapsed-time effect is rolled forward with the
206/// bumped grid's own theta (exact to first order in `d_time`, which is small
207/// for the daily PnL horizon this supports).
208pub(crate) fn npv_with(
209    option: &EquityOption,
210    d_spot: f64,
211    d_vol: f64,
212    d_rate: f64,
213    d_time: f64,
214) -> f64 {
215    let sol = solve_dispatch(option, d_vol, d_rate, d_spot);
216    sol.npv + sol.theta * d_time
217}
218
219fn solve_dispatch(
220    option: &EquityOption,
221    sigma_bump: f64,
222    r_bump: f64,
223    spot_bump: f64,
224) -> FdSolution {
225    let t = option.time_to_maturity();
226    assert!(t >= 0.0, "Option is expired or negative time");
227    let s0 = option.market.spot.value() + spot_bump;
228    assert!(s0 > 0.0, "underlying price must be positive");
229    if t == 0.0 {
230        let mut sol = FdSolution::zero();
231        sol.npv = option.payoff.payoff(s0, option.base.strike_price);
232        return sol;
233    }
234
235    if option.model.is_heston() {
236        return super::heston_adi::solve(option, sigma_bump, r_bump, spot_bump);
237    }
238
239    if let Some(barrier) = option.payoff.as_any().downcast_ref::<BarrierPayoff>() {
240        assert!(
241            barrier.barrier2.is_none() && barrier.rebate == 0.0,
242            "double barriers and rebates are not supported on the FD engine;              use the Analytical or MonteCarlo engine"
243        );
244        let down = barrier.direction == BarrierDirection::Down;
245        let knocked = if down { s0 <= barrier.barrier } else { s0 >= barrier.barrier };
246        return match barrier.knock {
247            KnockType::Out => {
248                if knocked {
249                    FdSolution::zero()
250                } else {
251                    solve(option, sigma_bump, r_bump, spot_bump, Some(barrier))
252                }
253            }
254            KnockType::In => {
255                // knock-in by parity (European only; guarded upstream):
256                // KI = vanilla leg - KO, which is linear in all Greeks
257                let vanilla = solve(option, sigma_bump, r_bump, spot_bump, None);
258                if knocked {
259                    vanilla
260                } else {
261                    vanilla.minus(solve(option, sigma_bump, r_bump, spot_bump, Some(barrier)))
262                }
263            }
264        };
265    }
266    solve(option, sigma_bump, r_bump, spot_bump, None)
267}
268
269/// Volatility field used to assemble the PDE coefficients.
270enum FdVol<'a> {
271    Const(f64),
272    Local(LocalVol<'a>),
273}
274
275impl FdVol<'_> {
276    fn vol(&self, s: f64, calendar_t: f64) -> f64 {
277        match self {
278            FdVol::Const(v) => *v,
279            FdVol::Local(lv) => lv.vol(s, calendar_t),
280        }
281    }
282}
283
284fn solve(
285    option: &EquityOption,
286    sigma_bump: f64,
287    r_bump: f64,
288    spot_bump: f64,
289    knock_out: Option<&BarrierPayoff>,
290) -> FdSolution {
291    let cfg = option.fd_cfg();
292    let payoff = option.payoff.as_ref();
293    let strike = option.base.strike_price;
294    let s0 = option.market.spot.value() + spot_bump;
295    let q = option.carry_yield();
296    let t = option.time_to_maturity();
297    let sigma_ref = option.volatility() + sigma_bump;
298    assert!(sigma_ref > 0.0, "volatility must be positive");
299    let american = matches!(payoff.exercise_style(), ContractStyle::American);
300    // Bermudan: backward step s covers calendar time t-(s+1)dt, so an
301    // exercise time tm (forward, 1-based grid index g) maps to s = steps-g-1
302    let bermudan_backward: Option<Vec<bool>> = match payoff.exercise_style() {
303        ContractStyle::Bermudan(times) => {
304            let steps_total = cfg.time_steps;
305            let mut mask = vec![false; steps_total];
306            for g in crate::core::utils::times_to_grid_steps(times, t, steps_total) {
307                if g < steps_total {
308                    mask[steps_total - g - 1] = true;
309                }
310            }
311            Some(mask)
312        }
313        _ => None,
314    };
315    let put = matches!(payoff.put_or_call(), PutOrCall::Put);
316
317    let vol_field = match option.model {
318        Model::Gbm => FdVol::Const(sigma_ref),
319        Model::LocalVol => FdVol::Local(LocalVol::new(
320            &option.market.vol_surface,
321            &option.market.discount_curve,
322            // A spot bump moves the valuation point, not the calibrated
323            // local-vol surface reference spot.
324            option.market.spot.value(),
325            q,
326            sigma_bump,
327        )),
328        // routed to the 2-D ADI solver in solve_dispatch
329        Model::Heston(_) => unreachable!("Heston is dispatched to heston_adi::solve"),
330    };
331
332    // ── Grid geometry (log-spot). A knock-out barrier becomes the exact
333    // grid edge (absorbing boundary); otherwise the grid centers on x0.
334    let x0 = s0.ln();
335    let r_flat = option.risk_free_rate() + r_bump;
336    let drift_width = ((r_flat - q - 0.5 * sigma_ref * sigma_ref) * t).abs();
337    let half_width =
338        cfg.grid_stdevs * sigma_ref * t.sqrt() + drift_width + (strike / s0).ln().abs().max(1e-2);
339    let (x_min, x_max, barrier_low, barrier_high) = match knock_out {
340        Some(b) if b.direction == BarrierDirection::Down => {
341            (b.barrier.ln(), x0 + half_width, true, false)
342        }
343        Some(b) => (x0 - half_width, b.barrier.ln(), false, true),
344        None => (x0 - half_width, x0 + half_width, false, false),
345    };
346    let n = cfg.spot_steps;
347    let dx = (x_max - x_min) / n as f64;
348    let x_at = |i: usize| x_min + i as f64 * dx;
349    let s_grid: Vec<f64> = (0..=n).map(|i| x_at(i).exp()).collect();
350    let exercise: Vec<f64> = s_grid.iter().map(|&s| payoff.payoff(s, strike)).collect();
351
352    // ── Per-step forward rates from the discount curve (term-structure
353    // consistent drift and discounting), plus any rho bump.
354    let steps = cfg.time_steps;
355    let dt = t / steps as f64;
356    let curve = &option.market.discount_curve;
357    let step_rates: Vec<f64> = (0..steps)
358        .map(|k| {
359            // step k advances time-to-expiry tau from k*dt to (k+1)*dt,
360            // i.e. calendar time from t - k*dt back to t - (k+1)*dt
361            let t2 = t - k as f64 * dt;
362            let t1 = t - (k + 1) as f64 * dt;
363            let fwd = if t1 <= 0.0 {
364                curve.zero_rate_with(t2.max(1e-8), Compounding::Continuous)
365            } else {
366                curve
367                    .forward_rate_with(t1, t2, Compounding::Continuous)
368                    .unwrap_or_else(|_| curve.zero_rate_with(t2, Compounding::Continuous))
369            };
370            fwd + r_bump
371        })
372        .collect();
373
374    // cash dividend ex-dates as year fractions inside the option's life
375    let cash_divs: Vec<(f64, f64)> = option
376        .market
377        .cash_dividends
378        .iter()
379        .filter_map(|(date, amount)| {
380            let td = (*date - option.market.valuation_date).num_days() as f64 / 365.0;
381            (td > 0.0 && td <= t).then_some((td, *amount))
382        })
383        .collect();
384
385    // terminal condition: cell-averaged payoff
386    let mut v: Vec<f64> = (0..=n)
387        .map(|i| cell_average_payoff(payoff, strike, x_at(i), dx))
388        .collect();
389    if barrier_low {
390        v[0] = 0.0;
391    }
392    if barrier_high {
393        v[n] = 0.0;
394    }
395
396    let m = n - 1; // interior unknowns
397    let mut sub = vec![0.0; m - 1];
398    let mut dia = vec![0.0; m];
399    let mut sup = vec![0.0; m - 1];
400    let mut rhs = vec![0.0; m];
401    let mut lower = vec![0.0; n + 1];
402    let mut diag = vec![0.0; n + 1];
403    let mut upper = vec![0.0; n + 1];
404
405    // cumulative discount and forward growth to the current time layer,
406    // for the generic Dirichlet boundary V(S_b, tau) = D * payoff(S_b * G)
407    let mut cum_df = 1.0;
408    let mut cum_growth = 1.0;
409    let mut theta_layer_value = 0.0; // value at spot one step before the end
410
411    for step in 0..steps {
412        let exercise_now = american
413            || bermudan_backward.as_ref().map_or(false, |m| m.get(step).copied().unwrap_or(false));
414        let theta_w = if step < RANNACHER_STEPS { 1.0 } else { 0.5 };
415        let r_step = step_rates[step];
416        let calendar_mid = (t - (step as f64 + 0.5) * dt).max(0.0);
417        cum_df *= (-r_step * dt).exp();
418        cum_growth *= ((r_step - q) * dt).exp();
419
420        // per-node coefficients at this time layer
421        for i in 0..=n {
422            let sigma = vol_field.vol(s_grid[i], calendar_mid);
423            let s2 = 0.5 * sigma * sigma;
424            let mu = r_step - q - s2;
425            lower[i] = s2 / (dx * dx) - mu / (2.0 * dx);
426            diag[i] = -2.0 * s2 / (dx * dx) - r_step;
427            upper[i] = s2 / (dx * dx) + mu / (2.0 * dx);
428        }
429
430        // boundary values at the new time layer
431        let boundary = |i: usize, is_barrier: bool| -> f64 {
432            if is_barrier {
433                return 0.0;
434            }
435            let mut val = cum_df * payoff.payoff(s_grid[i] * cum_growth, strike);
436            if exercise_now {
437                val = val.max(exercise[i]);
438            }
439            val
440        };
441        let v_low = boundary(0, barrier_low);
442        let v_high = boundary(n, barrier_high);
443
444        for i in 1..n {
445            let av = lower[i] * v[i - 1] + diag[i] * v[i] + upper[i] * v[i + 1];
446            rhs[i - 1] = v[i] + (1.0 - theta_w) * dt * av;
447        }
448        rhs[0] += theta_w * dt * lower[1] * v_low;
449        rhs[m - 1] += theta_w * dt * upper[n - 1] * v_high;
450        for i in 1..n {
451            dia[i - 1] = 1.0 - theta_w * dt * diag[i];
452        }
453        for i in 1..n - 1 {
454            sub[i - 1] = -theta_w * dt * lower[i + 1];
455            sup[i - 1] = -theta_w * dt * upper[i];
456        }
457
458        let interior = if exercise_now {
459            // Brennan-Schwartz: apply the exercise constraint inside the
460            // back-substitution, sweeping from the out-of-the-money side
461            // toward the exercise region (low spot for puts, high for calls)
462            brennan_schwartz(&sub, &dia, &sup, &rhs, &exercise[1..n], put)
463        } else {
464            thomas_algorithm(&sub, &dia, &sup, &rhs)
465        };
466        v[0] = v_low;
467        v[n] = v_high;
468        v[1..n].copy_from_slice(&interior);
469
470        // cash dividend jump condition: when the backward induction crosses
471        // an ex-date, V(S, t_ex^-) = V(S - D, t_ex^+)
472        if !cash_divs.is_empty() {
473            let cal_old = t - step as f64 * dt;
474            let cal_new = t - (step + 1) as f64 * dt;
475            let crossing: f64 = cash_divs
476                .iter()
477                .filter(|(td, _)| *td < cal_old && *td >= cal_new)
478                .map(|(_, amount)| *amount)
479                .sum();
480            if crossing > 0.0 {
481                let shifted: Vec<f64> = (0..=n)
482                    .map(|i| {
483                        let s_target = s_grid[i] - crossing;
484                        if s_target <= s_grid[0] {
485                            v[0]
486                        } else {
487                            let x_target = s_target.ln();
488                            let j =
489                                (((x_target - x_min) / dx).floor() as usize).min(n - 1);
490                            let w = ((x_target - x_at(j)) / dx).clamp(0.0, 1.0);
491                            v[j] * (1.0 - w) + v[j + 1] * w
492                        }
493                    })
494                    .collect();
495                v = shifted;
496                if exercise_now {
497                    for i in 0..=n {
498                        if v[i] < exercise[i] {
499                            v[i] = exercise[i];
500                        }
501                    }
502                }
503            }
504        }
505
506        if step + 1 == steps.saturating_sub(1) {
507            theta_layer_value = read_grid(&v, x_min, dx, x0).0;
508        }
509    }
510
511    let (npv, delta_x, gamma_x) = read_grid(&v, x_min, dx, x0);
512    // chain rule from log-spot: V_S = V_x / S, V_SS = (V_xx - V_x) / S^2
513    let delta = delta_x / s0;
514    let gamma = (gamma_x - delta_x) / (s0 * s0);
515    let theta = if steps >= 2 { (theta_layer_value - npv) / dt } else { 0.0 };
516    FdSolution { npv, delta, gamma, theta }
517}
518
519/// Quadratic fit through the three nodes nearest `x0`:
520/// returns (value, dV/dx, d2V/dx2) at x0.
521fn read_grid(v: &[f64], x_min: f64, dx: f64, x0: f64) -> (f64, f64, f64) {
522    let n = v.len() - 1;
523    let i = (((x0 - x_min) / dx).round() as usize).clamp(1, n - 1);
524    let e = x0 - (x_min + i as f64 * dx);
525    let b = (v[i + 1] - v[i - 1]) / (2.0 * dx);
526    let c = (v[i + 1] - 2.0 * v[i] + v[i - 1]) / (2.0 * dx * dx);
527    (v[i] + b * e + c * e * e, b + 2.0 * c * e, 2.0 * c)
528}
529
530/// Average of the payoff over the grid cell `[x - dx/2, x + dx/2]`.
531fn cell_average_payoff(payoff: &dyn Payoff, strike: f64, x: f64, dx: f64) -> f64 {
532    let k = CELL_AVG_POINTS;
533    let mut sum = 0.0;
534    for j in 0..k {
535        let xi = x - 0.5 * dx + (j as f64 + 0.5) * dx / k as f64;
536        sum += payoff.payoff(xi.exp(), strike);
537    }
538    sum / k as f64
539}