use crate::derivatives::basic::BasicInfo;
use crate::derivatives::forex::basic::CurrencyValue;
use crate::derivatives::interestrate::basic::{
CapFloorKind, CapStyle, IRDerivatives, RateShiftMode, caplet_total_variance,
};
use crate::derivatives::interestrate::swap::InterestRateSchedulePeriod;
use crate::error::Error;
use crate::error::Result;
use crate::markets::interestrate::market_context::IrMarketContext;
use crate::markets::interestrate::volsurface::IRNormalVolSurface;
use crate::markets::termstructures::yieldcurve::{InterpolationMethodEnum, YieldTermStructure};
use crate::models::common::bachelier::{bachelier_call, bachelier_put, bachelier_vega_variance};
use crate::time::daycounters::DayCounters;
use crate::time::daycounters::actual365fixed::Actual365Fixed;
use iso_currency::Currency;
use serde::{Deserialize, Serialize};
#[derive(Deserialize, Serialize, Debug)]
pub struct CapFloor {
pub basic_info: BasicInfo,
pub kind: CapFloorKind,
pub style: CapStyle,
pub currency: Currency,
pub notional: f64,
pub strike: f64,
pub valuation_date: chrono::NaiveDate,
pub schedule: Vec<InterestRateSchedulePeriod>,
pub accrual_day_counter: Box<dyn DayCounters>,
}
impl CapFloor {
fn direction_sign(&self) -> f64 {
self.basic_info.direction as i8 as f64
}
fn caplet_market(
&self,
period: &InterestRateSchedulePeriod,
yts: &YieldTermStructure,
vs: &IRNormalVolSurface,
rate_shift_bp: f64,
vol_shift_bp: f64,
) -> Result<CapletMarket> {
let method = &InterpolationMethodEnum::StepFunctionForward;
let tau = self
.accrual_day_counter
.year_fraction(period.accrual_start_date, period.accrual_end_date)?;
let df_start = yts.shifted_discount(period.accrual_start_date, method, rate_shift_bp)?;
let df_end = yts.shifted_discount(period.accrual_end_date, method, rate_shift_bp)?;
let df_pay = yts.shifted_discount(period.pay_date, method, rate_shift_bp)?;
let forward = (df_start / df_end - 1.0) / tau;
let vol_time = Actual365Fixed::default();
let yf_start = vol_time.year_fraction(self.valuation_date, period.accrual_start_date)?;
let yf_end = vol_time.year_fraction(self.valuation_date, period.accrual_end_date)?;
let sigma_base = vs.caplet_volatility(period.accrual_start_date, self.strike)?;
let sigma = sigma_base + vol_shift_bp * 1.0e-4;
let variance = caplet_total_variance(self.style, sigma, yf_start, yf_end);
Ok(CapletMarket {
tau,
df_pay,
forward,
variance,
})
}
fn pv_under_shift(
&self,
yts: &YieldTermStructure,
vs: &IRNormalVolSurface,
rate_shift_bp: f64,
vol_shift_bp: f64,
) -> Result<f64> {
let mut pv = 0.0_f64;
for period in &self.schedule {
let m = self.caplet_market(period, yts, vs, rate_shift_bp, vol_shift_bp)?;
let opt = match self.kind {
CapFloorKind::Cap => bachelier_call(m.forward, self.strike, m.variance),
CapFloorKind::Floor => bachelier_put(m.forward, self.strike, m.variance),
};
pv += self.direction_sign() * self.notional * m.tau * m.df_pay * opt;
}
Ok(pv)
}
}
struct CapletMarket {
tau: f64,
df_pay: f64,
forward: f64,
variance: f64,
}
fn unpack(market: &IrMarketContext) -> Result<(&YieldTermStructure, &IRNormalVolSurface)> {
let vs = market.cap_surface.as_ref().ok_or_else(|| {
Error::InvalidData("CapFloor: IrMarketContext.cap_surface must be present".to_string())
})?;
Ok((&market.curve, vs))
}
impl IRDerivatives for CapFloor {
fn mtm(&self, market: &IrMarketContext) -> Result<CurrencyValue> {
let (yts, vs) = unpack(market)?;
let pv = self.pv_under_shift(yts, vs, 0.0, 0.0)?;
Ok(CurrencyValue {
currency: self.currency,
value: pv,
})
}
fn dv01(&self, market: &IrMarketContext) -> Result<f64> {
let (yts, vs) = unpack(market)?;
let base = self.pv_under_shift(yts, vs, 0.0, 0.0)?;
let up = self.pv_under_shift(yts, vs, 1.0, 0.0)?;
Ok(up - base)
}
fn gamma(
&self,
market: &IrMarketContext,
rate_shift_bp: f64,
mode: RateShiftMode,
) -> Result<f64> {
ensure_supported_mode(mode)?;
let (yts, vs) = unpack(market)?;
let base = self.pv_under_shift(yts, vs, 0.0, 0.0)?;
let up = self.pv_under_shift(yts, vs, rate_shift_bp, 0.0)?;
let down = self.pv_under_shift(yts, vs, -rate_shift_bp, 0.0)?;
Ok(up + down - 2.0 * base)
}
fn vega(&self, market: &IrMarketContext, vol_shift_bp: f64) -> Result<f64> {
let (yts, vs) = unpack(market)?;
let base = self.pv_under_shift(yts, vs, 0.0, 0.0)?;
let up = self.pv_under_shift(yts, vs, 0.0, vol_shift_bp)?;
Ok(up - base)
}
}
fn ensure_supported_mode(mode: RateShiftMode) -> Result<()> {
match mode {
RateShiftMode::Zeros => Ok(()),
other => Err(Error::InvalidData(format!(
"rate shift mode {:?} is not yet implemented; only Zeros is supported",
other
))),
}
}
#[allow(dead_code)]
fn analytic_vega_1bp(
cap: &CapFloor,
yts: &YieldTermStructure,
vs: &IRNormalVolSurface,
) -> Result<f64> {
let vol_time = Actual365Fixed::default();
let mut vega = 0.0_f64;
for period in &cap.schedule {
let m = cap.caplet_market(period, yts, vs, 0.0, 0.0)?;
if m.variance <= 0.0 {
continue;
}
let yf_start = vol_time.year_fraction(cap.valuation_date, period.accrual_start_date)?;
let yf_end = vol_time.year_fraction(cap.valuation_date, period.accrual_end_date)?;
let sigma = vs.caplet_volatility(period.accrual_start_date, cap.strike)?;
if sigma <= 0.0 {
continue;
}
let t_var = match cap.style {
CapStyle::ForwardLooking => yf_start.max(0.0),
CapStyle::BackwardCompounded => {
let te_minus_ts = yf_end - yf_start;
if yf_start >= 0.0 {
yf_start + te_minus_ts / 3.0
} else {
yf_end.powi(3) / (3.0 * te_minus_ts * te_minus_ts)
}
}
};
let dv_dsigma = 2.0 * sigma * t_var;
let vega_per_v = bachelier_vega_variance(m.forward, cap.strike, m.variance);
vega += cap.direction_sign()
* cap.notional
* m.tau
* m.df_pay
* vega_per_v
* dv_dsigma
* 1.0e-4;
}
Ok(vega)
}
#[cfg(test)]
mod tests {
use super::{CapFloor, CapFloorKind, CapStyle, IRDerivatives};
use crate::derivatives::basic::{BasicInfo, Direction, Style};
use crate::derivatives::interestrate::basic::{
DEFAULT_RATE_SHIFT_BP, DEFAULT_VOL_SHIFT_BP, RateShiftMode,
};
use crate::derivatives::interestrate::swap::InterestRateSchedulePeriod;
use crate::error::Result;
use crate::markets::interestrate::market_context::IrMarketContext;
use crate::markets::interestrate::volsurface::{
CapQuote, CapletVolPillar, IRCapMarketData, IRNormalVolSurface,
};
use crate::markets::termstructures::yieldcurve::{
InterestRateQuoteEnum, StrippedCurve, YieldTermStructure,
};
use crate::time::calendars::UnitedStates;
use crate::time::daycounters::actual360::Actual360;
use crate::time::daycounters::actual365fixed::Actual365Fixed;
use chrono::NaiveDate;
use iso_currency::Currency;
fn clone_yts(yts: &YieldTermStructure) -> YieldTermStructure {
YieldTermStructure::new(
Box::new(UnitedStates::default()),
Box::new(Actual365Fixed::default()),
yts.valuation_date,
yts.stripped_curves.clone(),
)
}
#[test]
fn usd_sofr_5y_cap_matches_expected_reference() -> Result<()> {
let curve_date = NaiveDate::from_ymd_opt(2026, 4, 22).unwrap();
let valuation_date = curve_date;
let yts = build_expected_usd_sofr_curve(curve_date, valuation_date);
let d = |y, m, dd| NaiveDate::from_ymd_opt(y, m, dd).unwrap();
let notional = 10_000_000.0_f64;
let strike = 0.03543236;
let schedule = expected_sofr_5y_schedule();
let quote = CapQuote {
strike,
notional,
direction: Direction::Buy,
kind: CapFloorKind::Cap,
style: CapStyle::BackwardCompounded,
currency: Currency::USD,
schedule: schedule.clone(),
accrual_day_counter: Box::new(Actual360),
market_npv: 237_665.49,
};
let md = IRCapMarketData::new(valuation_date, vec![quote]);
let mut vs = IRNormalVolSurface::new(valuation_date);
vs.rebuild(&yts, &md)?;
assert_eq!(vs.pillars.len(), 1);
assert_eq!(vs.pillars[0].nodes.len(), 1);
let (node_strike, sigma) = vs.pillars[0].nodes[0];
assert!((node_strike - strike).abs() < 1e-12);
assert!(
(0.005..0.020).contains(&sigma),
"stripped σ {} (= {:.1} bp) outside plausible SOFR 5Y band",
sigma,
sigma * 10_000.0
);
let cap = CapFloor {
basic_info: BasicInfo {
trade_date: valuation_date,
style: Style::IRSwap, direction: Direction::Buy,
expiry_date: d(2031, 4, 24),
delivery_date: d(2031, 4, 28),
},
kind: CapFloorKind::Cap,
style: CapStyle::BackwardCompounded,
currency: Currency::USD,
notional,
strike,
valuation_date,
schedule,
accrual_day_counter: Box::new(Actual360),
};
let ctx = IrMarketContext::new(valuation_date, Currency::USD, yts, Some(vs));
let mtm = cap.mtm(&ctx)?;
assert!(
(mtm.value - 237_665.49).abs() < 1.0,
"repriced NPV {} vs expected reference 237,665.49",
mtm.value
);
assert_eq!(mtm.currency, Currency::USD);
let vega = cap.vega(&ctx, DEFAULT_VOL_SHIFT_BP)?;
let ref_vega = 2_656.12_f64;
let err_pct = (vega - ref_vega).abs() / ref_vega;
assert!(
err_pct < 0.10,
"Vega(1bp) {} vs expected reference {} — {:.2}% off",
vega,
ref_vega,
err_pct * 100.0
);
let dv01 = cap.dv01(&ctx)?;
let gamma_10bp = cap.gamma(&ctx, DEFAULT_RATE_SHIFT_BP, RateShiftMode::default())?;
let gamma_1bp = cap.gamma(&ctx, 1.0, RateShiftMode::default())?;
let mod_dur = cap.modified_duration(&ctx)?;
assert!(dv01 > 0.0, "long cap DV01 should be positive, got {}", dv01);
assert!(
gamma_10bp > 0.0,
"long cap gamma(10bp) should be positive, got {}",
gamma_10bp
);
let ratio = gamma_10bp / gamma_1bp.max(1e-12);
assert!(
(70.0..130.0).contains(&ratio),
"gamma(10bp)/gamma(1bp) = {:.1}, expected ≈ 100",
ratio
);
assert!(vega > 0.0);
assert!(
mod_dur < 0.0,
"long cap modified duration should be negative (PV rises with rates), got {}",
mod_dur
);
Ok(())
}
#[test]
fn unsupported_rate_shift_mode_errors() -> Result<()> {
let curve_date = NaiveDate::from_ymd_opt(2026, 4, 22).unwrap();
let valuation_date = curve_date;
let yts = build_expected_usd_sofr_curve(curve_date, valuation_date);
let d = |y, m, dd| NaiveDate::from_ymd_opt(y, m, dd).unwrap();
let notional = 10_000_000.0_f64;
let strike = 0.03543236;
let schedule = expected_sofr_5y_schedule();
let quote = CapQuote {
strike,
notional,
direction: Direction::Buy,
kind: CapFloorKind::Cap,
style: CapStyle::BackwardCompounded,
currency: Currency::USD,
schedule: schedule.clone(),
accrual_day_counter: Box::new(Actual360),
market_npv: 237_665.49,
};
let md = IRCapMarketData::new(valuation_date, vec![quote]);
let mut vs = IRNormalVolSurface::new(valuation_date);
vs.rebuild(&yts, &md)?;
let cap = CapFloor {
basic_info: BasicInfo {
trade_date: valuation_date,
style: Style::IRSwap,
direction: Direction::Buy,
expiry_date: d(2031, 4, 24),
delivery_date: d(2031, 4, 28),
},
kind: CapFloorKind::Cap,
style: CapStyle::BackwardCompounded,
currency: Currency::USD,
notional,
strike,
valuation_date,
schedule,
accrual_day_counter: Box::new(Actual360),
};
let ctx = IrMarketContext::new(valuation_date, Currency::USD, yts, Some(vs));
for unsupported in [
RateShiftMode::Instruments,
RateShiftMode::Forwards,
RateShiftMode::Swaps,
] {
let err = cap.gamma(&ctx, DEFAULT_RATE_SHIFT_BP, unsupported);
assert!(
err.is_err(),
"expected error for unsupported mode {:?}",
unsupported
);
}
Ok(())
}
#[test]
fn smile_strike_bootstrap_roundtrip() -> Result<()> {
let curve_date = NaiveDate::from_ymd_opt(2026, 4, 22).unwrap();
let valuation_date = curve_date;
let yts = build_expected_usd_sofr_curve(curve_date, valuation_date);
let d = |y, m, dd| NaiveDate::from_ymd_opt(y, m, dd).unwrap();
let notional = 10_000_000.0_f64;
let atm = 0.03543236;
let inputs: [(f64, f64); 3] = [
(atm - 0.0050, 0.0078),
(atm, 0.0085),
(atm + 0.0050, 0.0092),
];
let schedule = expected_sofr_5y_schedule();
let last_accrual_start = schedule.last().unwrap().accrual_start_date;
let quotes: Vec<CapQuote> = inputs
.iter()
.map(|(k, sigma)| {
let cap = CapFloor {
basic_info: BasicInfo {
trade_date: valuation_date,
style: Style::IRSwap,
direction: Direction::Buy,
expiry_date: d(2031, 4, 24),
delivery_date: d(2031, 4, 28),
},
kind: CapFloorKind::Cap,
style: CapStyle::BackwardCompounded,
currency: Currency::USD,
notional,
strike: *k,
valuation_date,
schedule: schedule.clone(),
accrual_day_counter: Box::new(Actual360),
};
let mut vs = IRNormalVolSurface::new(valuation_date);
vs.pillars = vec![CapletVolPillar {
expiry: last_accrual_start,
nodes: vec![(*k, *sigma)],
}];
let ctx =
IrMarketContext::new(valuation_date, Currency::USD, clone_yts(&yts), Some(vs));
let npv = cap.mtm(&ctx).unwrap().value;
CapQuote {
strike: *k,
notional,
direction: Direction::Buy,
kind: CapFloorKind::Cap,
style: CapStyle::BackwardCompounded,
currency: Currency::USD,
schedule: schedule.clone(),
accrual_day_counter: Box::new(Actual360),
market_npv: npv,
}
})
.collect();
let md = IRCapMarketData::new(valuation_date, quotes);
let mut vs = IRNormalVolSurface::new(valuation_date);
vs.rebuild(&yts, &md)?;
assert_eq!(vs.pillars.len(), 1, "all three quotes share one expiry");
let pillar = &vs.pillars[0];
assert_eq!(pillar.expiry, last_accrual_start);
assert_eq!(pillar.nodes.len(), 3, "three strikes ⇒ three nodes");
for (i, (expected_k, expected_sigma)) in inputs.iter().enumerate() {
let (k, sigma) = pillar.nodes[i];
assert!((k - expected_k).abs() < 1.0e-12);
assert!(
(sigma - expected_sigma).abs() < 1.0e-6,
"round-trip σ at K={}: got {}, expected {}",
k,
sigma,
expected_sigma
);
}
let mid_strike = atm + 0.0025;
let expected_mid = 0.5 * (inputs[1].1 + inputs[2].1);
let sigma_mid = vs.caplet_volatility(last_accrual_start, mid_strike)?;
assert!(
(sigma_mid - expected_mid).abs() < 1.0e-6,
"mid-strike σ: got {}, expected {}",
sigma_mid,
expected_mid
);
let sigma_far_put = vs.caplet_volatility(last_accrual_start, 0.010)?;
assert!((sigma_far_put - inputs[0].1).abs() < 1.0e-6);
let sigma_far_call = vs.caplet_volatility(last_accrual_start, 0.080)?;
assert!((sigma_far_call - inputs[2].1).abs() < 1.0e-6);
Ok(())
}
fn pillar(
first_settle: NaiveDate,
date: NaiveDate,
market_rate: f64,
zero_rate: f64,
discount: f64,
) -> StrippedCurve {
StrippedCurve {
first_settle_date: first_settle,
date,
market_rate,
zero_rate,
discount,
source: InterestRateQuoteEnum::Swap,
hidden_pillar: false,
}
}
fn build_expected_usd_sofr_curve(
curve_date: NaiveDate,
valuation_date: NaiveDate,
) -> YieldTermStructure {
let d = |y, m, dd| NaiveDate::from_ymd_opt(y, m, dd).unwrap();
let stripped_curves = vec![
pillar(curve_date, d(2026, 7, 28), 0.0366740, 0.0370115, 0.990614),
pillar(curve_date, d(2026, 10, 28), 0.0367845, 0.0369477, 0.981249),
pillar(curve_date, d(2027, 1, 27), 0.0368130, 0.0366428, 0.963633),
pillar(curve_date, d(2027, 4, 28), 0.0363006, 0.0362530, 0.946743),
pillar(curve_date, d(2027, 7, 27), 0.0359295, 0.0357673, 0.930690),
pillar(curve_date, d(2027, 10, 27), 0.0357000, 0.0355700, 0.915000),
pillar(curve_date, d(2028, 1, 26), 0.0355274, 0.0353648, 0.899078),
pillar(curve_date, d(2028, 4, 26), 0.0355985, 0.0354437, 0.883300),
pillar(curve_date, d(2028, 7, 26), 0.0356600, 0.0354800, 0.867564),
pillar(curve_date, d(2028, 10, 26), 0.0357300, 0.0355400, 0.851900),
pillar(curve_date, d(2029, 1, 26), 0.0358000, 0.0356000, 0.836300),
pillar(curve_date, d(2029, 4, 26), 0.0358700, 0.0356600, 0.820800),
pillar(curve_date, d(2029, 7, 26), 0.0359125, 0.0357764, 0.835959),
pillar(curve_date, d(2029, 10, 26), 0.0360000, 0.0358200, 0.790000),
pillar(curve_date, d(2030, 1, 28), 0.0360800, 0.0359000, 0.774500),
pillar(curve_date, d(2030, 4, 26), 0.0361500, 0.0359700, 0.759500),
pillar(curve_date, d(2030, 7, 26), 0.0362200, 0.0360400, 0.744600),
pillar(curve_date, d(2030, 10, 28), 0.0362900, 0.0361100, 0.729500),
pillar(curve_date, d(2031, 1, 27), 0.0363600, 0.0361800, 0.714500),
pillar(curve_date, d(2031, 4, 28), 0.0363860, 0.0362811, 0.803898),
pillar(curve_date, d(2032, 4, 28), 0.0369019, 0.0368388, 0.772305),
];
YieldTermStructure::new(
Box::new(UnitedStates::default()),
Box::new(Actual365Fixed::default()),
valuation_date,
stripped_curves,
)
}
fn expected_sofr_5y_schedule() -> Vec<InterestRateSchedulePeriod> {
let notional = 10_000_000.0_f64;
let d = |y, m, dd| NaiveDate::from_ymd_opt(y, m, dd).unwrap();
let rows: &[(NaiveDate, NaiveDate, NaiveDate)] = &[
(d(2026, 4, 24), d(2026, 7, 24), d(2026, 7, 28)),
(d(2026, 7, 24), d(2026, 10, 26), d(2026, 10, 28)),
(d(2026, 10, 26), d(2027, 1, 25), d(2027, 1, 27)),
(d(2027, 1, 25), d(2027, 4, 26), d(2027, 4, 28)),
(d(2027, 4, 26), d(2027, 7, 26), d(2027, 7, 28)),
(d(2027, 7, 26), d(2027, 10, 25), d(2027, 10, 27)),
(d(2027, 10, 25), d(2028, 1, 24), d(2028, 1, 26)),
(d(2028, 1, 24), d(2028, 4, 24), d(2028, 4, 26)),
(d(2028, 4, 24), d(2028, 7, 24), d(2028, 7, 26)),
(d(2028, 7, 24), d(2028, 10, 24), d(2028, 10, 26)),
(d(2028, 10, 24), d(2029, 1, 24), d(2029, 1, 26)),
(d(2029, 1, 24), d(2029, 4, 24), d(2029, 4, 26)),
(d(2029, 4, 24), d(2029, 7, 24), d(2029, 7, 26)),
(d(2029, 7, 24), d(2029, 10, 24), d(2029, 10, 26)),
(d(2029, 10, 24), d(2030, 1, 24), d(2030, 1, 28)),
(d(2030, 1, 24), d(2030, 4, 24), d(2030, 4, 26)),
(d(2030, 4, 24), d(2030, 7, 24), d(2030, 7, 26)),
(d(2030, 7, 24), d(2030, 10, 24), d(2030, 10, 28)),
(d(2030, 10, 24), d(2031, 1, 24), d(2031, 1, 27)),
(d(2031, 1, 24), d(2031, 4, 24), d(2031, 4, 28)),
];
rows.iter()
.map(|(start, end, pay)| InterestRateSchedulePeriod {
accrual_start_date: *start,
accrual_end_date: *end,
pay_date: *pay,
reset_date: *start,
amortisation_amounts: 0.0,
balance: notional,
})
.collect()
}
}