mollendorff_forge/real_options/
binomial.rs1pub struct BinomialTree {
9 pub spot: f64,
11 pub strike: f64,
13 pub rate: f64,
15 pub volatility: f64,
17 pub maturity: f64,
19 pub steps: usize,
21 pub dividend_yield: f64,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum OptionStyle {
28 European,
30 American,
32}
33
34impl BinomialTree {
35 #[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 #[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 #[allow(clippy::cast_precision_loss)]
66 fn dt(&self) -> f64 {
67 self.maturity / self.steps as f64
68 }
69
70 fn up(&self) -> f64 {
72 (self.volatility * self.dt().sqrt()).exp()
73 }
74
75 fn down(&self) -> f64 {
77 1.0 / self.up()
78 }
79
80 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 fn discount(&self) -> f64 {
91 (-self.rate * self.dt()).exp()
92 }
93
94 #[must_use]
96 pub fn call_price(&self, style: OptionStyle) -> f64 {
97 self.price_option(true, style)
98 }
99
100 #[must_use]
102 pub fn put_price(&self, style: OptionStyle) -> f64 {
103 self.price_option(false, style)
104 }
105
106 #[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 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 for step in (0..n).rev() {
129 for i in 0..=step {
130 let hold = disc * p.mul_add(prices[i + 1], (1.0 - p) * prices[i]);
132
133 if style == OptionStyle::American {
134 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 #[must_use]
153 pub fn defer_option_value(&self, max_deferral: f64, exercise_cost: f64) -> f64 {
154 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 #[must_use]
171 pub fn expand_option_value(&self, expansion_factor: f64, exercise_cost: f64) -> f64 {
172 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 #[must_use]
191 pub fn abandon_option_value(&self, salvage_value: f64) -> f64 {
192 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 #[must_use]
209 pub fn contract_option_value(&self, contraction_factor: f64, cost_savings: f64) -> f64 {
210 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 #[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 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 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]
293 fn test_european_convergence() {
294 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 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 assert!(defer_value > 0.0, "Defer option should have positive value");
337
338 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 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 assert!(
370 expand_value > 0.0,
371 "Expand option should have positive value"
372 );
373 }
374
375 #[test]
377 fn test_quantlib_equivalence() {
378 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}