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