1use crate::error::FinError;
17use rust_decimal::prelude::ToPrimitive;
18use rust_decimal::Decimal;
19
20fn phi(x: f64) -> f64 {
24 (-0.5 * x * x).exp() / (2.0 * std::f64::consts::PI).sqrt()
25}
26
27fn big_phi(x: f64) -> f64 {
30 let t = 1.0 / (1.0 + 0.2316419 * x.abs());
31 let poly = t
32 * (0.319_381_530
33 + t * (-0.356_563_782
34 + t * (1.781_477_937 + t * (-1.821_255_978 + t * 1.330_274_429))));
35 let pdf = phi(x);
36 let cdf_pos = 1.0 - pdf * poly;
37 if x >= 0.0 { cdf_pos } else { 1.0 - cdf_pos }
38}
39
40fn to_f64(d: Decimal) -> Result<f64, FinError> {
42 d.to_f64().ok_or(FinError::ArithmeticOverflow)
43}
44
45fn from_f64(f: f64) -> Result<Decimal, FinError> {
47 if !f.is_finite() {
48 return Err(FinError::ArithmeticOverflow);
49 }
50 Decimal::try_from(f).map_err(|_| FinError::ArithmeticOverflow)
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
57pub enum OptionType {
58 Call,
60 Put,
62}
63
64#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
66pub struct OptionSpec {
67 pub strike: Decimal,
69 pub expiry_days: u32,
71 pub spot: Decimal,
73 pub risk_free_rate: Decimal,
75 pub volatility: Decimal,
77 pub option_type: OptionType,
79}
80
81impl OptionSpec {
82 fn validate(&self) -> Result<(), FinError> {
84 if self.strike <= Decimal::ZERO {
85 return Err(FinError::InvalidInput(
86 "strike must be positive".to_owned(),
87 ));
88 }
89 if self.expiry_days == 0 {
90 return Err(FinError::InvalidInput(
91 "expiry_days must be > 0".to_owned(),
92 ));
93 }
94 if self.spot <= Decimal::ZERO {
95 return Err(FinError::InvalidInput(
96 "spot must be positive".to_owned(),
97 ));
98 }
99 if self.volatility <= Decimal::ZERO {
100 return Err(FinError::InvalidInput(
101 "volatility must be positive".to_owned(),
102 ));
103 }
104 Ok(())
105 }
106}
107
108#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
110pub struct OptionGreeks {
111 pub delta: Decimal,
113 pub gamma: Decimal,
115 pub theta: Decimal,
117 pub vega: Decimal,
119 pub rho: Decimal,
121}
122
123pub struct BlackScholes;
130
131impl BlackScholes {
132 pub fn greeks(spec: &OptionSpec) -> Result<OptionGreeks, FinError> {
138 spec.validate()?;
139
140 let s = to_f64(spec.spot)?;
141 let k = to_f64(spec.strike)?;
142 let r = to_f64(spec.risk_free_rate)?;
143 let v = to_f64(spec.volatility)?;
144 let t = f64::from(spec.expiry_days) / 365.0;
146
147 let sqrt_t = t.sqrt();
148 let ln_sk = (s / k).ln();
149 let d1 = (ln_sk + (r + 0.5 * v * v) * t) / (v * sqrt_t);
150 let d2 = d1 - v * sqrt_t;
151 let exp_rt = (-r * t).exp();
152
153 let (delta, gamma, theta, vega, rho) = match spec.option_type {
154 OptionType::Call => {
155 let nd1 = big_phi(d1);
156 let nd2 = big_phi(d2);
157 let phi_d1 = phi(d1);
158
159 let delta = nd1;
160 let gamma = phi_d1 / (s * v * sqrt_t);
161 let theta =
163 (-(s * phi_d1 * v) / (2.0 * sqrt_t) - r * k * exp_rt * nd2) / 365.0;
164 let vega = s * phi_d1 * sqrt_t / 100.0; let rho = k * t * exp_rt * nd2 / 100.0; (delta, gamma, theta, vega, rho)
168 }
169 OptionType::Put => {
170 let nd1_neg = big_phi(-d1); let nd2_neg = big_phi(-d2); let phi_d1 = phi(d1);
174
175 let delta = -nd1_neg;
177 let gamma = phi_d1 / (s * v * sqrt_t);
178 let theta =
179 (-(s * phi_d1 * v) / (2.0 * sqrt_t) + r * k * exp_rt * nd2_neg) / 365.0;
180 let vega = s * phi_d1 * sqrt_t / 100.0;
181 let rho = -k * t * exp_rt * nd2_neg / 100.0;
182
183 (delta, gamma, theta, vega, rho)
184 }
185 };
186
187 Ok(OptionGreeks {
188 delta: from_f64(delta)?,
189 gamma: from_f64(gamma)?,
190 theta: from_f64(theta)?,
191 vega: from_f64(vega)?,
192 rho: from_f64(rho)?,
193 })
194 }
195
196 pub fn price(spec: &OptionSpec) -> Result<Decimal, FinError> {
202 spec.validate()?;
203
204 let s = to_f64(spec.spot)?;
205 let k = to_f64(spec.strike)?;
206 let r = to_f64(spec.risk_free_rate)?;
207 let v = to_f64(spec.volatility)?;
208 let t = f64::from(spec.expiry_days) / 365.0;
209
210 let sqrt_t = t.sqrt();
211 let d1 = ((s / k).ln() + (r + 0.5 * v * v) * t) / (v * sqrt_t);
212 let d2 = d1 - v * sqrt_t;
213 let exp_rt = (-r * t).exp();
214
215 let price = match spec.option_type {
216 OptionType::Call => s * big_phi(d1) - k * exp_rt * big_phi(d2),
217 OptionType::Put => k * exp_rt * big_phi(-d2) - s * big_phi(-d1),
218 };
219
220 from_f64(price)
221 }
222
223 pub fn implied_vol(market_price: Decimal, spec: &OptionSpec) -> Result<Decimal, FinError> {
234 if market_price <= Decimal::ZERO {
235 return Err(FinError::InvalidInput(
236 "market_price must be positive".to_owned(),
237 ));
238 }
239 if spec.strike <= Decimal::ZERO {
241 return Err(FinError::InvalidInput(
242 "strike must be positive".to_owned(),
243 ));
244 }
245 if spec.expiry_days == 0 {
246 return Err(FinError::InvalidInput(
247 "expiry_days must be > 0".to_owned(),
248 ));
249 }
250 if spec.spot <= Decimal::ZERO {
251 return Err(FinError::InvalidInput(
252 "spot must be positive".to_owned(),
253 ));
254 }
255
256 let target = to_f64(market_price)?;
257
258 const LOW_VOL: f64 = 1e-6;
259 const HIGH_VOL: f64 = 5.0;
260 const TOL: f64 = 1e-7;
261 const MAX_ITER: usize = 200;
262
263 let price_at = |vol: f64| -> Result<f64, FinError> {
264 let trial_spec = OptionSpec {
265 volatility: from_f64(vol)?,
266 ..spec.clone()
267 };
268 to_f64(Self::price(&trial_spec)?)
269 };
270
271 let mut lo = LOW_VOL;
272 let mut hi = HIGH_VOL;
273
274 let p_lo = price_at(lo)?;
275 let p_hi = price_at(hi)?;
276
277 if target < p_lo || target > p_hi {
279 return Err(FinError::InvalidInput(
280 "market_price is outside no-arbitrage vol bounds [1e-6, 500%]".to_owned(),
281 ));
282 }
283
284 for _ in 0..MAX_ITER {
285 let mid = (lo + hi) / 2.0;
286 let p_mid = price_at(mid)?;
287 let err = p_mid - target;
288 if err.abs() < TOL {
289 return from_f64(mid);
290 }
291 if err < 0.0 {
292 lo = mid;
293 } else {
294 hi = mid;
295 }
296 }
297
298 from_f64((lo + hi) / 2.0)
300 }
301}
302
303#[derive(Debug, Clone)]
307pub struct Leg {
308 pub spec: OptionSpec,
310 pub quantity: i32,
312}
313
314impl Leg {
315 pub fn new(spec: OptionSpec, quantity: i32) -> Self {
317 Self { spec, quantity }
318 }
319
320 fn scaled_greeks(&self) -> Result<OptionGreeks, FinError> {
322 let g = BlackScholes::greeks(&self.spec)?;
323 let q = from_f64(f64::from(self.quantity))?;
324 Ok(OptionGreeks {
325 delta: g.delta * q,
326 gamma: g.gamma * q,
327 theta: g.theta * q,
328 vega: g.vega * q,
329 rho: g.rho * q,
330 })
331 }
332}
333
334#[derive(Debug, Clone)]
336pub struct SpreadGreeks {
337 legs: Vec<Leg>,
338}
339
340impl SpreadGreeks {
341 pub fn new(legs: Vec<Leg>) -> Self {
343 Self { legs }
344 }
345
346 pub fn bull_call_spread(
350 spot: Decimal,
351 low_strike: Decimal,
352 high_strike: Decimal,
353 expiry_days: u32,
354 risk_free_rate: Decimal,
355 volatility: Decimal,
356 ) -> Self {
357 let base = |strike| OptionSpec {
358 strike,
359 expiry_days,
360 spot,
361 risk_free_rate,
362 volatility,
363 option_type: OptionType::Call,
364 };
365 Self::new(vec![
366 Leg::new(base(low_strike), 1),
367 Leg::new(base(high_strike), -1),
368 ])
369 }
370
371 pub fn bear_put_spread(
373 spot: Decimal,
374 low_strike: Decimal,
375 high_strike: Decimal,
376 expiry_days: u32,
377 risk_free_rate: Decimal,
378 volatility: Decimal,
379 ) -> Self {
380 let base = |strike, ot| OptionSpec {
381 strike,
382 expiry_days,
383 spot,
384 risk_free_rate,
385 volatility,
386 option_type: ot,
387 };
388 Self::new(vec![
389 Leg::new(base(high_strike, OptionType::Put), 1),
390 Leg::new(base(low_strike, OptionType::Put), -1),
391 ])
392 }
393
394 pub fn straddle(
396 spot: Decimal,
397 strike: Decimal,
398 expiry_days: u32,
399 risk_free_rate: Decimal,
400 volatility: Decimal,
401 ) -> Self {
402 let base = |ot| OptionSpec {
403 strike,
404 expiry_days,
405 spot,
406 risk_free_rate,
407 volatility,
408 option_type: ot,
409 };
410 Self::new(vec![
411 Leg::new(base(OptionType::Call), 1),
412 Leg::new(base(OptionType::Put), 1),
413 ])
414 }
415
416 #[allow(clippy::too_many_arguments)]
423 pub fn iron_condor(
424 spot: Decimal,
425 put_low: Decimal,
426 put_high: Decimal,
427 call_low: Decimal,
428 call_high: Decimal,
429 expiry_days: u32,
430 risk_free_rate: Decimal,
431 volatility: Decimal,
432 ) -> Self {
433 let mk = |strike, ot, qty| {
434 Leg::new(
435 OptionSpec {
436 strike,
437 expiry_days,
438 spot,
439 risk_free_rate,
440 volatility,
441 option_type: ot,
442 },
443 qty,
444 )
445 };
446 Self::new(vec![
447 mk(put_low, OptionType::Put, 1),
448 mk(put_high, OptionType::Put, -1),
449 mk(call_low, OptionType::Call, -1),
450 mk(call_high, OptionType::Call, 1),
451 ])
452 }
453
454 pub fn net_greeks(&self) -> Result<OptionGreeks, FinError> {
459 let mut delta = Decimal::ZERO;
460 let mut gamma = Decimal::ZERO;
461 let mut theta = Decimal::ZERO;
462 let mut vega = Decimal::ZERO;
463 let mut rho = Decimal::ZERO;
464
465 for leg in &self.legs {
466 let g = leg.scaled_greeks()?;
467 delta += g.delta;
468 gamma += g.gamma;
469 theta += g.theta;
470 vega += g.vega;
471 rho += g.rho;
472 }
473
474 Ok(OptionGreeks { delta, gamma, theta, vega, rho })
475 }
476
477 pub fn leg_count(&self) -> usize {
479 self.legs.len()
480 }
481}
482
483#[derive(Debug, Clone, Copy)]
492pub struct BSParams {
493 pub spot: f64,
495 pub strike: f64,
497 pub time_to_expiry: f64,
499 pub risk_free_rate: f64,
501 pub volatility: f64,
503 pub option_type: OptionType,
505}
506
507#[derive(Debug, Clone, Copy)]
514pub struct Greeks {
515 pub delta: f64,
517 pub gamma: f64,
519 pub theta: f64,
521 pub vega: f64,
523 pub rho: f64,
525 pub vanna: f64,
527 pub volga: f64,
529 pub charm: f64,
531 pub speed: f64,
533}
534
535pub struct BSCalculator;
540
541impl BSCalculator {
542 pub fn price(p: &BSParams) -> Option<f64> {
547 let (d1, d2) = Self::d1_d2(p)?;
548 let exp_rt = (-p.risk_free_rate * p.time_to_expiry).exp();
549 let price = match p.option_type {
550 OptionType::Call => p.spot * norm_cdf(d1) - p.strike * exp_rt * norm_cdf(d2),
551 OptionType::Put => p.strike * exp_rt * norm_cdf(-d2) - p.spot * norm_cdf(-d1),
552 };
553 Some(price)
554 }
555
556 pub fn greeks(p: &BSParams) -> Option<Greeks> {
562 let (d1, d2) = Self::d1_d2(p)?;
563 let s = p.spot;
564 let k = p.strike;
565 let r = p.risk_free_rate;
566 let v = p.volatility;
567 let t = p.time_to_expiry;
568 let sqrt_t = t.sqrt();
569 let exp_rt = (-r * t).exp();
570 let phi_d1 = norm_pdf(d1);
571
572 let (delta, theta, rho) = match p.option_type {
574 OptionType::Call => {
575 let nd1 = norm_cdf(d1);
576 let nd2 = norm_cdf(d2);
577 let delta = nd1;
578 let theta = (-(s * phi_d1 * v) / (2.0 * sqrt_t)
579 - r * k * exp_rt * nd2)
580 / 365.0;
581 let rho = k * t * exp_rt * nd2 / 100.0;
582 (delta, theta, rho)
583 }
584 OptionType::Put => {
585 let nd1_neg = norm_cdf(-d1);
586 let nd2_neg = norm_cdf(-d2);
587 let delta = nd1_neg - 1.0;
588 let theta = (-(s * phi_d1 * v) / (2.0 * sqrt_t)
589 + r * k * exp_rt * nd2_neg)
590 / 365.0;
591 let rho = -k * t * exp_rt * nd2_neg / 100.0;
592 (delta, theta, rho)
593 }
594 };
595
596 let gamma = phi_d1 / (s * v * sqrt_t);
597 let vega = s * phi_d1 * sqrt_t / 100.0; let vanna = -phi_d1 * d2 / v;
602
603 let volga = vega * d1 * d2 / v;
607
608 let charm = match p.option_type {
611 OptionType::Call => {
612 (-phi_d1
613 * (2.0 * r * t - d2 * v * sqrt_t)
614 / (2.0 * t * v * sqrt_t))
615 / 365.0
616 }
617 OptionType::Put => {
618 (phi_d1
619 * (2.0 * r * t - d2 * v * sqrt_t)
620 / (2.0 * t * v * sqrt_t))
621 / 365.0
622 }
623 };
624
625 let speed = -gamma / s * (d1 / (v * sqrt_t) + 1.0);
627
628 Some(Greeks { delta, gamma, theta, vega, rho, vanna, volga, charm, speed })
629 }
630
631 pub fn delta(p: &BSParams) -> Option<f64> {
633 let (d1, _) = Self::d1_d2(p)?;
634 Some(match p.option_type {
635 OptionType::Call => norm_cdf(d1),
636 OptionType::Put => norm_cdf(-d1) - 1.0,
637 })
638 }
639
640 pub fn gamma(p: &BSParams) -> Option<f64> {
642 let (d1, _) = Self::d1_d2(p)?;
643 Some(norm_pdf(d1) / (p.spot * p.volatility * p.time_to_expiry.sqrt()))
644 }
645
646 pub fn theta(p: &BSParams) -> Option<f64> {
648 let (d1, d2) = Self::d1_d2(p)?;
649 let phi_d1 = norm_pdf(d1);
650 let exp_rt = (-p.risk_free_rate * p.time_to_expiry).exp();
651 let sqrt_t = p.time_to_expiry.sqrt();
652 let base = -(p.spot * phi_d1 * p.volatility) / (2.0 * sqrt_t);
653 let theta = match p.option_type {
654 OptionType::Call => (base - p.risk_free_rate * p.strike * exp_rt * norm_cdf(d2)) / 365.0,
655 OptionType::Put => (base + p.risk_free_rate * p.strike * exp_rt * norm_cdf(-d2)) / 365.0,
656 };
657 Some(theta)
658 }
659
660 pub fn vega(p: &BSParams) -> Option<f64> {
662 let (d1, _) = Self::d1_d2(p)?;
663 Some(p.spot * norm_pdf(d1) * p.time_to_expiry.sqrt() / 100.0)
664 }
665
666 pub fn rho(p: &BSParams) -> Option<f64> {
668 let (_, d2) = Self::d1_d2(p)?;
669 let exp_rt = (-p.risk_free_rate * p.time_to_expiry).exp();
670 Some(match p.option_type {
671 OptionType::Call => p.strike * p.time_to_expiry * exp_rt * norm_cdf(d2) / 100.0,
672 OptionType::Put => -p.strike * p.time_to_expiry * exp_rt * norm_cdf(-d2) / 100.0,
673 })
674 }
675
676 pub fn implied_volatility(
685 market_price: f64,
686 p: &BSParams,
687 tolerance: f64,
688 max_iter: usize,
689 ) -> Option<f64> {
690 if market_price <= 0.0 || p.spot <= 0.0 || p.strike <= 0.0 || p.time_to_expiry <= 0.0 {
691 return None;
692 }
693
694 let mut sigma = (2.0 * std::f64::consts::PI / p.time_to_expiry).sqrt()
696 * market_price
697 / p.spot;
698 sigma = sigma.clamp(1e-4, 5.0);
700
701 for _ in 0..max_iter {
702 let trial = BSParams { volatility: sigma, ..*p };
703 let price = Self::price(&trial)?;
704 let error = price - market_price;
705 if error.abs() < tolerance {
706 return Some(sigma);
707 }
708 let v = Self::vega(&trial)?;
709 let vega_raw = v * 100.0;
711 if vega_raw.abs() < 1e-10 {
712 break; }
714 sigma -= error / vega_raw;
715 sigma = sigma.clamp(1e-4, 5.0);
716 }
717
718 let trial = BSParams { volatility: sigma, ..*p };
720 let final_price = Self::price(&trial)?;
721 if (final_price - market_price).abs() < tolerance * 10.0 {
722 Some(sigma)
723 } else {
724 None
725 }
726 }
727
728 fn d1_d2(p: &BSParams) -> Option<(f64, f64)> {
732 if p.spot <= 0.0 || p.strike <= 0.0 || p.volatility <= 0.0 || p.time_to_expiry <= 0.0 {
733 return None;
734 }
735 let sqrt_t = p.time_to_expiry.sqrt();
736 let d1 = ((p.spot / p.strike).ln()
737 + (p.risk_free_rate + 0.5 * p.volatility * p.volatility) * p.time_to_expiry)
738 / (p.volatility * sqrt_t);
739 let d2 = d1 - p.volatility * sqrt_t;
740 Some((d1, d2))
741 }
742}
743
744fn norm_pdf(x: f64) -> f64 {
746 (-0.5 * x * x).exp() / (2.0 * std::f64::consts::PI).sqrt()
747}
748
749fn norm_cdf(x: f64) -> f64 {
753 let t = 1.0 / (1.0 + 0.2316419 * x.abs());
754 let poly = t
755 * (0.319_381_530
756 + t * (-0.356_563_782
757 + t * (1.781_477_937 + t * (-1.821_255_978 + t * 1.330_274_429))));
758 let cdf_pos = 1.0 - norm_pdf(x) * poly;
759 if x >= 0.0 { cdf_pos } else { 1.0 - cdf_pos }
760}
761
762#[cfg(test)]
765mod bs_tests {
766 use super::*;
767
768 fn atm_call() -> BSParams {
769 BSParams {
770 spot: 100.0,
771 strike: 100.0,
772 time_to_expiry: 30.0 / 365.0,
773 risk_free_rate: 0.05,
774 volatility: 0.20,
775 option_type: OptionType::Call,
776 }
777 }
778
779 fn atm_put() -> BSParams {
780 BSParams { option_type: OptionType::Put, ..atm_call() }
781 }
782
783 #[test]
784 fn price_call_positive() {
785 let p = BSCalculator::price(&atm_call()).unwrap();
786 assert!(p > 0.0 && p < 10.0, "call price out of range: {p}");
787 }
788
789 #[test]
790 fn put_call_parity() {
791 let c = BSCalculator::price(&atm_call()).unwrap();
792 let p = BSCalculator::price(&atm_put()).unwrap();
793 let params = atm_call();
794 let forward = params.spot
795 - params.strike * (-params.risk_free_rate * params.time_to_expiry).exp();
796 assert!((c - p - forward).abs() < 1e-6, "put-call parity violated: {}", c - p - forward);
797 }
798
799 #[test]
800 fn delta_call_between_zero_and_one() {
801 let d = BSCalculator::delta(&atm_call()).unwrap();
802 assert!(d > 0.0 && d < 1.0, "call delta out of range: {d}");
803 }
804
805 #[test]
806 fn delta_put_between_neg_one_and_zero() {
807 let d = BSCalculator::delta(&atm_put()).unwrap();
808 assert!(d > -1.0 && d < 0.0, "put delta out of range: {d}");
809 }
810
811 #[test]
812 fn gamma_positive() {
813 let g = BSCalculator::gamma(&atm_call()).unwrap();
814 assert!(g > 0.0, "gamma should be positive: {g}");
815 }
816
817 #[test]
818 fn theta_negative_call() {
819 let t = BSCalculator::theta(&atm_call()).unwrap();
820 assert!(t < 0.0, "theta should be negative for long call: {t}");
821 }
822
823 #[test]
824 fn vega_positive() {
825 let v = BSCalculator::vega(&atm_call()).unwrap();
826 assert!(v > 0.0, "vega should be positive: {v}");
827 }
828
829 #[test]
830 fn all_greeks_available() {
831 let g = BSCalculator::greeks(&atm_call()).unwrap();
832 assert!(g.delta > 0.0);
833 assert!(g.gamma > 0.0);
834 assert!(g.theta < 0.0);
835 assert!(g.vega > 0.0);
836 }
837
838 #[test]
839 fn implied_vol_roundtrip() {
840 let params = atm_call();
841 let market_price = BSCalculator::price(¶ms).unwrap();
842 let iv = BSCalculator::implied_volatility(market_price, ¶ms, 1e-6, 100).unwrap();
843 assert!((iv - params.volatility).abs() < 1e-4, "IV roundtrip error: {}", iv - params.volatility);
844 }
845
846 #[test]
847 fn implied_vol_invalid_price_returns_none() {
848 assert!(BSCalculator::implied_volatility(-1.0, &atm_call(), 1e-6, 100).is_none());
849 }
850
851 #[test]
852 fn invalid_params_return_none() {
853 let bad = BSParams { spot: -1.0, ..atm_call() };
854 assert!(BSCalculator::price(&bad).is_none());
855 assert!(BSCalculator::greeks(&bad).is_none());
856 }
857
858 #[test]
859 fn vanna_sign_for_atm_call() {
860 let g = BSCalculator::greeks(&atm_call()).unwrap();
863 assert!(g.vanna.is_finite(), "vanna should be finite: {}", g.vanna);
865 }
866
867 #[test]
868 fn speed_is_finite() {
869 let g = BSCalculator::greeks(&atm_call()).unwrap();
870 assert!(g.speed.is_finite(), "speed should be finite: {}", g.speed);
871 }
872}
873
874#[cfg(test)]
877mod tests {
878 use super::*;
879 use rust_decimal_macros::dec;
880
881 fn sample_call() -> OptionSpec {
882 OptionSpec {
883 strike: dec!(100),
884 expiry_days: 30,
885 spot: dec!(100),
886 risk_free_rate: dec!(0.05),
887 volatility: dec!(0.20),
888 option_type: OptionType::Call,
889 }
890 }
891
892 fn sample_put() -> OptionSpec {
893 OptionSpec { option_type: OptionType::Put, ..sample_call() }
894 }
895
896 #[test]
897 fn test_bs_price_call_atm_sanity() {
898 let price = BlackScholes::price(&sample_call()).unwrap();
899 assert!(price > dec!(1) && price < dec!(5), "call price={price}");
901 }
902
903 #[test]
904 fn test_bs_price_put_atm_sanity() {
905 let price = BlackScholes::price(&sample_put()).unwrap();
906 assert!(price > dec!(1) && price < dec!(5), "put price={price}");
907 }
908
909 #[test]
910 fn test_put_call_parity() {
911 let call_price = to_f64(BlackScholes::price(&sample_call()).unwrap()).unwrap();
913 let put_price = to_f64(BlackScholes::price(&sample_put()).unwrap()).unwrap();
914 let s = 100.0_f64;
915 let k = 100.0_f64;
916 let r = 0.05_f64;
917 let t = 30.0_f64 / 365.0;
918 let parity_rhs = s - k * (-r * t).exp();
919 let diff = (call_price - put_price - parity_rhs).abs();
920 assert!(diff < 1e-6, "put-call parity violation: {diff}");
921 }
922
923 #[test]
924 fn test_greeks_call_delta_between_zero_and_one() {
925 let g = BlackScholes::greeks(&sample_call()).unwrap();
926 assert!(g.delta > dec!(0) && g.delta < dec!(1));
927 }
928
929 #[test]
930 fn test_greeks_put_delta_between_neg_one_and_zero() {
931 let g = BlackScholes::greeks(&sample_put()).unwrap();
932 assert!(g.delta > dec!(-1) && g.delta < dec!(0));
933 }
934
935 #[test]
936 fn test_greeks_gamma_positive() {
937 let g = BlackScholes::greeks(&sample_call()).unwrap();
938 assert!(g.gamma > dec!(0));
939 }
940
941 #[test]
942 fn test_greeks_theta_negative_call() {
943 let g = BlackScholes::greeks(&sample_call()).unwrap();
944 assert!(g.theta < dec!(0));
945 }
946
947 #[test]
948 fn test_greeks_vega_positive() {
949 let g = BlackScholes::greeks(&sample_call()).unwrap();
950 assert!(g.vega > dec!(0));
951 }
952
953 #[test]
954 fn test_implied_vol_roundtrip() {
955 let spec = sample_call();
956 let market_price = BlackScholes::price(&spec).unwrap();
957 let iv = BlackScholes::implied_vol(market_price, &spec).unwrap();
958 let diff = (iv - spec.volatility).abs();
959 assert!(diff < dec!(0.0001), "IV roundtrip error: {diff}");
960 }
961
962 #[test]
963 fn test_invalid_strike_errors() {
964 let spec = OptionSpec { strike: dec!(0), ..sample_call() };
965 assert!(BlackScholes::price(&spec).is_err());
966 }
967
968 #[test]
969 fn test_invalid_spot_errors() {
970 let spec = OptionSpec { spot: dec!(-1), ..sample_call() };
971 assert!(BlackScholes::greeks(&spec).is_err());
972 }
973
974 #[test]
975 fn test_straddle_delta_near_zero_atm() {
976 let spread = SpreadGreeks::straddle(
977 dec!(100),
978 dec!(100),
979 30,
980 dec!(0.05),
981 dec!(0.20),
982 );
983 let g = spread.net_greeks().unwrap();
984 assert!(g.delta.abs() < dec!(0.1), "straddle delta={}", g.delta);
986 }
987
988 #[test]
989 fn test_bull_call_spread_positive_delta() {
990 let spread = SpreadGreeks::bull_call_spread(
991 dec!(100),
992 dec!(95),
993 dec!(105),
994 30,
995 dec!(0.05),
996 dec!(0.20),
997 );
998 let g = spread.net_greeks().unwrap();
999 assert!(g.delta > dec!(0));
1000 }
1001
1002 #[test]
1003 fn test_iron_condor_has_four_legs() {
1004 let spread = SpreadGreeks::iron_condor(
1005 dec!(100),
1006 dec!(85),
1007 dec!(90),
1008 dec!(110),
1009 dec!(115),
1010 30,
1011 dec!(0.05),
1012 dec!(0.20),
1013 );
1014 assert_eq!(spread.leg_count(), 4);
1015 }
1016
1017 #[test]
1018 fn test_bear_put_spread_negative_delta() {
1019 let spread = SpreadGreeks::bear_put_spread(
1020 dec!(100),
1021 dec!(95),
1022 dec!(105),
1023 30,
1024 dec!(0.05),
1025 dec!(0.20),
1026 );
1027 let g = spread.net_greeks().unwrap();
1028 assert!(g.delta < dec!(0));
1029 }
1030}