use crate::compat::Instant;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use serde::{Deserialize, Serialize};
use crate::error::CorpFinanceError;
use crate::types::{with_metadata, ComputationOutput, Money, Rate};
use crate::CorpFinanceResult;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DurationInput {
pub face_value: Money,
pub coupon_rate: Rate,
pub coupon_frequency: u8,
pub ytm: Rate,
pub years_to_maturity: Decimal,
#[serde(skip_serializing_if = "Option::is_none")]
pub yield_shift_bps: Option<Decimal>,
#[serde(skip_serializing_if = "Option::is_none")]
pub key_rate_tenors: Option<Vec<Decimal>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DurationOutput {
pub macaulay_duration: Decimal,
pub modified_duration: Decimal,
pub effective_duration: Decimal,
pub convexity: Decimal,
pub dv01: Money,
pub price: Money,
pub price_up: Money,
pub price_down: Money,
pub price_change_estimate: Decimal,
#[serde(skip_serializing_if = "Option::is_none")]
pub key_rate_durations: Option<Vec<KeyRateDuration>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeyRateDuration {
pub tenor: Decimal,
pub duration: Decimal,
pub contribution_pct: Rate,
}
pub fn calculate_duration(
input: &DurationInput,
) -> CorpFinanceResult<ComputationOutput<DurationOutput>> {
let start = Instant::now();
let warnings: Vec<String> = Vec::new();
validate_input(input)?;
let freq = Decimal::from(input.coupon_frequency);
let total_periods = compute_total_periods(input);
let coupon_per_period = input.face_value * input.coupon_rate / freq;
let yield_per_period = input.ytm / freq;
let shift_decimal = input.yield_shift_bps.unwrap_or(dec!(10)) * dec!(0.0001);
let price = price_bond(
coupon_per_period,
input.face_value,
yield_per_period,
total_periods,
)?;
let macaulay_duration = compute_macaulay(
coupon_per_period,
input.face_value,
yield_per_period,
total_periods,
freq,
price,
)?;
let modified_duration = macaulay_duration / (Decimal::ONE + yield_per_period);
let yield_up = (input.ytm + shift_decimal) / freq;
let yield_down = (input.ytm - shift_decimal) / freq;
let price_up = price_bond(coupon_per_period, input.face_value, yield_up, total_periods)?;
let price_down = price_bond(
coupon_per_period,
input.face_value,
yield_down,
total_periods,
)?;
let effective_duration = if price.is_zero() {
return Err(CorpFinanceError::DivisionByZero {
context: "effective duration: base price is zero".to_string(),
});
} else {
(price_down - price_up) / (dec!(2) * price * shift_decimal)
};
let convexity = compute_convexity(
coupon_per_period,
input.face_value,
yield_per_period,
total_periods,
freq,
price,
)?;
let dv01 = modified_duration * price * dec!(0.0001);
let delta_y = dec!(0.01);
let price_change_estimate =
-modified_duration * delta_y + dec!(0.5) * convexity * delta_y * delta_y;
let key_rate_durations = match &input.key_rate_tenors {
Some(tenors) => Some(compute_key_rate_durations(
input,
coupon_per_period,
total_periods,
freq,
price,
shift_decimal,
tenors,
)?),
None => None,
};
let output = DurationOutput {
macaulay_duration,
modified_duration,
effective_duration,
convexity,
dv01,
price,
price_up,
price_down,
price_change_estimate,
key_rate_durations,
};
let elapsed = start.elapsed().as_micros() as u64;
let assumptions = serde_json::json!({
"coupon_frequency": input.coupon_frequency,
"yield_shift_bps": input.yield_shift_bps.unwrap_or(dec!(10)).to_string(),
"settlement": "assumed on coupon date (no accrued interest)",
"day_count": "30/360 (period-based)",
"price_change_estimate_shift": "100 bps"
});
Ok(with_metadata(
"Bond Duration & Convexity (CFA Fixed Income Analytics)",
&assumptions,
warnings,
elapsed,
output,
))
}
fn validate_input(input: &DurationInput) -> CorpFinanceResult<()> {
if input.face_value <= Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "face_value".into(),
reason: "Face value must be positive.".into(),
});
}
if input.coupon_rate < Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "coupon_rate".into(),
reason: "Coupon rate must be non-negative.".into(),
});
}
if !matches!(input.coupon_frequency, 1 | 2 | 4 | 12) {
return Err(CorpFinanceError::InvalidInput {
field: "coupon_frequency".into(),
reason: "Coupon frequency must be 1, 2, 4, or 12.".into(),
});
}
if input.ytm <= dec!(-1) {
return Err(CorpFinanceError::InvalidInput {
field: "ytm".into(),
reason: "YTM must be greater than -1 (i.e. > -100%).".into(),
});
}
if input.years_to_maturity <= Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "years_to_maturity".into(),
reason: "Years to maturity must be positive.".into(),
});
}
Ok(())
}
fn compute_total_periods(input: &DurationInput) -> u32 {
let periods = input.years_to_maturity * Decimal::from(input.coupon_frequency);
periods.round().to_string().parse::<u32>().unwrap_or(0)
}
fn price_bond(
coupon: Money,
face_value: Money,
yield_per_period: Rate,
total_periods: u32,
) -> CorpFinanceResult<Money> {
let one_plus_y = Decimal::ONE + yield_per_period;
if one_plus_y.is_zero() {
return Err(CorpFinanceError::DivisionByZero {
context: "bond pricing: (1 + yield_per_period) is zero".to_string(),
});
}
let mut price = Decimal::ZERO;
let mut df = Decimal::ONE;
for t in 1..=total_periods {
df *= one_plus_y;
let cf = if t == total_periods {
coupon + face_value
} else {
coupon
};
price += cf / df;
}
Ok(price)
}
fn compute_macaulay(
coupon: Money,
face_value: Money,
yield_per_period: Rate,
total_periods: u32,
freq: Decimal,
price: Money,
) -> CorpFinanceResult<Decimal> {
if price.is_zero() {
return Err(CorpFinanceError::DivisionByZero {
context: "Macaulay duration: bond price is zero".to_string(),
});
}
let one_plus_y = Decimal::ONE + yield_per_period;
let mut weighted_sum = Decimal::ZERO;
let mut df = Decimal::ONE;
for t in 1..=total_periods {
df *= one_plus_y;
let t_years = Decimal::from(t) / freq;
let cf = if t == total_periods {
coupon + face_value
} else {
coupon
};
let pv_cf = cf / df;
weighted_sum += t_years * pv_cf;
}
Ok(weighted_sum / price)
}
fn compute_convexity(
coupon: Money,
face_value: Money,
yield_per_period: Rate,
total_periods: u32,
freq: Decimal,
price: Money,
) -> CorpFinanceResult<Decimal> {
if price.is_zero() {
return Err(CorpFinanceError::DivisionByZero {
context: "convexity: bond price is zero".to_string(),
});
}
let one_plus_y = Decimal::ONE + yield_per_period;
let mut numerator = Decimal::ZERO;
let mut df = Decimal::ONE;
for t in 1..=total_periods {
df *= one_plus_y;
let t_years = Decimal::from(t) / freq;
let t_years_next = t_years + Decimal::ONE / freq;
let cf = if t == total_periods {
coupon + face_value
} else {
coupon
};
let pv_cf = cf / df;
numerator += t_years * t_years_next * pv_cf;
}
let one_plus_y_sq = one_plus_y * one_plus_y;
let denominator = price * one_plus_y_sq;
if denominator.is_zero() {
return Err(CorpFinanceError::DivisionByZero {
context: "convexity: denominator is zero".to_string(),
});
}
Ok(numerator / denominator)
}
fn compute_key_rate_durations(
input: &DurationInput,
coupon: Money,
total_periods: u32,
freq: Decimal,
base_price: Money,
shift: Decimal,
tenors: &[Decimal],
) -> CorpFinanceResult<Vec<KeyRateDuration>> {
if base_price.is_zero() {
return Err(CorpFinanceError::DivisionByZero {
context: "key rate durations: base price is zero".to_string(),
});
}
let mut results: Vec<KeyRateDuration> = Vec::with_capacity(tenors.len());
for &tenor in tenors {
let price_up =
price_with_key_rate_shift(input, coupon, total_periods, freq, tenor, shift, tenors)?;
let price_down =
price_with_key_rate_shift(input, coupon, total_periods, freq, tenor, -shift, tenors)?;
let partial_dur = (price_down - price_up) / (dec!(2) * base_price * shift);
results.push(KeyRateDuration {
tenor,
duration: partial_dur,
contribution_pct: Decimal::ZERO, });
}
let total_krd: Decimal = results.iter().map(|r| r.duration).sum();
if !total_krd.is_zero() {
for r in &mut results {
r.contribution_pct = r.duration / total_krd;
}
}
Ok(results)
}
fn price_with_key_rate_shift(
input: &DurationInput,
coupon: Money,
total_periods: u32,
freq: Decimal,
target_tenor: Decimal,
shift: Decimal,
tenors: &[Decimal],
) -> CorpFinanceResult<Money> {
let one_base = Decimal::ONE + input.ytm / freq;
if one_base.is_zero() {
return Err(CorpFinanceError::DivisionByZero {
context: "key rate shift: (1 + ytm/freq) is zero".to_string(),
});
}
let lower_bound = tenors
.iter()
.filter(|&&t| t < target_tenor)
.copied()
.last()
.unwrap_or(Decimal::ZERO);
let upper_bound = tenors
.iter()
.filter(|&&t| t > target_tenor)
.copied()
.next()
.unwrap_or(input.years_to_maturity);
let mut price = Decimal::ZERO;
for t in 1..=total_periods {
let mut local_df = Decimal::ONE;
for s in 1..=t {
let s_years = Decimal::from(s) / freq;
let w = key_rate_weight(s_years, target_tenor, lower_bound, upper_bound);
let s_shift = shift * w;
let s_yield = input.ytm / freq + s_shift / freq;
local_df *= Decimal::ONE + s_yield;
}
if local_df.is_zero() {
return Err(CorpFinanceError::DivisionByZero {
context: format!("key rate shift: discount factor is zero at period {t}"),
});
}
let cf = if t == total_periods {
coupon + input.face_value
} else {
coupon
};
price += cf / local_df;
}
Ok(price)
}
fn key_rate_weight(t_years: Decimal, target: Decimal, lower: Decimal, upper: Decimal) -> Decimal {
if t_years == target {
Decimal::ONE
} else if t_years > lower && t_years < target {
let span = target - lower;
if span.is_zero() {
Decimal::ONE
} else {
(t_years - lower) / span
}
} else if t_years > target && t_years < upper {
let span = upper - target;
if span.is_zero() {
Decimal::ONE
} else {
(upper - t_years) / span
}
} else {
Decimal::ZERO
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn par_bond_input() -> DurationInput {
DurationInput {
face_value: dec!(1000),
coupon_rate: dec!(0.05),
coupon_frequency: 2,
ytm: dec!(0.05),
years_to_maturity: dec!(10),
yield_shift_bps: None,
key_rate_tenors: None,
}
}
fn zero_coupon_input() -> DurationInput {
DurationInput {
face_value: dec!(1000),
coupon_rate: dec!(0.0),
coupon_frequency: 2,
ytm: dec!(0.05),
years_to_maturity: dec!(10),
yield_shift_bps: None,
key_rate_tenors: None,
}
}
fn assert_close(actual: Decimal, expected: Decimal, tolerance: Decimal, label: &str) {
let diff = (actual - expected).abs();
assert!(
diff <= tolerance,
"{label}: expected ~{expected}, got {actual} (diff {diff} > tolerance {tolerance})"
);
}
#[test]
fn test_zero_coupon_macaulay_equals_maturity() {
let input = zero_coupon_input();
let result = calculate_duration(&input).unwrap();
let out = &result.result;
assert_close(
out.macaulay_duration,
dec!(10),
dec!(0.0001),
"Zero-coupon Macaulay duration should equal maturity",
);
}
#[test]
fn test_coupon_bond_macaulay_less_than_maturity() {
let input = par_bond_input();
let result = calculate_duration(&input).unwrap();
let out = &result.result;
assert!(
out.macaulay_duration < dec!(10),
"Coupon bond Macaulay ({}) should be less than maturity (10)",
out.macaulay_duration
);
assert!(
out.macaulay_duration > Decimal::ZERO,
"Macaulay duration should be positive"
);
}
#[test]
fn test_modified_duration_relationship() {
let input = par_bond_input();
let result = calculate_duration(&input).unwrap();
let out = &result.result;
let expected_mod = out.macaulay_duration / (Decimal::ONE + dec!(0.05) / dec!(2));
assert_close(
out.modified_duration,
expected_mod,
dec!(0.000001),
"Modified duration = Macaulay / (1 + y/freq)",
);
}
#[test]
fn test_effective_vs_modified_close() {
let input = par_bond_input();
let result = calculate_duration(&input).unwrap();
let out = &result.result;
assert_close(
out.effective_duration,
out.modified_duration,
dec!(0.05),
"Effective duration should approximate modified for option-free bonds",
);
}
#[test]
fn test_higher_coupon_lower_duration() {
let low_coupon = par_bond_input(); let mut high_coupon = par_bond_input();
high_coupon.coupon_rate = dec!(0.08);
let result_low = calculate_duration(&low_coupon).unwrap();
let result_high = calculate_duration(&high_coupon).unwrap();
assert!(
result_high.result.macaulay_duration < result_low.result.macaulay_duration,
"Higher coupon ({}) should have lower duration than lower coupon ({})",
result_high.result.macaulay_duration,
result_low.result.macaulay_duration
);
}
#[test]
fn test_higher_ytm_lower_duration() {
let mut low_ytm = par_bond_input();
low_ytm.ytm = dec!(0.03);
let mut high_ytm = par_bond_input();
high_ytm.ytm = dec!(0.08);
let result_low = calculate_duration(&low_ytm).unwrap();
let result_high = calculate_duration(&high_ytm).unwrap();
assert!(
result_high.result.macaulay_duration < result_low.result.macaulay_duration,
"Higher YTM ({}) should have lower Macaulay duration than lower YTM ({})",
result_high.result.macaulay_duration,
result_low.result.macaulay_duration
);
}
#[test]
fn test_longer_maturity_higher_duration() {
let short = DurationInput {
years_to_maturity: dec!(5),
..par_bond_input()
};
let long = DurationInput {
years_to_maturity: dec!(30),
..par_bond_input()
};
let result_short = calculate_duration(&short).unwrap();
let result_long = calculate_duration(&long).unwrap();
assert!(
result_long.result.macaulay_duration > result_short.result.macaulay_duration,
"30-year duration ({}) should exceed 5-year duration ({})",
result_long.result.macaulay_duration,
result_short.result.macaulay_duration
);
}
#[test]
fn test_convexity_positive() {
let input = par_bond_input();
let result = calculate_duration(&input).unwrap();
assert!(
result.result.convexity > Decimal::ZERO,
"Convexity should be positive for a standard bond, got {}",
result.result.convexity
);
}
#[test]
fn test_dv01_calculation() {
let input = par_bond_input();
let result = calculate_duration(&input).unwrap();
let out = &result.result;
let expected_dv01 = out.modified_duration * out.price * dec!(0.0001);
assert_close(
out.dv01,
expected_dv01,
dec!(0.000001),
"DV01 = modified_duration * price * 0.0001",
);
assert!(
out.dv01 > dec!(0.5) && out.dv01 < dec!(1.5),
"DV01 for 10y par bond should be in a reasonable range, got {}",
out.dv01
);
}
#[test]
fn test_price_change_estimate() {
let input = par_bond_input();
let result = calculate_duration(&input).unwrap();
let out = &result.result;
let delta_y = dec!(0.01);
let expected =
-out.modified_duration * delta_y + dec!(0.5) * out.convexity * delta_y * delta_y;
assert_close(
out.price_change_estimate,
expected,
dec!(0.000001),
"Price change estimate formula",
);
assert!(
out.price_change_estimate < Decimal::ZERO,
"Price change estimate for +100bp should be negative, got {}",
out.price_change_estimate
);
}
#[test]
fn test_zero_coupon_convexity() {
let input = zero_coupon_input();
let result = calculate_duration(&input).unwrap();
let out = &result.result;
assert!(
out.convexity > Decimal::ZERO,
"Zero-coupon convexity should be positive, got {}",
out.convexity
);
assert!(
out.convexity > dec!(90) && out.convexity < dec!(110),
"Zero-coupon 10y convexity should be ~100, got {}",
out.convexity
);
}
#[test]
fn test_semiannual_vs_annual_duration() {
let semi = par_bond_input();
let annual = DurationInput {
coupon_frequency: 1,
..par_bond_input()
};
let result_semi = calculate_duration(&semi).unwrap();
let result_annual = calculate_duration(&annual).unwrap();
assert!(
result_semi.result.macaulay_duration < result_annual.result.macaulay_duration,
"Semi-annual Macaulay ({}) should be less than annual ({})",
result_semi.result.macaulay_duration,
result_annual.result.macaulay_duration
);
}
#[test]
fn test_key_rate_durations_sum() {
let input = DurationInput {
key_rate_tenors: Some(vec![dec!(1), dec!(2), dec!(5), dec!(10)]),
..par_bond_input()
};
let result = calculate_duration(&input).unwrap();
let out = &result.result;
let krds = out
.key_rate_durations
.as_ref()
.expect("key_rate_durations should be Some");
assert_eq!(krds.len(), 4, "Should have 4 key rate tenors");
let krd_sum: Decimal = krds.iter().map(|k| k.duration).sum();
assert_close(
krd_sum,
out.effective_duration,
dec!(1.0),
"Sum of key rate durations should approximate effective duration",
);
let contrib_sum: Decimal = krds.iter().map(|k| k.contribution_pct).sum();
assert_close(
contrib_sum,
Decimal::ONE,
dec!(0.01),
"Contribution percentages should sum to ~1.0",
);
}
#[test]
fn test_invalid_face_value_error() {
let mut input = par_bond_input();
input.face_value = dec!(-1000);
let err = calculate_duration(&input).unwrap_err();
match err {
CorpFinanceError::InvalidInput { field, .. } => {
assert_eq!(field, "face_value");
}
other => panic!("Expected InvalidInput for face_value, got {other:?}"),
}
}
#[test]
fn test_negative_ytm_works() {
let input = DurationInput {
ytm: dec!(-0.005), ..par_bond_input()
};
let result = calculate_duration(&input).unwrap();
let out = &result.result;
assert!(
out.macaulay_duration > Decimal::ZERO,
"Macaulay duration should be positive even with negative YTM, got {}",
out.macaulay_duration
);
assert!(
out.price > input.face_value,
"Price ({}) should exceed face value ({}) for negative YTM",
out.price,
input.face_value
);
}
#[test]
fn test_metadata_populated() {
let input = par_bond_input();
let result = calculate_duration(&input).unwrap();
assert!(!result.methodology.is_empty());
assert!(result.methodology.contains("Duration"));
assert_eq!(result.metadata.precision, "rust_decimal_128bit");
assert!(result.metadata.computation_time_us < 1_000_000); }
#[test]
fn test_par_bond_price() {
let input = par_bond_input();
let result = calculate_duration(&input).unwrap();
assert_close(
result.result.price,
dec!(1000),
dec!(0.01),
"Par bond (coupon == YTM) should price at face value",
);
}
#[test]
fn test_invalid_coupon_frequency_error() {
let mut input = par_bond_input();
input.coupon_frequency = 3;
let err = calculate_duration(&input).unwrap_err();
match err {
CorpFinanceError::InvalidInput { field, .. } => {
assert_eq!(field, "coupon_frequency");
}
other => panic!("Expected InvalidInput for coupon_frequency, got {other:?}"),
}
}
#[test]
fn test_zero_maturity_rejected() {
let mut input = par_bond_input();
input.years_to_maturity = Decimal::ZERO;
let err = calculate_duration(&input).unwrap_err();
match err {
CorpFinanceError::InvalidInput { field, .. } => {
assert_eq!(field, "years_to_maturity");
}
other => panic!("Expected InvalidInput for years_to_maturity, got {other:?}"),
}
}
#[test]
fn test_monthly_frequency() {
let input = DurationInput {
coupon_frequency: 12,
..par_bond_input()
};
let result = calculate_duration(&input).unwrap();
let out = &result.result;
let semi = calculate_duration(&par_bond_input()).unwrap();
assert!(
out.macaulay_duration < semi.result.macaulay_duration,
"Monthly Macaulay ({}) should be less than semi-annual ({})",
out.macaulay_duration,
semi.result.macaulay_duration
);
}
#[test]
fn test_price_shift_ordering() {
let input = par_bond_input();
let result = calculate_duration(&input).unwrap();
let out = &result.result;
assert!(
out.price_up < out.price,
"Price with yield shifted up ({}) should be less than base price ({})",
out.price_up,
out.price
);
assert!(
out.price_down > out.price,
"Price with yield shifted down ({}) should exceed base price ({})",
out.price_down,
out.price
);
}
}