1use crate::derivatives::types::OptionType;
61use crate::util::error::{require_finite, FinanceError, FinanceResult};
62use crate::{columns_with_strings, print_table_locale_opt};
63
64#[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#[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 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 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#[derive(Clone, Copy, Debug, PartialEq)]
134pub struct CrrGreeks {
135 pub delta: f64,
136 pub gamma: f64,
137 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#[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 pub early_exercise: bool,
159}
160
161#[derive(Clone, Debug)]
163pub struct CrrSolution {
164 pub option_type: OptionType,
165 pub params: CrrParams,
166 pub price: f64,
167 pub greeks: CrrGreeks,
168 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 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#[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
256pub 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
263pub 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
270pub 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
311pub 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 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 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
360pub 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
399fn 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
423struct TreeResult {
425 price: f64,
426 step1: Option<(f64, f64)>,
428 step2: Option<(f64, f64, f64)>,
430 u: f64,
432 d: f64,
433 nodes: Vec<CrrNode>,
434}
435
436fn 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
525fn 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 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 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 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 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 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 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 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); }
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 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 let p = CrrParams::new(
837 100.0,
838 100.0,
839 1.0,
840 5.0, 0.0,
842 0.01,
843 2,
844 ExerciseStyle::European,
845 );
846 assert!(crr_price(p, OptionType::Call).is_err());
847 }
848}