use proptest::prelude::*;
use black_76::{
SolverConfig, call_price, compute_greeks, intrinsic_value, put_price, solve_iv, vega,
};
fn forward() -> impl Strategy<Value = f64> {
10.0_f64..10_000.0_f64
}
fn time() -> impl Strategy<Value = f64> {
0.05_f64..3.0_f64
}
fn sigma() -> impl Strategy<Value = f64> {
0.05_f64..2.0_f64
}
fn rate() -> impl Strategy<Value = f64> {
-0.05_f64..0.20_f64
}
proptest! {
#![proptest_config(ProptestConfig { cases: 256, .. ProptestConfig::default() })]
#[test]
fn prop_put_call_parity(
f in forward(),
t in time(),
s in sigma(),
r in rate(),
) {
let k = f; let c = call_price(f, k, t, s, r);
let p = put_price(f, k, t, s, r);
let df = (-r * t).exp();
let lhs = c - p;
let rhs = df * (f - k);
prop_assert!(
(lhs - rhs).abs() < 1e-8 * (1.0 + f.abs()),
"parity violated: C - P = {lhs}, df*(F - K) = {rhs} (F={f}, T={t}, sigma={s}, r={r})",
);
let k2 = f * 1.10;
let c2 = call_price(f, k2, t, s, r);
let p2 = put_price(f, k2, t, s, r);
let lhs2 = c2 - p2;
let rhs2 = df * (f - k2);
prop_assert!(
(lhs2 - rhs2).abs() < 1e-8 * (1.0 + f.abs()),
"OTM parity violated: C - P = {lhs2}, df*(F - K) = {rhs2}",
);
}
#[test]
fn prop_iv_roundtrip_call(
f in forward(),
m in 0.85_f64..1.15_f64,
t in 0.10_f64..2.0_f64,
s in 0.10_f64..1.0_f64,
r in -0.02_f64..0.10_f64,
) {
let k = f * m;
let market = call_price(f, k, t, s, r);
let df = (-r * t).exp();
let discounted_intrinsic = df * (f - k).max(0.0);
prop_assume!(market - discounted_intrinsic > 0.01 * f);
let cfg = SolverConfig::default();
let result = solve_iv(market, f, k, t, r, true, &cfg);
prop_assert!(
result.converged,
"solver failed to converge: F={f}, K={k}, T={t}, sigma={s}, r={r}, market={market}, result={result:?}",
);
prop_assert!(
result.residual < 1e-5 * (1.0 + market.abs()),
"price residual {} too large relative to market {market}",
result.residual,
);
prop_assert!(
(result.iv - s).abs() < 1e-2,
"IV diverged by more than 1e-2: solved {} vs true {s}, residual {}",
result.iv, result.residual,
);
}
#[test]
fn prop_vega_finite_difference(
f in forward(),
m in 0.6_f64..1.6_f64,
t in time(),
s in 0.10_f64..1.0_f64,
r in rate(),
) {
let k = f * m;
let h = 1e-5_f64;
let c_up = call_price(f, k, t, s + h, r);
let c_dn = call_price(f, k, t, s - h, r);
let fd = (c_up - c_dn) / (2.0 * h);
let analytic = vega(f, k, t, s, r);
let scale = (analytic.abs() + f).max(1.0);
prop_assert!(
(fd - analytic).abs() < 1e-3 * scale,
"vega FD={fd}, analytic={analytic} (F={f}, K={k}, T={t}, sigma={s}, r={r})",
);
let g = compute_greeks(f, k, t, s, r, true);
prop_assert!(
(g.vega - analytic / 100.0).abs() < 1e-12 * scale,
"Greek vega-per-1% disagrees with raw vega / 100",
);
}
#[test]
fn prop_monotonic_in_sigma(
f in forward(),
m in 0.5_f64..2.0_f64,
t in time(),
s1 in 0.05_f64..0.5_f64,
delta in 0.01_f64..0.5_f64,
r in rate(),
) {
let k = f * m;
let s2 = s1 + delta;
let c1 = call_price(f, k, t, s1, r);
let c2 = call_price(f, k, t, s2, r);
let p1 = put_price(f, k, t, s1, r);
let p2 = put_price(f, k, t, s2, r);
let tol_c = 1e-10 * (c1.abs() + c2.abs() + 1.0);
let tol_p = 1e-10 * (p1.abs() + p2.abs() + 1.0);
prop_assert!(
c2 + tol_c >= c1,
"call not monotonic: sigma={s1}->{c1}, sigma={s2}->{c2}",
);
prop_assert!(
p2 + tol_p >= p1,
"put not monotonic: sigma={s1}->{p1}, sigma={s2}->{p2}",
);
}
#[test]
fn prop_price_bounds(
f in forward(),
m in 0.5_f64..2.0_f64,
t in time(),
s in sigma(),
r in rate(),
) {
let k = f * m;
let df = (-r * t).exp();
let c = call_price(f, k, t, s, r);
let p = put_price(f, k, t, s, r);
let call_intrinsic = intrinsic_value(f, k, true) * df;
let put_intrinsic = intrinsic_value(f, k, false) * df;
let tol = 1e-10 * (f + 1.0);
prop_assert!(
c + tol >= call_intrinsic,
"call below discounted intrinsic: C={c}, intrinsic*df={call_intrinsic}",
);
prop_assert!(
c <= df * f + tol,
"call above df*F: C={c}, df*F={}",
df * f,
);
prop_assert!(
p + tol >= put_intrinsic,
"put below discounted intrinsic: P={p}, intrinsic*df={put_intrinsic}",
);
prop_assert!(
p <= df * k + tol,
"put above df*K: P={p}, df*K={}",
df * k,
);
prop_assert!(c >= -tol, "call must be non-negative");
prop_assert!(p >= -tol, "put must be non-negative");
}
#[test]
fn prop_parity_wide_moneyness(
f in forward(),
m in 0.2_f64..5.0_f64,
t in time(),
s in sigma(),
r in rate(),
) {
let k = f * m;
let c = call_price(f, k, t, s, r);
let p = put_price(f, k, t, s, r);
let df = (-r * t).exp();
let lhs = c - p;
let rhs = df * (f - k);
prop_assert!(
(lhs - rhs).abs() < 1e-8 * (1.0 + f.abs() + k.abs()),
"wide-moneyness parity violated: C-P={lhs}, df(F-K)={rhs} (F={f}, K={k}, T={t}, sigma={s}, r={r})",
);
}
#[test]
fn prop_greeks_finite_and_rho_fd(
f in forward(),
m in 0.7_f64..1.4_f64,
t in time(),
s in 0.10_f64..1.0_f64,
r in rate(),
) {
let k = f * m;
let g = compute_greeks(f, k, t, s, r, true);
prop_assert!(
g.delta.is_finite() && g.gamma.is_finite() && g.vega.is_finite()
&& g.theta.is_finite() && g.rho.is_finite(),
"non-finite greek: {g:?} (F={f}, K={k}, T={t}, sigma={s}, r={r})",
);
prop_assert!(g.gamma >= 0.0, "gamma must be non-negative, got {}", g.gamma);
let hr = 1e-6;
let c_up = call_price(f, k, t, s, r + hr);
let c_dn = call_price(f, k, t, s, r - hr);
let fd_rho = ((c_up - c_dn) / (2.0 * hr)) / 100.0;
let rho_scale = (g.rho.abs() + f).max(1.0);
prop_assert!(
(g.rho - fd_rho).abs() < 1e-4 * rho_scale,
"rho analytic {} vs FD {} (F={f}, K={k}, T={t}, sigma={s}, r={r})",
g.rho, fd_rho,
);
let hf = 1e-4 * f;
let fd_delta = (call_price(f + hf, k, t, s, r) - call_price(f - hf, k, t, s, r)) / (2.0 * hf);
prop_assert!(
(g.delta - fd_delta).abs() < 1e-4 * (g.delta.abs() + 1.0),
"delta analytic {} vs FD {} (F={f}, K={k}, T={t}, sigma={s}, r={r})",
g.delta, fd_delta,
);
let dt = 1e-4;
let theta_year_fd = -(call_price(f, k, t + dt, s, r) - call_price(f, k, t - dt, s, r)) / (2.0 * dt);
let theta_year_analytic = g.theta * 365.25;
let theta_scale = (theta_year_analytic.abs() + f).max(1.0);
prop_assert!(
(theta_year_analytic - theta_year_fd).abs() < 1e-3 * theta_scale,
"theta/yr analytic {} vs FD {} (F={f}, K={k}, T={t}, sigma={s}, r={r})",
theta_year_analytic, theta_year_fd,
);
}
}