use crate::derivatives::basic::Direction;
use crate::derivatives::interestrate::basic::{CapFloorKind, CapStyle, caplet_total_variance};
use crate::derivatives::interestrate::swap::InterestRateSchedulePeriod;
use crate::error::{Error, Result};
use crate::markets::termstructures::yieldcurve::{InterpolationMethodEnum, YieldTermStructure};
use crate::models::common::bachelier::{bachelier_call, bachelier_put};
use crate::patterns::observer::{Observable, Observer};
use crate::time::daycounters::DayCounters;
use crate::time::daycounters::actual365fixed::Actual365Fixed;
use chrono::NaiveDate;
use iso_currency::Currency;
use roots::{SimpleConvergency, find_root_brent};
use std::any::Any;
use std::cell::RefCell;
use std::rc::{Rc, Weak};
#[derive(Debug)]
pub struct CapQuote {
pub strike: f64,
pub notional: f64,
pub direction: Direction,
pub kind: CapFloorKind,
pub style: CapStyle,
pub currency: Currency,
pub schedule: Vec<InterestRateSchedulePeriod>,
pub accrual_day_counter: Box<dyn DayCounters>,
pub market_npv: f64,
}
impl CapQuote {
fn last_accrual_start(&self) -> Result<NaiveDate> {
self.schedule
.last()
.map(|p| p.accrual_start_date)
.ok_or_else(|| {
Error::InvalidData("cap quote must have at least one caplet".to_string())
})
}
}
#[derive(Debug)]
pub struct IRCapMarketData {
pub valuation_date: NaiveDate,
pub cap_quotes: Vec<CapQuote>,
observers: RefCell<Vec<Weak<RefCell<dyn Observer>>>>,
}
impl IRCapMarketData {
pub fn new(valuation_date: NaiveDate, cap_quotes: Vec<CapQuote>) -> Self {
Self {
valuation_date,
cap_quotes,
observers: RefCell::new(Vec::new()),
}
}
}
impl Observable for IRCapMarketData {
fn attach(&mut self, observer: Rc<RefCell<dyn Observer>>) {
self.observers.borrow_mut().push(Rc::downgrade(&observer));
}
fn notify_observers(&self) -> Result<()> {
let observers = self
.observers
.borrow()
.iter()
.filter_map(|w| w.upgrade())
.collect::<Vec<_>>();
for observer_rc in observers {
observer_rc.borrow_mut().update(self)?;
}
Ok(())
}
fn as_any(&self) -> &dyn Any {
self
}
}
#[derive(Clone, Debug)]
pub struct CapletVolPillar {
pub expiry: NaiveDate,
pub nodes: Vec<(f64, f64)>,
}
#[derive(Debug)]
pub struct IRNormalVolSurface {
pub valuation_date: NaiveDate,
pub pillars: Vec<CapletVolPillar>,
}
impl IRNormalVolSurface {
pub fn new(valuation_date: NaiveDate) -> Self {
Self {
valuation_date,
pillars: Vec::new(),
}
}
pub fn rebuild(&mut self, yts: &YieldTermStructure, md: &IRCapMarketData) -> Result<()> {
self.valuation_date = md.valuation_date;
self.pillars = strip_caplet_vols(yts, md)?;
Ok(())
}
pub fn caplet_volatility(&self, expiry: NaiveDate, strike: f64) -> Result<f64> {
if self.pillars.is_empty() {
return Err(Error::InvalidData(
"IRNormalVolSurface has not been stripped (no pillars)".to_string(),
));
}
let pillar = self
.pillars
.iter()
.find(|p| expiry <= p.expiry)
.unwrap_or_else(|| self.pillars.last().unwrap());
interpolate_nodes(&pillar.nodes, strike)
}
}
fn interpolate_nodes(nodes: &[(f64, f64)], strike: f64) -> Result<f64> {
match nodes.len() {
0 => Err(Error::InvalidData(
"caplet vol pillar has no nodes".to_string(),
)),
1 => Ok(nodes[0].1),
_ => {
let first = nodes.first().unwrap();
let last = nodes.last().unwrap();
if strike <= first.0 {
return Ok(first.1);
}
if strike >= last.0 {
return Ok(last.1);
}
for w in nodes.windows(2) {
let (k0, s0) = w[0];
let (k1, s1) = w[1];
if strike >= k0 && strike <= k1 {
let t = (strike - k0) / (k1 - k0);
return Ok(s0 + t * (s1 - s0));
}
}
Ok(last.1)
}
}
}
impl Observer for IRNormalVolSurface {
fn update(&mut self, _observable: &dyn Observable) -> Result<()> {
Ok(())
}
fn as_any(&self) -> &dyn Any {
self
}
}
#[derive(Copy, Clone, Debug)]
struct StripPoint {
expiry: NaiveDate,
sigma: f64,
}
fn strip_caplet_vols(
yts: &YieldTermStructure,
md: &IRCapMarketData,
) -> Result<Vec<CapletVolPillar>> {
let mut groups: Vec<(f64, Vec<&CapQuote>)> = Vec::new();
for q in &md.cap_quotes {
if let Some(slot) = groups
.iter_mut()
.find(|(k, _)| (k - q.strike).abs() < 1.0e-12)
{
slot.1.push(q);
} else {
groups.push((q.strike, vec![q]));
}
}
groups.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
let mut columns: Vec<(f64, Vec<StripPoint>)> = Vec::with_capacity(groups.len());
for (k, qs) in &groups {
let strip = strip_one_strike(yts, md.valuation_date, qs)?;
columns.push((*k, strip));
}
let mut all_expiries: Vec<NaiveDate> = columns
.iter()
.flat_map(|(_, strip)| strip.iter().map(|s| s.expiry))
.collect();
all_expiries.sort();
all_expiries.dedup();
let mut pillars: Vec<CapletVolPillar> = Vec::with_capacity(all_expiries.len());
for exp in all_expiries {
let mut nodes: Vec<(f64, f64)> = columns
.iter()
.filter_map(|(k, strip)| {
strip
.iter()
.rfind(|p| p.expiry <= exp)
.map(|p| (*k, p.sigma))
})
.collect();
nodes.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
if !nodes.is_empty() {
pillars.push(CapletVolPillar { expiry: exp, nodes });
}
}
Ok(pillars)
}
fn strip_one_strike(
yts: &YieldTermStructure,
valuation_date: NaiveDate,
quotes: &[&CapQuote],
) -> Result<Vec<StripPoint>> {
let mut quotes: Vec<&CapQuote> = quotes.to_vec();
quotes.sort_by_key(|q| q.last_accrual_start().unwrap_or(valuation_date));
let mut strip: Vec<StripPoint> = Vec::new();
let mut convergency = SimpleConvergency {
eps: 1e-10_f64,
max_iter: 200,
};
for quote in quotes {
let last_start = quote.last_accrual_start()?;
if let Some(last) = strip.last()
&& last_start <= last.expiry
{
continue;
}
let snapshot = strip.clone();
let mut residual = |trial_sigma: f64| -> f64 {
match price_cap_with_strip(yts, valuation_date, quote, &snapshot, trial_sigma) {
Ok(npv) => npv - quote.market_npv,
Err(_) => f64::NAN,
}
};
let sigma = find_root_brent(1.0e-5_f64, 0.10_f64, &mut residual, &mut convergency)
.map_err(|e| {
Error::InvalidData(format!(
"caplet vol stripping failed at strike {} / expiry {}: {:?}",
quote.strike, last_start, e
))
})?;
strip.push(StripPoint {
expiry: last_start,
sigma,
});
}
Ok(strip)
}
fn price_cap_with_strip(
yts: &YieldTermStructure,
valuation_date: NaiveDate,
quote: &CapQuote,
strip: &[StripPoint],
trial_sigma: f64,
) -> Result<f64> {
let vol_time = Actual365Fixed::default();
let mut npv = 0.0_f64;
let dir_sign = quote.direction as i8 as f64;
for period in "e.schedule {
let sigma = caplet_sigma_for_start(period.accrual_start_date, strip, trial_sigma);
let yf_start = vol_time.year_fraction(valuation_date, period.accrual_start_date)?;
let yf_end = vol_time.year_fraction(valuation_date, period.accrual_end_date)?;
let v = caplet_total_variance(quote.style, sigma, yf_start, yf_end);
let tau = quote
.accrual_day_counter
.year_fraction(period.accrual_start_date, period.accrual_end_date)?;
let df_start = yts.discount(
period.accrual_start_date,
&InterpolationMethodEnum::StepFunctionForward,
)?;
let df_end = yts.discount(
period.accrual_end_date,
&InterpolationMethodEnum::StepFunctionForward,
)?;
let df_pay = yts.discount(
period.pay_date,
&InterpolationMethodEnum::StepFunctionForward,
)?;
let forward = (df_start / df_end - 1.0) / tau;
let opt = match quote.kind {
CapFloorKind::Cap => bachelier_call(forward, quote.strike, v),
CapFloorKind::Floor => bachelier_put(forward, quote.strike, v),
};
npv += dir_sign * quote.notional * tau * df_pay * opt;
}
Ok(npv)
}
fn caplet_sigma_for_start(start: NaiveDate, strip: &[StripPoint], fallback: f64) -> f64 {
for s in strip {
if start <= s.expiry {
return s.sigma;
}
}
fallback
}
#[cfg(test)]
mod tests {
use super::{CapletVolPillar, IRNormalVolSurface};
use chrono::NaiveDate;
#[test]
fn piecewise_flat_right_continuous_lookup() {
let vd = NaiveDate::from_ymd_opt(2026, 4, 22).unwrap();
let mut surface = IRNormalVolSurface::new(vd);
surface.pillars = vec![
CapletVolPillar {
expiry: NaiveDate::from_ymd_opt(2027, 1, 24).unwrap(),
nodes: vec![(0.035, 0.0080)],
},
CapletVolPillar {
expiry: NaiveDate::from_ymd_opt(2028, 1, 24).unwrap(),
nodes: vec![(0.035, 0.0095)],
},
CapletVolPillar {
expiry: NaiveDate::from_ymd_opt(2031, 1, 24).unwrap(),
nodes: vec![(0.035, 0.0090)],
},
];
assert_eq!(
surface
.caplet_volatility(NaiveDate::from_ymd_opt(2027, 1, 24).unwrap(), 0.035)
.unwrap(),
0.0080
);
assert_eq!(
surface
.caplet_volatility(NaiveDate::from_ymd_opt(2026, 7, 24).unwrap(), 0.035)
.unwrap(),
0.0080
);
assert_eq!(
surface
.caplet_volatility(NaiveDate::from_ymd_opt(2027, 7, 24).unwrap(), 0.035)
.unwrap(),
0.0095
);
assert_eq!(
surface
.caplet_volatility(NaiveDate::from_ymd_opt(2035, 1, 1).unwrap(), 0.035)
.unwrap(),
0.0090
);
assert_eq!(
surface
.caplet_volatility(NaiveDate::from_ymd_opt(2027, 1, 24).unwrap(), 0.020)
.unwrap(),
0.0080
);
}
#[test]
fn smile_linear_interpolation_in_strike() {
let vd = NaiveDate::from_ymd_opt(2026, 4, 22).unwrap();
let mut surface = IRNormalVolSurface::new(vd);
let exp = NaiveDate::from_ymd_opt(2031, 1, 24).unwrap();
surface.pillars = vec![CapletVolPillar {
expiry: exp,
nodes: vec![(0.030, 0.0100), (0.035, 0.0090), (0.040, 0.0085)],
}];
let q = |k: f64| surface.caplet_volatility(exp, k).unwrap();
assert!((q(0.030) - 0.0100).abs() < 1e-15);
assert!((q(0.035) - 0.0090).abs() < 1e-15);
assert!((q(0.040) - 0.0085).abs() < 1e-15);
assert!((q(0.0325) - 0.0095).abs() < 1e-15);
assert!((q(0.0375) - 0.00875).abs() < 1e-15);
assert!((q(0.020) - 0.0100).abs() < 1e-15);
assert!((q(0.060) - 0.0085).abs() < 1e-15);
}
#[test]
fn empty_surface_errors() {
let vd = NaiveDate::from_ymd_opt(2026, 4, 22).unwrap();
let surface = IRNormalVolSurface::new(vd);
assert!(
surface
.caplet_volatility(NaiveDate::from_ymd_opt(2027, 1, 1).unwrap(), 0.035)
.is_err()
);
}
}