use crate::errors::CurveError;
use super::Interpolator;
#[derive(Debug, Clone)]
pub struct ConvexMonotone {
times: Vec<f64>,
values: Vec<f64>,
log_values: Vec<f64>,
discrete_forwards: Vec<f64>,
fhat: Vec<f64>,
segments: Vec<Segment>,
}
#[derive(Debug, Clone, Copy)]
enum Segment {
QuadraticI { g0: f64, g1: f64 },
FlatThenQuadII { g0: f64, g1: f64, eta: f64 },
QuadThenFlatIII { g0: f64, g1: f64, eta: f64 },
TwoPieceIV { g0: f64, g1: f64, a: f64, eta: f64 },
}
impl ConvexMonotone {
pub fn new(knots: &[(f64, f64)]) -> Result<Self, CurveError> {
if knots.len() < 2 {
return Err(CurveError::TooFewNodes { found: knots.len() });
}
let n = knots.len();
let mut times = Vec::with_capacity(n);
let mut values = Vec::with_capacity(n);
let mut log_values = Vec::with_capacity(n);
for (i, &(t, y)) in knots.iter().enumerate() {
if !t.is_finite() {
return Err(CurveError::InvalidTime { t });
}
if !y.is_finite() || y <= 0.0 {
return Err(CurveError::NonPositiveDiscount {
at_index: i,
value: y,
});
}
if i > 0 {
let prev = times[i - 1];
#[allow(clippy::float_cmp)]
let is_duplicate = t == prev;
if is_duplicate {
return Err(CurveError::DuplicateNode { t });
}
if t < prev {
return Err(CurveError::NodesNotIncreasing { at_index: i });
}
}
times.push(t);
values.push(y);
log_values.push(y.ln());
}
let mut discrete_forwards = Vec::with_capacity(n - 1);
for i in 1..n {
let h = times[i] - times[i - 1];
let f = (log_values[i - 1] - log_values[i]) / h;
discrete_forwards.push(f);
}
let fhat = compute_fhat(×, &discrete_forwards);
let mut segments = Vec::with_capacity(n - 1);
for i in 0..n - 1 {
let f_avg = discrete_forwards[i];
let g0 = fhat[i] - f_avg;
let g1 = fhat[i + 1] - f_avg;
segments.push(classify_segment(g0, g1));
}
Ok(Self {
times,
values,
log_values,
discrete_forwards,
fhat,
segments,
})
}
#[must_use]
#[inline]
pub fn len(&self) -> usize {
self.times.len()
}
#[must_use]
#[inline]
pub fn is_empty(&self) -> bool {
self.times.is_empty()
}
#[must_use]
#[allow(clippy::many_single_char_names)] pub fn forward_at(&self, t: f64) -> f64 {
let n = self.times.len();
if t <= self.times[0] {
return self.fhat[0];
}
if t >= self.times[n - 1] {
return self.fhat[n - 1];
}
let i = self.locate(t);
let h = self.times[i + 1] - self.times[i];
let x = (t - self.times[i]) / h;
let f_avg = self.discrete_forwards[i];
f_avg + segment_g(self.segments[i], x)
}
#[inline]
fn locate(&self, t: f64) -> usize {
let n = self.times.len();
if t <= self.times[0] {
return 0;
}
if t >= self.times[n - 1] {
return n - 2;
}
let mut lo = 0_usize;
let mut hi = n - 1;
while hi - lo > 1 {
let mid = lo + (hi - lo) / 2;
if self.times[mid] <= t {
lo = mid;
} else {
hi = mid;
}
}
lo
}
#[allow(clippy::many_single_char_names)] fn discount_at(&self, t: f64) -> f64 {
let n = self.times.len();
if t <= self.times[0] {
return self.values[0] * (self.fhat[0] * (self.times[0] - t)).exp();
}
if t >= self.times[n - 1] {
return self.values[n - 1] * (-self.fhat[n - 1] * (t - self.times[n - 1])).exp();
}
let i = self.locate(t);
let h = self.times[i + 1] - self.times[i];
let x = (t - self.times[i]) / h;
let f_avg = self.discrete_forwards[i];
let int_g = segment_int_g(self.segments[i], x);
let int_f = h * (f_avg * x + int_g);
(self.log_values[i] - int_f).exp()
}
}
fn compute_fhat(times: &[f64], df: &[f64]) -> Vec<f64> {
let n = times.len();
let mut fhat = vec![0.0_f64; n];
if n == 2 {
fhat[0] = df[0];
fhat[1] = df[0];
return fhat;
}
for i in 1..n - 1 {
let h_left = times[i] - times[i - 1];
let h_right = times[i + 1] - times[i];
let total = h_left + h_right;
fhat[i] = (h_left / total) * df[i] + (h_right / total) * df[i - 1];
}
fhat[0] = df[0] - 0.5 * (fhat[1] - df[0]);
fhat[n - 1] = df[n - 2] - 0.5 * (fhat[n - 2] - df[n - 2]);
clip_endpoint(&mut fhat[0], df[0]);
let last_df = df[n - 2];
let last = n - 1;
clip_endpoint(&mut fhat[last], last_df);
for i in 1..n - 1 {
clip_interior(&mut fhat[i], df[i - 1], df[i]);
}
fhat
}
#[inline]
fn clip_endpoint(fhat: &mut f64, f: f64) {
let cap = 2.0 * f;
if cap >= 0.0 {
*fhat = fhat.clamp(0.0, cap);
} else {
*fhat = fhat.clamp(cap, 0.0);
}
}
#[inline]
fn clip_interior(fhat: &mut f64, f_left: f64, f_right: f64) {
if f_left >= 0.0 && f_right >= 0.0 {
let cap = 2.0 * f_left.min(f_right);
*fhat = fhat.clamp(0.0, cap);
} else if f_left <= 0.0 && f_right <= 0.0 {
let cap = 2.0 * f_left.max(f_right);
*fhat = fhat.clamp(cap, 0.0);
} else {
*fhat = 0.0;
}
}
fn classify_segment(g0: f64, g1: f64) -> Segment {
let s = 2.0 * g0 + g1;
let t = g0 + 2.0 * g1;
if s * t <= 0.0 {
return Segment::QuadraticI { g0, g1 };
}
if g0 * g1 < 0.0 {
if g0.abs() > g1.abs() {
let eta = 3.0 * g1 / (g1 - g0);
Segment::QuadThenFlatIII { g0, g1, eta }
} else {
let eta = (g1 + 2.0 * g0) / (g1 - g0);
Segment::FlatThenQuadII { g0, g1, eta }
}
} else {
let sum = g0 + g1;
let a = -g0 * g1 / sum;
let eta = g1 / sum;
Segment::TwoPieceIV { g0, g1, a, eta }
}
}
fn segment_g(seg: Segment, x: f64) -> f64 {
match seg {
Segment::QuadraticI { g0, g1 } => {
let x2 = x * x;
g0 * (1.0 - 4.0 * x + 3.0 * x2) + g1 * (-2.0 * x + 3.0 * x2)
}
Segment::FlatThenQuadII { g0, g1, eta } => {
if x <= eta {
g0
} else {
let r = (x - eta) / (1.0 - eta);
g0 + (g1 - g0) * r * r
}
}
Segment::QuadThenFlatIII { g0, g1, eta } => {
if x >= eta {
g1
} else {
let r = (eta - x) / eta;
g1 + (g0 - g1) * r * r
}
}
Segment::TwoPieceIV { g0, g1, a, eta } => {
if x <= eta {
let r = (eta - x) / eta;
a + (g0 - a) * r * r
} else {
let r = (x - eta) / (1.0 - eta);
a + (g1 - a) * r * r
}
}
}
}
fn segment_int_g(seg: Segment, x: f64) -> f64 {
match seg {
Segment::QuadraticI { g0, g1 } => {
let x2 = x * x;
let x3 = x2 * x;
g0 * (x - 2.0 * x2 + x3) + g1 * (x3 - x2)
}
Segment::FlatThenQuadII { g0, g1, eta } => {
if x <= eta {
g0 * x
} else {
let r = (x - eta) / (1.0 - eta);
let r3 = r * r * r;
g0 * x + (g1 - g0) * (1.0 - eta) * r3 / 3.0
}
}
Segment::QuadThenFlatIII { g0, g1, eta } => {
if x <= eta {
let r = (eta - x) / eta;
let r3 = r * r * r;
g1 * x + (g0 - g1) * eta * (1.0 - r3) / 3.0
} else {
let full = (g0 - g1) * eta / 3.0;
g1 * x + full
}
}
Segment::TwoPieceIV { g0, g1, a, eta } => {
if x <= eta {
let r = (eta - x) / eta;
let r3 = r * r * r;
a * x + (g0 - a) * eta * (1.0 - r3) / 3.0
} else {
let first = a * eta + (g0 - a) * eta / 3.0;
let r = (x - eta) / (1.0 - eta);
let r3 = r * r * r;
first + a * (x - eta) + (g1 - a) * (1.0 - eta) * r3 / 3.0
}
}
}
}
impl Interpolator for ConvexMonotone {
fn build(knots: &[(f64, f64)]) -> Result<Self, CurveError> {
Self::new(knots)
}
fn eval(&self, t: f64) -> f64 {
self.discount_at(t)
}
fn deriv(&self, t: f64) -> Option<f64> {
Some(-self.forward_at(t) * self.discount_at(t))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_empty() {
let err = ConvexMonotone::new(&[]).unwrap_err();
assert!(matches!(err, CurveError::TooFewNodes { found: 0 }));
}
#[test]
fn rejects_single_knot() {
let err = ConvexMonotone::new(&[(0.0, 1.0)]).unwrap_err();
assert!(matches!(err, CurveError::TooFewNodes { found: 1 }));
}
#[test]
fn rejects_non_monotone_times() {
let err = ConvexMonotone::new(&[(0.0, 1.0), (2.0, 0.9), (1.0, 0.95)]).unwrap_err();
assert!(matches!(
err,
CurveError::NodesNotIncreasing { at_index: 2 }
));
}
#[test]
fn rejects_duplicate_times() {
let err = ConvexMonotone::new(&[(0.0, 1.0), (1.0, 0.95), (1.0, 0.9)]).unwrap_err();
assert!(matches!(err, CurveError::DuplicateNode { .. }));
}
#[test]
fn rejects_negative_value() {
let err = ConvexMonotone::new(&[(0.0, 1.0), (1.0, -0.5)]).unwrap_err();
assert!(matches!(
err,
CurveError::NonPositiveDiscount { at_index: 1, .. }
));
}
#[test]
fn rejects_zero_value() {
let err = ConvexMonotone::new(&[(0.0, 1.0), (1.0, 0.0)]).unwrap_err();
assert!(matches!(
err,
CurveError::NonPositiveDiscount { at_index: 1, .. }
));
}
#[test]
fn rejects_nan_value() {
let err = ConvexMonotone::new(&[(0.0, 1.0), (1.0, f64::NAN)]).unwrap_err();
assert!(matches!(
err,
CurveError::NonPositiveDiscount { at_index: 1, .. }
));
}
#[test]
fn rejects_nan_time() {
let err = ConvexMonotone::new(&[(0.0, 1.0), (f64::NAN, 0.9)]).unwrap_err();
assert!(matches!(err, CurveError::InvalidTime { .. }));
}
#[test]
fn rejects_inf_time() {
let err = ConvexMonotone::new(&[(0.0, 1.0), (f64::INFINITY, 0.9)]).unwrap_err();
assert!(matches!(err, CurveError::InvalidTime { .. }));
}
#[test]
fn knot_reproduction_exact() {
let knots = [
(0.0, 1.0),
(0.25, 0.99),
(0.5, 0.975),
(1.0, 0.95),
(2.0, 0.90),
(5.0, 0.78),
];
let interp = ConvexMonotone::new(&knots).unwrap();
for &(t, y) in &knots {
let v = interp.eval(t);
assert!((v - y).abs() < 1e-12, "knot ({t}, {y}) -> {v}");
}
}
#[test]
fn segment_integrals_match_discrete_forwards() {
let knots = [
(0.0, 1.0),
(0.5, 0.975),
(1.5, 0.93),
(3.0, 0.85),
(5.0, 0.78),
];
let interp = ConvexMonotone::new(&knots).unwrap();
let n = knots.len();
for i in 0..n - 1 {
let (t_lo, y_lo) = knots[i];
let (t_hi, y_hi) = knots[i + 1];
let h = t_hi - t_lo;
let expected_integral = y_lo.ln() - y_hi.ln();
let int_f = -((interp.eval(t_hi) / y_lo).ln());
assert!(
(int_f - expected_integral).abs() < 1e-10,
"segment {i}: integral {int_f}, expected {expected_integral}",
);
let f_avg_expected = (y_lo.ln() - y_hi.ln()) / h;
assert!(
(interp.discrete_forwards[i] - f_avg_expected).abs() < 1e-12,
"segment {i}: discrete forward {} vs expected {f_avg_expected}",
interp.discrete_forwards[i],
);
}
}
#[test]
fn reproduces_linear_discount_factor_at_knots() {
let f = |t: f64| 1.0 - 0.05 * t;
let knots: Vec<(f64, f64)> = [0.0_f64, 0.5, 1.0, 2.0, 5.0]
.iter()
.map(|&t| (t, f(t)))
.collect();
let interp = ConvexMonotone::new(&knots).unwrap();
for &(t, y) in &knots {
let v = interp.eval(t);
assert!((v - y).abs() < 1e-12, "linear knot ({t}, {y}) -> {v}");
}
}
struct Lcg(u64);
impl Lcg {
fn new(seed: u64) -> Self {
Self(seed)
}
#[allow(clippy::cast_possible_truncation)] fn next_u32(&mut self) -> u32 {
self.0 = self.0.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
(self.0 >> 16) as u32
}
fn next_unit(&mut self) -> f64 {
f64::from(self.next_u32()) / f64::from(u32::MAX)
}
}
#[test]
fn random_monotone_discounts_yield_non_negative_forward() {
let mut rng = Lcg::new(0xCAFE_BABE_u64);
for set_idx in 0..20 {
let n = 6 + (rng.next_u32() % 5) as usize;
let mut times = Vec::with_capacity(n);
let mut values = Vec::with_capacity(n);
let mut t = 0.0_f64;
let mut y = 1.0_f64;
for _ in 0..n {
times.push(t);
values.push(y);
t += 0.1 + 0.5 * rng.next_unit();
y *= (-0.005 - 0.08 * rng.next_unit()).exp();
}
let knots: Vec<(f64, f64)> =
times.iter().copied().zip(values.iter().copied()).collect();
let interp = ConvexMonotone::new(&knots).unwrap();
let t_lo = times[0];
let t_hi = times[n - 1];
let grid: u32 = 200;
for k in 0..=grid {
let t = t_lo + (t_hi - t_lo) * f64::from(k) / f64::from(grid);
let f = interp.forward_at(t);
assert!(
f >= -1e-12,
"set {set_idx}: negative forward at t={t}: f={f}",
);
}
}
}
#[test]
fn constant_values_produce_constant_output() {
let knots = [(0.0, 0.9), (1.0, 0.9), (2.5, 0.9), (5.0, 0.9)];
let interp = ConvexMonotone::new(&knots).unwrap();
for &t in &[0.0_f64, 0.3, 1.0, 1.7, 2.5, 3.1, 4.9, 5.0] {
let v = interp.eval(t);
assert!((v - 0.9).abs() < 1e-14, "t={t}: {v} vs 0.9");
}
for &t in &[0.0_f64, 0.5, 1.0, 2.0, 5.0] {
assert!(interp.forward_at(t).abs() < 1e-14);
}
}
#[test]
fn two_knot_is_flat_forward() {
let knots = [(0.0_f64, 1.0_f64), (2.0_f64, (-0.08_f64).exp())];
let interp = ConvexMonotone::new(&knots).unwrap();
for &t in &[0.0_f64, 0.25, 1.0, 1.5, 2.0] {
let f = interp.forward_at(t);
assert!((f - 0.04).abs() < 1e-12, "t={t}: f={f}");
}
for &t in &[0.0_f64, 0.5, 1.0, 1.5, 2.0] {
let expected = (-0.04 * t).exp();
let v = interp.eval(t);
assert!((v - expected).abs() < 1e-12, "t={t}: {v} vs {expected}");
}
}
#[test]
fn deriv_matches_minus_f_times_y() {
let knots = [
(0.0, 1.0),
(0.5, 0.975),
(1.0, 0.95),
(2.0, 0.90),
(5.0, 0.78),
];
let interp = ConvexMonotone::new(&knots).unwrap();
let h = 1e-6_f64;
for &t in &[0.1_f64, 0.7, 1.3, 2.5, 3.7, 4.6] {
let analytic = interp.deriv(t).unwrap();
let fd = (interp.eval(t + h) - interp.eval(t - h)) / (2.0 * h);
assert!(
(analytic - fd).abs() < 1e-6,
"t={t}: analytic={analytic}, fd={fd}",
);
let prod = -interp.forward_at(t) * interp.eval(t);
assert!((analytic - prod).abs() < 1e-12);
}
}
#[test]
fn flat_forward_extrapolation_outside_knot_range() {
let knots = [(0.5_f64, 0.975_f64), (1.0_f64, 0.95_f64), (2.0_f64, 0.90)];
let interp = ConvexMonotone::new(&knots).unwrap();
let f0 = interp.fhat[0];
for &t in &[0.0_f64, 0.1, 0.25] {
let f = interp.forward_at(t);
assert!((f - f0).abs() < 1e-12);
}
let v_left = interp.eval(0.0);
let expected_left = 0.975_f64 * (f0 * 0.5).exp();
assert!((v_left - expected_left).abs() < 1e-12);
let fn_ = interp.fhat[interp.len() - 1];
for &t in &[2.5_f64, 5.0, 10.0] {
let f = interp.forward_at(t);
assert!((f - fn_).abs() < 1e-12);
}
let v_right = interp.eval(3.0);
let expected_right = 0.90_f64 * (-fn_ * 1.0).exp();
assert!((v_right - expected_right).abs() < 1e-12);
}
#[test]
fn tf_quant_finance_forward_rate_fixture() {
let tf_times = [0.25_f64, 0.5, 1.0, 2.0, 3.0];
let tf_dfwd = [0.05_f64, 0.051, 0.052, 0.053, 0.055];
let mut knots = vec![(0.0_f64, 1.0_f64)];
let mut y = 1.0_f64;
let mut t_prev = 0.0_f64;
for (&t, &f) in tf_times.iter().zip(tf_dfwd.iter()) {
y *= (-f * (t - t_prev)).exp();
knots.push((t, y));
t_prev = t;
}
let interp = ConvexMonotone::new(&knots).unwrap();
let test_times = [0.25_f64, 0.5, 1.0, 2.0, 3.0, 1.1];
let expected = [
0.0505_f64,
0.051_333_333_333_333_333,
0.052_333_333_333_333_333,
0.054,
0.0555,
0.052_41,
];
for (&t, &exp) in test_times.iter().zip(expected.iter()) {
let f = interp.forward_at(t);
assert!(
(f - exp).abs() < 1e-9,
"tf-qf forward fixture: t={t}, got {f}, expected {exp}",
);
}
}
#[test]
fn tf_quant_finance_yield_fixture_with_filter() {
let tf_times = [1.0_f64, 2.0, 3.0, 4.0];
let tf_dfwd = [0.05_f64, 0.045, 0.041, 0.055]; let mut knots = vec![(0.0_f64, 1.0_f64)];
let mut y = 1.0_f64;
let mut t_prev = 0.0_f64;
for (&t, &f) in tf_times.iter().zip(tf_dfwd.iter()) {
y *= (-f * (t - t_prev)).exp();
knots.push((t, y));
t_prev = t;
}
let interp = ConvexMonotone::new(&knots).unwrap();
let test_times = [0.25_f64, 0.5, 1.0, 2.0, 3.0, 1.1, 2.5, 2.9, 3.6, 4.0];
let expected_pct = [
5.117_187_5_f64,
5.093_75,
5.0,
4.75,
4.533_333,
4.974_6,
4.624_082,
4.535_422,
4.661_777,
4.775,
];
for (&t, &exp) in test_times.iter().zip(expected_pct.iter()) {
let v = interp.eval(t);
let yld_pct = -v.ln() / t * 100.0;
assert!(
(yld_pct - exp).abs() < 1e-4,
"tf-qf yield fixture: t={t}, got {yld_pct}, expected {exp}",
);
}
}
#[test]
fn build_trait_method_equivalent_to_new() {
let knots = [(0.0, 1.0), (1.0, 0.95), (2.0, 0.9)];
let a = ConvexMonotone::new(&knots).unwrap();
let b = <ConvexMonotone as Interpolator>::build(&knots).unwrap();
assert!((a.eval(0.5) - b.eval(0.5)).abs() < 1e-15);
assert_eq!(a.len(), b.len());
}
#[test]
fn len_and_is_empty() {
let interp = ConvexMonotone::new(&[(0.0, 1.0), (1.0, 0.95), (2.0, 0.9)]).unwrap();
assert_eq!(interp.len(), 3);
assert!(!interp.is_empty());
}
#[test]
fn clone_yields_equivalent_interpolant() {
let interp = ConvexMonotone::new(&[(0.0, 1.0), (1.0, 0.95), (2.0, 0.9)]).unwrap();
let copy = interp.clone();
assert!((interp.eval(0.5) - copy.eval(0.5)).abs() < 1e-15);
assert!((interp.forward_at(0.5) - copy.forward_at(0.5)).abs() < 1e-15);
}
#[test]
fn forward_continuous_at_interior_knots() {
let knots = [
(0.0_f64, 1.0_f64),
(0.5, 0.975),
(1.0, 0.95),
(2.0, 0.90),
(5.0, 0.78),
];
let interp = ConvexMonotone::new(&knots).unwrap();
let h = 1e-7_f64;
for &(t, _) in &knots[1..knots.len() - 1] {
let f_left = interp.forward_at(t - h);
let f_right = interp.forward_at(t + h);
let f_at = interp.forward_at(t);
assert!(
(f_left - f_right).abs() < 1e-5,
"forward discontinuous at t={t}: left={f_left}, right={f_right}",
);
assert!((f_at - f_right).abs() < 1e-5);
}
}
}