1use crate::core::curves::Compounding;
25use crate::core::data_models::EquityOptionData;
26use crate::core::errors::RustyQLibError;
27use 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 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#[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 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
126pub 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
133pub 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
140pub 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
147pub 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
157pub fn solution(option: &EquityOption) -> FdSolution {
159 solve_dispatch(option, 0.0, 0.0, 0.0)
160}
161
162pub 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
204pub(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 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
269enum 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 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 option.market.spot.value(),
325 q,
326 sigma_bump,
327 )),
328 Model::Heston(_) => unreachable!("Heston is dispatched to heston_adi::solve"),
330 };
331
332 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 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 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 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 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; 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 let mut cum_df = 1.0;
408 let mut cum_growth = 1.0;
409 let mut theta_layer_value = 0.0; 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 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 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(&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 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 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
519fn 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
530fn 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}