1use 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#[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 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
107pub 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 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
149enum 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 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 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 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 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 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; 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 let mut cum_df = 1.0;
272 let mut cum_growth = 1.0;
273 let mut theta_layer_value = 0.0; 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 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 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(&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 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 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
381fn 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
392fn 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
403pub 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
433fn 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 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 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}