Skip to main content

finance_solution/derivatives/
crr.rs

1//! # Cox–Ross–Rubinstein (CRR) binomial tree
2//!
3//! Discrete multiperiod tree for **European and American** equity-style options
4//! under continuous rates and continuous dividend yield (same \((S,K,T,r,q,\sigma)\)
5//! language as BSM).
6//!
7//! ---
8//!
9//! ## Trading perspective
10//!
11//! | Question | Tree answer |
12//! |----------|-------------|
13//! | Fair value with **early exercise**? | American put (and call when \(q>0\)) |
14//! | How far from European BSM? | Early-exercise premium = American − European tree |
15//! | What IV is the **mid** implying (American)? | [`american_implied_vol`] |
16//! | Teaching / audit? | [`crr_solution`] + node table for small \(N\) |
17//!
18//! European CRR → BSM as \(N\to\infty\) (within model assumptions).
19//!
20//! ---
21//!
22//! ## Engineering perspective
23//!
24//! - Hot path: [`crr_price`] with fixed [`CrrParams`] (steps often 100–500).  
25//! - Greeks: tree Δ/Γ from first-step nodes; vega via bump (finite difference).  
26//! - Teaching: [`crr_solution::print_table`] prints **layer 0…min(N, max_print)** node values.  
27//! - Cost: \(O(N^2)\) nodes — not nanosecond; use closed form for European when valid.  
28//! - Risk-neutral \(p^*\) must lie in \([0,1]\); otherwise [`crr_price`] returns
29//!   [`FinanceError::Unsolvable`] (do not silently clamp).
30//!
31//! ## CRR mechanics
32//!
33//! ```text
34//! Δt = T/N
35//! u  = exp(σ √Δt),  d = 1/u
36//! p* = (e^{(r−q)Δt} − d) / (u − d)     risk-neutral up probability
37//! disc = e^{−r Δt}
38//!
39//! Terminal: max(S u^j d^{N−j} − K, 0)  (call) / put dual
40//! Backward: cont = disc [ p* V_up + (1−p*) V_down ]
41//! American: V = max(exercise, cont)
42//! ```
43//!
44//! ## Word problem
45//!
46//! > S=K=100, T=1, r=5%, q=0, σ=20%, N=100. American put vs European put?
47//!
48//! American ≥ European; early-exercise premium often small but positive for puts.
49//!
50//! ```
51//! use finance_solution::derivatives::{
52//!     crr_price, CrrParams, ExerciseStyle, OptionType,
53//! };
54//! let p = CrrParams::new(100.0, 100.0, 1.0, 0.05, 0.0, 0.20, 100, ExerciseStyle::American);
55//! let am = crr_price(p, OptionType::Put).unwrap();
56//! let eu = crr_price(p.with_style(ExerciseStyle::European), OptionType::Put).unwrap();
57//! assert!(am + 1e-12 >= eu);
58//! ```
59
60use crate::derivatives::types::OptionType;
61use crate::util::error::{require_finite, FinanceError, FinanceResult};
62use crate::{columns_with_strings, print_table_locale_opt};
63
64/// European vs American exercise at each node.
65#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
66pub enum ExerciseStyle {
67    European,
68    American,
69}
70
71impl ExerciseStyle {
72    pub fn is_american(self) -> bool {
73        matches!(self, ExerciseStyle::American)
74    }
75}
76
77/// CRR tree inputs (equity-style continuous \(q\)).
78#[derive(Clone, Copy, Debug, PartialEq)]
79pub struct CrrParams {
80    pub spot: f64,
81    pub strike: f64,
82    pub time_years: f64,
83    pub rate: f64,
84    pub dividend_yield: f64,
85    pub vol: f64,
86    /// Number of time steps \(N \ge 1\).
87    pub steps: usize,
88    pub style: ExerciseStyle,
89}
90
91impl CrrParams {
92    #[allow(clippy::too_many_arguments)]
93    pub const fn new(
94        spot: f64,
95        strike: f64,
96        time_years: f64,
97        rate: f64,
98        dividend_yield: f64,
99        vol: f64,
100        steps: usize,
101        style: ExerciseStyle,
102    ) -> Self {
103        Self {
104            spot,
105            strike,
106            time_years,
107            rate,
108            dividend_yield,
109            vol,
110            steps,
111            style,
112        }
113    }
114
115    pub fn with_style(mut self, style: ExerciseStyle) -> Self {
116        self.style = style;
117        self
118    }
119
120    /// ATM one-year fixture.
121    pub const fn atm_one_year(
122        spot: f64,
123        rate: f64,
124        vol: f64,
125        steps: usize,
126        style: ExerciseStyle,
127    ) -> Self {
128        Self::new(spot, spot, 1.0, rate, 0.0, vol, steps, style)
129    }
130}
131
132/// Tree first-order risk (Δ/Γ from nodes; vega bumped).
133#[derive(Clone, Copy, Debug, PartialEq)]
134pub struct CrrGreeks {
135    pub delta: f64,
136    pub gamma: f64,
137    /// Finite-difference ∂V/∂σ per +1.0 absolute vol.
138    pub vega: f64,
139}
140
141impl CrrGreeks {
142    #[inline]
143    pub fn vega_per_vol_point(self) -> f64 {
144        self.vega / 100.0
145    }
146}
147
148/// One node for teaching tables.
149#[derive(Clone, Copy, Debug, PartialEq)]
150pub struct CrrNode {
151    pub step: usize,
152    pub up_moves: usize,
153    pub stock: f64,
154    pub option: f64,
155    pub exercise: f64,
156    pub continuation: f64,
157    /// True if American and exercise > continuation (within eps).
158    pub early_exercise: bool,
159}
160
161/// Full teaching solution.
162#[derive(Clone, Debug)]
163pub struct CrrSolution {
164    pub option_type: OptionType,
165    pub params: CrrParams,
166    pub price: f64,
167    pub greeks: CrrGreeks,
168    /// All nodes when `steps <= print_cap` at build; else empty (use price only).
169    pub nodes: Vec<CrrNode>,
170    formula: String,
171    symbolic_formula: String,
172}
173
174impl CrrSolution {
175    pub fn formula(&self) -> &str {
176        &self.formula
177    }
178    pub fn symbolic_formula(&self) -> &str {
179        &self.symbolic_formula
180    }
181
182    /// Print node table (empty if tree was large and nodes not retained).
183    pub fn print_table(&self) {
184        self.print_table_locale_opt(None, None);
185    }
186
187    pub fn print_table_locale(&self, locale: &num_format::Locale, precision: usize) {
188        self.print_table_locale_opt(Some(locale), Some(precision));
189    }
190
191    fn print_table_locale_opt(
192        &self,
193        locale: Option<&num_format::Locale>,
194        precision: Option<usize>,
195    ) {
196        if self.nodes.is_empty() {
197            println!(
198                "(no node table: steps={} > capture cap; price={:.6})",
199                self.params.steps, self.price
200            );
201            return;
202        }
203        let columns = columns_with_strings(&[
204            ("step", "i", true),
205            ("ups", "i", true),
206            ("stock", "f", true),
207            ("option", "f", true),
208            ("exercise", "f", true),
209            ("cont", "f", true),
210            ("early", "s", true),
211        ]);
212        let data = self
213            .nodes
214            .iter()
215            .map(|n| {
216                vec![
217                    n.step.to_string(),
218                    n.up_moves.to_string(),
219                    n.stock.to_string(),
220                    n.option.to_string(),
221                    n.exercise.to_string(),
222                    n.continuation.to_string(),
223                    if n.early_exercise { "Y" } else { "" }.to_string(),
224                ]
225            })
226            .collect();
227        print_table_locale_opt(&columns, data, locale, precision);
228    }
229}
230
231/// Validated CRR pack.
232#[derive(Clone, Copy, Debug, PartialEq)]
233pub struct ValidatedCrr {
234    params: CrrParams,
235}
236
237impl ValidatedCrr {
238    pub fn new(params: CrrParams) -> FinanceResult<Self> {
239        validate_crr_params(params)?;
240        Ok(Self { params })
241    }
242
243    pub fn params(self) -> CrrParams {
244        self.params
245    }
246
247    pub fn price(self, option_type: OptionType) -> FinanceResult<f64> {
248        crr_price(self.params, option_type)
249    }
250
251    pub fn greeks(self, option_type: OptionType) -> FinanceResult<CrrGreeks> {
252        crr_greeks(self.params, option_type)
253    }
254}
255
256/// CRR option price.
257pub fn crr_price(params: CrrParams, option_type: OptionType) -> FinanceResult<f64> {
258    validate_crr_params(params)?;
259    ensure_risk_neutral_prob(params)?;
260    Ok(price_unchecked(params, option_type).0)
261}
262
263/// Tree Δ/Γ + FD vega.
264pub fn crr_greeks(params: CrrParams, option_type: OptionType) -> FinanceResult<CrrGreeks> {
265    validate_crr_params(params)?;
266    ensure_risk_neutral_prob(params)?;
267    Ok(greeks_unchecked(params, option_type))
268}
269
270/// Teaching solution; retains nodes when `steps <= 12` (readable table).
271pub fn crr_solution(params: CrrParams, option_type: OptionType) -> FinanceResult<CrrSolution> {
272    validate_crr_params(params)?;
273    ensure_risk_neutral_prob(params)?;
274    let capture = params.steps <= 12;
275    let (price, nodes) = if capture {
276        let (px, nd) = price_with_nodes(params, option_type, true);
277        (px, nd)
278    } else {
279        (price_unchecked(params, option_type).0, Vec::new())
280    };
281    let greeks = greeks_unchecked(params, option_type);
282    let style = match params.style {
283        ExerciseStyle::European => "European",
284        ExerciseStyle::American => "American",
285    };
286    let formula = format!(
287        "{option_type} CRR {style} S={} K={} T={} r={} q={} σ={} N={} → {:.6}",
288        params.spot,
289        params.strike,
290        params.time_years,
291        params.rate,
292        params.dividend_yield,
293        params.vol,
294        params.steps,
295        price
296    );
297    let symbolic =
298        "u=e^{σ√Δt}, d=1/u, p*=(e^{(r-q)Δt}-d)/(u-d); V=max(exercise, disc·E*[V]) American"
299            .to_string();
300    Ok(CrrSolution {
301        option_type,
302        params,
303        price,
304        greeks,
305        nodes,
306        formula,
307        symbolic_formula: symbolic,
308    })
309}
310
311/// American (or European) implied vol via Newton on CRR price + FD vega.
312///
313/// Uses the same Newton + bisection pattern as European closed-form IV.
314pub fn tree_implied_vol(
315    params: CrrParams,
316    option_type: OptionType,
317    market_price: f64,
318) -> FinanceResult<f64> {
319    validate_crr_params(params)?;
320    ensure_risk_neutral_prob(params)?;
321    // Note: IV path varies σ; intermediate probes may briefly leave the p* interval and
322    // use a defensive clamp inside the tree. Extreme market_price fails the root finder.
323    require_finite("market_price", market_price)?;
324    if market_price < 0.0 {
325        return Err(FinanceError::Unsolvable {
326            message: "market_price must be non-negative",
327        });
328    }
329    if params.time_years == 0.0 {
330        return Err(FinanceError::Unsolvable {
331            message: "implied vol undefined at expiry (T=0)",
332        });
333    }
334    // Intrinsic floor for American (and European at T>0 can be above discounted intrinsic)
335    let intrinsic = match option_type {
336        OptionType::Call => (params.spot - params.strike).max(0.0),
337        OptionType::Put => (params.strike - params.spot).max(0.0),
338    };
339    if market_price + 1e-12 < intrinsic && params.style.is_american() {
340        return Err(FinanceError::Unsolvable {
341            message: "market_price below American intrinsic floor",
342        });
343    }
344
345    crate::derivatives::implied_vol::solve_implied_vol(
346        market_price,
347        |sigma| {
348            let mut p = params;
349            p.vol = sigma;
350            price_unchecked(p, option_type).0
351        },
352        |sigma| {
353            let mut p = params;
354            p.vol = sigma;
355            greeks_unchecked(p, option_type).vega
356        },
357    )
358}
359
360/// Alias: American IV when style is American (any style works).
361pub fn american_implied_vol(
362    params: CrrParams,
363    option_type: OptionType,
364    market_price: f64,
365) -> FinanceResult<f64> {
366    tree_implied_vol(params, option_type, market_price)
367}
368
369fn validate_crr_params(p: CrrParams) -> FinanceResult<()> {
370    require_finite("spot", p.spot)?;
371    require_finite("strike", p.strike)?;
372    require_finite("time_years", p.time_years)?;
373    require_finite("rate", p.rate)?;
374    require_finite("dividend_yield", p.dividend_yield)?;
375    require_finite("vol", p.vol)?;
376    if p.spot <= 0.0 || p.strike <= 0.0 {
377        return Err(FinanceError::InvalidCashflow {
378            message: "spot and strike must be strictly positive",
379        });
380    }
381    if p.time_years < 0.0 {
382        return Err(FinanceError::Unsolvable {
383            message: "time_years must be non-negative",
384        });
385    }
386    if p.vol < 0.0 {
387        return Err(FinanceError::Unsolvable {
388            message: "vol must be non-negative",
389        });
390    }
391    if p.steps == 0 {
392        return Err(FinanceError::Unsolvable {
393            message: "CRR steps must be >= 1",
394        });
395    }
396    Ok(())
397}
398
399/// Risk-neutral up probability must lie in \[0, 1\] for the CRR step size.
400fn ensure_risk_neutral_prob(p: CrrParams) -> FinanceResult<()> {
401    if p.time_years == 0.0 || p.vol == 0.0 {
402        return Ok(());
403    }
404    let dt = p.time_years / p.steps as f64;
405    let u = (p.vol * dt.sqrt()).exp();
406    let d = 1.0 / u;
407    let a = ((p.rate - p.dividend_yield) * dt).exp();
408    let denom = u - d;
409    if denom <= 0.0 {
410        return Err(FinanceError::Unsolvable {
411            message: "CRR up/down factors degenerate (check vol and steps)",
412        });
413    }
414    let p_star = (a - d) / denom;
415    if !(0.0..=1.0).contains(&p_star) {
416        return Err(FinanceError::Unsolvable {
417            message: "CRR risk-neutral probability outside [0, 1]; reduce steps or check r,q,σ,T",
418        });
419    }
420    Ok(())
421}
422
423/// Tree layers used for price and finite-difference-free Δ/Γ.
424struct TreeResult {
425    price: f64,
426    /// Option values at step 1: (0 ups / down node, 1 up).
427    step1: Option<(f64, f64)>,
428    /// Option values at step 2: (0, 1, 2 ups).
429    step2: Option<(f64, f64, f64)>,
430    /// CRR up factor for this tree (needed for stock spacing in Δ/Γ).
431    u: f64,
432    d: f64,
433    nodes: Vec<CrrNode>,
434}
435
436/// Returns (price, dummy triple for call sites that only need price.0).
437fn price_unchecked(p: CrrParams, option_type: OptionType) -> (f64, (f64, f64, f64)) {
438    let tr = tree_core_full(p, option_type, false);
439    let trip = match tr.step1 {
440        Some((dn, up)) => (up, tr.price, dn),
441        None => (tr.price, tr.price, tr.price),
442    };
443    (tr.price, trip)
444}
445
446fn price_with_nodes(p: CrrParams, option_type: OptionType, capture: bool) -> (f64, Vec<CrrNode>) {
447    let tr = tree_core_full(p, option_type, capture);
448    (tr.price, tr.nodes)
449}
450
451fn greeks_unchecked(p: CrrParams, option_type: OptionType) -> CrrGreeks {
452    if p.time_years == 0.0 || p.steps == 0 {
453        let delta = match option_type {
454            OptionType::Call => {
455                if p.spot > p.strike {
456                    1.0
457                } else if p.spot < p.strike {
458                    0.0
459                } else {
460                    0.5
461                }
462            }
463            OptionType::Put => {
464                if p.spot < p.strike {
465                    -1.0
466                } else if p.spot > p.strike {
467                    0.0
468                } else {
469                    -0.5
470                }
471            }
472        };
473        return CrrGreeks {
474            delta,
475            gamma: 0.0,
476            vega: 0.0,
477        };
478    }
479
480    let tr = tree_core_full(p, option_type, false);
481    let (delta, gamma) = match tr.step1 {
482        Some((v_dn, v_up)) if (tr.u - tr.d).abs() > 1e-14 => {
483            let s_up = p.spot * tr.u;
484            let s_dn = p.spot * tr.d;
485            let delta = (v_up - v_dn) / (s_up - s_dn);
486            let gamma = match tr.step2 {
487                Some((v_dd, v_ud, v_uu)) if p.steps >= 2 => {
488                    let s_uu = p.spot * tr.u * tr.u;
489                    let s_ud = p.spot * tr.u * tr.d;
490                    let s_dd = p.spot * tr.d * tr.d;
491                    if (s_uu - s_ud).abs() > 1e-14 && (s_ud - s_dd).abs() > 1e-14 {
492                        let d_u = (v_uu - v_ud) / (s_uu - s_ud);
493                        let d_d = (v_ud - v_dd) / (s_ud - s_dd);
494                        (d_u - d_d) / (0.5 * (s_uu - s_dd))
495                    } else {
496                        0.0
497                    }
498                }
499                _ => 0.0,
500            };
501            (delta, gamma)
502        }
503        _ => (0.0, 0.0),
504    };
505
506    let h = (p.vol * 0.01).max(1e-4);
507    let mut p_up = p;
508    p_up.vol = p.vol + h;
509    let mut p_dn = p;
510    p_dn.vol = (p.vol - h).max(1e-8);
511    let v_sigma_up = price_unchecked(p_up, option_type).0;
512    let v_sigma_dn = price_unchecked(p_dn, option_type).0;
513    let vega = (v_sigma_up - v_sigma_dn) / (p_up.vol - p_dn.vol);
514
515    CrrGreeks { delta, gamma, vega }
516}
517
518fn payoff(s: f64, k: f64, option_type: OptionType) -> f64 {
519    match option_type {
520        OptionType::Call => (s - k).max(0.0),
521        OptionType::Put => (k - s).max(0.0),
522    }
523}
524
525/// Build CRR tree. Captures step-1 / step-2 option values from the **same** tree
526/// (critical for N=1 Δ and for Γ). Does **not** reprice independent sub-trees.
527fn tree_core_full(p: CrrParams, option_type: OptionType, capture: bool) -> TreeResult {
528    let n = p.steps;
529    if p.time_years == 0.0 {
530        let px = payoff(p.spot, p.strike, option_type);
531        return TreeResult {
532            price: px,
533            step1: None,
534            step2: None,
535            u: 1.0,
536            d: 1.0,
537            nodes: Vec::new(),
538        };
539    }
540    if p.vol == 0.0 {
541        let f = p.spot * ((p.rate - p.dividend_yield) * p.time_years).exp();
542        let disc = (-p.rate * p.time_years).exp();
543        let px = disc * payoff(f, p.strike, option_type);
544        return TreeResult {
545            price: px,
546            step1: None,
547            step2: None,
548            u: 1.0,
549            d: 1.0,
550            nodes: Vec::new(),
551        };
552    }
553
554    let dt = p.time_years / n as f64;
555    let u = (p.vol * dt.sqrt()).exp();
556    let d = 1.0 / u;
557    let a = ((p.rate - p.dividend_yield) * dt).exp();
558    let denom = u - d;
559    // Caller should have run ensure_risk_neutral_prob; keep a defensive clamp only
560    // for IV intermediate probes that may briefly leave the interval.
561    let p_star = if denom <= 0.0 {
562        0.5
563    } else {
564        ((a - d) / denom).clamp(0.0, 1.0)
565    };
566    let disc = (-p.rate * dt).exp();
567    let q_star = 1.0 - p_star;
568
569    // Terminal (step n): index j = number of up moves
570    let mut v: Vec<f64> = (0..=n)
571        .map(|j| {
572            let s = p.spot * u.powi(j as i32) * d.powi((n - j) as i32);
573            payoff(s, p.strike, option_type)
574        })
575        .collect();
576
577    let mut nodes = Vec::new();
578    if capture {
579        for j in 0..=n {
580            let s = p.spot * u.powi(j as i32) * d.powi((n - j) as i32);
581            let ex = payoff(s, p.strike, option_type);
582            nodes.push(CrrNode {
583                step: n,
584                up_moves: j,
585                stock: s,
586                option: v[j],
587                exercise: ex,
588                continuation: v[j],
589                early_exercise: false,
590            });
591        }
592    }
593
594    // When N=1 terminal *is* step 1; when N=2 terminal *is* step 2.
595    let mut step1 = if n == 1 && v.len() >= 2 {
596        Some((v[0], v[1]))
597    } else {
598        None
599    };
600    let mut step2 = if n == 2 && v.len() >= 3 {
601        Some((v[0], v[1], v[2]))
602    } else {
603        None
604    };
605
606    for step in (0..n).rev() {
607        let mut next = vec![0.0; step + 1];
608        for j in 0..=step {
609            let s = p.spot * u.powi(j as i32) * d.powi((step - j) as i32);
610            let cont = disc * (p_star * v[j + 1] + q_star * v[j]);
611            let ex = payoff(s, p.strike, option_type);
612            let val = match p.style {
613                ExerciseStyle::European => cont,
614                ExerciseStyle::American => cont.max(ex),
615            };
616            next[j] = val;
617            if capture {
618                nodes.push(CrrNode {
619                    step,
620                    up_moves: j,
621                    stock: s,
622                    option: val,
623                    exercise: ex,
624                    continuation: cont,
625                    early_exercise: p.style.is_american() && ex > cont + 1e-12,
626                });
627            }
628        }
629        v = next;
630        if step == 1 && v.len() >= 2 {
631            step1 = Some((v[0], v[1]));
632        }
633        if step == 2 && v.len() >= 3 {
634            step2 = Some((v[0], v[1], v[2]));
635        }
636    }
637
638    let price = v[0];
639    if capture {
640        nodes.sort_by(|a, b| a.step.cmp(&b.step).then(a.up_moves.cmp(&b.up_moves)));
641    }
642    TreeResult {
643        price,
644        step1,
645        step2,
646        u,
647        d,
648        nodes,
649    }
650}
651
652#[cfg(test)]
653mod tests {
654    use super::*;
655    use crate::derivatives::black_scholes::bsm_price;
656    use crate::derivatives::types::BsmParams;
657
658    #[test]
659    fn european_near_bsm() {
660        let p = CrrParams::atm_one_year(100.0, 0.05, 0.20, 500, ExerciseStyle::European);
661        let tree = crr_price(p, OptionType::Call).unwrap();
662        let bsm = bsm_price(BsmParams::atm_one_year(100.0, 0.05, 0.20), OptionType::Call).unwrap();
663        assert!((tree - bsm).abs() < 0.05, "tree={tree} bsm={bsm}");
664    }
665
666    #[test]
667    fn american_put_ge_european() {
668        let base = CrrParams::new(
669            100.0,
670            100.0,
671            1.0,
672            0.05,
673            0.0,
674            0.25,
675            100,
676            ExerciseStyle::American,
677        );
678        let am = crr_price(base, OptionType::Put).unwrap();
679        let eu = crr_price(base.with_style(ExerciseStyle::European), OptionType::Put).unwrap();
680        assert!(am + 1e-9 >= eu, "am={am} eu={eu}");
681    }
682
683    #[test]
684    fn american_call_q0_near_european() {
685        // With q=0, American call ≈ European (no early exercise optimally)
686        let base = CrrParams::atm_one_year(100.0, 0.05, 0.2, 80, ExerciseStyle::American);
687        let am = crr_price(base, OptionType::Call).unwrap();
688        let eu = crr_price(base.with_style(ExerciseStyle::European), OptionType::Call).unwrap();
689        assert!((am - eu).abs() < 1e-6);
690    }
691
692    #[test]
693    fn american_call_with_dividend_ge_european() {
694        // q > 0: American call can exceed European
695        let base = CrrParams::new(
696            100.0,
697            100.0,
698            1.0,
699            0.05,
700            0.08,
701            0.25,
702            120,
703            ExerciseStyle::American,
704        );
705        let am = crr_price(base, OptionType::Call).unwrap();
706        let eu = crr_price(base.with_style(ExerciseStyle::European), OptionType::Call).unwrap();
707        assert!(am + 1e-9 >= eu, "am={am} eu={eu}");
708    }
709
710    #[test]
711    fn iv_round_trip_american_put() {
712        let p = CrrParams::new(
713            100.0,
714            100.0,
715            1.0,
716            0.05,
717            0.0,
718            0.30,
719            80,
720            ExerciseStyle::American,
721        );
722        let mkt = crr_price(p, OptionType::Put).unwrap();
723        let iv = american_implied_vol(p, OptionType::Put, mkt).unwrap();
724        assert!((iv - 0.30).abs() < 1e-3, "iv={iv}");
725    }
726
727    #[test]
728    fn solution_nodes_small_n() {
729        let p = CrrParams::atm_one_year(100.0, 0.05, 0.2, 3, ExerciseStyle::American);
730        let sol = crr_solution(p, OptionType::Put).unwrap();
731        assert!(!sol.nodes.is_empty());
732        assert!(sol.price > 0.0);
733        // Node count = sum_{k=0}^{N} (k+1) = (N+1)(N+2)/2
734        assert_eq!(sol.nodes.len(), (3 + 1) * (3 + 2) / 2);
735    }
736
737    #[test]
738    fn rejects_zero_steps() {
739        let mut p = CrrParams::atm_one_year(100.0, 0.05, 0.2, 1, ExerciseStyle::European);
740        p.steps = 0;
741        assert!(crr_price(p, OptionType::Call).is_err());
742    }
743
744    #[test]
745    fn n1_delta_not_zero_for_otm_put() {
746        // Regression: N=1 used to leave step-1 values at 0 → delta = 0 wrongly.
747        let p = CrrParams::new(
748            100.0,
749            100.0,
750            1.0,
751            0.05,
752            0.0,
753            0.25,
754            1,
755            ExerciseStyle::European,
756        );
757        let g = crr_greeks(p, OptionType::Put).unwrap();
758        assert!(
759            g.delta < -0.05 && g.delta > -1.0,
760            "N=1 put delta should be meaningfully negative, got {}",
761            g.delta
762        );
763        assert_eq!(g.gamma, 0.0); // need N>=2 for tree gamma
764    }
765
766    #[test]
767    fn delta_matches_finite_difference() {
768        let p = CrrParams::atm_one_year(100.0, 0.05, 0.2, 80, ExerciseStyle::European);
769        let g = crr_greeks(p, OptionType::Call).unwrap();
770        let h = 0.05;
771        let mut up = p;
772        up.spot += h;
773        let mut dn = p;
774        dn.spot -= h;
775        let fd = (crr_price(up, OptionType::Call).unwrap()
776            - crr_price(dn, OptionType::Call).unwrap())
777            / (2.0 * h);
778        assert!(
779            (g.delta - fd).abs() < 0.02,
780            "tree delta {} vs fd {}",
781            g.delta,
782            fd
783        );
784    }
785
786    #[test]
787    fn gamma_nonnegative_call() {
788        let p = CrrParams::atm_one_year(100.0, 0.05, 0.25, 60, ExerciseStyle::European);
789        let g = crr_greeks(p, OptionType::Call).unwrap();
790        assert!(g.gamma >= -1e-8, "gamma={}", g.gamma);
791    }
792
793    #[test]
794    fn expiry_is_intrinsic() {
795        let p = CrrParams::new(
796            110.0,
797            100.0,
798            0.0,
799            0.05,
800            0.0,
801            0.2,
802            10,
803            ExerciseStyle::American,
804        );
805        assert!((crr_price(p, OptionType::Call).unwrap() - 10.0).abs() < 1e-12);
806        assert!(crr_price(p, OptionType::Put).unwrap().abs() < 1e-12);
807    }
808
809    #[test]
810    fn american_iv_rejects_below_intrinsic() {
811        let p = CrrParams::new(
812            90.0,
813            100.0,
814            0.5,
815            0.05,
816            0.0,
817            0.2,
818            40,
819            ExerciseStyle::American,
820        );
821        // Intrinsic put = 10; mid 5 is impossible
822        assert!(american_implied_vol(p, OptionType::Put, 5.0).is_err());
823    }
824
825    #[test]
826    fn large_n_solution_omits_nodes() {
827        let p = CrrParams::atm_one_year(100.0, 0.05, 0.2, 50, ExerciseStyle::European);
828        let sol = crr_solution(p, OptionType::Call).unwrap();
829        assert!(sol.nodes.is_empty());
830        assert!(sol.price > 0.0);
831    }
832
833    #[test]
834    fn rejects_impossible_risk_neutral_prob() {
835        // Huge rate vs tiny vol and few steps can push p* outside [0,1]
836        let p = CrrParams::new(
837            100.0,
838            100.0,
839            1.0,
840            5.0, // absurd continuous rate
841            0.0,
842            0.01,
843            2,
844            ExerciseStyle::European,
845        );
846        assert!(crr_price(p, OptionType::Call).is_err());
847    }
848}