1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum OptionKind {
9 Call,
10 Put,
11}
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum OptionStyle {
15 European,
16 American,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum DerivativesError {
21 InvalidInput,
22 InvalidSteps,
23 NonRecombiningTree,
24}
25
26#[repr(C)]
27#[derive(Debug, Clone, Copy, PartialEq)]
28pub struct BlackScholesResult {
29 pub price: f64,
30 pub delta: f64,
31 pub gamma: f64,
32 pub vega: f64,
33 pub theta: f64,
34 pub rho: f64,
35}
36
37pub const MAX_BINOMIAL_STEPS: usize = 1024;
38
39fn finite_positive(x: f64) -> bool {
40 x.is_finite() && x > 0.0
41}
42
43fn finite_nonnegative(x: f64) -> bool {
44 x.is_finite() && x >= 0.0
45}
46
47fn validate_option_inputs(
48 spot: f64,
49 strike: f64,
50 risk_free_rate: f64,
51 dividend_yield: f64,
52 volatility: f64,
53 time_years: f64,
54) -> Result<(), DerivativesError> {
55 if !finite_positive(spot)
56 || !finite_positive(strike)
57 || !risk_free_rate.is_finite()
58 || !dividend_yield.is_finite()
59 || !finite_positive(volatility)
60 || !finite_nonnegative(time_years)
61 {
62 return Err(DerivativesError::InvalidInput);
63 }
64 Ok(())
65}
66
67fn payoff(kind: OptionKind, underlying: f64, strike: f64) -> f64 {
68 match kind {
69 OptionKind::Call => (underlying - strike).max(0.0),
70 OptionKind::Put => (strike - underlying).max(0.0),
71 }
72}
73
74fn normal_pdf(x: f64) -> f64 {
75 const INV_SQRT_2PI: f64 = 0.398_942_280_401_432_7;
76 INV_SQRT_2PI * (-0.5 * x * x).exp()
77}
78
79pub fn normal_cdf(x: f64) -> f64 {
81 if x >= 8.0 {
82 return 1.0;
83 }
84 if x <= -8.0 {
85 return 0.0;
86 }
87
88 let z = x.abs();
89 let t = 1.0 / (1.0 + 0.231_641_9 * z);
90 let poly = (((((1.330_274_429 * t - 1.821_255_978) * t) + 1.781_477_937) * t - 0.356_563_782)
91 * t
92 + 0.319_381_530)
93 * t;
94 let cdf = 1.0 - normal_pdf(z) * poly;
95 if x >= 0.0 {
96 cdf
97 } else {
98 1.0 - cdf
99 }
100}
101
102pub fn black_scholes_price_and_greeks(
107 kind: OptionKind,
108 spot: f64,
109 strike: f64,
110 risk_free_rate: f64,
111 dividend_yield: f64,
112 volatility: f64,
113 time_years: f64,
114) -> Result<BlackScholesResult, DerivativesError> {
115 validate_option_inputs(
116 spot,
117 strike,
118 risk_free_rate,
119 dividend_yield,
120 volatility,
121 time_years,
122 )?;
123
124 if time_years == 0.0 {
125 return Ok(BlackScholesResult {
126 price: payoff(kind, spot, strike),
127 delta: match kind {
128 OptionKind::Call if spot > strike => 1.0,
129 OptionKind::Call => 0.0,
130 OptionKind::Put if spot < strike => -1.0,
131 OptionKind::Put => 0.0,
132 },
133 gamma: 0.0,
134 vega: 0.0,
135 theta: 0.0,
136 rho: 0.0,
137 });
138 }
139
140 let sqrt_t = time_years.sqrt();
141 let sigma_sqrt_t = volatility * sqrt_t;
142 let d1 = ((spot / strike).ln()
143 + (risk_free_rate - dividend_yield + 0.5 * volatility * volatility) * time_years)
144 / sigma_sqrt_t;
145 let d2 = d1 - sigma_sqrt_t;
146 let discount_r = (-risk_free_rate * time_years).exp();
147 let discount_q = (-dividend_yield * time_years).exp();
148 let nd1 = normal_cdf(d1);
149 let nd2 = normal_cdf(d2);
150 let pdf_d1 = normal_pdf(d1);
151
152 let common_theta = -(spot * discount_q * pdf_d1 * volatility) / (2.0 * sqrt_t);
153 let result = match kind {
154 OptionKind::Call => BlackScholesResult {
155 price: spot * discount_q * nd1 - strike * discount_r * nd2,
156 delta: discount_q * nd1,
157 gamma: discount_q * pdf_d1 / (spot * sigma_sqrt_t),
158 vega: spot * discount_q * pdf_d1 * sqrt_t,
159 theta: common_theta - risk_free_rate * strike * discount_r * nd2
160 + dividend_yield * spot * discount_q * nd1,
161 rho: strike * time_years * discount_r * nd2,
162 },
163 OptionKind::Put => {
164 let n_minus_d1 = normal_cdf(-d1);
165 let n_minus_d2 = normal_cdf(-d2);
166 BlackScholesResult {
167 price: strike * discount_r * n_minus_d2 - spot * discount_q * n_minus_d1,
168 delta: -discount_q * n_minus_d1,
169 gamma: discount_q * pdf_d1 / (spot * sigma_sqrt_t),
170 vega: spot * discount_q * pdf_d1 * sqrt_t,
171 theta: common_theta + risk_free_rate * strike * discount_r * n_minus_d2
172 - dividend_yield * spot * discount_q * n_minus_d1,
173 rho: -strike * time_years * discount_r * n_minus_d2,
174 }
175 }
176 };
177
178 if result.price.is_finite()
179 && result.delta.is_finite()
180 && result.gamma.is_finite()
181 && result.vega.is_finite()
182 && result.theta.is_finite()
183 && result.rho.is_finite()
184 {
185 Ok(result)
186 } else {
187 Err(DerivativesError::InvalidInput)
188 }
189}
190
191pub fn put_call_parity(
194 call_price: f64,
195 put_price: f64,
196 spot: f64,
197 strike: f64,
198 risk_free_rate: f64,
199 dividend_yield: f64,
200 time_years: f64,
201) -> Result<f64, DerivativesError> {
202 if !call_price.is_finite()
203 || !put_price.is_finite()
204 || !finite_positive(spot)
205 || !finite_positive(strike)
206 || !risk_free_rate.is_finite()
207 || !dividend_yield.is_finite()
208 || !finite_nonnegative(time_years)
209 {
210 return Err(DerivativesError::InvalidInput);
211 }
212
213 Ok(call_price
214 - put_price
215 - (spot * (-dividend_yield * time_years).exp()
216 - strike * (-risk_free_rate * time_years).exp()))
217}
218
219pub fn parity_implied_call_price(
220 put_price: f64,
221 spot: f64,
222 strike: f64,
223 risk_free_rate: f64,
224 dividend_yield: f64,
225 time_years: f64,
226) -> Result<f64, DerivativesError> {
227 if !put_price.is_finite()
228 || !finite_positive(spot)
229 || !finite_positive(strike)
230 || !risk_free_rate.is_finite()
231 || !dividend_yield.is_finite()
232 || !finite_nonnegative(time_years)
233 {
234 return Err(DerivativesError::InvalidInput);
235 }
236 Ok(put_price + spot * (-dividend_yield * time_years).exp()
237 - strike * (-risk_free_rate * time_years).exp())
238}
239
240pub fn parity_implied_put_price(
241 call_price: f64,
242 spot: f64,
243 strike: f64,
244 risk_free_rate: f64,
245 dividend_yield: f64,
246 time_years: f64,
247) -> Result<f64, DerivativesError> {
248 if !call_price.is_finite()
249 || !finite_positive(spot)
250 || !finite_positive(strike)
251 || !risk_free_rate.is_finite()
252 || !dividend_yield.is_finite()
253 || !finite_nonnegative(time_years)
254 {
255 return Err(DerivativesError::InvalidInput);
256 }
257 Ok(call_price - spot * (-dividend_yield * time_years).exp()
258 + strike * (-risk_free_rate * time_years).exp())
259}
260
261pub fn binomial_option_price(
267 kind: OptionKind,
268 style: OptionStyle,
269 spot: f64,
270 strike: f64,
271 risk_free_rate: f64,
272 dividend_yield: f64,
273 volatility: f64,
274 time_years: f64,
275 steps: usize,
276) -> Result<f64, DerivativesError> {
277 validate_option_inputs(
278 spot,
279 strike,
280 risk_free_rate,
281 dividend_yield,
282 volatility,
283 time_years,
284 )?;
285 if steps == 0 || steps > MAX_BINOMIAL_STEPS {
286 return Err(DerivativesError::InvalidSteps);
287 }
288 if time_years == 0.0 {
289 return Ok(payoff(kind, spot, strike));
290 }
291
292 let dt = time_years / steps as f64;
293 let sqrt_dt = dt.sqrt();
294 let up = (volatility * sqrt_dt).exp();
295 let down = 1.0 / up;
296 let growth = ((risk_free_rate - dividend_yield) * dt).exp();
297 let denom = up - down;
298 if denom <= 0.0 || !denom.is_finite() {
299 return Err(DerivativesError::NonRecombiningTree);
300 }
301 let p = (growth - down) / denom;
302 if !p.is_finite() || !(0.0..=1.0).contains(&p) {
303 return Err(DerivativesError::NonRecombiningTree);
304 }
305
306 let discount = (-risk_free_rate * dt).exp();
307 let ratio = up / down;
308 let mut values = [0.0_f64; MAX_BINOMIAL_STEPS + 1];
309
310 let mut underlying = spot * down.powi(steps as i32);
311 for slot in values.iter_mut().take(steps + 1) {
312 *slot = payoff(kind, underlying, strike);
313 underlying *= ratio;
314 }
315
316 for step in (0..steps).rev() {
317 let mut node_underlying = spot * down.powi(step as i32);
318 for node in 0..=step {
319 let continuation = discount * (p * values[node + 1] + (1.0 - p) * values[node]);
320 values[node] = match style {
321 OptionStyle::European => continuation,
322 OptionStyle::American => continuation.max(payoff(kind, node_underlying, strike)),
323 };
324 node_underlying *= ratio;
325 }
326 }
327
328 if values[0].is_finite() {
329 Ok(values[0])
330 } else {
331 Err(DerivativesError::InvalidInput)
332 }
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338
339 fn approx_eq(a: f64, b: f64, tol: f64) -> bool {
340 (a - b).abs() <= tol
341 }
342
343 #[test]
344 fn black_scholes_put_call_parity_residual_is_small() {
345 let call =
346 black_scholes_price_and_greeks(OptionKind::Call, 100.0, 100.0, 0.05, 0.01, 0.2, 1.0)
347 .unwrap();
348 let put =
349 black_scholes_price_and_greeks(OptionKind::Put, 100.0, 100.0, 0.05, 0.01, 0.2, 1.0)
350 .unwrap();
351
352 let residual =
353 put_call_parity(call.price, put.price, 100.0, 100.0, 0.05, 0.01, 1.0).unwrap();
354 assert!(residual.abs() < 1e-6);
355 }
356
357 #[test]
358 fn black_scholes_atm_call_is_in_known_bounds() {
359 let result =
360 black_scholes_price_and_greeks(OptionKind::Call, 100.0, 100.0, 0.05, 0.0, 0.2, 1.0)
361 .unwrap();
362
363 assert!(result.price > 10.40 && result.price < 10.47);
364 assert!(result.gamma > 0.018 && result.gamma < 0.020);
365 assert!(result.vega > 37.0 && result.vega < 38.0);
366 }
367
368 #[test]
369 fn deltas_have_expected_signs() {
370 let call =
371 black_scholes_price_and_greeks(OptionKind::Call, 100.0, 105.0, 0.03, 0.0, 0.25, 0.75)
372 .unwrap();
373 let put =
374 black_scholes_price_and_greeks(OptionKind::Put, 100.0, 105.0, 0.03, 0.0, 0.25, 0.75)
375 .unwrap();
376
377 assert!(call.delta > 0.0 && call.delta < 1.0);
378 assert!(put.delta < 0.0 && put.delta > -1.0);
379 }
380
381 #[test]
382 fn american_put_is_at_least_european_put_in_binomial_tree() {
383 let european = binomial_option_price(
384 OptionKind::Put,
385 OptionStyle::European,
386 100.0,
387 105.0,
388 0.04,
389 0.0,
390 0.25,
391 1.0,
392 256,
393 )
394 .unwrap();
395 let american = binomial_option_price(
396 OptionKind::Put,
397 OptionStyle::American,
398 100.0,
399 105.0,
400 0.04,
401 0.0,
402 0.25,
403 1.0,
404 256,
405 )
406 .unwrap();
407
408 assert!(american >= european);
409 }
410
411 #[test]
412 fn binomial_european_call_converges_near_black_scholes() {
413 let bs =
414 black_scholes_price_and_greeks(OptionKind::Call, 100.0, 100.0, 0.05, 0.0, 0.2, 1.0)
415 .unwrap();
416 let tree = binomial_option_price(
417 OptionKind::Call,
418 OptionStyle::European,
419 100.0,
420 100.0,
421 0.05,
422 0.0,
423 0.2,
424 1.0,
425 512,
426 )
427 .unwrap();
428
429 assert!(approx_eq(tree, bs.price, 0.05));
430 }
431
432 #[test]
433 fn rejects_invalid_volatility_and_steps() {
434 let bad_vol =
435 black_scholes_price_and_greeks(OptionKind::Call, 100.0, 100.0, 0.05, 0.0, 0.0, 1.0);
436 assert_eq!(bad_vol, Err(DerivativesError::InvalidInput));
437
438 let bad_steps = binomial_option_price(
439 OptionKind::Call,
440 OptionStyle::European,
441 100.0,
442 100.0,
443 0.05,
444 0.0,
445 0.2,
446 1.0,
447 0,
448 );
449 assert_eq!(bad_steps, Err(DerivativesError::InvalidSteps));
450 }
451}