#[cfg(test)]
mod test {
use crate::math::normal::inverse_cdf;
use crate::math::optimize::NelderMeadOptions;
use crate::models::common::cir::CirProcess;
use crate::models::common::simulation::{DatedPaths, simulate_at_dates};
use crate::models::forex::fx_hhw::{Correlation4x4, FxHhwParams, FxHhwSimulator};
use crate::models::forex::fx_hhw_calibrator::{
CalibrationResult, CalibrationTarget, calibrate, calibrate_bounded,
};
use crate::models::interestrate::hull_white::HullWhite1F;
use crate::time::daycounters::actual365fixed::Actual365Fixed;
use chrono::NaiveDate;
const VALUATION: (i32, u32, u32) = (2026, 4, 22);
const SPOT: f64 = 1.17095;
const MC_PATHS: usize = 100_000;
const MC_SEED: u64 = 20_260_422;
struct Pillar {
expiry: NaiveDate,
tenor: f64,
forward: f64,
atm: f64,
p25: f64,
c25: f64,
p10: f64,
c10: f64,
expected_ci: Option<(f64, f64)>,
}
fn pillars() -> Vec<Pillar> {
let d = |y: i32, m: u32, dd: u32| NaiveDate::from_ymd_opt(y, m, dd).unwrap();
vec![
Pillar {
expiry: d(2027, 4, 22),
tenor: 1.0,
forward: 1.1865,
atm: 0.0663,
p25: 0.06855,
c25: 0.07125,
p10: 0.077225,
c10: 0.082775,
expected_ci: Some((1.0454, 1.3273)),
},
Pillar {
expiry: d(2028, 4, 20),
tenor: 2.0,
forward: 1.1984,
atm: 0.07025,
p25: 0.072775,
c25: 0.075375,
p10: 0.081775,
c10: 0.087125,
expected_ci: Some((0.9860, 1.4108)),
},
Pillar {
expiry: d(2029, 4, 22),
tenor: 3.0,
forward: 1.2099,
atm: 0.072625,
p25: 0.075275,
c25: 0.077775,
p10: 0.084335,
c10: 0.08961,
expected_ci: None,
},
Pillar {
expiry: d(2031, 4, 22),
tenor: 5.0,
forward: 1.23395,
atm: 0.076925,
p25: 0.08006,
c25: 0.08164,
p10: 0.08940,
c10: 0.09295,
expected_ci: Some((0.8600, 1.6089)),
},
]
}
fn sofr_anchors() -> Vec<(f64, f64)> {
vec![
(0.0, 0.036509),
(1.0, 0.036920),
(2.0, 0.036142),
(3.0, 0.035746),
(4.0, 0.035784),
(5.0, 0.036091),
(7.0, 0.037032),
(10.0, 0.038491),
]
}
fn estr_anchors() -> Vec<(f64, f64)> {
vec![
(0.0, 0.020427),
(1.0, 0.023817),
(2.0, 0.024690),
(3.0, 0.024920),
(4.0, 0.025328),
(5.0, 0.025773),
(7.0, 0.026775),
(10.0, 0.028150),
]
}
fn curve_at(nodes: &[(f64, f64)], tau: f64) -> f64 {
if tau <= nodes[0].0 {
return nodes[0].1;
}
let last = nodes.last().unwrap();
if tau >= last.0 {
return last.1;
}
for w in nodes.windows(2) {
if tau >= w[0].0 && tau <= w[1].0 {
let a = (tau - w[0].0) / (w[1].0 - w[0].0);
return w[0].1 + a * (w[1].1 - w[0].1);
}
}
last.1
}
fn curve_slope_at(nodes: &[(f64, f64)], tau: f64) -> f64 {
if tau <= nodes[0].0 || tau >= nodes.last().unwrap().0 {
return 0.0;
}
for w in nodes.windows(2) {
if tau >= w[0].0 && tau <= w[1].0 {
return (w[1].1 - w[0].1) / (w[1].0 - w[0].0);
}
}
0.0
}
fn jamshidian_theta(nodes: &[(f64, f64)], lambda: f64, eta: f64, tau: f64) -> f64 {
let f = curve_at(nodes, tau);
let df_dt = curve_slope_at(nodes, tau);
let convex = eta * eta / (2.0 * lambda * lambda) * (1.0 - (-2.0 * lambda * tau).exp());
f + df_dt / lambda + convex
}
fn strike_from_call_delta(delta: f64, sigma: f64, fwd: f64, tau: f64) -> f64 {
let sqrt_t = tau.sqrt();
let d1 = inverse_cdf(delta);
fwd * (0.5 * sigma * sigma * tau - d1 * sigma * sqrt_t).exp()
}
fn strike_from_put_delta(delta: f64, sigma: f64, fwd: f64, tau: f64) -> f64 {
let sqrt_t = tau.sqrt();
let d1 = inverse_cdf(1.0 - delta);
fwd * (0.5 * sigma * sigma * tau - d1 * sigma * sqrt_t).exp()
}
fn atm_strike(fwd: f64, sigma: f64, tau: f64) -> f64 {
fwd * (0.5 * sigma * sigma * tau).exp()
}
#[derive(Copy, Clone, Debug)]
enum Variant {
FivePoint,
ThreePoint,
FivePointGammaBounded { gamma_max: f64 },
}
fn static_hw_d() -> HullWhite1F {
HullWhite1F {
mean_reversion: 0.01,
sigma: 0.007,
}
}
fn static_hw_f() -> HullWhite1F {
HullWhite1F {
mean_reversion: 0.05,
sigma: 0.012,
}
}
fn static_corr(rho_xi_sigma: f64) -> Correlation4x4 {
Correlation4x4 {
rho_xi_sigma,
rho_xi_d: -0.15,
rho_xi_f: -0.15,
rho_sigma_d: 0.30,
rho_sigma_f: 0.30,
rho_d_f: 0.25,
}
}
fn build_targets(pi: &Pillar, five_pt: bool) -> Vec<CalibrationTarget> {
let k_atm = atm_strike(pi.forward, pi.atm, pi.tenor);
let k_25p = strike_from_put_delta(0.25, pi.p25, pi.forward, pi.tenor);
let k_25c = strike_from_call_delta(0.25, pi.c25, pi.forward, pi.tenor);
let mut out = vec![
CalibrationTarget {
strike: k_25p,
market_vol: pi.p25,
},
CalibrationTarget {
strike: k_atm,
market_vol: pi.atm,
},
CalibrationTarget {
strike: k_25c,
market_vol: pi.c25,
},
];
if five_pt {
let k_10p = strike_from_put_delta(0.10, pi.p10, pi.forward, pi.tenor);
let k_10c = strike_from_call_delta(0.10, pi.c10, pi.forward, pi.tenor);
out.insert(
0,
CalibrationTarget {
strike: k_10p,
market_vol: pi.p10,
},
);
out.push(CalibrationTarget {
strike: k_10c,
market_vol: pi.c10,
});
}
out
}
fn calibrate_one(variant: Variant, pi: &Pillar) -> CalibrationResult {
let five_pt = matches!(
variant,
Variant::FivePoint | Variant::FivePointGammaBounded { .. }
);
let targets = build_targets(pi, five_pt);
let rho_seed = if pi.c25 > pi.p25 { 0.20 } else { -0.20 };
let rd_0 = curve_at(&sofr_anchors(), 0.0);
let rf_0 = curve_at(&estr_anchors(), 0.0);
let initial = FxHhwParams {
fx_0: SPOT,
heston: CirProcess {
kappa: 1.0,
theta: pi.atm * pi.atm,
gamma: match variant {
Variant::FivePointGammaBounded { .. } => 0.15,
_ => 0.30,
},
sigma_0: pi.atm * pi.atm,
},
domestic: static_hw_d(),
foreign: static_hw_f(),
rd_0,
rf_0,
theta_d: rd_0,
theta_f: rf_0,
correlations: static_corr(rho_seed),
};
let options = NelderMeadOptions {
max_iter: 400,
ftol: 1.0e-9,
xtol: 1.0e-8,
step_frac: 0.10,
};
match variant {
Variant::FivePoint | Variant::ThreePoint => {
calibrate(initial, &targets, pi.tenor, 1.0e-3, options)
}
Variant::FivePointGammaBounded { gamma_max } => {
calibrate_bounded(initial, &targets, pi.tenor, 1.0e-3, gamma_max, options)
}
}
}
struct MonteCarlo {
paths: DatedPaths<crate::models::forex::fx_hhw::FxHhwState>,
}
impl MonteCarlo {
fn fx_at(&self, date: NaiveDate) -> Vec<f64> {
self.paths.sample(date, |s| s.fx).expect("date in grid")
}
fn rd_at(&self, date: NaiveDate) -> Vec<f64> {
self.paths.sample(date, |s| s.rd).expect("date in grid")
}
}
fn run_mc(
params: FxHhwParams,
observation: NaiveDate,
n_paths: usize,
seed: u64,
) -> MonteCarlo {
let valuation = NaiveDate::from_ymd_opt(VALUATION.0, VALUATION.1, VALUATION.2).unwrap();
let dc = Actual365Fixed::default();
let lambda_d = params.domestic.mean_reversion;
let eta_d = params.domestic.sigma;
let lambda_f = params.foreign.mean_reversion;
let eta_f = params.foreign.sigma;
let sofr = sofr_anchors();
let estr = estr_anchors();
let mut sim = FxHhwSimulator::new(params, seed)
.unwrap()
.with_theta_fn(move |tau| {
(
jamshidian_theta(&sofr, lambda_d, eta_d, tau),
jamshidian_theta(&estr, lambda_f, eta_f, tau),
)
});
let paths = simulate_at_dates(&mut sim, valuation, &[observation], n_paths, 1, &dc);
MonteCarlo { paths }
}
fn percentiles(values: &mut [f64], lo_p: f64, hi_p: f64) -> (f64, f64) {
values.sort_by(|a, b| a.partial_cmp(b).unwrap());
let n = values.len();
let lo = values[(n as f64 * lo_p) as usize];
let hi = values[(n as f64 * hi_p) as usize];
(lo, hi)
}
fn mean_of(values: &[f64]) -> f64 {
values.iter().sum::<f64>() / values.len() as f64
}
#[test]
fn smile_rmse_is_acceptable_at_every_expiry_five_point() {
for pi in pillars() {
let cal = calibrate_one(Variant::FivePoint, &pi);
assert!(
cal.rmse < 5.0e-4,
"T={}Y 5-pt smile RMSE {:.3} bp vol > 5 bp",
pi.tenor,
cal.rmse * 10_000.0
);
}
}
#[test]
fn smile_rmse_is_acceptable_at_every_expiry_three_point() {
for pi in pillars() {
let cal = calibrate_one(Variant::ThreePoint, &pi);
assert!(
cal.rmse < 5.0e-4,
"T={}Y 3-pt smile RMSE {:.3} bp vol > 5 bp",
pi.tenor,
cal.rmse * 10_000.0
);
}
}
#[test]
fn smile_rmse_is_acceptable_with_gamma_bound() {
for pi in pillars() {
let cal = calibrate_one(Variant::FivePointGammaBounded { gamma_max: 0.25 }, &pi);
assert!(
cal.rmse < 1.0e-3,
"T={}Y γ-bounded smile RMSE {:.3} bp vol > 10 bp",
pi.tenor,
cal.rmse * 10_000.0
);
assert!(cal.params.heston.gamma <= 0.25 + 1.0e-9);
}
}
#[test]
#[ignore = "Monte Carlo regression — run with --ignored in --release"]
fn mc_forward_martingale_holds_at_every_expiry() {
for pi in pillars() {
let cal = calibrate_one(Variant::FivePoint, &pi);
let mc = run_mc(cal.params, pi.expiry, MC_PATHS, MC_SEED);
let fx = mc.fx_at(pi.expiry);
let m = mean_of(&fx);
assert!(
(m - pi.forward).abs() < 0.01 * pi.forward,
"T={}Y: E[ξ] {} vs fwd {} — drift {:.2} bp",
pi.tenor,
m,
pi.forward,
(m - pi.forward).abs() / pi.forward * 10_000.0,
);
}
}
#[test]
#[ignore = "Monte Carlo regression — run with --ignored in --release"]
fn mc_sofr_mean_tracks_market_curve() {
for pi in pillars() {
let cal = calibrate_one(Variant::FivePoint, &pi);
let mc = run_mc(cal.params, pi.expiry, MC_PATHS, MC_SEED);
let rd = mc.rd_at(pi.expiry);
let rd_mean = mean_of(&rd);
let rd_market = curve_at(&sofr_anchors(), pi.tenor);
assert!(
(rd_mean - rd_market).abs() < 10.0e-4,
"T={}Y: SOFR̄ {:.4} vs market {:.4}",
pi.tenor,
rd_mean,
rd_market
);
}
}
#[test]
#[ignore = "Monte Carlo regression — run with --ignored in --release"]
fn mc_gamma_bounded_tails_align_with_expected_at_long_tenors() {
for pi in pillars() {
let Some((expected_p5, expected_p95)) = pi.expected_ci else {
continue;
};
let cal = calibrate_one(Variant::FivePointGammaBounded { gamma_max: 0.25 }, &pi);
let mc = run_mc(cal.params, pi.expiry, MC_PATHS, MC_SEED);
let mut fx = mc.fx_at(pi.expiry);
let (p5, p95) = percentiles(&mut fx, 0.05, 0.95);
let model_sig = (p95 / p5).ln() / (2.0 * 1.645 * pi.tenor.sqrt());
let expected_sig = (expected_p95 / expected_p5).ln() / (2.0 * 1.645 * pi.tenor.sqrt());
assert!(
(model_sig - expected_sig).abs() < 50.0e-4,
"T={}Y: model σ-eq {:.3}%, expected {:.3}% (Δ={:.3}%)",
pi.tenor,
model_sig * 100.0,
expected_sig * 100.0,
(model_sig - expected_sig) * 100.0,
);
}
}
#[test]
#[ignore = "Monte Carlo regression — run with --ignored in --release"]
fn mc_tails_are_wider_than_atm_but_bounded() {
for pi in pillars() {
let cal = calibrate_one(Variant::FivePoint, &pi);
let mc = run_mc(cal.params, pi.expiry, MC_PATHS, MC_SEED);
let mut fx = mc.fx_at(pi.expiry);
let (p5, p95) = percentiles(&mut fx, 0.05, 0.95);
let sig_eq = (p95 / p5).ln() / (2.0 * 1.645 * pi.tenor.sqrt());
assert!(
sig_eq > pi.atm * 0.95,
"T={}Y: σ-eq {:.3}% < 0.95·ATM {:.3}%",
pi.tenor,
sig_eq * 100.0,
pi.atm * 100.0
);
assert!(
sig_eq < 2.0 * pi.atm,
"T={}Y: σ-eq {:.3}% > 2·ATM {:.3}%",
pi.tenor,
sig_eq * 100.0,
pi.atm * 100.0
);
}
}
use crate::models::forex::sabr::{SabrParams, SabrSimulator};
use crate::models::forex::sabr_calibrator::{
CalibrationResult as SabrCalResult, calibrate as calibrate_sabr,
targets_from_grid as sabr_targets_from_grid,
};
fn calibrate_sabr_one(pi: &Pillar) -> SabrCalResult {
let hhw_targets = build_targets(pi, true);
let strikes: Vec<f64> = hhw_targets.iter().map(|t| t.strike).collect();
let vols: Vec<f64> = hhw_targets.iter().map(|t| t.market_vol).collect();
let targets = sabr_targets_from_grid(&strikes, &vols);
let initial = SabrParams::new(pi.atm, 0.5, -0.20, 0.30);
let options = NelderMeadOptions {
max_iter: 600,
ftol: 1.0e-10,
xtol: 1.0e-8,
step_frac: 0.10,
};
calibrate_sabr(initial, pi.forward, &targets, pi.tenor, options)
}
#[test]
fn sabr_smile_rmse_is_acceptable_at_every_expiry() {
for pi in pillars() {
let cal = calibrate_sabr_one(&pi);
assert!(
cal.rmse < 15.0e-4,
"T={}Y SABR smile RMSE {:.3} bp vol > 15 bp — cal {:?}",
pi.tenor,
cal.rmse * 10_000.0,
cal.params,
);
}
}
#[test]
#[ignore = "Monte Carlo regression — run with --ignored in --release"]
fn sabr_mc_forward_martingale_holds_at_every_expiry() {
for pi in pillars() {
let cal = calibrate_sabr_one(&pi);
let mut sim = SabrSimulator::new(cal.params, pi.forward, MC_SEED);
let n_steps = (pi.tenor * 365.0).ceil() as usize;
let terminals = sim.simulate(pi.tenor, n_steps, MC_PATHS);
let mean: f64 = terminals.iter().map(|s| s.forward).sum::<f64>() / MC_PATHS as f64;
let rel = (mean - pi.forward).abs() / pi.forward;
assert!(
rel < 0.01,
"T={}Y: SABR E[F] {} vs fwd {} — drift {:.2} bp",
pi.tenor,
mean,
pi.forward,
rel * 10_000.0,
);
}
}
#[test]
#[ignore = "Monte Carlo regression — run with --ignored in --release"]
fn sabr_mc_tails_align_with_vendor_ci_at_long_tenors() {
for pi in pillars() {
let Some((expected_p5, expected_p95)) = pi.expected_ci else {
continue;
};
let cal = calibrate_sabr_one(&pi);
let mut sim = SabrSimulator::new(cal.params, pi.forward, MC_SEED);
let n_steps = (pi.tenor * 365.0).ceil() as usize;
let terminals = sim.simulate(pi.tenor, n_steps, MC_PATHS);
let mut fx: Vec<f64> = terminals.iter().map(|s| s.forward).collect();
let (p5, p95) = percentiles(&mut fx, 0.05, 0.95);
let model_sig = (p95 / p5).ln() / (2.0 * 1.645 * pi.tenor.sqrt());
let expected_sig = (expected_p95 / expected_p5).ln() / (2.0 * 1.645 * pi.tenor.sqrt());
assert!(
(model_sig - expected_sig).abs() < 75.0e-4,
"T={}Y: SABR σ-eq {:.3}% vs vendor {:.3}% (Δ={:.3}%)",
pi.tenor,
model_sig * 100.0,
expected_sig * 100.0,
(model_sig - expected_sig) * 100.0,
);
}
}
#[test]
#[ignore = "Monte Carlo regression — run with --ignored in --release"]
fn sabr_mc_tails_are_wider_than_atm_but_bounded() {
for pi in pillars() {
let cal = calibrate_sabr_one(&pi);
let mut sim = SabrSimulator::new(cal.params, pi.forward, MC_SEED);
let n_steps = (pi.tenor * 365.0).ceil() as usize;
let terminals = sim.simulate(pi.tenor, n_steps, MC_PATHS);
let mut fx: Vec<f64> = terminals.iter().map(|s| s.forward).collect();
let (p5, p95) = percentiles(&mut fx, 0.05, 0.95);
let sig_eq = (p95 / p5).ln() / (2.0 * 1.645 * pi.tenor.sqrt());
assert!(
sig_eq > pi.atm * 0.90,
"T={}Y: SABR σ-eq {:.3}% < 0.90·ATM {:.3}%",
pi.tenor,
sig_eq * 100.0,
pi.atm * 100.0
);
assert!(
sig_eq < 2.0 * pi.atm,
"T={}Y: SABR σ-eq {:.3}% > 2·ATM {:.3}%",
pi.tenor,
sig_eq * 100.0,
pi.atm * 100.0
);
}
}
#[test]
#[ignore = "Diagnostic report — run with --ignored in --release, --nocapture"]
fn mc_report_table() {
use crate::models::forex::dupire_local_vol::build as dupire_build;
use crate::models::forex::sabr_slv::TimeDependentSabrSlvSimulator;
use crate::models::forex::sabr_time_dependent::TimeDependentSabrSimulator;
use crate::models::forex::sabr_time_dependent_calibrator::{
PillarTarget, calibrate_time_dependent,
};
let sabr_pillars: Vec<PillarTarget> = pillars()
.iter()
.map(|pi| {
let hhw_targets = build_targets(pi, true);
PillarTarget {
expiry: pi.tenor,
forward: pi.forward,
strikes: hhw_targets.iter().map(|t| t.strike).collect(),
market_vols: hhw_targets.iter().map(|t| t.market_vol).collect(),
}
})
.collect();
let options = NelderMeadOptions {
max_iter: 600,
ftol: 1.0e-10,
xtol: 1.0e-8,
step_frac: 0.10,
};
let td_res = calibrate_time_dependent(&sabr_pillars, 0.5, pillars()[0].forward, options);
let exp_grid: Vec<f64> = pillars().iter().map(|p| p.tenor).collect();
let k_grid: Vec<f64> = (0..11).map(|i| SPOT * (0.7 + 0.06 * i as f64)).collect();
let surface = eurusd_vol_surface();
let valuation = NaiveDate::from_ymd_opt(VALUATION.0, VALUATION.1, VALUATION.2).unwrap();
let mut vol_grid: Vec<Vec<f64>> = Vec::new();
for pi in pillars() {
let row: Vec<f64> = k_grid
.iter()
.map(|&k| surface.volatility(pi.expiry, k).unwrap_or(pi.atm))
.collect();
vol_grid.push(row);
}
let _ = valuation;
let dupire = dupire_build(&exp_grid, &k_grid, &vol_grid, SPOT, 0.0, 0.0);
eprintln!("\n{:=<96}", "");
eprintln!(" CALIBRATED PARAMETERS (per pillar)");
eprintln!("{:-<96}", "");
eprintln!(
"{:>3} | {:>8} | {:>42}",
"T", "model", "parameters (from Nelder-Mead on 5-strike smile)"
);
eprintln!("{:-<96}", "");
for pi in pillars() {
let hhw_cal = calibrate_one(Variant::FivePointGammaBounded { gamma_max: 0.25 }, &pi);
eprintln!(
"{:>3.0}Y | {:>8} | κ={:.3} γ={:.3} σ̄={:.4} σ₀={:.4} ρ_ξσ={:+.3}",
pi.tenor,
"HHW",
hhw_cal.params.heston.kappa,
hhw_cal.params.heston.gamma,
hhw_cal.params.heston.theta,
hhw_cal.params.heston.sigma_0,
hhw_cal.params.correlations.rho_xi_sigma,
);
}
for pi in pillars() {
let sabr_cal = calibrate_sabr_one(&pi);
eprintln!(
"{:>3.0}Y | {:>8} | α={:.4} ρ={:+.3} ν={:.4} (β=0.5 fixed)",
pi.tenor, "SABR", sabr_cal.params.alpha, sabr_cal.params.rho, sabr_cal.params.nu,
);
}
eprintln!(
" — | {:>8} | one shared schedule across all pillars",
"SABR-T",
);
let knots = &td_res.params.alpha.knots;
eprintln!(" {:>8} | knots (Y): {:?}", "", knots);
eprintln!(
" {:>8} | α segments: {:?}",
"",
td_res
.params
.alpha
.values
.iter()
.map(|v| format!("{:.4}", v))
.collect::<Vec<_>>()
);
eprintln!(
" {:>8} | ρ segments: {:?}",
"",
td_res
.params
.rho
.values
.iter()
.map(|v| format!("{:+.3}", v))
.collect::<Vec<_>>()
);
eprintln!(
" {:>8} | ν segments: {:?}",
"",
td_res
.params
.nu
.values
.iter()
.map(|v| format!("{:.4}", v))
.collect::<Vec<_>>()
);
eprintln!(
" — | {:>8} | uses SABR-T schedule + Dupire LV (rebuilt from FXVolSurface)",
"SABR-SLV",
);
eprintln!("{:=<96}\n", "");
eprintln!("{:=<96}", "");
eprintln!(" HEADLINE METRICS");
eprintln!("{:-<96}", "");
eprintln!(
"{:>3} | {:>8} | {:>9} | {:>10} | {:>7} | {:>7} | {:>10} | {:>10}",
"T", "model", "smile RMSE", "E[X] drift", "σ-eq %", "vs ATM", "vs vendor", "notes",
);
eprintln!("{:-<96}", "");
let mut tail_rows: Vec<String> = Vec::new();
for pi in pillars() {
let hhw_cal = calibrate_one(Variant::FivePointGammaBounded { gamma_max: 0.25 }, &pi);
let hhw_mc = run_mc(hhw_cal.params, pi.expiry, MC_PATHS, MC_SEED);
let hhw_fx = hhw_mc.fx_at(pi.expiry);
let (hhw_drift_bp, hhw_sig) = drift_and_sig(&hhw_fx, pi.forward, pi.tenor);
let hhw_vs_vendor = vs_vendor(&pi, hhw_sig);
let sofr_rd = hhw_mc.rd_at(pi.expiry);
let sofr_err_bp = (mean_of(&sofr_rd) - curve_at(&sofr_anchors(), pi.tenor)) * 10_000.0;
report_row(
pi.tenor,
"HHW",
hhw_cal.rmse,
hhw_drift_bp,
hhw_sig,
pi.atm,
hhw_vs_vendor,
&format!("SOFR Δ={:.1} bp", sofr_err_bp),
);
tail_rows.push(tail_row(&pi, "HHW", &hhw_fx));
let sabr_cal = calibrate_sabr_one(&pi);
let mut sabr_sim = SabrSimulator::new(sabr_cal.params, pi.forward, MC_SEED);
let n_steps = (pi.tenor * 365.0).ceil() as usize;
let sabr_terms = sabr_sim.simulate(pi.tenor, n_steps, MC_PATHS);
let sabr_fx: Vec<f64> = sabr_terms.iter().map(|s| s.forward).collect();
let (sabr_drift_bp, sabr_sig) = drift_and_sig(&sabr_fx, pi.forward, pi.tenor);
let sabr_vs_vendor = vs_vendor(&pi, sabr_sig);
report_row(
pi.tenor,
"SABR",
sabr_cal.rmse,
sabr_drift_bp,
sabr_sig,
pi.atm,
sabr_vs_vendor,
"β=0.5 fixed",
);
tail_rows.push(tail_row(&pi, "SABR", &sabr_fx));
use crate::models::forex::sabr_time_dependent::TimeDependentSabrParams;
let td_params = TimeDependentSabrParams::new(
td_res.params.alpha.clone(),
td_res.params.rho.clone(),
td_res.params.nu.clone(),
td_res.params.beta,
pi.forward,
);
let mut td_sim = TimeDependentSabrSimulator::new(td_params.clone(), MC_SEED);
let td_terms = td_sim.simulate(pi.tenor, n_steps, MC_PATHS);
let td_fx: Vec<f64> = td_terms.iter().map(|s| s.forward).collect();
let (td_drift_bp, td_sig) = drift_and_sig(&td_fx, pi.forward, pi.tenor);
let td_vs_vendor = vs_vendor(&pi, td_sig);
let td_pillar_rmse = td_res
.per_pillar
.iter()
.find(|d| (d.expiry - pi.tenor).abs() < 1e-6)
.map(|d| d.stage1_rmse)
.unwrap_or(0.0);
report_row(
pi.tenor,
"SABR-T",
td_pillar_rmse,
td_drift_bp,
td_sig,
pi.atm,
td_vs_vendor,
"stage-1 RMSE",
);
tail_rows.push(tail_row(&pi, "SABR-T", &td_fx));
let mut slv_sim =
TimeDependentSabrSlvSimulator::new(td_params.clone(), dupire.clone(), MC_SEED)
.with_bins(40);
let slv_terms = slv_sim.simulate(pi.tenor, n_steps, MC_PATHS);
let slv_fx: Vec<f64> = slv_terms.iter().map(|s| s.forward).collect();
let (slv_drift_bp, slv_sig) = drift_and_sig(&slv_fx, pi.forward, pi.tenor);
let slv_vs_vendor = vs_vendor(&pi, slv_sig);
report_row(
pi.tenor,
"SABR-SLV",
td_pillar_rmse, slv_drift_bp,
slv_sig,
pi.atm,
slv_vs_vendor,
"Dupire LV",
);
tail_rows.push(tail_row(&pi, "SABR-SLV", &slv_fx));
eprintln!("{:-<96}", "");
}
eprintln!("{:=<96}\n", "");
eprintln!("{:=<96}", "");
eprintln!(" 2-SIDED 90 % CI — put-wing / call-wing split");
eprintln!("{:-<96}", "");
eprintln!(
"{:>3} | {:>8} | {:>7} | {:>7} | {:>7} | {:>7} | {:>10} | {:>10}",
"T", "model", "p5", "p95", "σ_down%", "σ_up%", "p5 vs vnd", "p95 vs vnd",
);
eprintln!("{:-<96}", "");
for row in &tail_rows {
eprintln!("{}", row);
}
eprintln!("{:=<96}\n", "");
eprintln!("{:=<96}", "");
eprintln!(" VENDOR 90 % CI bands (FXFO-style, published pillars)");
eprintln!("{:-<96}", "");
eprintln!(
"{:>3} | {:>7} | {:>7} | {:>7} | {:>7}",
"T", "vnd p5", "vnd p95", "σ_down%", "σ_up%"
);
eprintln!("{:-<96}", "");
for pi in pillars() {
if let Some((vp5, vp95)) = pi.expected_ci {
let sd = -(vp5 / pi.forward).ln() / (1.645 * pi.tenor.sqrt());
let su = (vp95 / pi.forward).ln() / (1.645 * pi.tenor.sqrt());
eprintln!(
"{:>3.0}Y | {:>7.4} | {:>7.4} | {:>6.2}% | {:>6.2}%",
pi.tenor,
vp5,
vp95,
sd * 100.0,
su * 100.0,
);
} else {
eprintln!(
"{:>3.0}Y | {:>7} | {:>7} | {:>7} | {:>7}",
pi.tenor, "—", "—", "—", "—"
);
}
}
eprintln!("{:=<96}\n", "");
}
fn tail_row(pi: &Pillar, model: &str, fx: &[f64]) -> String {
let mut sorted: Vec<f64> = fx.to_vec();
let (p5, p95) = percentiles(&mut sorted, 0.05, 0.95);
let sd = -(p5 / pi.forward).ln() / (1.645 * pi.tenor.sqrt());
let su = (p95 / pi.forward).ln() / (1.645 * pi.tenor.sqrt());
let (p5_vs_vnd, p95_vs_vnd) = pi
.expected_ci
.map(|(vp5, vp95)| {
let vsd = -(vp5 / pi.forward).ln() / (1.645 * pi.tenor.sqrt());
let vsu = (vp95 / pi.forward).ln() / (1.645 * pi.tenor.sqrt());
(
format!("{:+.1} bp", (sd - vsd) * 10_000.0),
format!("{:+.1} bp", (su - vsu) * 10_000.0),
)
})
.unwrap_or_else(|| ("—".into(), "—".into()));
format!(
"{:>3.0}Y | {:>8} | {:>7.4} | {:>7.4} | {:>6.2}% | {:>6.2}% | {:>10} | {:>10}",
pi.tenor,
model,
p5,
p95,
sd * 100.0,
su * 100.0,
p5_vs_vnd,
p95_vs_vnd,
)
}
fn drift_and_sig(fx: &[f64], fwd: f64, tenor: f64) -> (f64, f64) {
let mean: f64 = fx.iter().sum::<f64>() / fx.len() as f64;
let drift_bp = (mean - fwd) / fwd * 10_000.0;
let mut sorted: Vec<f64> = fx.to_vec();
let (p5, p95) = percentiles(&mut sorted, 0.05, 0.95);
let sig = (p95 / p5).ln() / (2.0 * 1.645 * tenor.sqrt());
(drift_bp, sig)
}
fn vs_vendor(pi: &Pillar, sig: f64) -> Option<f64> {
pi.expected_ci.map(|(ep5, ep95)| {
let evs = (ep95 / ep5).ln() / (2.0 * 1.645 * pi.tenor.sqrt());
(sig - evs) * 100.0
})
}
fn report_row(
tenor: f64,
model: &str,
rmse: f64,
drift_bp: f64,
sig: f64,
atm: f64,
vs_vendor: Option<f64>,
notes: &str,
) {
eprintln!(
"{:>3.0}Y | {:>8} | {:>6.2} bp | {:>7.1} bp | {:>6.2}% | {:>6.2}x | {:>10} | {}",
tenor,
model,
rmse * 10_000.0,
drift_bp,
sig * 100.0,
sig / atm,
vs_vendor
.map(|b| format!("{:+.1} bp", b * 100.0))
.unwrap_or_else(|| "—".into()),
notes,
);
}
use crate::markets::forex::quotes::volsurface::{FXDeltaVolPillar, FXVolQuote, FXVolSurface};
use crate::models::forex::market_data::smile_strip;
fn eurusd_vol_surface() -> FXVolSurface {
let val = NaiveDate::from_ymd_opt(VALUATION.0, VALUATION.1, VALUATION.2).unwrap();
let fx_pillars: Vec<FXDeltaVolPillar> = pillars()
.into_iter()
.map(|pi| FXDeltaVolPillar {
expiry: pi.expiry,
forward: pi.forward,
quotes: vec![
FXVolQuote::Atm(pi.atm),
FXVolQuote::Put {
delta: 0.25,
vol: pi.p25,
},
FXVolQuote::Call {
delta: 0.25,
vol: pi.c25,
},
FXVolQuote::Put {
delta: 0.10,
vol: pi.p10,
},
FXVolQuote::Call {
delta: 0.10,
vol: pi.c10,
},
],
})
.collect();
FXVolSurface::new(val, fx_pillars).expect("EURUSD surface builds")
}
#[test]
fn markets_pipeline_sabr_calibration_holds_at_every_expiry() {
let val = NaiveDate::from_ymd_opt(VALUATION.0, VALUATION.1, VALUATION.2).unwrap();
let surface = eurusd_vol_surface();
for pi in pillars() {
let k_atm = atm_strike(pi.forward, pi.atm, pi.tenor);
let k_25p = strike_from_put_delta(0.25, pi.p25, pi.forward, pi.tenor);
let k_25c = strike_from_call_delta(0.25, pi.c25, pi.forward, pi.tenor);
let k_10p = strike_from_put_delta(0.10, pi.p10, pi.forward, pi.tenor);
let k_10c = strike_from_call_delta(0.10, pi.c10, pi.forward, pi.tenor);
let strikes = vec![k_10p, k_25p, k_atm, k_25c, k_10c];
let strip = smile_strip(&surface, val, pi.expiry, pi.forward, &strikes)
.expect("surface should evaluate at every quoted strike");
let targets = strip.sabr_targets();
let initial = SabrParams::new(pi.atm, 0.5, -0.20, 0.30);
let options = NelderMeadOptions {
max_iter: 600,
ftol: 1.0e-10,
xtol: 1.0e-8,
step_frac: 0.10,
};
let cal = calibrate_sabr(initial, pi.forward, &targets, pi.tenor, options);
assert!(
cal.rmse < 20.0e-4,
"T={}Y markets-pipeline SABR RMSE {:.3} bp vol > 20 bp",
pi.tenor,
cal.rmse * 10_000.0,
);
}
}
}