Skip to main content

rustyqlib/core/
lattice.rs

1//! Recombining binomial lattices, asset-class agnostic.
2//!
3//! Every classic parameterization reduces to one recombining structure:
4//! a node after `j` up-moves out of `i` steps sits at
5//! `S(i, j) = S0 * exp(j*log_up + (i-j)*log_down)`, with up-probability
6//! `p_up` — [`LatticeParams`]. The [`BinomialTreeType`] enum supplies the
7//! `(log_up, log_down, p_up)` triple for Cox-Ross-Rubinstein, Jarrow-Rudd,
8//! Tian, Trigeorgis, Leisen-Reimer (Peizer-Pratt method 2, strike-aware,
9//! second-order smooth convergence) and the equal-probability additive
10//! tree (Clewlow-Strickland / QuantLib's `AdditiveEQPBinomialTree`).
11//!
12//! Three engines share the parameterization:
13//! - [`price_backward`] — the production engine: a rolling one-dimensional
14//!   value array (O(n) memory, no tree materialized) with the layer spot
15//!   levels rebuilt from two precomputed power tables.
16//! - [`price_backward_with_greeks`] — the same rolling pass, additionally
17//!   keeping the first two layers so the value and the tree
18//!   delta/gamma/theta come from a single induction.
19//! - [`price_with_diagnostics`] — the debug engine: keeps the full spot
20//!   and value trees, records the early-exercise boundary per layer, tree
21//!   Greeks read off the first layers, and wall-clock time.
22//!
23//! [`convergence_study`] prices across a ladder of step counts (with
24//! per-point timing) to expose each scheme's convergence behavior — CRR
25//! oscillates at first order, Leisen-Reimer converges smoothly at second.
26//!
27//! Payoffs and early exercise enter as closures, so the same lattice
28//! prices equity payoffs today and other asset classes later.
29
30use std::time::{Duration, Instant};
31
32use serde::{Deserialize, Serialize};
33
34use crate::core::errors::RustyQLibError;
35
36// ── Tree types ──────────────────────────────────────────────────────────
37
38/// The lattice parameterization: how `(u, d, p)` are chosen.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum BinomialTreeType {
42    /// `u = e^{sigma sqrt(dt)}`, `d = 1/u`; the classic. First-order,
43    /// oscillating convergence.
44    #[serde(alias = "CRR", alias = "crr")]
45    CoxRossRubinstein,
46    /// Equal probabilities with the drift in the node spacing.
47    #[serde(alias = "JR", alias = "jr")]
48    JarrowRudd,
49    /// Matches the first three moments of the lognormal step.
50    Tian,
51    /// Additive in log-space with drift-adjusted spacing.
52    Trigeorgis,
53    /// Peizer-Pratt inversion centered on the strike; needs an odd step
54    /// count (even counts are bumped up by one). Second-order, smooth —
55    /// the default: at ~100 steps it matches CRR at 1000.
56    #[default]
57    #[serde(alias = "LR", alias = "lr")]
58    LeisenReimer,
59    /// Equal-probability additive tree (Clewlow-Strickland).
60    #[serde(alias = "EQP", alias = "eqp")]
61    AdditiveEqp,
62}
63
64impl std::str::FromStr for BinomialTreeType {
65    type Err = RustyQLibError;
66    fn from_str(s: &str) -> Result<Self, RustyQLibError> {
67        use BinomialTreeType::*;
68        Ok(match s.trim().to_lowercase().as_str() {
69            "crr" | "coxrossrubinstein" | "cox_ross_rubinstein" => CoxRossRubinstein,
70            "jr" | "jarrowrudd" | "jarrow_rudd" => JarrowRudd,
71            "tian" => Tian,
72            "trigeorgis" => Trigeorgis,
73            "lr" | "leisenreimer" | "leisen_reimer" => LeisenReimer,
74            "eqp" | "additiveeqp" | "additive_eqp" => AdditiveEqp,
75            other => {
76                return Err(RustyQLibError::invalid_input(
77                    "tree_type",
78                    format!(
79                        "unknown tree type '{other}' (use CRR, JarrowRudd, Tian, \
80                         Trigeorgis, LeisenReimer or EQP)"
81                    ),
82                ))
83            }
84        })
85    }
86}
87
88/// Lattice engine configuration carried by an instrument.
89#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
90pub struct LatticeConfig {
91    pub tree_type: BinomialTreeType,
92    pub steps: usize,
93    /// Price on the [`TermLattice`]: term structures of rates, carry and
94    /// volatility applied per step (variance-equal time grid). When set,
95    /// `tree_type` is ignored — the term lattice has its own
96    /// CRR-in-variance spacing.
97    #[serde(default)]
98    pub term_structure: bool,
99}
100
101impl Default for LatticeConfig {
102    fn default() -> Self {
103        LatticeConfig {
104            tree_type: BinomialTreeType::LeisenReimer,
105            steps: 1000,
106            term_structure: false,
107        }
108    }
109}
110
111impl LatticeConfig {
112    /// Domain checks on the tree dimensions.
113    pub fn validate(&self) -> Result<(), RustyQLibError> {
114        if self.steps < 2 {
115            return Err(RustyQLibError::invalid_input(
116                "tree_steps",
117                format!("the binomial tree needs at least 2 steps, got {}", self.steps),
118            ));
119        }
120        Ok(())
121    }
122}
123
124/// The general recombining step: `S(i, j) = S0 e^{j lu + (i-j) ld}`,
125/// up with probability `p_up`.
126#[derive(Debug, Clone, Copy, PartialEq)]
127pub struct LatticeParams {
128    pub log_up: f64,
129    pub log_down: f64,
130    pub p_up: f64,
131}
132
133impl BinomialTreeType {
134    /// The step count this scheme actually uses (Leisen-Reimer needs an
135    /// odd number of steps for the Peizer-Pratt inversion).
136    pub fn effective_steps(&self, steps: usize) -> usize {
137        let steps = steps.max(2);
138        match self {
139            BinomialTreeType::LeisenReimer if steps % 2 == 0 => steps + 1,
140            _ => steps,
141        }
142    }
143
144    /// The `(log_up, log_down, p_up)` triple for `n` steps over life `t`
145    /// with carry drift `b` (`r - q`) and volatility `sigma`. `s0` and
146    /// `strike` are used by the strike-aware Leisen-Reimer scheme only.
147    pub fn params(
148        &self,
149        s0: f64,
150        strike: f64,
151        b: f64,
152        sigma: f64,
153        t: f64,
154        n: usize,
155    ) -> Result<LatticeParams, RustyQLibError> {
156        if !(sigma > 0.0 && sigma.is_finite()) {
157            return Err(RustyQLibError::invalid_input(
158                "sigma",
159                format!("lattice volatility must be positive and finite, got {sigma}"),
160            ));
161        }
162        if !(t > 0.0) || n < 2 {
163            return Err(RustyQLibError::invalid_input(
164                "lattice",
165                "the lattice needs positive maturity and at least two steps",
166            ));
167        }
168        let dt = t / n as f64;
169        let nu = b - 0.5 * sigma * sigma;
170        let params = match self {
171            BinomialTreeType::CoxRossRubinstein => {
172                let dx = sigma * dt.sqrt();
173                let (u, d) = (dx.exp(), (-dx).exp());
174                LatticeParams {
175                    log_up: dx,
176                    log_down: -dx,
177                    p_up: ((b * dt).exp() - d) / (u - d),
178                }
179            }
180            BinomialTreeType::JarrowRudd => LatticeParams {
181                log_up: nu * dt + sigma * dt.sqrt(),
182                log_down: nu * dt - sigma * dt.sqrt(),
183                p_up: 0.5,
184            },
185            BinomialTreeType::Tian => {
186                let m = (b * dt).exp();
187                let v = (sigma * sigma * dt).exp();
188                let root = (v * v + 2.0 * v - 3.0).sqrt();
189                let u = 0.5 * m * v * (v + 1.0 + root);
190                let d = 0.5 * m * v * (v + 1.0 - root);
191                LatticeParams { log_up: u.ln(), log_down: d.ln(), p_up: (m - d) / (u - d) }
192            }
193            BinomialTreeType::Trigeorgis => {
194                let dx = (sigma * sigma * dt + nu * nu * dt * dt).sqrt();
195                LatticeParams {
196                    log_up: dx,
197                    log_down: -dx,
198                    p_up: 0.5 + 0.5 * nu * dt / dx,
199                }
200            }
201            BinomialTreeType::LeisenReimer => {
202                if !(s0 > 0.0 && strike > 0.0) {
203                    return Err(RustyQLibError::invalid_input(
204                        "lattice",
205                        "Leisen-Reimer needs positive spot and strike",
206                    ));
207                }
208                let n_odd = self.effective_steps(n);
209                if n_odd != n {
210                    return Err(RustyQLibError::invalid_input(
211                        "lattice",
212                        "Leisen-Reimer needs an odd step count (use effective_steps)",
213                    ));
214                }
215                let sq_t = sigma * t.sqrt();
216                let d1 = ((s0 / strike).ln() + (b + 0.5 * sigma * sigma) * t) / sq_t;
217                let d2 = d1 - sq_t;
218                let p = peizer_pratt(d2, n);
219                let p_star = peizer_pratt(d1, n);
220                let m = (b * dt).exp();
221                let u = m * p_star / p;
222                let d = (m - p * u) / (1.0 - p);
223                if !(d > 0.0 && u > d) {
224                    return Err(RustyQLibError::NumericalError(format!(
225                        "Leisen-Reimer step degenerated (u {u}, d {d}); increase the step count"
226                    )));
227                }
228                LatticeParams { log_up: u.ln(), log_down: d.ln(), p_up: p }
229            }
230            BinomialTreeType::AdditiveEqp => {
231                let disc = 4.0 * sigma * sigma * dt - 3.0 * nu * nu * dt * dt;
232                if disc <= 0.0 {
233                    return Err(RustyQLibError::NumericalError(
234                        "EQP tree needs 4 sigma^2 dt > 3 nu^2 dt^2; increase the step count"
235                            .to_string(),
236                    ));
237                }
238                LatticeParams {
239                    log_up: 0.5 * nu * dt + 0.5 * disc.sqrt(),
240                    log_down: 1.5 * nu * dt - 0.5 * disc.sqrt(),
241                    p_up: 0.5,
242                }
243            }
244        };
245        if !(params.p_up > 0.0 && params.p_up < 1.0) {
246            return Err(RustyQLibError::NumericalError(format!(
247                "lattice probability {:.6} outside (0, 1): the time step is too \
248                 large for this drift/volatility (increase the step count)",
249                params.p_up
250            )));
251        }
252        Ok(params)
253    }
254}
255
256/// Peizer-Pratt method-2 inversion of the binomial CDF.
257fn peizer_pratt(z: f64, n: usize) -> f64 {
258    let nf = n as f64;
259    let scaled = z / (nf + 1.0 / 3.0 + 0.1 / (nf + 1.0));
260    let inner = (1.0 - (-scaled * scaled * (nf + 1.0 / 6.0)).exp()).sqrt();
261    0.5 + 0.5 * inner.copysign(z)
262}
263
264// ── Optimized engine ────────────────────────────────────────────────────
265
266/// Backward induction on a rolling one-dimensional array: O(n) memory,
267/// O(n^2) work, spot levels rebuilt from two precomputed power tables.
268///
269/// `terminal(spot)` is the payoff at expiry; `exercise` (when given)
270/// maps `(step, spot, continuation)` to the node value, which expresses
271/// American (`intrinsic.max(cont)` at every step), Bermudan (only on
272/// listed steps) or any custom early-exercise rule.
273pub fn price_backward(
274    s0: f64,
275    params: &LatticeParams,
276    n: usize,
277    df_step: f64,
278    terminal: &dyn Fn(f64) -> f64,
279    exercise: Option<&dyn Fn(usize, f64, f64) -> f64>,
280) -> f64 {
281    // spot(i, j) = s0 * up_pow[j] * down_pow[i - j]
282    let up_pow: Vec<f64> = (0..=n).map(|j| (j as f64 * params.log_up).exp()).collect();
283    let down_pow: Vec<f64> = (0..=n).map(|j| (j as f64 * params.log_down).exp()).collect();
284    let spot = |i: usize, j: usize| s0 * up_pow[j] * down_pow[i - j];
285
286    let mut v: Vec<f64> = (0..=n).map(|j| terminal(spot(n, j))).collect();
287    let (p, q) = (params.p_up, 1.0 - params.p_up);
288    for i in (0..n).rev() {
289        for j in 0..=i {
290            // from (i, j): up -> (i+1, j+1), down -> (i+1, j)
291            let cont = df_step * (p * v[j + 1] + q * v[j]);
292            v[j] = match exercise {
293                Some(ex) => ex(i, spot(i, j), cont),
294                None => cont,
295            };
296        }
297    }
298    v[0]
299}
300
301/// Value and the tree Greeks read off a single backward pass.
302#[derive(Debug, Clone, Copy, PartialEq)]
303pub struct LatticeSolution {
304    pub price: f64,
305    /// Tree delta from the first layer.
306    pub delta: f64,
307    /// Tree gamma from the second layer.
308    pub gamma: f64,
309    /// Calendar theta (per year) from the second-layer center vs the root,
310    /// drift-corrected with the tree's own delta and gamma so asymmetric
311    /// trees (Leisen-Reimer, Jarrow-Rudd, Tian) are handled too.
312    pub theta: f64,
313}
314
315/// The same rolling-array induction as [`price_backward`] (identical price,
316/// bit for bit) that additionally keeps the first two layers, so the value
317/// and the tree delta/gamma/theta come out of **one** pass — no re-pricing
318/// per Greek.
319pub fn price_backward_with_greeks(
320    s0: f64,
321    params: &LatticeParams,
322    n: usize,
323    dt: f64,
324    df_step: f64,
325    terminal: &dyn Fn(f64) -> f64,
326    exercise: Option<&dyn Fn(usize, f64, f64) -> f64>,
327) -> LatticeSolution {
328    let up_pow: Vec<f64> = (0..=n).map(|j| (j as f64 * params.log_up).exp()).collect();
329    let down_pow: Vec<f64> = (0..=n).map(|j| (j as f64 * params.log_down).exp()).collect();
330    let spot = |i: usize, j: usize| s0 * up_pow[j] * down_pow[i - j];
331
332    let mut v: Vec<f64> = (0..=n).map(|j| terminal(spot(n, j))).collect();
333    let mut layer2 = [0.0; 3];
334    let mut layer1 = [0.0; 2];
335    if n == 2 {
336        layer2.copy_from_slice(&v[0..3]);
337    }
338    let (p, q) = (params.p_up, 1.0 - params.p_up);
339    for i in (0..n).rev() {
340        for j in 0..=i {
341            let cont = df_step * (p * v[j + 1] + q * v[j]);
342            v[j] = match exercise {
343                Some(ex) => ex(i, spot(i, j), cont),
344                None => cont,
345            };
346        }
347        match i {
348            2 => layer2.copy_from_slice(&v[0..3]),
349            1 => layer1.copy_from_slice(&v[0..2]),
350            _ => {}
351        }
352    }
353
354    let price = v[0];
355    let delta = (layer1[1] - layer1[0]) / (spot(1, 1) - spot(1, 0));
356    let (s_uu, s_ud, s_dd) = (spot(2, 2), spot(2, 1), spot(2, 0));
357    let d_up = (layer2[2] - layer2[1]) / (s_uu - s_ud);
358    let d_down = (layer2[1] - layer2[0]) / (s_ud - s_dd);
359    let gamma = (d_up - d_down) / (0.5 * (s_uu - s_dd));
360    // the second-layer center sits at s0 only on symmetric trees; remove
361    // the spot displacement with the tree's delta and gamma before reading
362    // the calendar decay over the 2*dt elapsed
363    let ds = s_ud - s0;
364    let theta = (layer2[1] - price - delta * ds - 0.5 * gamma * ds * ds) / (2.0 * dt);
365
366    LatticeSolution { price, delta, gamma, theta }
367}
368
369// ── Diagnostic engine ───────────────────────────────────────────────────
370
371/// Everything the debug lattice records beyond the price.
372#[derive(Debug, Clone)]
373pub struct LatticeDiagnostics {
374    pub price: f64,
375    pub tree_type: BinomialTreeType,
376    pub steps: usize,
377    pub params: LatticeParams,
378    /// Wall-clock time of the build + induction.
379    pub elapsed: Duration,
380    /// `spot_tree[i][j]`: layer `i` has `i + 1` nodes, `j` up-moves.
381    pub spot_tree: Vec<Vec<f64>>,
382    pub value_tree: Vec<Vec<f64>>,
383    /// Per layer, the `(min, max)` spot at which early exercise was
384    /// optimal; `None` where the option was never exercised.
385    pub exercise_boundary: Vec<Option<(f64, f64)>>,
386    /// Tree delta from the first layer.
387    pub delta: f64,
388    /// Tree gamma from the second layer.
389    pub gamma: f64,
390    /// Tree theta from the second-layer center vs the root, per year
391    /// (exact for symmetric trees where that node returns to `s0`,
392    /// approximate otherwise).
393    pub theta: f64,
394}
395
396/// The debug engine: same induction as [`price_backward`] but keeping
397/// every layer, the exercise boundary, tree Greeks and timing.
398pub fn price_with_diagnostics(
399    tree_type: BinomialTreeType,
400    s0: f64,
401    params: &LatticeParams,
402    n: usize,
403    dt: f64,
404    df_step: f64,
405    terminal: &dyn Fn(f64) -> f64,
406    exercise: Option<&dyn Fn(usize, f64, f64) -> f64>,
407) -> LatticeDiagnostics {
408    let start = Instant::now();
409    // identical spot computation to `price_backward`, so the two engines
410    // agree bit-for-bit
411    let up_pow: Vec<f64> = (0..=n).map(|j| (j as f64 * params.log_up).exp()).collect();
412    let down_pow: Vec<f64> = (0..=n).map(|j| (j as f64 * params.log_down).exp()).collect();
413    let spot_tree: Vec<Vec<f64>> = (0..=n)
414        .map(|i| (0..=i).map(|j| s0 * up_pow[j] * down_pow[i - j]).collect())
415        .collect();
416
417    let mut value_tree: Vec<Vec<f64>> = spot_tree
418        .iter()
419        .map(|layer| layer.iter().map(|_| 0.0).collect())
420        .collect();
421    value_tree[n] = spot_tree[n].iter().map(|&s| terminal(s)).collect();
422
423    let mut exercise_boundary: Vec<Option<(f64, f64)>> = vec![None; n + 1];
424    let (p, q) = (params.p_up, 1.0 - params.p_up);
425    for i in (0..n).rev() {
426        for j in 0..=i {
427            let cont = df_step * (p * value_tree[i + 1][j + 1] + q * value_tree[i + 1][j]);
428            let spot = spot_tree[i][j];
429            let value = match exercise {
430                Some(ex) => ex(i, spot, cont),
431                None => cont,
432            };
433            if value > cont {
434                let entry = exercise_boundary[i].get_or_insert((spot, spot));
435                entry.0 = entry.0.min(spot);
436                entry.1 = entry.1.max(spot);
437            }
438            value_tree[i][j] = value;
439        }
440    }
441
442    let price = value_tree[0][0];
443    let delta = (value_tree[1][1] - value_tree[1][0]) / (spot_tree[1][1] - spot_tree[1][0]);
444    let (s_uu, s_ud, s_dd) = (spot_tree[2][2], spot_tree[2][1], spot_tree[2][0]);
445    let (v_uu, v_ud, v_dd) = (value_tree[2][2], value_tree[2][1], value_tree[2][0]);
446    let d_up = (v_uu - v_ud) / (s_uu - s_ud);
447    let d_down = (v_ud - v_dd) / (s_ud - s_dd);
448    let gamma = (d_up - d_down) / (0.5 * (s_uu - s_dd));
449    let theta = (v_ud - price) / (2.0 * dt);
450
451    LatticeDiagnostics {
452        price,
453        tree_type,
454        steps: n,
455        params: *params,
456        elapsed: start.elapsed(),
457        spot_tree,
458        value_tree,
459        exercise_boundary,
460        delta,
461        gamma,
462        theta,
463    }
464}
465
466// ── Convergence study ───────────────────────────────────────────────────
467
468/// One rung of a convergence ladder.
469#[derive(Debug, Clone, Copy)]
470pub struct ConvergencePoint {
471    pub steps: usize,
472    pub price: f64,
473    pub elapsed: Duration,
474}
475
476/// Price the same contract across a ladder of step counts on the
477/// optimized engine, timing each rung — the raw material for studying a
478/// scheme's convergence order and oscillation.
479#[allow(clippy::too_many_arguments)]
480pub fn convergence_study(
481    tree_type: BinomialTreeType,
482    s0: f64,
483    strike: f64,
484    b: f64,
485    sigma: f64,
486    r: f64,
487    t: f64,
488    steps_ladder: &[usize],
489    terminal: &dyn Fn(f64) -> f64,
490    exercise: Option<&dyn Fn(usize, f64, f64) -> f64>,
491) -> Result<Vec<ConvergencePoint>, RustyQLibError> {
492    steps_ladder
493        .iter()
494        .map(|&steps| {
495            let n = tree_type.effective_steps(steps);
496            let params = tree_type.params(s0, strike, b, sigma, t, n)?;
497            let df_step = (-r * t / n as f64).exp();
498            let start = Instant::now();
499            let price = price_backward(s0, &params, n, df_step, terminal, exercise);
500            Ok(ConvergencePoint { steps: n, price, elapsed: start.elapsed() })
501        })
502        .collect()
503}
504
505
506// ── Time-dependent (term-structure) lattice ─────────────────────────────
507
508/// A recombining binomial lattice under **time-dependent parameters**:
509/// term structures of rates, carry and volatility applied directly on
510/// the tree.
511///
512/// Construction (the standard variance-grid method):
513/// 1. The time grid is warped so every step accrues equal variance
514///    `w = V(T)/n`, where `V(t)` is the cumulative variance supplied by
515///    the caller. Fixed log-spacing `dx = sqrt(w)` then keeps the tree
516///    recombining even though volatility varies with time.
517/// 2. Each step's drift is matched exactly by a **per-step probability**
518///    from the forward rate and carry over that step, and each step
519///    discounts with its own forward discount factor.
520///
521/// So flat inputs reduce to the classic CRR tree, while curved inputs
522/// reprice the exact term structure: for a European payoff the tree
523/// converges to Black-Scholes with the equivalent average variance and
524/// the curve's exact discount factor.
525#[derive(Debug, Clone)]
526pub struct TermLattice {
527    /// Layer times `t_0 = 0 .. t_n = T` (unequal spacing in general).
528    pub times: Vec<f64>,
529    /// Fixed log-spacing between adjacent nodes.
530    pub dx: f64,
531    /// Per-step up-probability (drift-matched from the forward rates).
532    pub p_up: Vec<f64>,
533    /// Per-step discount factor (forward rate over the step).
534    pub df: Vec<f64>,
535}
536
537impl TermLattice {
538    /// Build the grid for `n` steps over `[0, t]`.
539    ///
540    /// - `forward_rate(t1, t2)`: continuously compounded forward rate
541    ///   over the step (from a discount curve:
542    ///   `ln(df(t1)/df(t2)) / (t2 - t1)`).
543    /// - `forward_carry(t1, t2)`: forward dividend + borrow yield over
544    ///   the step; the drift per step is `rate - carry`.
545    /// - `total_variance(t)`: cumulative variance `sigma(t)^2 * t` (or
546    ///   an integral of instantaneous variance); must be strictly
547    ///   increasing — a calendar-arbitrage-free vol term structure.
548    pub fn build(
549        n: usize,
550        t: f64,
551        forward_rate: &dyn Fn(f64, f64) -> f64,
552        forward_carry: &dyn Fn(f64, f64) -> f64,
553        total_variance: &dyn Fn(f64) -> f64,
554    ) -> Result<TermLattice, RustyQLibError> {
555        if !(t > 0.0) || n < 2 {
556            return Err(RustyQLibError::invalid_input(
557                "lattice",
558                "the lattice needs positive maturity and at least two steps",
559            ));
560        }
561        let w_total = total_variance(t);
562        if !(w_total > 0.0 && w_total.is_finite()) {
563            return Err(RustyQLibError::invalid_input(
564                "total_variance",
565                format!("total variance to maturity must be positive and finite, got {w_total}"),
566            ));
567        }
568
569        // variance-equal time grid: invert V(t) = i * w by bisection
570        let mut times = Vec::with_capacity(n + 1);
571        times.push(0.0);
572        for i in 1..n {
573            let target = w_total * i as f64 / n as f64;
574            let (mut lo, mut hi) = (*times.last().expect("nonempty"), t);
575            for _ in 0..80 {
576                let mid = 0.5 * (lo + hi);
577                if total_variance(mid) < target {
578                    lo = mid;
579                } else {
580                    hi = mid;
581                }
582            }
583            let ti = 0.5 * (lo + hi);
584            if ti <= *times.last().expect("nonempty") {
585                return Err(RustyQLibError::NumericalError(
586                    "cumulative variance is not strictly increasing (calendar \
587                     arbitrage in the vol term structure)"
588                        .to_string(),
589                ));
590            }
591            times.push(ti);
592        }
593        times.push(t);
594
595        let dx = (w_total / n as f64).sqrt();
596        let (u, d) = (dx.exp(), (-dx).exp());
597        let mut p_up = Vec::with_capacity(n);
598        let mut df = Vec::with_capacity(n);
599        for i in 0..n {
600            let (t1, t2) = (times[i], times[i + 1]);
601            let dt = t2 - t1;
602            let r = forward_rate(t1, t2);
603            let b = r - forward_carry(t1, t2);
604            let growth = (b * dt).exp();
605            let p = (growth - d) / (u - d);
606            if !(p > 0.0 && p < 1.0) {
607                return Err(RustyQLibError::NumericalError(format!(
608                    "step {i} probability {p:.6} outside (0, 1): the local drift \
609                     is too large for the variance spacing (increase the step count)"
610                )));
611            }
612            p_up.push(p);
613            df.push((-r * dt).exp());
614        }
615
616        Ok(TermLattice { times, dx, p_up, df })
617    }
618
619    pub fn steps(&self) -> usize {
620        self.p_up.len()
621    }
622
623    /// Backward induction on the rolling array with per-step
624    /// probabilities and discounting. The early-exercise closure receives
625    /// `(step, time, spot, continuation)` — time-aware because the layer
626    /// times are unequal.
627    pub fn price(
628        &self,
629        s0: f64,
630        terminal: &dyn Fn(f64) -> f64,
631        exercise: Option<&dyn Fn(usize, f64, f64, f64) -> f64>,
632    ) -> f64 {
633        let n = self.steps();
634        // spot(i, j) = s0 * e^{(2j - i) dx}: one power table over [-n, n]
635        let pow: Vec<f64> = (0..=2 * n).map(|k| ((k as f64 - n as f64) * self.dx).exp()).collect();
636        let spot = |i: usize, j: usize| s0 * pow[2 * j + n - i];
637
638        let mut v: Vec<f64> = (0..=n).map(|j| terminal(spot(n, j))).collect();
639        for i in (0..n).rev() {
640            let (p, q, df) = (self.p_up[i], 1.0 - self.p_up[i], self.df[i]);
641            for j in 0..=i {
642                let cont = df * (p * v[j + 1] + q * v[j]);
643                v[j] = match exercise {
644                    Some(ex) => ex(i, self.times[i], spot(i, j), cont),
645                    None => cont,
646                };
647            }
648        }
649        v[0]
650    }
651
652    /// The same induction as [`price`](Self::price) (identical price, bit
653    /// for bit) keeping the first two layers, so the value and the tree
654    /// delta/gamma/theta come out of one pass. The log-grid is symmetric,
655    /// so the second-layer center returns exactly to `s0` and theta needs
656    /// no drift correction; the elapsed time is the grid's own `times[2]`
657    /// (the layer times are unequal).
658    pub fn price_with_greeks(
659        &self,
660        s0: f64,
661        terminal: &dyn Fn(f64) -> f64,
662        exercise: Option<&dyn Fn(usize, f64, f64, f64) -> f64>,
663    ) -> LatticeSolution {
664        let n = self.steps();
665        let pow: Vec<f64> = (0..=2 * n).map(|k| ((k as f64 - n as f64) * self.dx).exp()).collect();
666        let spot = |i: usize, j: usize| s0 * pow[2 * j + n - i];
667
668        let mut v: Vec<f64> = (0..=n).map(|j| terminal(spot(n, j))).collect();
669        let mut layer2 = [0.0; 3];
670        let mut layer1 = [0.0; 2];
671        if n == 2 {
672            layer2.copy_from_slice(&v[0..3]);
673        }
674        for i in (0..n).rev() {
675            let (p, q, df) = (self.p_up[i], 1.0 - self.p_up[i], self.df[i]);
676            for j in 0..=i {
677                let cont = df * (p * v[j + 1] + q * v[j]);
678                v[j] = match exercise {
679                    Some(ex) => ex(i, self.times[i], spot(i, j), cont),
680                    None => cont,
681                };
682            }
683            match i {
684                2 => layer2.copy_from_slice(&v[0..3]),
685                1 => layer1.copy_from_slice(&v[0..2]),
686                _ => {}
687            }
688        }
689
690        let price = v[0];
691        let delta = (layer1[1] - layer1[0]) / (spot(1, 1) - spot(1, 0));
692        let (s_uu, s_ud, s_dd) = (spot(2, 2), spot(2, 1), spot(2, 0));
693        let d_up = (layer2[2] - layer2[1]) / (s_uu - s_ud);
694        let d_down = (layer2[1] - layer2[0]) / (s_ud - s_dd);
695        let gamma = (d_up - d_down) / (0.5 * (s_uu - s_dd));
696        let theta = (layer2[1] - price) / self.times[2];
697        LatticeSolution { price, delta, gamma, theta }
698    }
699}
700
701
702// ── Trinomial lattice ───────────────────────────────────────────────────
703
704/// One node's branching: the **middle child's absolute index** on the
705/// next layer and the probabilities onto `(target+1, target, target-1)`.
706///
707/// A plain diffusion always targets its own index; a mean-reverting
708/// short-rate tree (Hull-White) shifts the target at the edge nodes so
709/// probabilities stay positive — the reason rates trees are trinomial.
710#[derive(Debug, Clone, Copy, PartialEq)]
711pub struct TrinomialBranch {
712    pub target: i32,
713    pub p_up: f64,
714    pub p_mid: f64,
715    pub p_down: f64,
716}
717
718/// A recombining trinomial lattice over the integer state grid
719/// `x_j = j * dx` (the caller maps `j` to its own state, e.g.
720/// `r(i, j) = alpha_i + j * dx` for a fitted short-rate tree).
721///
722/// Built from a per-node branching closure, so edge-switching trees
723/// (Hull-White clamping) and plain diffusions use the same engine. The
724/// per-layer index ranges follow from reachability. Discounting is
725/// **per node** — `df(layer, j)` — because for fixed income the short
726/// rate lives on the node; equity-style trees pass a constant.
727#[derive(Debug, Clone)]
728pub struct TrinomialLattice {
729    pub dt: f64,
730    pub dx: f64,
731    j_min: Vec<i32>,
732    j_max: Vec<i32>,
733    /// `branches[i][j - j_min[i]]` for layers `0..n`.
734    branches: Vec<Vec<TrinomialBranch>>,
735}
736
737impl TrinomialLattice {
738    /// Build `n` steps of the lattice from the branching rule.
739    /// Probabilities are validated per node; targets may shift by at most
740    /// one index per step (`|target - j| <= 1`), which keeps the tree
741    /// recombining.
742    pub fn build(
743        n: usize,
744        dt: f64,
745        dx: f64,
746        branching: &dyn Fn(usize, i32) -> TrinomialBranch,
747    ) -> Result<TrinomialLattice, RustyQLibError> {
748        if n < 1 || !(dt > 0.0) || !(dx > 0.0) {
749            return Err(RustyQLibError::invalid_input(
750                "trinomial",
751                "the lattice needs at least one step and positive dt / dx",
752            ));
753        }
754        let mut j_min = vec![0i32];
755        let mut j_max = vec![0i32];
756        let mut branches: Vec<Vec<TrinomialBranch>> = Vec::with_capacity(n);
757        for i in 0..n {
758            let (lo, hi) = (j_min[i], j_max[i]);
759            let mut layer = Vec::with_capacity((hi - lo + 1) as usize);
760            let (mut next_lo, mut next_hi) = (i32::MAX, i32::MIN);
761            for j in lo..=hi {
762                let b = branching(i, j);
763                if (b.target - j).abs() > 1 {
764                    return Err(RustyQLibError::NumericalError(format!(
765                        "node ({i}, {j}) branches to target {} — more than one \
766                         index away, which breaks recombination",
767                        b.target
768                    )));
769                }
770                for (name, prob) in
771                    [("p_up", b.p_up), ("p_mid", b.p_mid), ("p_down", b.p_down)]
772                {
773                    if !(prob >= 0.0 && prob <= 1.0) {
774                        return Err(RustyQLibError::NumericalError(format!(
775                            "node ({i}, {j}): {name} = {prob:.6} outside [0, 1] \
776                             (adjust the spacing or the branching rule)"
777                        )));
778                    }
779                }
780                if (b.p_up + b.p_mid + b.p_down - 1.0).abs() > 1e-9 {
781                    return Err(RustyQLibError::NumericalError(format!(
782                        "node ({i}, {j}): probabilities sum to {:.9}, not 1",
783                        b.p_up + b.p_mid + b.p_down
784                    )));
785                }
786                next_lo = next_lo.min(b.target - 1);
787                next_hi = next_hi.max(b.target + 1);
788                layer.push(b);
789            }
790            branches.push(layer);
791            j_min.push(next_lo);
792            j_max.push(next_hi);
793        }
794        Ok(TrinomialLattice { dt, dx, j_min, j_max, branches })
795    }
796
797    pub fn steps(&self) -> usize {
798        self.branches.len()
799    }
800
801    /// Node index range `(j_min, j_max)` of a layer.
802    pub fn layer_range(&self, i: usize) -> (i32, i32) {
803        (self.j_min[i], self.j_max[i])
804    }
805
806    /// Backward induction. `node_df(i, j)` is the one-step discount at
807    /// the node (state-dependent: `e^{-r(i,j) dt}` on a short-rate
808    /// tree); `terminal(j)` values the final layer; `exercise` (when
809    /// given) maps `(layer, j, continuation)` to the node value.
810    pub fn price(
811        &self,
812        node_df: &dyn Fn(usize, i32) -> f64,
813        terminal: &dyn Fn(i32) -> f64,
814        exercise: Option<&dyn Fn(usize, i32, f64) -> f64>,
815    ) -> f64 {
816        let n = self.steps();
817        let (lo_n, hi_n) = (self.j_min[n], self.j_max[n]);
818        let mut values: Vec<f64> = (lo_n..=hi_n).map(terminal).collect();
819        for i in (0..n).rev() {
820            let (lo, hi) = (self.j_min[i], self.j_max[i]);
821            let next_lo = self.j_min[i + 1];
822            let mut layer = Vec::with_capacity((hi - lo + 1) as usize);
823            for j in lo..=hi {
824                let b = self.branches[i][(j - lo) as usize];
825                let k = (b.target - next_lo) as usize;
826                let expected = b.p_up * values[k + 1] + b.p_mid * values[k]
827                    + b.p_down * values[k - 1];
828                let cont = node_df(i, j) * expected;
829                layer.push(match exercise {
830                    Some(ex) => ex(i, j, cont),
831                    None => cont,
832                });
833            }
834            values = layer;
835        }
836        values[0]
837    }
838
839    /// Arrow-Debreu state prices by forward induction: `Q[i][j - j_min[i]]`
840    /// is the value today of receiving 1 at node `(i, j)`. The workhorse of
841    /// short-rate curve fitting — Hull-White's `alpha_i` shifts solve
842    /// `sum_j Q[i][j] e^{-(alpha_i + j dx) dt} = P(0, t_{i+1})` layer by
843    /// layer. `sum_j Q[n][j]` is the tree's discount factor to `t_n`.
844    pub fn arrow_debreu(&self, node_df: &dyn Fn(usize, i32) -> f64) -> Vec<Vec<f64>> {
845        let n = self.steps();
846        let mut q: Vec<Vec<f64>> = Vec::with_capacity(n + 1);
847        q.push(vec![1.0]);
848        for i in 0..n {
849            let (lo, hi) = (self.j_min[i], self.j_max[i]);
850            let (next_lo, next_hi) = (self.j_min[i + 1], self.j_max[i + 1]);
851            let mut next = vec![0.0; (next_hi - next_lo + 1) as usize];
852            for j in lo..=hi {
853                let b = self.branches[i][(j - lo) as usize];
854                let flow = q[i][(j - lo) as usize] * node_df(i, j);
855                let k = (b.target - next_lo) as usize;
856                next[k + 1] += b.p_up * flow;
857                next[k] += b.p_mid * flow;
858                next[k - 1] += b.p_down * flow;
859            }
860            q.push(next);
861        }
862        q
863    }
864}
865
866/// Moment-matched branching for a constant-coefficient diffusion
867/// `dx_t = nu dt + sigma dW` on spacing `dx` (Boyle / Kamrad-Ritchken:
868/// `dx = sigma sqrt(3 dt)` gives the classic 1/6, 2/3, 1/6 weights at
869/// zero drift). Same rule at every node — the equity-style tree.
870pub fn diffusion_branching(nu: f64, sigma: f64, dt: f64, dx: f64) -> TrinomialBranch {
871    let v = (sigma * sigma * dt + nu * nu * dt * dt) / (dx * dx);
872    let m = nu * dt / dx;
873    TrinomialBranch {
874        target: 0, // filled per node by the caller closure (target = j)
875        p_up: 0.5 * (v + m),
876        p_mid: 1.0 - v,
877        p_down: 0.5 * (v - m),
878    }
879}
880
881/// Hull-White branching for the mean-reverting state
882/// `dx_t = -a x_t dt + sigma dW` with `dx = sigma sqrt(3 dt)`:
883/// standard branching in the interior, switching to downward branching at
884/// `+j_cap` and upward at `-j_cap` so probabilities stay positive
885/// (Hull's `j_max = ceil(0.184 / (a dt))` is the usual cap).
886pub fn hull_white_branching(a: f64, dt: f64, j: i32, j_cap: i32) -> TrinomialBranch {
887    let ajdt = a * j as f64 * dt;
888    let ajdt2 = ajdt * ajdt;
889    if j >= j_cap {
890        // downward branching: children (j, j-1, j-2)
891        TrinomialBranch {
892            target: j - 1,
893            p_up: 7.0 / 6.0 + 0.5 * (ajdt2 - 3.0 * ajdt),
894            p_mid: -1.0 / 3.0 - ajdt2 + 2.0 * ajdt,
895            p_down: 1.0 / 6.0 + 0.5 * (ajdt2 - ajdt),
896        }
897    } else if j <= -j_cap {
898        // upward branching: children (j+2, j+1, j)
899        TrinomialBranch {
900            target: j + 1,
901            p_up: 1.0 / 6.0 + 0.5 * (ajdt2 + ajdt),
902            p_mid: -1.0 / 3.0 - ajdt2 - 2.0 * ajdt,
903            p_down: 7.0 / 6.0 + 0.5 * (ajdt2 + 3.0 * ajdt),
904        }
905    } else {
906        TrinomialBranch {
907            target: j,
908            p_up: 1.0 / 6.0 + 0.5 * (ajdt2 - ajdt),
909            p_mid: 2.0 / 3.0 - ajdt2,
910            p_down: 1.0 / 6.0 + 0.5 * (ajdt2 + ajdt),
911        }
912    }
913}
914
915/// Hull's recommended clamp for [`hull_white_branching`].
916pub fn hull_white_j_cap(a: f64, dt: f64) -> i32 {
917    (0.184 / (a * dt)).ceil() as i32
918}
919
920#[cfg(test)]
921mod tests {
922    use super::*;
923    use crate::core::utils::norm_cdf;
924
925    const S: f64 = 100.0;
926    const K: f64 = 100.0;
927    const R: f64 = 0.05;
928    const Q: f64 = 0.02;
929    const SIGMA: f64 = 0.3;
930    const T: f64 = 1.0;
931
932    fn bs_call() -> f64 {
933        let b = R - Q;
934        let d1 = ((S / K).ln() + (b + 0.5 * SIGMA * SIGMA) * T) / (SIGMA * T.sqrt());
935        let d2 = d1 - SIGMA * T.sqrt();
936        S * ((b - R) * T).exp() * norm_cdf(d1) - K * (-R * T).exp() * norm_cdf(d2)
937    }
938
939    fn tree_call(tree_type: BinomialTreeType, steps: usize) -> f64 {
940        let n = tree_type.effective_steps(steps);
941        let params = tree_type.params(S, K, R - Q, SIGMA, T, n).unwrap();
942        let df = (-R * T / n as f64).exp();
943        price_backward(S, &params, n, df, &|s| (s - K).max(0.0), None)
944    }
945
946    fn all_types() -> [BinomialTreeType; 6] {
947        use BinomialTreeType::*;
948        [CoxRossRubinstein, JarrowRudd, Tian, Trigeorgis, LeisenReimer, AdditiveEqp]
949    }
950
951    #[test]
952    fn every_scheme_converges_to_black_scholes() {
953        let reference = bs_call();
954        for tree_type in all_types() {
955            let price = tree_call(tree_type, 1000);
956            // EQP converges at O(sqrt(dt)) — the known laggard, kept for
957            // completeness (same formulation as QuantLib's AdditiveEQP)
958            let tol = if tree_type == BinomialTreeType::AdditiveEqp { 2e-2 } else { 5e-3 };
959            assert!(
960                (price - reference).abs() < tol,
961                "{tree_type:?}: {price} vs BS {reference}"
962            );
963        }
964    }
965
966    #[test]
967    fn leisen_reimer_beats_crr_by_orders_of_magnitude() {
968        let reference = bs_call();
969        let lr_err = (tree_call(BinomialTreeType::LeisenReimer, 101) - reference).abs();
970        let crr_err = (tree_call(BinomialTreeType::CoxRossRubinstein, 101) - reference).abs();
971        assert!(
972            lr_err < 1e-4,
973            "LR at 101 steps is second order, err {lr_err}"
974        );
975        assert!(
976            lr_err * 10.0 < crr_err,
977            "LR(101) err {lr_err} must be >10x tighter than CRR(101) err {crr_err}"
978        );
979    }
980
981    #[test]
982    fn leisen_reimer_bumps_even_step_counts_to_odd() {
983        assert_eq!(BinomialTreeType::LeisenReimer.effective_steps(100), 101);
984        assert_eq!(BinomialTreeType::LeisenReimer.effective_steps(101), 101);
985        assert_eq!(BinomialTreeType::CoxRossRubinstein.effective_steps(100), 100);
986    }
987
988    #[test]
989    fn optimized_and_diagnostic_engines_agree_exactly() {
990        for tree_type in all_types() {
991            let n = tree_type.effective_steps(200);
992            let params = tree_type.params(S, K, R - Q, SIGMA, T, n).unwrap();
993            let dt = T / n as f64;
994            let df = (-R * dt).exp();
995            let terminal = |s: f64| (K - s).max(0.0);
996            let exercise = |_: usize, s: f64, cont: f64| (K - s).max(0.0).max(cont);
997            let fast = price_backward(S, &params, n, df, &terminal, Some(&exercise));
998            let diag = price_with_diagnostics(
999                tree_type, S, &params, n, dt, df, &terminal, Some(&exercise),
1000            );
1001            assert_eq!(fast, diag.price, "{tree_type:?} engines disagree");
1002            assert_eq!(diag.spot_tree.len(), n + 1);
1003            assert_eq!(diag.spot_tree[n].len(), n + 1);
1004        }
1005    }
1006
1007    #[test]
1008    fn one_pass_greeks_match_black_scholes() {
1009        let norm_pdf = |x: f64| (-0.5 * x * x).exp() / (2.0 * std::f64::consts::PI).sqrt();
1010        let b = R - Q;
1011        let sq_t = SIGMA * T.sqrt();
1012        let d1 = ((S / K).ln() + (b + 0.5 * SIGMA * SIGMA) * T) / sq_t;
1013        let d2 = d1 - sq_t;
1014        let carry_df = ((b - R) * T).exp(); // e^{-qT}
1015        let bs_delta = carry_df * norm_cdf(d1);
1016        let bs_gamma = carry_df * norm_pdf(d1) / (S * sq_t);
1017        let bs_theta = -S * carry_df * norm_pdf(d1) * SIGMA / (2.0 * T.sqrt())
1018            - (b - R) * S * carry_df * norm_cdf(d1)
1019            - R * K * (-R * T).exp() * norm_cdf(d2);
1020
1021        // the asymmetric schemes exercise the drift-corrected theta read
1022        for tree_type in [BinomialTreeType::LeisenReimer, BinomialTreeType::JarrowRudd] {
1023            let n = tree_type.effective_steps(1001);
1024            let params = tree_type.params(S, K, b, SIGMA, T, n).unwrap();
1025            let dt = T / n as f64;
1026            let df = (-R * dt).exp();
1027            let terminal = |s: f64| (s - K).max(0.0);
1028            let sol = price_backward_with_greeks(S, &params, n, dt, df, &terminal, None);
1029            let plain = price_backward(S, &params, n, df, &terminal, None);
1030            assert_eq!(sol.price, plain, "{tree_type:?}: one-pass price must match exactly");
1031            assert!(
1032                (sol.delta - bs_delta).abs() < 2e-3,
1033                "{tree_type:?} delta {} vs BS {bs_delta}",
1034                sol.delta
1035            );
1036            assert!(
1037                (sol.gamma - bs_gamma).abs() < 2e-4,
1038                "{tree_type:?} gamma {} vs BS {bs_gamma}",
1039                sol.gamma
1040            );
1041            assert!(
1042                (sol.theta - bs_theta).abs() < 2e-2,
1043                "{tree_type:?} theta {} vs BS {bs_theta}",
1044                sol.theta
1045            );
1046        }
1047    }
1048
1049    #[test]
1050    fn one_pass_greeks_agree_with_the_diagnostic_engine() {
1051        // American put: same induction, so delta and gamma must be identical
1052        let tree_type = BinomialTreeType::CoxRossRubinstein;
1053        let n = 200;
1054        let params = tree_type.params(S, K, R - Q, SIGMA, T, n).unwrap();
1055        let dt = T / n as f64;
1056        let df = (-R * dt).exp();
1057        let terminal = |s: f64| (K - s).max(0.0);
1058        let exercise = |_: usize, s: f64, cont: f64| (K - s).max(0.0).max(cont);
1059        let sol =
1060            price_backward_with_greeks(S, &params, n, dt, df, &terminal, Some(&exercise));
1061        let diag = price_with_diagnostics(
1062            tree_type, S, &params, n, dt, df, &terminal, Some(&exercise),
1063        );
1064        assert_eq!(sol.price, diag.price);
1065        assert_eq!(sol.delta, diag.delta);
1066        assert_eq!(sol.gamma, diag.gamma);
1067        // CRR is symmetric (the layer-2 center returns to s0), so the
1068        // drift correction vanishes and the thetas coincide
1069        assert!((sol.theta - diag.theta).abs() < 1e-10, "{} vs {}", sol.theta, diag.theta);
1070    }
1071
1072    #[test]
1073    fn american_put_diagnostics_show_the_exercise_region() {
1074        let tree_type = BinomialTreeType::LeisenReimer;
1075        let n = tree_type.effective_steps(201);
1076        let params = tree_type.params(S, K, R - Q, SIGMA, T, n).unwrap();
1077        let dt = T / n as f64;
1078        let df = (-R * dt).exp();
1079        let terminal = |s: f64| (K - s).max(0.0);
1080        let exercise = |_: usize, s: f64, cont: f64| (K - s).max(0.0).max(cont);
1081        let diag = price_with_diagnostics(
1082            tree_type, S, &params, n, dt, df, &terminal, Some(&exercise),
1083        );
1084        // an American put on a dividend payer exercises early somewhere
1085        let exercised_layers = diag.exercise_boundary.iter().flatten().count();
1086        assert!(exercised_layers > 0, "the put must have an exercise region");
1087        // the boundary lies below the strike and its max is below spot levels
1088        for (lo, hi) in diag.exercise_boundary.iter().flatten() {
1089            assert!(*lo <= *hi && *hi < K);
1090        }
1091        // early exercise premium over the European put
1092        let euro = price_backward(S, &params, n, df, &terminal, None);
1093        assert!(diag.price > euro + 1e-4, "american {} european {euro}", diag.price);
1094        // tree Greeks are sane for an ATM put
1095        assert!(diag.delta > -1.0 && diag.delta < 0.0);
1096        assert!(diag.gamma > 0.0);
1097        assert!(diag.theta < 0.0);
1098        assert!(diag.elapsed > Duration::ZERO);
1099    }
1100
1101    #[test]
1102    fn convergence_ladder_reports_prices_and_timing() {
1103        let terminal = |s: f64| (s - K).max(0.0);
1104        let ladder =
1105            convergence_study(
1106                BinomialTreeType::LeisenReimer, S, K, R - Q, SIGMA, R, T,
1107                &[25, 51, 101, 201], &terminal, None,
1108            )
1109            .unwrap();
1110        assert_eq!(ladder.len(), 4);
1111        let reference = bs_call();
1112        let errors: Vec<f64> = ladder.iter().map(|p| (p.price - reference).abs()).collect();
1113        // LR converges monotonically (smoothly) for the vanilla
1114        assert!(
1115            errors.windows(2).all(|w| w[1] <= w[0] * 1.5),
1116            "LR errors should shrink along the ladder: {errors:?}"
1117        );
1118        assert!(errors[3] < 2e-5, "LR(201) err {}", errors[3]);
1119    }
1120
1121    #[test]
1122    fn degenerate_steps_are_rejected_with_typed_errors() {
1123        // enormous drift with one coarse step: probability leaves (0, 1)
1124        let r = BinomialTreeType::CoxRossRubinstein.params(100.0, 100.0, 2.0, 0.05, 1.0, 2);
1125        assert!(matches!(r, Err(RustyQLibError::NumericalError(_))), "{r:?}");
1126        // EQP discriminant failure on the same setup
1127        let r = BinomialTreeType::AdditiveEqp.params(100.0, 100.0, 2.0, 0.05, 1.0, 2);
1128        assert!(matches!(r, Err(RustyQLibError::NumericalError(_))), "{r:?}");
1129        // invalid vol
1130        let r = BinomialTreeType::Tian.params(100.0, 100.0, 0.03, -0.1, 1.0, 100);
1131        assert!(matches!(r, Err(RustyQLibError::InvalidInput { .. })));
1132    }
1133
1134    #[test]
1135    fn trinomial_diffusion_converges_to_black_scholes() {
1136        // GBM in log space: nu = b - sigma^2/2, terminal S = S0 e^{j dx}
1137        let nu = (R - Q) - 0.5 * SIGMA * SIGMA;
1138        let price_at = |n: usize| {
1139            let dt = T / n as f64;
1140            let dx = SIGMA * (3.0 * dt).sqrt();
1141            let proto = diffusion_branching(nu, SIGMA, dt, dx);
1142            let branching = |_: usize, j: i32| TrinomialBranch { target: j, ..proto };
1143            let lattice = TrinomialLattice::build(n, dt, dx, &branching).unwrap();
1144            let df = (-R * dt).exp();
1145            (
1146                lattice.price(&|_, _| df, &|j| (S * (j as f64 * dx).exp() - K).max(0.0), None),
1147                dx,
1148                lattice,
1149                df,
1150            )
1151        };
1152        let reference = bs_call();
1153        let coarse_err = (price_at(250).0 - reference).abs();
1154        let (call, dx, lattice, df) = price_at(1000);
1155        let fine_err = (call - reference).abs();
1156        assert!(fine_err < 5e-3, "trinomial {call} vs BS {reference}");
1157        // first-order convergence: quadrupling the steps shrinks the error
1158        assert!(
1159            fine_err < coarse_err,
1160            "error must shrink with steps: {fine_err} vs {coarse_err}"
1161        );
1162        let n = 1000usize;
1163        let _ = n;
1164
1165        // American put dominates European on the same tree
1166        let terminal_put = |j: i32| (K - S * (j as f64 * dx).exp()).max(0.0);
1167        let euro = lattice.price(&|_, _| df, &terminal_put, None);
1168        let ex = |_: usize, j: i32, cont: f64| {
1169            (K - S * (j as f64 * dx).exp()).max(0.0).max(cont)
1170        };
1171        let amer = lattice.price(&|_, _| df, &terminal_put, Some(&ex));
1172        assert!(amer >= euro - 1e-12, "american {amer} vs european {euro}");
1173    }
1174
1175    #[test]
1176    fn arrow_debreu_prices_sum_to_the_discount_factor() {
1177        let n = 100;
1178        let dt = 0.01;
1179        let dx = 0.2 * (3.0_f64 * dt).sqrt(); // Kamrad-Ritchken spacing
1180        let proto = diffusion_branching(0.0, 0.2, dt, dx);
1181        let branching = |_: usize, j: i32| TrinomialBranch { target: j, ..proto };
1182        let lattice = TrinomialLattice::build(n, dt, dx, &branching).unwrap();
1183        let df = (-0.05_f64 * dt).exp();
1184        let q = lattice.arrow_debreu(&|_, _| df);
1185        // sum of state prices at layer i = P(0, t_i) under a flat rate
1186        for i in [1usize, 50, 100] {
1187            let total: f64 = q[i].iter().sum();
1188            let expected = (-0.05 * i as f64 * dt).exp();
1189            assert!(
1190                (total - expected).abs() < 1e-12,
1191                "layer {i}: sum {total} vs df {expected}"
1192            );
1193        }
1194    }
1195
1196    #[test]
1197    fn hull_white_tree_reprices_the_vasicek_bond() {
1198        // Vasicek with b = 0, r0 = 0: dr = -a r dt + sigma dW. The tree
1199        // state IS the short rate; per-node discounting e^{-r(i,j) dt}.
1200        // Closed form: P(0,T) = exp(sigma^2 (T - B)/(2 a^2) - sigma^2 B^2/(4a)),
1201        // B = (1 - e^{-aT})/a.
1202        let (a, sigma, t_mat) = (0.10, 0.015, 5.0);
1203        let n = 500;
1204        let dt = t_mat / n as f64;
1205        let dx = sigma * (3.0 * dt).sqrt();
1206        let cap = hull_white_j_cap(a, dt);
1207        let branching = |_: usize, j: i32| hull_white_branching(a, dt, j, cap);
1208        let lattice = TrinomialLattice::build(n, dt, dx, &branching).unwrap();
1209        // the clamp must actually bite for the edge formulas to be used
1210        assert!(lattice.layer_range(n).1 == cap, "edge branching untested");
1211        let node_df = |_: usize, j: i32| (-(j as f64 * dx) * dt).exp();
1212        let tree_price = lattice.price(&node_df, &|_| 1.0, None);
1213
1214        let b_t = (1.0 - (-a * t_mat).exp()) / a;
1215        let closed_form = (sigma * sigma * (t_mat - b_t) / (2.0 * a * a)
1216            - sigma * sigma * b_t * b_t / (4.0 * a))
1217            .exp();
1218        let rel_err = (tree_price - closed_form).abs() / closed_form;
1219        assert!(
1220            rel_err < 1e-3,
1221            "tree {tree_price} vs Vasicek {closed_form} (rel err {rel_err:.2e})"
1222        );
1223
1224        // Arrow-Debreu consistency: state prices reprice the same bond
1225        let q = lattice.arrow_debreu(&node_df);
1226        let via_q: f64 = q[n].iter().sum();
1227        assert!((via_q - tree_price).abs() < 1e-12);
1228    }
1229
1230    #[test]
1231    fn trinomial_rejects_invalid_branching() {
1232        // fat drift on a coarse grid: probabilities leave [0, 1]
1233        let proto = diffusion_branching(5.0, 0.05, 0.5, 0.02);
1234        let branching = |_: usize, j: i32| TrinomialBranch { target: j, ..proto };
1235        let r = TrinomialLattice::build(4, 0.5, 0.02, &branching);
1236        assert!(matches!(r, Err(RustyQLibError::NumericalError(_))), "{r:?}");
1237        // non-recombining target shift
1238        let jumpy = |_: usize, j: i32| TrinomialBranch {
1239            target: j + 2,
1240            p_up: 1.0 / 6.0,
1241            p_mid: 2.0 / 3.0,
1242            p_down: 1.0 / 6.0,
1243        };
1244        let r = TrinomialLattice::build(4, 0.01, 0.02, &jumpy);
1245        assert!(matches!(r, Err(RustyQLibError::NumericalError(_))), "{r:?}");
1246    }
1247
1248    #[test]
1249    fn term_lattice_with_flat_inputs_matches_the_uniform_tree() {
1250        let n = 500;
1251        let flat_rate = |_: f64, _: f64| R;
1252        let flat_carry = |_: f64, _: f64| Q;
1253        let flat_var = |t: f64| SIGMA * SIGMA * t;
1254        let lattice = TermLattice::build(n, T, &flat_rate, &flat_carry, &flat_var).unwrap();
1255        let term = lattice.price(S, &|s| (s - K).max(0.0), None);
1256        // flat inputs reduce to the CRR tree
1257        let params = BinomialTreeType::CoxRossRubinstein.params(S, K, R - Q, SIGMA, T, n).unwrap();
1258        let uniform =
1259            price_backward(S, &params, n, (-R * T / n as f64).exp(), &|s| (s - K).max(0.0), None);
1260        assert!((term - uniform).abs() < 1e-9, "term {term} vs uniform {uniform}");
1261    }
1262
1263    #[test]
1264    fn time_dependent_vol_prices_to_the_equivalent_total_variance() {
1265        // two vol regimes: 20% for the first half-year, 40% after; a
1266        // European option only sees the total variance, so the tree must
1267        // converge to BS at the equivalent vol sqrt((0.2^2 + 0.4^2)/2)
1268        let (s1, s2) = (0.2, 0.4);
1269        let var = move |t: f64| {
1270            if t <= 0.5 { s1 * s1 * t } else { s1 * s1 * 0.5 + s2 * s2 * (t - 0.5) }
1271        };
1272        let sigma_eq = (0.5f64 * (s1 * s1 + s2 * s2)).sqrt();
1273        let flat_rate = |_: f64, _: f64| R;
1274        let flat_carry = |_: f64, _: f64| Q;
1275        let lattice = TermLattice::build(2000, T, &flat_rate, &flat_carry, &var).unwrap();
1276        let term = lattice.price(S, &|s| (s - K).max(0.0), None);
1277        let b = R - Q;
1278        let d1 = ((S / K).ln() + (b + 0.5 * sigma_eq * sigma_eq) * T) / (sigma_eq * T.sqrt());
1279        let d2 = d1 - sigma_eq * T.sqrt();
1280        let bs = S * ((b - R) * T).exp() * norm_cdf(d1) - K * (-R * T).exp() * norm_cdf(d2);
1281        assert!((term - bs).abs() < 5e-3, "term {term} vs BS(sigma_eq) {bs}");
1282    }
1283
1284    #[test]
1285    fn time_dependent_rates_discount_and_drift_exactly() {
1286        // stepwise forward rates: 2% then 8%; the European price must
1287        // match BS with the average rate 5% (exact discounting + drift)
1288        let fwd = |t1: f64, t2: f64| {
1289            let integral = |t: f64| {
1290                if t <= 0.5 { 0.02 * t } else { 0.02 * 0.5 + 0.08 * (t - 0.5) }
1291            };
1292            (integral(t2) - integral(t1)) / (t2 - t1)
1293        };
1294        let flat_carry = |_: f64, _: f64| Q;
1295        let var = |t: f64| SIGMA * SIGMA * t;
1296        let lattice = TermLattice::build(2000, T, &fwd, &flat_carry, &var).unwrap();
1297        let term = lattice.price(S, &|s| (s - K).max(0.0), None);
1298        let r_eq = 0.05;
1299        let b = r_eq - Q;
1300        let d1 = ((S / K).ln() + (b + 0.5 * SIGMA * SIGMA) * T) / (SIGMA * T.sqrt());
1301        let d2 = d1 - SIGMA * T.sqrt();
1302        let bs = S * ((b - r_eq) * T).exp() * norm_cdf(d1) - K * (-r_eq * T).exp() * norm_cdf(d2);
1303        assert!((term - bs).abs() < 5e-3, "term {term} vs BS(r_eq) {bs}");
1304
1305        // American exercise still works and dominates European
1306        let exercise = |_: usize, _: f64, s: f64, cont: f64| (K - s).max(0.0).max(cont);
1307        let amer = lattice.price(S, &|s| (K - s).max(0.0), Some(&exercise));
1308        let euro = lattice.price(S, &|s| (K - s).max(0.0), None);
1309        assert!(amer >= euro - 1e-12, "american {amer} vs european {euro}");
1310    }
1311
1312    #[test]
1313    fn term_lattice_rejects_bad_term_structures() {
1314        let flat_rate = |_: f64, _: f64| R;
1315        let flat_carry = |_: f64, _: f64| Q;
1316        // decreasing total variance = calendar arbitrage
1317        let bad_var = |t: f64| 0.09 * (1.0 - t).max(0.01);
1318        let r = TermLattice::build(100, T, &flat_rate, &flat_carry, &bad_var);
1319        assert!(r.is_err(), "{r:?}");
1320        // giant drift on a coarse grid: probability leaves (0, 1)
1321        let big_rate = |_: f64, _: f64| 5.0;
1322        let var = |t: f64| 0.01 * t;
1323        let r = TermLattice::build(4, T, &big_rate, &flat_carry, &var);
1324        assert!(matches!(r, Err(RustyQLibError::NumericalError(_))), "{r:?}");
1325    }
1326
1327    #[test]
1328    fn tree_type_parses_from_contract_strings() {
1329        use std::str::FromStr;
1330        for (s, expected) in [
1331            ("CRR", BinomialTreeType::CoxRossRubinstein),
1332            ("LeisenReimer", BinomialTreeType::LeisenReimer),
1333            ("lr", BinomialTreeType::LeisenReimer),
1334            ("jarrow_rudd", BinomialTreeType::JarrowRudd),
1335            ("Tian", BinomialTreeType::Tian),
1336            ("trigeorgis", BinomialTreeType::Trigeorgis),
1337            ("EQP", BinomialTreeType::AdditiveEqp),
1338        ] {
1339            assert_eq!(BinomialTreeType::from_str(s).unwrap(), expected);
1340        }
1341        assert!(BinomialTreeType::from_str("no_such_tree").is_err());
1342    }
1343}