1use crate::derivatives::norm::{norm_cdf, norm_pdf};
55use crate::derivatives::types::OptionType;
56use crate::util::error::{require_finite, FinanceError, FinanceResult};
57use crate::{columns_with_strings, print_table_locale_opt};
58
59#[derive(Clone, Copy, Debug, PartialEq)]
61pub struct Black76Params {
62 pub forward: f64,
64 pub strike: f64,
65 pub time_years: f64,
66 pub rate: f64,
68 pub vol: f64,
69}
70
71impl Black76Params {
72 pub const fn atm_one_year(forward: f64, rate: f64, vol: f64) -> Self {
73 Self {
74 forward,
75 strike: forward,
76 time_years: 1.0,
77 rate,
78 vol,
79 }
80 }
81
82 pub fn with_days_365_25(forward: f64, strike: f64, days: f64, rate: f64, vol: f64) -> Self {
83 Self {
84 forward,
85 strike,
86 time_years: days / 365.25,
87 rate,
88 vol,
89 }
90 }
91
92 pub fn to_bsm_equiv(self) -> crate::derivatives::types::BsmParams {
94 crate::derivatives::types::BsmParams {
95 spot: self.forward,
96 strike: self.strike,
97 time_years: self.time_years,
98 rate: self.rate,
99 dividend_yield: self.rate,
100 vol: self.vol,
101 }
102 }
103}
104
105#[derive(Clone, Copy, Debug, PartialEq)]
107pub struct ValidatedBlack76 {
108 params: Black76Params,
109}
110
111impl ValidatedBlack76 {
112 pub fn new(params: Black76Params) -> FinanceResult<Self> {
113 validate_black76_params(params)?;
114 Ok(Self { params })
115 }
116
117 pub fn params(self) -> Black76Params {
118 self.params
119 }
120
121 pub fn price(self, option_type: OptionType) -> FinanceResult<f64> {
122 black76_price(self.params, option_type)
123 }
124
125 pub fn greeks(self, option_type: OptionType) -> FinanceResult<Black76Greeks> {
126 black76_greeks(self.params, option_type)
127 }
128}
129
130#[derive(Clone, Copy, Debug, PartialEq)]
132pub struct Black76Greeks {
133 pub delta: f64,
135 pub gamma: f64,
136 pub vega: f64,
137 pub theta: f64,
138 pub rho: f64,
140}
141
142impl Black76Greeks {
143 #[inline]
144 pub fn vega_per_vol_point(self) -> f64 {
145 self.vega / 100.0
146 }
147
148 #[inline]
149 pub fn theta_per_calendar_day(self) -> f64 {
150 self.theta / 365.25
151 }
152}
153
154#[derive(Clone, Copy, Debug, PartialEq)]
156pub struct Black76Terms {
157 pub d1: f64,
158 pub d2: f64,
159 pub discount: f64,
160 pub sqrt_t: f64,
161}
162
163#[derive(Clone, Debug)]
165pub struct Black76Solution {
166 pub option_type: OptionType,
167 pub params: Black76Params,
168 pub price: f64,
169 pub greeks: Black76Greeks,
170 pub terms: Black76Terms,
171 pub parity_residual: f64,
173 formula: String,
174 symbolic_formula: String,
175}
176
177impl Black76Solution {
178 pub fn formula(&self) -> &str {
179 &self.formula
180 }
181 pub fn symbolic_formula(&self) -> &str {
182 &self.symbolic_formula
183 }
184
185 pub fn print_table(&self) {
186 self.print_table_locale_opt(None, None);
187 }
188
189 pub fn print_table_locale(&self, locale: &num_format::Locale, precision: usize) {
190 self.print_table_locale_opt(Some(locale), Some(precision));
191 }
192
193 fn print_table_locale_opt(
194 &self,
195 locale: Option<&num_format::Locale>,
196 precision: Option<usize>,
197 ) {
198 let columns = columns_with_strings(&[
199 ("type", "s", true),
200 ("price", "f", true),
201 ("delta", "f", true),
202 ("gamma", "f", true),
203 ("vega", "f", true),
204 ("theta", "f", true),
205 ("rho", "f", true),
206 ]);
207 let data = vec![vec![
208 self.option_type.to_string(),
209 self.price.to_string(),
210 self.greeks.delta.to_string(),
211 self.greeks.gamma.to_string(),
212 self.greeks.vega.to_string(),
213 self.greeks.theta.to_string(),
214 self.greeks.rho.to_string(),
215 ]];
216 print_table_locale_opt(&columns, data, locale, precision);
217 }
218}
219
220#[derive(Clone, Debug, PartialEq)]
222pub struct Black76State {
223 params: Black76Params,
224 option_type: OptionType,
225}
226
227impl Black76State {
228 pub fn new(params: Black76Params, option_type: OptionType) -> FinanceResult<Self> {
229 validate_black76_params(params)?;
230 Ok(Self {
231 params,
232 option_type,
233 })
234 }
235
236 pub fn params(&self) -> Black76Params {
237 self.params
238 }
239
240 pub fn option_type(&self) -> OptionType {
241 self.option_type
242 }
243
244 pub fn set_forward(&mut self, forward: f64) -> FinanceResult<()> {
245 require_finite("forward", forward)?;
246 let mut p = self.params;
247 p.forward = forward;
248 validate_black76_params(p)?;
249 self.params = p;
250 Ok(())
251 }
252
253 pub fn set_vol(&mut self, vol: f64) -> FinanceResult<()> {
254 require_finite("vol", vol)?;
255 let mut p = self.params;
256 p.vol = vol;
257 validate_black76_params(p)?;
258 self.params = p;
259 Ok(())
260 }
261
262 pub fn set_time_years(&mut self, time_years: f64) -> FinanceResult<()> {
263 require_finite("time_years", time_years)?;
264 let mut p = self.params;
265 p.time_years = time_years;
266 validate_black76_params(p)?;
267 self.params = p;
268 Ok(())
269 }
270
271 pub fn set_vol_from_price(&mut self, market_price: f64) -> FinanceResult<f64> {
272 let iv = black76_implied_vol(self.params, self.option_type, market_price)?;
273 self.set_vol(iv)?;
274 Ok(iv)
275 }
276
277 pub fn price(&self) -> FinanceResult<f64> {
278 black76_price(self.params, self.option_type)
279 }
280
281 pub fn greeks(&self) -> FinanceResult<Black76Greeks> {
282 black76_greeks(self.params, self.option_type)
283 }
284}
285
286pub fn black76_price(params: Black76Params, option_type: OptionType) -> FinanceResult<f64> {
287 validate_black76_params(params)?;
288 Ok(price_unchecked(params, option_type))
289}
290
291pub fn black76_greeks(
292 params: Black76Params,
293 option_type: OptionType,
294) -> FinanceResult<Black76Greeks> {
295 validate_black76_params(params)?;
296 Ok(greeks_unchecked(params, option_type))
297}
298
299pub fn black76_terms(params: Black76Params) -> FinanceResult<Black76Terms> {
300 validate_black76_params(params)?;
301 Ok(terms_unchecked(params))
302}
303
304pub fn black76_parity_residual(params: Black76Params) -> FinanceResult<f64> {
306 let c = black76_price(params, OptionType::Call)?;
307 let p = black76_price(params, OptionType::Put)?;
308 let disc = (-params.rate * params.time_years).exp();
309 Ok(c - p - disc * (params.forward - params.strike))
310}
311
312pub fn black76_solution(
313 params: Black76Params,
314 option_type: OptionType,
315) -> FinanceResult<Black76Solution> {
316 let _ = ValidatedBlack76::new(params)?;
317 let price = price_unchecked(params, option_type);
318 let greeks = greeks_unchecked(params, option_type);
319 let terms = terms_unchecked(params);
320 let parity = black76_parity_residual(params)?;
321 let formula = format!(
322 "{option_type} Black76 F={} K={} T={} r={} σ={} → price={:.6}",
323 params.forward, params.strike, params.time_years, params.rate, params.vol, price
324 );
325 let symbolic = match option_type {
326 OptionType::Call => {
327 "C = e^{-rT}[F N(d1) - K N(d2)]; d1=[ln(F/K)+σ²T/2]/(σ√T); d2=d1-σ√T".to_string()
328 }
329 OptionType::Put => "P = e^{-rT}[K N(-d2) - F N(-d1)]; d1,d2 as in call".to_string(),
330 };
331 Ok(Black76Solution {
332 option_type,
333 params,
334 price,
335 greeks,
336 terms,
337 parity_residual: parity,
338 formula,
339 symbolic_formula: symbolic,
340 })
341}
342
343pub fn black76_implied_vol(
345 params: Black76Params,
346 option_type: OptionType,
347 market_price: f64,
348) -> FinanceResult<f64> {
349 validate_black76_params(params)?;
350 require_finite("market_price", market_price)?;
351 if market_price < 0.0 {
352 return Err(FinanceError::Unsolvable {
353 message: "market_price must be non-negative",
354 });
355 }
356 if params.time_years == 0.0 {
357 return Err(FinanceError::Unsolvable {
358 message: "implied vol undefined at expiry (T=0)",
359 });
360 }
361
362 crate::derivatives::implied_vol::solve_implied_vol(
363 market_price,
364 |sigma| {
365 let mut p = params;
366 p.vol = sigma;
367 price_unchecked(p, option_type)
368 },
369 |sigma| {
370 let mut p = params;
371 p.vol = sigma;
372 greeks_unchecked(p, option_type).vega
373 },
374 )
375}
376
377pub(crate) fn validate_black76_params(p: Black76Params) -> FinanceResult<()> {
378 require_finite("forward", p.forward)?;
379 require_finite("strike", p.strike)?;
380 require_finite("time_years", p.time_years)?;
381 require_finite("rate", p.rate)?;
382 require_finite("vol", p.vol)?;
383 if p.forward <= 0.0 {
384 return Err(FinanceError::InvalidCashflow {
385 message: "forward must be strictly positive",
386 });
387 }
388 if p.strike <= 0.0 {
389 return Err(FinanceError::InvalidCashflow {
390 message: "strike must be strictly positive",
391 });
392 }
393 if p.time_years < 0.0 {
394 return Err(FinanceError::Unsolvable {
395 message: "time_years must be non-negative",
396 });
397 }
398 if p.vol < 0.0 {
399 return Err(FinanceError::Unsolvable {
400 message: "vol must be non-negative",
401 });
402 }
403 Ok(())
404}
405
406fn terms_unchecked(p: Black76Params) -> Black76Terms {
407 let sqrt_t = p.time_years.sqrt();
408 let discount = (-p.rate * p.time_years).exp();
409 if p.time_years == 0.0 || p.vol == 0.0 {
410 let d1 = if p.forward > p.strike {
411 f64::INFINITY
412 } else if p.forward < p.strike {
413 f64::NEG_INFINITY
414 } else {
415 0.0
416 };
417 return Black76Terms {
418 d1,
419 d2: d1,
420 discount,
421 sqrt_t,
422 };
423 }
424 let sig_s = p.vol * sqrt_t;
425 let d1 = ((p.forward / p.strike).ln() + 0.5 * p.vol * p.vol * p.time_years) / sig_s;
426 let d2 = d1 - sig_s;
427 Black76Terms {
428 d1,
429 d2,
430 discount,
431 sqrt_t,
432 }
433}
434
435fn price_unchecked(p: Black76Params, option_type: OptionType) -> f64 {
436 if p.time_years == 0.0 {
437 return match option_type {
438 OptionType::Call => (p.forward - p.strike).max(0.0),
439 OptionType::Put => (p.strike - p.forward).max(0.0),
440 };
441 }
442 if p.vol == 0.0 {
443 let disc = (-p.rate * p.time_years).exp();
444 return match option_type {
445 OptionType::Call => disc * (p.forward - p.strike).max(0.0),
446 OptionType::Put => disc * (p.strike - p.forward).max(0.0),
447 };
448 }
449 let t = terms_unchecked(p);
450 let df = t.discount;
451 match option_type {
452 OptionType::Call => df * (p.forward * norm_cdf(t.d1) - p.strike * norm_cdf(t.d2)),
453 OptionType::Put => df * (p.strike * norm_cdf(-t.d2) - p.forward * norm_cdf(-t.d1)),
454 }
455}
456
457fn greeks_unchecked(p: Black76Params, option_type: OptionType) -> Black76Greeks {
458 if p.time_years == 0.0 {
459 let delta = match option_type {
460 OptionType::Call => {
461 if p.forward > p.strike {
462 1.0
463 } else if p.forward < p.strike {
464 0.0
465 } else {
466 0.5
467 }
468 }
469 OptionType::Put => {
470 if p.forward < p.strike {
471 -1.0
472 } else if p.forward > p.strike {
473 0.0
474 } else {
475 -0.5
476 }
477 }
478 };
479 return Black76Greeks {
480 delta,
481 gamma: 0.0,
482 vega: 0.0,
483 theta: 0.0,
484 rho: 0.0,
485 };
486 }
487
488 if p.vol == 0.0 {
489 let price = price_unchecked(p, option_type);
490 let disc = (-p.rate * p.time_years).exp();
491 let delta = match option_type {
492 OptionType::Call => {
493 if p.forward > p.strike {
494 disc
495 } else {
496 0.0
497 }
498 }
499 OptionType::Put => {
500 if p.forward < p.strike {
501 -disc
502 } else {
503 0.0
504 }
505 }
506 };
507 return Black76Greeks {
508 delta,
509 gamma: 0.0,
510 vega: 0.0,
511 theta: 0.0,
512 rho: -p.time_years * price,
513 };
514 }
515
516 let t = terms_unchecked(p);
517 let df = t.discount;
518 let n_d1 = norm_pdf(t.d1);
519 let sqrt_t = t.sqrt_t;
520 let gamma = df * n_d1 / (p.forward * p.vol * sqrt_t);
521 let vega = df * p.forward * n_d1 * sqrt_t;
522 let price = price_unchecked(p, option_type);
523 let rho = -p.time_years * price;
524
525 let theta = -df * p.forward * n_d1 * p.vol / (2.0 * sqrt_t) - p.rate * price;
527 let delta = match option_type {
528 OptionType::Call => df * norm_cdf(t.d1),
529 OptionType::Put => -df * norm_cdf(-t.d1),
530 };
531
532 Black76Greeks {
533 delta,
534 gamma,
535 vega,
536 theta,
537 rho,
538 }
539}
540
541#[cfg(test)]
542mod tests {
543 use super::*;
544 use crate::derivatives::black_scholes::bsm_price;
545 use crate::derivatives::types::BsmParams;
546
547 #[test]
548 fn matches_bsm_with_q_eq_r() {
549 let b = Black76Params::atm_one_year(100.0, 0.05, 0.20);
550 let bsm = BsmParams {
551 spot: 100.0,
552 strike: 100.0,
553 time_years: 1.0,
554 rate: 0.05,
555 dividend_yield: 0.05,
556 vol: 0.20,
557 };
558 let c76 = black76_price(b, OptionType::Call).unwrap();
559 let c_bsm = bsm_price(bsm, OptionType::Call).unwrap();
560 assert!((c76 - c_bsm).abs() < 1e-10);
561 assert!((c76 - 7.577_082).abs() < 1e-3);
562 }
563
564 #[test]
565 fn parity() {
566 let p = Black76Params {
567 forward: 105.0,
568 strike: 100.0,
569 time_years: 0.5,
570 rate: 0.03,
571 vol: 0.22,
572 };
573 assert!(black76_parity_residual(p).unwrap().abs() < 1e-10);
574 }
575
576 #[test]
577 fn iv_round_trip() {
578 let p = Black76Params::atm_one_year(100.0, 0.04, 0.28);
579 let mkt = black76_price(p, OptionType::Call).unwrap();
580 let iv = black76_implied_vol(p, OptionType::Call, mkt).unwrap();
581 assert!((iv - 0.28).abs() < 1e-6);
582 }
583
584 #[test]
585 fn state_forward_moves_delta() {
586 let p = Black76Params::atm_one_year(100.0, 0.05, 0.2);
587 let mut s = Black76State::new(p, OptionType::Call).unwrap();
588 let d0 = s.greeks().unwrap().delta;
589 s.set_forward(110.0).unwrap();
590 assert!(s.greeks().unwrap().delta > d0);
591 }
592
593 #[test]
594 fn expiry_intrinsic_undiscounted() {
595 let p = Black76Params {
596 forward: 120.0,
597 strike: 100.0,
598 time_years: 0.0,
599 rate: 0.05,
600 vol: 0.2,
601 };
602 assert!((black76_price(p, OptionType::Call).unwrap() - 20.0).abs() < 1e-12);
603 assert!(black76_price(p, OptionType::Put).unwrap().abs() < 1e-12);
604 }
605
606 #[test]
607 fn put_delta_negative() {
608 let g = black76_greeks(
609 Black76Params::atm_one_year(100.0, 0.05, 0.2),
610 OptionType::Put,
611 )
612 .unwrap();
613 assert!(g.delta < 0.0 && g.delta > -1.0);
614 }
615
616 #[test]
617 fn rejects_nonpositive_forward() {
618 let mut p = Black76Params::atm_one_year(100.0, 0.05, 0.2);
619 p.forward = 0.0;
620 assert!(black76_price(p, OptionType::Call).is_err());
621 }
622
623 #[test]
624 fn rho_equals_minus_t_times_price() {
625 let p = Black76Params::atm_one_year(100.0, 0.05, 0.2);
626 let px = black76_price(p, OptionType::Call).unwrap();
627 let g = black76_greeks(p, OptionType::Call).unwrap();
628 assert!((g.rho + p.time_years * px).abs() < 1e-10);
629 }
630}