use crate::errors::{BootstrapError, TypeError};
use crate::types::{Date, Daycount};
use super::{CurveSnapshot, InstrumentLike};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Future {
pub start: Date,
pub end: Date,
pub price: f64,
pub convexity_adjustment: f64,
pub daycount: Daycount,
}
impl Future {
pub fn new(
start: Date,
end: Date,
price: f64,
convexity_adjustment: f64,
daycount: Daycount,
) -> Result<Self, BootstrapError> {
if !price.is_finite() {
return Err(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "future price must be finite",
});
}
if !convexity_adjustment.is_finite() {
return Err(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "future convexity adjustment must be finite",
});
}
if start.days_between(end) <= 0 {
return Err(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "future start must be strictly before end",
});
}
Ok(Self {
start,
end,
price,
convexity_adjustment,
daycount,
})
}
#[inline]
#[must_use]
pub fn quoted_rate(&self) -> f64 {
(100.0 - self.price) / 100.0
}
#[inline]
#[must_use]
pub fn implied_forward_rate(&self) -> f64 {
self.quoted_rate() - self.convexity_adjustment
}
pub fn accrual(&self) -> Result<f64, TypeError> {
self.daycount.year_fraction(self.start, self.end)
}
pub fn implied_discount(&self, discount_at_start: f64) -> Result<f64, BootstrapError> {
if !discount_at_start.is_finite() || discount_at_start <= 0.0 {
return Err(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "discount factor at start must be finite and positive",
});
}
let tau = self.accrual()?;
let growth = 1.0 + self.implied_forward_rate() * tau;
if !growth.is_finite() || growth <= 0.0 {
return Err(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "non-positive accrual factor (1 + r_fwd * tau)",
});
}
Ok(discount_at_start / growth)
}
}
impl InstrumentLike for Future {
#[inline]
fn pillar(&self) -> Date {
self.end
}
fn residual(
&self,
_reference_date: Date,
curve: &CurveSnapshot<'_>,
) -> Result<f64, BootstrapError> {
let tau = self.accrual()?;
let t_start = curve
.daycount
.year_fraction(curve.reference_date, self.start)?;
let t_end = curve
.daycount
.year_fraction(curve.reference_date, self.end)?;
let d_start = curve
.discount_at(t_start)
.ok_or(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "curve snapshot is empty",
})?;
let d_end = curve
.discount_at(t_end)
.ok_or(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "curve snapshot is empty",
})?;
if d_end <= 0.0 {
return Err(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "non-positive discount factor in curve snapshot",
});
}
Ok(d_start / d_end - (1.0 + self.implied_forward_rate() * tau))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::instruments::CurveSnapshot;
fn d(y: i32, m: u32, day: u32) -> Date {
Date::from_ymd(y, m, day).unwrap()
}
#[test]
fn new_accepts_valid_future() {
let fut = Future::new(
d(2024, 3, 20),
d(2024, 6, 19),
95.0,
0.0005,
Daycount::Act360,
)
.unwrap();
assert_eq!(fut.start, d(2024, 3, 20));
assert_eq!(fut.end, d(2024, 6, 19));
assert!((fut.price - 95.0).abs() < 1e-15);
assert!((fut.convexity_adjustment - 0.0005).abs() < 1e-15);
}
#[test]
fn new_accepts_zero_convexity_adjustment() {
let fut = Future::new(d(2024, 3, 20), d(2024, 6, 19), 95.0, 0.0, Daycount::Act360).unwrap();
assert!((fut.implied_forward_rate() - fut.quoted_rate()).abs() < 1e-15);
}
#[test]
fn new_accepts_negative_rate_environment_price_above_100() {
let fut =
Future::new(d(2024, 3, 20), d(2024, 6, 19), 100.5, 0.0, Daycount::Act360).unwrap();
assert!(fut.quoted_rate() < 0.0);
assert!((fut.quoted_rate() + 0.005).abs() < 1e-15);
}
#[test]
fn new_accepts_price_exactly_100() {
let fut =
Future::new(d(2024, 3, 20), d(2024, 6, 19), 100.0, 0.0, Daycount::Act360).unwrap();
assert!((fut.quoted_rate() - 0.0).abs() < 1e-15);
}
#[test]
fn new_rejects_nan_price() {
let err = Future::new(
d(2024, 3, 20),
d(2024, 6, 19),
f64::NAN,
0.0,
Daycount::Act360,
)
.unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn new_rejects_inf_price() {
let err = Future::new(
d(2024, 3, 20),
d(2024, 6, 19),
f64::INFINITY,
0.0,
Daycount::Act360,
)
.unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn new_rejects_nan_convexity_adjustment() {
let err = Future::new(
d(2024, 3, 20),
d(2024, 6, 19),
95.0,
f64::NAN,
Daycount::Act360,
)
.unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn new_rejects_inf_convexity_adjustment() {
let err = Future::new(
d(2024, 3, 20),
d(2024, 6, 19),
95.0,
f64::NEG_INFINITY,
Daycount::Act360,
)
.unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn new_rejects_inverted_dates() {
let err =
Future::new(d(2024, 6, 19), d(2024, 3, 20), 95.0, 0.0, Daycount::Act360).unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn new_rejects_equal_dates() {
let err =
Future::new(d(2024, 3, 20), d(2024, 3, 20), 95.0, 0.0, Daycount::Act360).unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn quoted_rate_matches_price_definition() {
let fut = Future::new(d(2024, 3, 20), d(2024, 6, 19), 95.0, 0.0, Daycount::Act360).unwrap();
assert!((fut.quoted_rate() - 0.05).abs() < 1e-15);
}
#[test]
fn implied_forward_rate_subtracts_convexity_adjustment() {
let fut = Future::new(
d(2024, 3, 20),
d(2024, 6, 19),
95.0,
0.0005,
Daycount::Act360,
)
.unwrap();
assert!((fut.implied_forward_rate() - 0.0495).abs() < 1e-15);
}
#[test]
fn accrual_matches_imm_mar_2024_act360() {
let fut = Future::new(d(2024, 3, 20), d(2024, 6, 19), 95.0, 0.0, Daycount::Act360).unwrap();
let tau = fut.accrual().unwrap();
assert!((tau - 91.0 / 360.0).abs() < 1e-15);
}
#[test]
fn accrual_propagates_business252_error() {
let fut = Future::new(
d(2024, 3, 20),
d(2024, 6, 19),
95.0,
0.0,
Daycount::Business252,
)
.unwrap();
let err = fut.accrual().unwrap_err();
assert!(matches!(err, TypeError::InvalidTenor { .. }));
}
#[test]
fn implied_discount_basic_formula() {
let fut = Future::new(
d(2024, 3, 20),
d(2024, 6, 19),
95.0,
0.0005,
Daycount::Act360,
)
.unwrap();
let d_end = fut.implied_discount(1.0).unwrap();
let tau = 91.0_f64 / 360.0;
let expected = 1.0 / (1.0 + 0.0495 * tau);
assert!((d_end - expected).abs() < 1e-15);
}
#[test]
fn implied_discount_zero_convexity_matches_quoted_rate() {
let fut = Future::new(d(2024, 3, 20), d(2024, 6, 19), 95.0, 0.0, Daycount::Act360).unwrap();
let d_end = fut.implied_discount(1.0).unwrap();
let tau = 91.0_f64 / 360.0;
let expected = 1.0 / (1.0 + 0.05 * tau);
assert!((d_end - expected).abs() < 1e-15);
}
#[test]
fn implied_discount_scales_linearly_in_d_start() {
let fut = Future::new(d(2024, 3, 20), d(2024, 6, 19), 95.0, 0.0, Daycount::Act360).unwrap();
let d1 = fut.implied_discount(1.0).unwrap();
let d2 = fut.implied_discount(0.5).unwrap();
assert!((d2 - 0.5 * d1).abs() < 1e-15);
}
#[test]
fn implied_discount_rejects_non_finite_d_start() {
let fut = Future::new(d(2024, 3, 20), d(2024, 6, 19), 95.0, 0.0, Daycount::Act360).unwrap();
assert!(matches!(
fut.implied_discount(f64::NAN).unwrap_err(),
BootstrapError::InvalidInstrument { .. },
));
assert!(matches!(
fut.implied_discount(f64::INFINITY).unwrap_err(),
BootstrapError::InvalidInstrument { .. },
));
}
#[test]
fn implied_discount_rejects_non_positive_d_start() {
let fut = Future::new(d(2024, 3, 20), d(2024, 6, 19), 95.0, 0.0, Daycount::Act360).unwrap();
assert!(matches!(
fut.implied_discount(0.0).unwrap_err(),
BootstrapError::InvalidInstrument { .. },
));
assert!(matches!(
fut.implied_discount(-0.5).unwrap_err(),
BootstrapError::InvalidInstrument { .. },
));
}
#[test]
fn implied_discount_rejects_non_positive_growth() {
let fut = Future::new(
d(2024, 3, 20),
d(2024, 6, 19),
1000.0,
0.0,
Daycount::Act360,
)
.unwrap();
let err = fut.implied_discount(1.0).unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn implied_discount_propagates_business252_error() {
let fut = Future::new(
d(2024, 3, 20),
d(2024, 6, 19),
95.0,
0.0,
Daycount::Business252,
)
.unwrap();
let err = fut.implied_discount(1.0).unwrap_err();
assert!(matches!(err, BootstrapError::Type(_)));
}
fn curve_consistent_with_forward(
reference: Date,
daycount: Daycount,
start: Date,
end: Date,
r_fwd: f64,
r_c: f64,
) -> (Vec<f64>, Vec<f64>) {
let t_start = daycount.year_fraction(reference, start).unwrap();
let t_end = daycount.year_fraction(reference, end).unwrap();
let tau = daycount.year_fraction(start, end).unwrap();
let d_start = (-r_c * t_start).exp();
let d_end = d_start / (1.0 + r_fwd * tau);
(vec![0.0, t_start, t_end], vec![1.0, d_start, d_end])
}
#[test]
fn future_residual_is_zero_on_self_consistent_curve() {
let reference = d(2024, 1, 2);
let daycount = Daycount::Act360;
let start = d(2024, 3, 20);
let end = d(2024, 6, 19);
let fut = Future::new(start, end, 95.0, 0.0005, daycount).unwrap();
let r_fwd = fut.implied_forward_rate();
assert!((r_fwd - 0.0495).abs() < 1e-15);
let (times, discounts) =
curve_consistent_with_forward(reference, daycount, start, end, r_fwd, 0.04);
let snapshot = CurveSnapshot {
reference_date: reference,
daycount,
times: ×,
discounts: &discounts,
};
let residual = fut.residual(reference, &snapshot).unwrap();
assert!(
residual.abs() < 1e-12,
"residual on self-consistent curve must be zero to 1e-12, got {residual}",
);
}
#[test]
fn future_residual_sign_responds_to_price_perturbation() {
let reference = d(2024, 1, 2);
let daycount = Daycount::Act360;
let start = d(2024, 3, 20);
let end = d(2024, 6, 19);
let par_fut = Future::new(start, end, 95.0, 0.0005, daycount).unwrap();
let r_fwd_par = par_fut.implied_forward_rate();
let (times, discounts) =
curve_consistent_with_forward(reference, daycount, start, end, r_fwd_par, 0.04);
let snapshot = CurveSnapshot {
reference_date: reference,
daycount,
times: ×,
discounts: &discounts,
};
let mispriced = Future::new(start, end, 94.5, 0.0005, daycount).unwrap();
let residual = mispriced.residual(reference, &snapshot).unwrap();
assert!(residual < -1e-6);
}
#[test]
fn future_residual_errors_on_empty_curve_snapshot() {
let reference = d(2024, 1, 2);
let fut = Future::new(d(2024, 3, 20), d(2024, 6, 19), 95.0, 0.0, Daycount::Act360).unwrap();
let snapshot = CurveSnapshot {
reference_date: reference,
daycount: Daycount::Act360,
times: &[],
discounts: &[],
};
let err = fut.residual(reference, &snapshot).unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn future_residual_propagates_business252_error() {
let reference = d(2024, 1, 2);
let fut = Future::new(
d(2024, 3, 20),
d(2024, 6, 19),
95.0,
0.0,
Daycount::Business252,
)
.unwrap();
let snapshot = CurveSnapshot {
reference_date: reference,
daycount: Daycount::Act360,
times: &[0.0_f64, 1.0],
discounts: &[1.0_f64, 0.95],
};
let err = fut.residual(reference, &snapshot).unwrap_err();
assert!(matches!(err, BootstrapError::Type(_)));
}
#[test]
fn future_pillar_is_end_date() {
let fut = Future::new(d(2024, 3, 20), d(2024, 6, 19), 95.0, 0.0, Daycount::Act360).unwrap();
assert_eq!(fut.pillar(), d(2024, 6, 19));
}
#[test]
fn future_discount_roundtrip_through_growth_factor() {
let fut = Future::new(
d(2024, 3, 20),
d(2024, 6, 19),
95.0,
0.0005,
daycount_for_test(),
)
.unwrap();
let d_s = 0.9876;
let d_e = fut.implied_discount(d_s).unwrap();
let tau = fut.accrual().unwrap();
assert!((d_s / d_e - (1.0 + 0.0495 * tau)).abs() < 1e-15);
}
fn daycount_for_test() -> Daycount {
Daycount::Act360
}
}