Skip to main content

mollendorff_forge/real_options/
binomial.rs

1//! Binomial Tree Option Pricing
2//!
3//! Cox-Ross-Rubinstein binomial model for American and European options.
4//! Supports early exercise and path-dependent features.
5//! Validated against `QuantLib`.
6
7/// Binomial tree model for option pricing
8pub struct BinomialTree {
9    /// Spot price (current value)
10    pub spot: f64,
11    /// Strike price (exercise cost)
12    pub strike: f64,
13    /// Risk-free rate (annual)
14    pub rate: f64,
15    /// Volatility (annual)
16    pub volatility: f64,
17    /// Time to maturity (years)
18    pub maturity: f64,
19    /// Number of time steps
20    pub steps: usize,
21    /// Dividend yield (continuous)
22    pub dividend_yield: f64,
23}
24
25/// Option style (American vs European)
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum OptionStyle {
28    /// Can only exercise at expiry
29    European,
30    /// Can exercise anytime
31    American,
32}
33
34impl BinomialTree {
35    /// Create a new binomial tree model
36    #[must_use]
37    pub const fn new(
38        spot: f64,
39        strike: f64,
40        rate: f64,
41        volatility: f64,
42        maturity: f64,
43        steps: usize,
44    ) -> Self {
45        Self {
46            spot,
47            strike,
48            rate,
49            volatility,
50            maturity,
51            steps,
52            dividend_yield: 0.0,
53        }
54    }
55
56    /// Set dividend yield
57    #[must_use]
58    pub const fn with_dividend_yield(mut self, yield_rate: f64) -> Self {
59        self.dividend_yield = yield_rate;
60        self
61    }
62
63    /// Calculate time step
64    // Truncation is mathematically impossible: steps is bounded by practical tree sizes (< 2^52)
65    #[allow(clippy::cast_precision_loss)]
66    fn dt(&self) -> f64 {
67        self.maturity / self.steps as f64
68    }
69
70    /// Calculate up factor
71    fn up(&self) -> f64 {
72        (self.volatility * self.dt().sqrt()).exp()
73    }
74
75    /// Calculate down factor
76    fn down(&self) -> f64 {
77        1.0 / self.up()
78    }
79
80    /// Calculate risk-neutral probability of up move
81    fn prob_up(&self) -> f64 {
82        let dt = self.dt();
83        let u = self.up();
84        let d = self.down();
85        let growth = ((self.rate - self.dividend_yield) * dt).exp();
86        (growth - d) / (u - d)
87    }
88
89    /// Calculate discount factor per step
90    fn discount(&self) -> f64 {
91        (-self.rate * self.dt()).exp()
92    }
93
94    /// Price a call option
95    #[must_use]
96    pub fn call_price(&self, style: OptionStyle) -> f64 {
97        self.price_option(true, style)
98    }
99
100    /// Price a put option
101    #[must_use]
102    pub fn put_price(&self, style: OptionStyle) -> f64 {
103        self.price_option(false, style)
104    }
105
106    /// General option pricing using backward induction
107    // Truncation is mathematically impossible: step indices are bounded by self.steps (< 2^31)
108    #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
109    fn price_option(&self, is_call: bool, style: OptionStyle) -> f64 {
110        let n = self.steps;
111        let u = self.up();
112        let d = self.down();
113        let p = self.prob_up();
114        let disc = self.discount();
115
116        // Build terminal payoffs
117        let mut prices = vec![0.0; n + 1];
118        for (i, price) in prices.iter_mut().enumerate() {
119            let spot_t = self.spot * u.powi(i as i32) * d.powi((n - i) as i32);
120            *price = if is_call {
121                (spot_t - self.strike).max(0.0)
122            } else {
123                (self.strike - spot_t).max(0.0)
124            };
125        }
126
127        // Backward induction
128        for step in (0..n).rev() {
129            for i in 0..=step {
130                // Continuation value
131                let hold = disc * p.mul_add(prices[i + 1], (1.0 - p) * prices[i]);
132
133                if style == OptionStyle::American {
134                    // Early exercise value
135                    let spot_t = self.spot * u.powi(i as i32) * d.powi((step - i) as i32);
136                    let exercise = if is_call {
137                        (spot_t - self.strike).max(0.0)
138                    } else {
139                        (self.strike - spot_t).max(0.0)
140                    };
141                    prices[i] = hold.max(exercise);
142                } else {
143                    prices[i] = hold;
144                }
145            }
146        }
147
148        prices[0]
149    }
150
151    /// Price a defer option (option to wait)
152    #[must_use]
153    pub fn defer_option_value(&self, max_deferral: f64, exercise_cost: f64) -> f64 {
154        // The defer option is essentially a call option on the project
155        // with the exercise cost as the strike
156        let defer_tree = Self::new(
157            self.spot,
158            exercise_cost,
159            self.rate,
160            self.volatility,
161            max_deferral.min(self.maturity),
162            self.steps,
163        )
164        .with_dividend_yield(self.dividend_yield);
165
166        defer_tree.call_price(OptionStyle::American)
167    }
168
169    /// Price an expand option
170    #[must_use]
171    pub fn expand_option_value(&self, expansion_factor: f64, exercise_cost: f64) -> f64 {
172        // The expand option lets you scale up by expansion_factor
173        // Value = Call on (expansion_factor - 1) * spot, strike = exercise_cost
174        let additional_value = (expansion_factor - 1.0) * self.spot;
175
176        let expand_tree = Self::new(
177            additional_value,
178            exercise_cost,
179            self.rate,
180            self.volatility,
181            self.maturity,
182            self.steps,
183        )
184        .with_dividend_yield(self.dividend_yield);
185
186        expand_tree.call_price(OptionStyle::American)
187    }
188
189    /// Price an abandon option
190    #[must_use]
191    pub fn abandon_option_value(&self, salvage_value: f64) -> f64 {
192        // The abandon option is a put option on the project
193        // with salvage value as the strike
194        let abandon_tree = Self::new(
195            self.spot,
196            salvage_value,
197            self.rate,
198            self.volatility,
199            self.maturity,
200            self.steps,
201        )
202        .with_dividend_yield(self.dividend_yield);
203
204        abandon_tree.put_price(OptionStyle::American)
205    }
206
207    /// Price a contract option
208    #[must_use]
209    pub fn contract_option_value(&self, contraction_factor: f64, cost_savings: f64) -> f64 {
210        // The contract option lets you scale down by contraction_factor
211        // Value = Put on (1 - contraction_factor) * spot, strike = cost_savings
212        let reduction = (1.0 - contraction_factor) * self.spot;
213
214        let contract_tree = Self::new(
215            reduction,
216            cost_savings,
217            self.rate,
218            self.volatility,
219            self.maturity,
220            self.steps,
221        )
222        .with_dividend_yield(self.dividend_yield);
223
224        contract_tree.put_price(OptionStyle::American)
225    }
226
227    /// Get early exercise boundary (for American options)
228    // Truncation is mathematically impossible: step indices are bounded by self.steps (< 2^31)
229    // Truncation is mathematically impossible: step indices are bounded by self.steps (< 2^31)
230    #[allow(
231        clippy::cast_possible_truncation,
232        clippy::cast_possible_wrap,
233        clippy::cast_precision_loss
234    )]
235    #[must_use]
236    pub fn early_exercise_boundary(&self, is_call: bool) -> Vec<(f64, f64)> {
237        let n = self.steps;
238        let dt = self.dt();
239        let u = self.up();
240        let d = self.down();
241        let p = self.prob_up();
242        let disc = self.discount();
243
244        let mut boundary = Vec::new();
245
246        // Build terminal payoffs
247        let mut prices = vec![0.0; n + 1];
248        for (i, price) in prices.iter_mut().enumerate() {
249            let spot_t = self.spot * u.powi(i as i32) * d.powi((n - i) as i32);
250            *price = if is_call {
251                (spot_t - self.strike).max(0.0)
252            } else {
253                (self.strike - spot_t).max(0.0)
254            };
255        }
256
257        // Backward induction with boundary tracking
258        for step in (0..n).rev() {
259            let time = step as f64 * dt;
260            let mut exercise_at = None;
261
262            for i in 0..=step {
263                let hold = disc * p.mul_add(prices[i + 1], (1.0 - p) * prices[i]);
264                let spot_t = self.spot * u.powi(i as i32) * d.powi((step - i) as i32);
265                let exercise = if is_call {
266                    (spot_t - self.strike).max(0.0)
267                } else {
268                    (self.strike - spot_t).max(0.0)
269                };
270
271                if exercise > hold && exercise_at.is_none() {
272                    exercise_at = Some(spot_t);
273                }
274
275                prices[i] = hold.max(exercise);
276            }
277
278            if let Some(spot_boundary) = exercise_at {
279                boundary.push((time, spot_boundary));
280            }
281        }
282
283        boundary
284    }
285}
286
287#[cfg(test)]
288mod binomial_tests {
289    use super::*;
290
291    /// Test convergence to Black-Scholes for European options
292    #[test]
293    fn test_european_convergence() {
294        // With enough steps, binomial should converge to Black-Scholes
295        // BS call ≈ 10.4506 for S=100, K=100, r=5%, σ=20%, T=1
296        let tree = BinomialTree::new(100.0, 100.0, 0.05, 0.20, 1.0, 200);
297        let call = tree.call_price(OptionStyle::European);
298
299        assert!(
300            (call - 10.4506).abs() < 0.1,
301            "European call should converge to BS: got {call}"
302        );
303    }
304
305    #[test]
306    fn test_american_premium() {
307        // American put should be worth more than European put
308        let tree = BinomialTree::new(100.0, 100.0, 0.05, 0.20, 1.0, 100);
309        let euro_put = tree.put_price(OptionStyle::European);
310        let amer_put = tree.put_price(OptionStyle::American);
311
312        assert!(
313            amer_put >= euro_put,
314            "American put should be >= European put"
315        );
316    }
317
318    #[test]
319    fn test_put_call_parity_european() {
320        let tree = BinomialTree::new(100.0, 100.0, 0.05, 0.20, 1.0, 100);
321        let call = tree.call_price(OptionStyle::European);
322        let put = tree.put_price(OptionStyle::European);
323
324        let lhs = call - put;
325        let rhs = 100.0f64.mul_add(-(-0.05_f64).exp(), 100.0);
326
327        assert!((lhs - rhs).abs() < 0.5, "Put-call parity: {lhs} != {rhs}");
328    }
329
330    #[test]
331    fn test_defer_option() {
332        let tree = BinomialTree::new(10_000_000.0, 10_000_000.0, 0.05, 0.30, 3.0, 100);
333        let defer_value = tree.defer_option_value(2.0, 8_000_000.0);
334
335        // Should have positive value (option to wait has value)
336        assert!(defer_value > 0.0, "Defer option should have positive value");
337
338        // For a call with S=10M, K=8M, T=2, σ=30%:
339        // Intrinsic value = 2M (minimum for in-the-money American call)
340        // With time value, should be between intrinsic and spot price
341        assert!(
342            defer_value >= 2_000_000.0,
343            "Defer value should be at least intrinsic value (2M): {defer_value}"
344        );
345        assert!(
346            defer_value < 10_000_000.0,
347            "Defer value should be less than spot price (10M): {defer_value}"
348        );
349    }
350
351    #[test]
352    fn test_abandon_option() {
353        let tree = BinomialTree::new(10_000_000.0, 10_000_000.0, 0.05, 0.30, 3.0, 100);
354        let abandon_value = tree.abandon_option_value(3_000_000.0);
355
356        // Should have positive value
357        assert!(
358            abandon_value > 0.0,
359            "Abandon option should have positive value"
360        );
361    }
362
363    #[test]
364    fn test_expand_option() {
365        let tree = BinomialTree::new(10_000_000.0, 10_000_000.0, 0.05, 0.30, 3.0, 100);
366        let expand_value = tree.expand_option_value(1.5, 4_000_000.0);
367
368        // Should have positive value
369        assert!(
370            expand_value > 0.0,
371            "Expand option should have positive value"
372        );
373    }
374
375    /// Roundtrip validation against `QuantLib`
376    #[test]
377    fn test_quantlib_equivalence() {
378        // QuantLib binomial tree (CRR) reference values:
379        // S=100, K=100, r=5%, σ=20%, T=1, N=100
380        // European call ≈ 10.44
381        // American put ≈ 6.08 (not 5.63 - that's European put)
382
383        let tree = BinomialTree::new(100.0, 100.0, 0.05, 0.20, 1.0, 100);
384
385        let euro_call = tree.call_price(OptionStyle::European);
386        assert!(
387            (euro_call - 10.44).abs() < 0.2,
388            "Euro call should match QuantLib: {euro_call}"
389        );
390
391        let amer_put = tree.put_price(OptionStyle::American);
392        assert!(
393            (amer_put - 6.08).abs() < 0.2,
394            "American put should match QuantLib: {amer_put}"
395        );
396    }
397}