Files
IronCalc/base/src/test/test_fn_duration.rs
Brian Hung 557582934b merge duration, mduration #44
merge price, yield #29

merge fvschedule #56

merge pricedisc, pricemat, yielddisc, yieldmat, disc, received, intrate #57

merge accrint, accrintm #58

merge coupdaybs, coupdays, coupdaysnc, coupncd, coupnum, couppcd #59

fix cursor

refactor

refactor

fix build
2025-11-06 21:52:45 +01:00

351 lines
11 KiB
Rust

#![allow(clippy::unwrap_used)]
#![allow(clippy::panic)]
use crate::{cell::CellValue, test::util::new_empty_model};
// Test constants for realistic bond scenarios
const BOND_SETTLEMENT: &str = "=DATE(2020,1,1)";
const BOND_MATURITY_4Y: &str = "=DATE(2024,1,1)";
const BOND_MATURITY_INVALID: &str = "=DATE(2016,1,1)"; // Before settlement
const BOND_MATURITY_SAME: &str = "=DATE(2020,1,1)"; // Same as settlement
const BOND_MATURITY_1DAY: &str = "=DATE(2020,1,2)"; // Very short term
// Standard investment-grade corporate bond parameters
const STD_COUPON: f64 = 0.08; // 8% annual coupon rate
const STD_YIELD: f64 = 0.09; // 9% yield (discount bond scenario)
const STD_FREQUENCY: i32 = 2; // Semi-annual payments (most common)
// Helper function to reduce test repetition
fn assert_numerical_result(model: &crate::Model, cell_ref: &str, should_be_positive: bool) {
if let Ok(CellValue::Number(v)) = model.get_cell_value_by_ref(cell_ref) {
if should_be_positive {
assert!(v > 0.0, "Expected positive value at {cell_ref}, got {v}");
}
// Value is valid - test passes
} else {
panic!("Expected numerical result at {cell_ref}");
}
}
#[test]
fn fn_duration_mduration_arguments() {
let mut model = new_empty_model();
// Test argument count validation
model._set("A1", "=DURATION()");
model._set("A2", "=DURATION(1,2,3,4)");
model._set("A3", "=DURATION(1,2,3,4,5,6,7)");
model._set("B1", "=MDURATION()");
model._set("B2", "=MDURATION(1,2,3,4)");
model._set("B3", "=MDURATION(1,2,3,4,5,6,7)");
model.evaluate();
// Too few or too many arguments should result in errors
assert_eq!(model._get_text("A1"), *"#ERROR!");
assert_eq!(model._get_text("A2"), *"#ERROR!");
assert_eq!(model._get_text("A3"), *"#ERROR!");
assert_eq!(model._get_text("B1"), *"#ERROR!");
assert_eq!(model._get_text("B2"), *"#ERROR!");
assert_eq!(model._get_text("B3"), *"#ERROR!");
}
#[test]
fn fn_duration_mduration_settlement_maturity_errors() {
let mut model = new_empty_model();
model._set("A1", BOND_SETTLEMENT);
model._set("A2", BOND_MATURITY_INVALID); // Before settlement
model._set("A3", BOND_MATURITY_SAME); // Same as settlement
// Both settlement > maturity and settlement = maturity should error
model._set(
"B1",
&format!("=DURATION(A1,A2,{STD_COUPON},{STD_YIELD},{STD_FREQUENCY})"),
);
model._set(
"B2",
&format!("=DURATION(A1,A3,{STD_COUPON},{STD_YIELD},{STD_FREQUENCY})"),
);
model._set(
"B3",
&format!("=MDURATION(A1,A2,{STD_COUPON},{STD_YIELD},{STD_FREQUENCY})"),
);
model._set(
"B4",
&format!("=MDURATION(A1,A3,{STD_COUPON},{STD_YIELD},{STD_FREQUENCY})"),
);
model.evaluate();
assert_eq!(model._get_text("B1"), *"#NUM!");
assert_eq!(model._get_text("B2"), *"#NUM!");
assert_eq!(model._get_text("B3"), *"#NUM!");
assert_eq!(model._get_text("B4"), *"#NUM!");
}
#[test]
fn fn_duration_mduration_negative_values_errors() {
let mut model = new_empty_model();
model._set("A1", BOND_SETTLEMENT);
model._set("A2", BOND_MATURITY_4Y);
// Test negative coupon (coupons must be >= 0)
model._set(
"B1",
&format!("=DURATION(A1,A2,-0.01,{STD_YIELD},{STD_FREQUENCY})"),
);
model._set(
"B2",
&format!("=MDURATION(A1,A2,-0.01,{STD_YIELD},{STD_FREQUENCY})"),
);
// Test negative yield (yields must be >= 0)
model._set(
"C1",
&format!("=DURATION(A1,A2,{STD_COUPON},-0.01,{STD_FREQUENCY})"),
);
model._set(
"C2",
&format!("=MDURATION(A1,A2,{STD_COUPON},-0.01,{STD_FREQUENCY})"),
);
model.evaluate();
assert_eq!(model._get_text("B1"), *"#NUM!");
assert_eq!(model._get_text("B2"), *"#NUM!");
assert_eq!(model._get_text("C1"), *"#NUM!");
assert_eq!(model._get_text("C2"), *"#NUM!");
}
#[test]
fn fn_duration_mduration_invalid_frequency_errors() {
let mut model = new_empty_model();
model._set("A1", BOND_SETTLEMENT);
model._set("A2", BOND_MATURITY_4Y);
// Only 1, 2, and 4 are valid frequencies (annual, semi-annual, quarterly)
let invalid_frequencies = [0, 3, 5, 12]; // Common invalid values
for (i, &freq) in invalid_frequencies.iter().enumerate() {
let row = i + 1;
model._set(
&format!("B{row}"),
&format!("=DURATION(A1,A2,{STD_COUPON},{STD_YIELD},{freq})"),
);
model._set(
&format!("C{row}"),
&format!("=MDURATION(A1,A2,{STD_COUPON},{STD_YIELD},{freq})"),
);
}
model.evaluate();
for i in 1..=invalid_frequencies.len() {
assert_eq!(model._get_text(&format!("B{i}")), *"#NUM!");
assert_eq!(model._get_text(&format!("C{i}")), *"#NUM!");
}
}
#[test]
fn fn_duration_mduration_frequency_variations() {
let mut model = new_empty_model();
model._set("A1", BOND_SETTLEMENT);
model._set("A2", BOND_MATURITY_4Y);
// Test all valid frequencies: 1=annual, 2=semi-annual, 4=quarterly
let valid_frequencies = [1, 2, 4];
for (i, &freq) in valid_frequencies.iter().enumerate() {
let row = i + 1;
model._set(
&format!("B{row}"),
&format!("=DURATION(A1,A2,{STD_COUPON},{STD_YIELD},{freq})"),
);
model._set(
&format!("C{row}"),
&format!("=MDURATION(A1,A2,{STD_COUPON},{STD_YIELD},{freq})"),
);
}
model.evaluate();
// All should return positive numerical values
for i in 1..=valid_frequencies.len() {
assert_numerical_result(&model, &format!("Sheet1!B{i}"), true);
assert_numerical_result(&model, &format!("Sheet1!C{i}"), true);
}
}
#[test]
fn fn_duration_mduration_basis_variations() {
let mut model = new_empty_model();
model._set("A1", BOND_SETTLEMENT);
model._set("A2", BOND_MATURITY_4Y);
// Test all valid basis values (day count conventions)
// 0=30/360 US, 1=Actual/actual, 2=Actual/360, 3=Actual/365, 4=30/360 European
for basis in 0..=4 {
let row = basis + 1;
model._set(
&format!("B{row}"),
&format!("=DURATION(A1,A2,{STD_COUPON},{STD_YIELD},{STD_FREQUENCY},{basis})"),
);
model._set(
&format!("C{row}"),
&format!("=MDURATION(A1,A2,{STD_COUPON},{STD_YIELD},{STD_FREQUENCY},{basis})"),
);
}
// Test default basis (should be 0)
model._set(
"D1",
&format!("=DURATION(A1,A2,{STD_COUPON},{STD_YIELD},{STD_FREQUENCY})"),
);
model._set(
"D2",
&format!("=MDURATION(A1,A2,{STD_COUPON},{STD_YIELD},{STD_FREQUENCY})"),
);
model.evaluate();
// All basis values should work
for row in 1..=5 {
assert_numerical_result(&model, &format!("Sheet1!B{row}"), true);
assert_numerical_result(&model, &format!("Sheet1!C{row}"), true);
}
// Default basis should match basis 0
if let (Ok(CellValue::Number(d1)), Ok(CellValue::Number(b1))) = (
model.get_cell_value_by_ref("Sheet1!D1"),
model.get_cell_value_by_ref("Sheet1!B1"),
) {
assert!(
(d1 - b1).abs() < 1e-10,
"Default basis should match basis 0"
);
}
}
#[test]
fn fn_duration_mduration_edge_cases() {
let mut model = new_empty_model();
model._set("A1", BOND_SETTLEMENT);
model._set("A2", BOND_MATURITY_1DAY); // Very short term (1 day)
model._set("A3", BOND_MATURITY_4Y); // Standard term
// Edge case scenarios with explanations
let test_cases = [
("B", "A1", "A2", STD_COUPON, STD_YIELD, "short_term"), // 1-day bond
("C", "A1", "A3", 0.0, STD_YIELD, "zero_coupon"), // Zero coupon bond
("D", "A1", "A3", STD_COUPON, 0.0, "zero_yield"), // Zero yield
("E", "A1", "A3", 1.0, 0.5, "high_rates"), // High coupon/yield (100%/50%)
];
for (col, settlement, maturity, coupon, yield_rate, _scenario) in test_cases {
model._set(
&format!("{col}1"),
&format!("=DURATION({settlement},{maturity},{coupon},{yield_rate},{STD_FREQUENCY})"),
);
model._set(
&format!("{col}2"),
&format!("=MDURATION({settlement},{maturity},{coupon},{yield_rate},{STD_FREQUENCY})"),
);
}
model.evaluate();
// All edge cases should return positive values
for col in ["B", "C", "D", "E"] {
assert_numerical_result(&model, &format!("Sheet1!{col}1"), true);
assert_numerical_result(&model, &format!("Sheet1!{col}2"), true);
}
}
#[test]
fn fn_duration_mduration_relationship() {
let mut model = new_empty_model();
model._set("A1", BOND_SETTLEMENT);
model._set("A2", BOND_MATURITY_4Y);
// Test mathematical relationship: MDURATION = DURATION / (1 + yield/frequency)
model._set(
"B1",
&format!("=DURATION(A1,A2,{STD_COUPON},{STD_YIELD},{STD_FREQUENCY})"),
);
model._set(
"B2",
&format!("=MDURATION(A1,A2,{STD_COUPON},{STD_YIELD},{STD_FREQUENCY})"),
);
model._set("B3", &format!("=B1/(1+{STD_YIELD}/{STD_FREQUENCY})")); // Manual calculation
// Test with quarterly frequency and different yield
model._set("C1", &format!("=DURATION(A1,A2,{STD_COUPON},0.12,4)"));
model._set("C2", &format!("=MDURATION(A1,A2,{STD_COUPON},0.12,4)"));
model._set("C3", "=C1/(1+0.12/4)"); // Manual calculation for quarterly
model.evaluate();
// MDURATION should equal DURATION / (1 + yield/frequency) for both scenarios
if let (Ok(CellValue::Number(md)), Ok(CellValue::Number(manual))) = (
model.get_cell_value_by_ref("Sheet1!B2"),
model.get_cell_value_by_ref("Sheet1!B3"),
) {
assert!(
(md - manual).abs() < 1e-10,
"MDURATION should equal DURATION/(1+yield/freq)"
);
}
if let (Ok(CellValue::Number(md)), Ok(CellValue::Number(manual))) = (
model.get_cell_value_by_ref("Sheet1!C2"),
model.get_cell_value_by_ref("Sheet1!C3"),
) {
assert!(
(md - manual).abs() < 1e-10,
"MDURATION should equal DURATION/(1+yield/freq) for quarterly"
);
}
}
#[test]
fn fn_duration_mduration_regression() {
// Original regression test with known expected values
let mut model = new_empty_model();
model._set("A1", "=DATE(2016,1,1)");
model._set("A2", "=DATE(2020,1,1)");
model._set(
"B1",
&format!("=DURATION(A1,A2,{STD_COUPON},{STD_YIELD},{STD_FREQUENCY})"),
);
model._set(
"B2",
&format!("=MDURATION(A1,A2,{STD_COUPON},{STD_YIELD},{STD_FREQUENCY})"),
);
model.evaluate();
// Verify exact values for regression testing
if let Ok(CellValue::Number(v1)) = model.get_cell_value_by_ref("Sheet1!B1") {
assert!(
(v1 - 3.410746844012284).abs() < 1e-9,
"DURATION regression test failed"
);
} else {
panic!("Unexpected value for DURATION");
}
if let Ok(CellValue::Number(v2)) = model.get_cell_value_by_ref("Sheet1!B2") {
assert!(
(v2 - 3.263872578002186).abs() < 1e-9,
"MDURATION regression test failed"
);
} else {
panic!("Unexpected value for MDURATION");
}
}