1use crate::core::lattice::{
22 price_backward, price_backward_with_greeks, price_with_diagnostics, LatticeDiagnostics,
23 LatticeParams, LatticeSolution, TermLattice,
24};
25use crate::core::results::{Greeks, PricingResult};
26use crate::core::utils::{times_to_grid_steps, ContractStyle};
27use super::vanilla_option::EquityOption;
28
29struct TreeSetup {
30 params: LatticeParams,
31 n: usize,
32 df_step: f64,
33 dt: f64,
34 s0: f64,
35 t: f64,
36}
37
38fn setup_with(
42 option: &EquityOption,
43 d_spot: f64,
44 d_vol: f64,
45 d_rate: f64,
46 d_time: f64,
47) -> TreeSetup {
48 assert!(option.market.spot.mid() >= 0.0);
49 let t = (option.time_to_maturity() - d_time).max(1e-6);
50 let r = option.risk_free_rate() + d_rate;
51 let b = r - option.carry_yield();
52 let s0 = option.effective_spot() + d_spot;
53 let cfg = option.lattice_cfg();
54 let n = cfg.tree_type.effective_steps(cfg.steps);
55 let params = cfg
56 .tree_type
57 .params(
58 s0,
59 option.base.strike_price,
60 b,
61 option.volatility() + d_vol,
62 t,
63 n,
64 )
65 .unwrap_or_else(|e| panic!("{e}"));
66 let dt = t / n as f64;
67 TreeSetup { params, n, df_step: (-r * dt).exp(), dt, s0, t }
68}
69
70fn exercise_rule<'a>(
73 option: &'a EquityOption,
74 t: f64,
75 n: usize,
76) -> Option<Box<dyn Fn(usize, f64, f64) -> f64 + 'a>> {
77 let strike = option.base.strike_price;
78 match option.payoff.exercise_style() {
79 ContractStyle::European => None,
80 ContractStyle::American => Some(Box::new(move |_i, spot, cont| {
81 option.payoff.payoff(spot, strike).max(cont)
82 })),
83 ContractStyle::Bermudan(times) => {
84 let mut exercisable = vec![false; n + 1];
85 for idx in times_to_grid_steps(times, t, n) {
86 exercisable[idx] = true;
87 }
88 Some(Box::new(move |i, spot, cont| {
89 if exercisable[i] {
90 option.payoff.payoff(spot, strike).max(cont)
91 } else {
92 cont
93 }
94 }))
95 }
96 }
97}
98
99fn term_solution_with(
106 option: &EquityOption,
107 d_spot: f64,
108 d_vol: f64,
109 d_rate: f64,
110 d_time: f64,
111) -> LatticeSolution {
112 let t = (option.time_to_maturity() - d_time).max(1e-6);
113 let s0 = option.effective_spot() + d_spot;
114 let strike = option.base.strike_price;
115 let carry = option.carry_yield();
116 let curve = &option.market.discount_curve;
117 let forward_rate =
118 |t1: f64, t2: f64| (curve.df(t1) / curve.df(t2)).ln() / (t2 - t1) + d_rate;
119 let forward_carry = |_: f64, _: f64| carry;
120 let total_variance = |tt: f64| {
121 if tt <= 1e-12 {
122 return 0.0;
123 }
124 let fwd = s0 / curve.df(tt) * (-carry * tt).exp();
126 let sigma = option.market.vol_surface.vol(strike, fwd, tt) + d_vol;
127 sigma * sigma * tt
128 };
129 let lattice =
130 TermLattice::build(option.lattice_cfg().steps, t, &forward_rate, &forward_carry, &total_variance)
131 .unwrap_or_else(|e| panic!("{e}"));
132 let terminal = |spot: f64| option.payoff.payoff(spot, strike);
133 match option.payoff.exercise_style() {
134 ContractStyle::European => lattice.price_with_greeks(s0, &terminal, None),
135 ContractStyle::American => {
136 let ex = |_: usize, _: f64, spot: f64, cont: f64| {
137 option.payoff.payoff(spot, strike).max(cont)
138 };
139 lattice.price_with_greeks(s0, &terminal, Some(&ex))
140 }
141 ContractStyle::Bermudan(times) => {
142 let n = lattice.steps();
145 let mut exercisable = vec![false; n];
146 for tm in times {
147 let mut best = 1usize;
148 for i in 1..n {
149 if (lattice.times[i] - tm).abs() < (lattice.times[best] - tm).abs() {
150 best = i;
151 }
152 }
153 exercisable[best] = true;
154 }
155 let ex = move |i: usize, _: f64, spot: f64, cont: f64| {
156 if exercisable[i] {
157 option.payoff.payoff(spot, strike).max(cont)
158 } else {
159 cont
160 }
161 };
162 lattice.price_with_greeks(s0, &terminal, Some(&ex))
163 }
164 }
165}
166
167pub fn npv(option: &EquityOption) -> f64 {
170 npv_with(option, 0.0, 0.0, 0.0, 0.0)
171}
172
173pub(crate) fn npv_with(
177 option: &EquityOption,
178 d_spot: f64,
179 d_vol: f64,
180 d_rate: f64,
181 d_time: f64,
182) -> f64 {
183 if option.lattice_cfg().term_structure {
184 return term_solution_with(option, d_spot, d_vol, d_rate, d_time).price;
185 }
186 let s = setup_with(option, d_spot, d_vol, d_rate, d_time);
187 let strike = option.base.strike_price;
188 let terminal = |spot: f64| option.payoff.payoff(spot, strike);
189 let exercise = exercise_rule(option, s.t, s.n);
190 price_backward(s.s0, &s.params, s.n, s.df_step, &terminal, exercise.as_deref())
191}
192
193pub fn solution(option: &EquityOption) -> LatticeSolution {
197 solution_with(option, 0.0, 0.0, 0.0, 0.0)
198}
199
200fn solution_with(
201 option: &EquityOption,
202 d_spot: f64,
203 d_vol: f64,
204 d_rate: f64,
205 d_time: f64,
206) -> LatticeSolution {
207 if option.lattice_cfg().term_structure {
208 return term_solution_with(option, d_spot, d_vol, d_rate, d_time);
209 }
210 let s = setup_with(option, d_spot, d_vol, d_rate, d_time);
211 let strike = option.base.strike_price;
212 let terminal = |spot: f64| option.payoff.payoff(spot, strike);
213 let exercise = exercise_rule(option, s.t, s.n);
214 price_backward_with_greeks(
215 s.s0,
216 &s.params,
217 s.n,
218 s.dt,
219 s.df_step,
220 &terminal,
221 exercise.as_deref(),
222 )
223}
224
225const VOL_BUMP: f64 = 1e-3;
227const RATE_BUMP: f64 = 1e-4;
228const VOLGA_BUMP: f64 = 1e-2;
229
230pub fn delta(option: &EquityOption) -> f64 {
231 solution(option).delta
232}
233pub fn gamma(option: &EquityOption) -> f64 {
234 solution(option).gamma
235}
236pub fn theta(option: &EquityOption) -> f64 {
237 solution(option).theta
238}
239pub fn vega(option: &EquityOption) -> f64 {
240 let h = VOL_BUMP;
241 (npv_with(option, 0.0, h, 0.0, 0.0) - npv_with(option, 0.0, -h, 0.0, 0.0)) / (2.0 * h)
242}
243pub fn rho(option: &EquityOption) -> f64 {
244 let h = RATE_BUMP;
245 (npv_with(option, 0.0, 0.0, h, 0.0) - npv_with(option, 0.0, 0.0, -h, 0.0)) / (2.0 * h)
246}
247
248pub fn vanna(option: &EquityOption) -> f64 {
250 let h = VOL_BUMP;
251 (solution_with(option, 0.0, h, 0.0, 0.0).delta
252 - solution_with(option, 0.0, -h, 0.0, 0.0).delta)
253 / (2.0 * h)
254}
255
256pub fn charm(option: &EquityOption) -> f64 {
258 let h = option.market.spot.value() * 1e-3;
259 (solution_with(option, h, 0.0, 0.0, 0.0).theta
260 - solution_with(option, -h, 0.0, 0.0, 0.0).theta)
261 / (2.0 * h)
262}
263
264pub fn zomma(option: &EquityOption) -> f64 {
266 let h = VOL_BUMP;
267 (solution_with(option, 0.0, h, 0.0, 0.0).gamma
268 - solution_with(option, 0.0, -h, 0.0, 0.0).gamma)
269 / (2.0 * h)
270}
271
272pub fn volga(option: &EquityOption) -> f64 {
275 let h = VOLGA_BUMP;
276 (npv_with(option, 0.0, h, 0.0, 0.0) - 2.0 * npv(option)
277 + npv_with(option, 0.0, -h, 0.0, 0.0))
278 / (h * h)
279}
280
281pub fn pricing_result(option: &EquityOption) -> PricingResult {
287 let base = solution(option);
288 let hv = VOL_BUMP;
289 let vol_up = solution_with(option, 0.0, hv, 0.0, 0.0);
290 let vol_down = solution_with(option, 0.0, -hv, 0.0, 0.0);
291 let hr = RATE_BUMP;
292 let rho = (npv_with(option, 0.0, 0.0, hr, 0.0) - npv_with(option, 0.0, 0.0, -hr, 0.0))
293 / (2.0 * hr);
294 let hs = option.market.spot.value() * 1e-3;
295 let charm = (solution_with(option, hs, 0.0, 0.0, 0.0).theta
296 - solution_with(option, -hs, 0.0, 0.0, 0.0).theta)
297 / (2.0 * hs);
298 let gamma_p = if base.delta == 0.0 {
299 f64::NAN
300 } else {
301 option.market.spot.value() * base.gamma / base.delta
302 };
303 PricingResult {
304 pv: base.price,
305 greeks: Greeks {
306 delta: base.delta,
307 gamma: base.gamma,
308 vega: (vol_up.price - vol_down.price) / (2.0 * hv),
309 theta: base.theta,
310 rho,
311 vanna: (vol_up.delta - vol_down.delta) / (2.0 * hv),
312 charm,
313 gamma_p,
314 zomma: (vol_up.gamma - vol_down.gamma) / (2.0 * hv),
315 },
316 std_err: None,
317 }
318}
319
320pub fn npv_with_diagnostics(option: &EquityOption) -> LatticeDiagnostics {
325 let s = setup_with(option, 0.0, 0.0, 0.0, 0.0);
326 let strike = option.base.strike_price;
327 let terminal = |spot: f64| option.payoff.payoff(spot, strike);
328 let exercise = exercise_rule(option, s.t, s.n);
329 price_with_diagnostics(
330 option.lattice_cfg().tree_type,
331 s.s0,
332 &s.params,
333 s.n,
334 s.dt,
335 s.df_step,
336 &terminal,
337 exercise.as_deref(),
338 )
339}
340
341#[cfg(test)]
342mod tests {
343 use crate::core::trade::PutOrCall;
344 use crate::core::traits::Instrument;
345 use crate::equity::builder::EquityOptionBuilder;
346 use crate::equity::utils::Engine;
347 use chrono::NaiveDate;
348
349 fn builder(put_or_call: PutOrCall, engine: Engine) -> EquityOptionBuilder {
350 EquityOptionBuilder::new()
351 .symbol("TEST")
352 .spot(100.0)
353 .strike(100.0)
354 .flat_vol(0.3)
355 .flat_rate(0.05)
356 .dividend_yield(0.02)
357 .valuation_date(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap())
358 .maturity_date(NaiveDate::from_ymd_opt(2027, 1, 1).unwrap())
359 .vanilla(put_or_call)
360 .engine(engine)
361 }
362
363 #[test]
364 fn european_tree_greeks_match_the_analytic_engine() {
365 let tree = builder(PutOrCall::Call, Engine::Binomial).build().unwrap();
366 let analytic = builder(PutOrCall::Call, Engine::BlackScholes).build().unwrap();
367 let result = tree.price().unwrap();
368 assert_eq!(result.pv, tree.npv(), "pricing_result must reuse the npv price");
369 assert!((result.greeks.delta - analytic.delta()).abs() < 2e-3);
370 assert!((result.greeks.gamma - analytic.gamma()).abs() < 2e-4);
371 assert!((result.greeks.theta - analytic.theta()).abs() < 2e-2);
372 assert!((result.greeks.vega - analytic.vega()).abs() < 5e-2);
373 assert!((result.greeks.rho - analytic.rho()).abs() < 5e-2);
374 assert!((result.greeks.vanna - analytic.vanna()).abs() < 5e-3);
375 assert!((result.greeks.charm - analytic.charm()).abs() < 5e-3);
376 assert!((result.greeks.zomma - analytic.zomma()).abs() < 5e-3);
377 assert!((tree.volga() - analytic.volga()).abs() < 5e-1);
378 }
379
380 #[test]
381 fn pricing_result_matches_the_per_greek_dispatch() {
382 let option = builder(PutOrCall::Put, Engine::Binomial)
384 .american()
385 .build()
386 .unwrap();
387 let result = option.price().unwrap();
388 assert_eq!(result.greeks.delta, option.delta());
389 assert_eq!(result.greeks.gamma, option.gamma());
390 assert_eq!(result.greeks.theta, option.theta());
391 assert_eq!(result.greeks.vega, option.vega());
392 assert_eq!(result.greeks.rho, option.rho());
393 assert_eq!(result.greeks.vanna, option.vanna());
394 assert_eq!(result.greeks.charm, option.charm());
395 assert_eq!(result.greeks.zomma, option.zomma());
396 }
397
398 #[test]
399 fn american_put_greeks_reflect_the_exercise_boundary() {
400 let american = builder(PutOrCall::Put, Engine::Binomial).american().build().unwrap();
401 let european = builder(PutOrCall::Put, Engine::Binomial).build().unwrap();
402 assert!(american.delta() < european.delta() - 1e-3);
406 assert!(american.npv() > european.npv() + 1e-3);
407 assert!(american.delta() > -1.0 && american.delta() < 0.0);
409 assert!(american.gamma() > 0.0);
410 assert!(american.theta() < 0.0);
411 }
412
413 #[test]
414 fn price_with_zero_shifts_reproduces_npv() {
415 let option = builder(PutOrCall::Put, Engine::Binomial).american().build().unwrap();
416 assert_eq!(option.price_with(0.0, 0.0, 0.0, 0.0), option.npv());
417 let bumped = option.price_with(1.0, 0.0, 0.0, 0.0);
419 assert!(bumped < option.npv(), "put value must fall as spot rises");
420 }
421
422 #[test]
423 fn term_structure_lattice_reports_greeks_too() {
424 let option = builder(PutOrCall::Call, Engine::Binomial)
425 .tree_term_structure()
426 .build()
427 .unwrap();
428 let analytic = builder(PutOrCall::Call, Engine::BlackScholes).build().unwrap();
429 assert!((option.delta() - analytic.delta()).abs() < 5e-3);
431 assert!(
432 (option.gamma() - analytic.gamma()).abs() < 2e-3,
433 "term gamma {} vs analytic {}",
434 option.gamma(),
435 analytic.gamma()
436 );
437 assert!((option.theta() - analytic.theta()).abs() < 5e-2);
438 assert!((option.vega() - analytic.vega()).abs() < 2e-1);
439 }
440}