Skip to main content

rustyqlib/equity/
binomial.rs

1//! Binomial lattice engine for equity options: a thin adapter over the
2//! asset-class-agnostic [`core::lattice`](crate::core::lattice) framework.
3//!
4//! The tree parameterization and step count come from the option's
5//! [`LatticeConfig`](crate::core::lattice::LatticeConfig) (`tree_type` /
6//! `tree_steps` in JSON, `.tree_type()` / `.tree_steps()` on the builder).
7//! The default is **Leisen-Reimer** at 1001 steps — strictly more accurate
8//! than the historical CRR-1000 default at the same cost; select `CRR`
9//! explicitly to reproduce the classic tree. European, American and Bermudan exercise
10//! all price on the optimized rolling-array engine;
11//! [`npv_with_diagnostics`] runs the debug engine instead, keeping the
12//! full trees, the exercise boundary, tree Greeks and timing.
13//!
14//! Greeks are engine-consistent (so American and Bermudan sensitivities
15//! reflect the exercise boundary, not a European closed form):
16//! [`solution`] reads value, delta, gamma and theta off **one** backward
17//! pass, and [`pricing_result`] adds the bump-based Greeks (vega, rho,
18//! vanna, charm, zomma) from a shared set of shifted trees — seven passes
19//! for the value plus all nine Greeks, instead of a rebuild per Greek.
20
21use 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
38/// Tree build under a shifted market — spot `+ d_spot`, a parallel vol
39/// shift `+ d_vol`, rate `+ d_rate`, `d_time` years of elapsed calendar
40/// time. All-zero shifts reproduce the base tree bit for bit.
41fn 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
70/// Early-exercise rule for the option's style: intrinsic-vs-continuation
71/// at every layer (American), only at mapped layers (Bermudan), or none.
72fn 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
99/// Term-structure lattice: forward rates from the option's discount
100/// curve, its carry, and the vol surface's term structure at the strike,
101/// applied per step on a variance-equal time grid. The market shifts
102/// enter the same way as in [`setup_with`]: parallel on the forward
103/// rates and the implied surface, additive on spot, elapsed on time.
104/// One pass yields the value and the tree delta/gamma/theta together.
105fn 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        // strike-frozen implied term structure: sigma(K, t)^2 * t
125        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            // unequal layer times: map each exercise date to the nearest
143            // interior layer by calendar time
144            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
167/// Lattice price on the optimized rolling-array engine; routes to the
168/// term-structure lattice when `lattice.term_structure` is set.
169pub fn npv(option: &EquityOption) -> f64 {
170    npv_with(option, 0.0, 0.0, 0.0, 0.0)
171}
172
173/// Lattice price under a shifted market (spot / parallel vol / rate /
174/// elapsed time) — the bump machinery behind the higher-order Greeks and
175/// the PnL-attribution reprice. Zero shifts equal [`npv`] bit for bit.
176pub(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
193/// Value and the tree delta/gamma/theta from **one** backward pass, on
194/// both the uniform and the term-structure lattice. Same price as
195/// [`npv`], bit for bit.
196pub 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
225// Bump sizes shared with the finite-difference engine's Greeks.
226const 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
248/// Vanna from the change in the tree delta under a parallel vol bump.
249pub 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
256/// Charm from the spot derivative of the tree's calendar theta.
257pub 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
264/// Zomma from the change in the tree gamma under a parallel vol bump.
265pub 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
272/// Volga as the second price derivative under a parallel vol bump (the
273/// larger step tempers roundoff in the second difference).
274pub 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
281/// Value and all nine Greeks from a **shared** set of tree passes instead
282/// of a rebuild per Greek: the base pass yields the price plus
283/// delta/gamma/theta for free, the two vol-bumped passes yield vega, vanna
284/// and zomma together, two rate bumps yield rho, and two spot-bumped
285/// passes yield charm — seven passes in total.
286pub 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
320/// Lattice price on the debug engine: the full spot/value trees, the
321/// early-exercise boundary per layer, tree Greeks and wall-clock time.
322/// Same price as [`npv`], bit for bit — greeks come from
323/// [`solution`] / [`pricing_result`] on the production engine.
324pub 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        // one-go greeks and the individual accessors share the same passes
383        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        // deeper (more negative) delta and faster decay than the European:
403        // the tree greeks see the exercise boundary, the old analytic
404        // fallback could not
405        assert!(american.delta() < european.delta() - 1e-3);
406        assert!(american.npv() > european.npv() + 1e-3);
407        // sanity: an ATM American put is short delta, long gamma
408        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        // a spot shift moves the reprice in the direction of delta
418        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        // flat inputs: the term lattice's bump greeks sit near the closed form
430        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}