merge duration, mduration #44
This commit is contained in:
committed by
Nicolás Hatcher
parent
d4f69f2ec2
commit
dacf03d82d
@@ -759,6 +759,8 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec<Signatu
|
|||||||
Function::Nominal => args_signature_scalars(arg_count, 2, 0),
|
Function::Nominal => args_signature_scalars(arg_count, 2, 0),
|
||||||
Function::Nper => args_signature_scalars(arg_count, 3, 2),
|
Function::Nper => args_signature_scalars(arg_count, 3, 2),
|
||||||
Function::Npv => args_signature_npv(arg_count),
|
Function::Npv => args_signature_npv(arg_count),
|
||||||
|
Function::Duration => args_signature_scalars(arg_count, 5, 1),
|
||||||
|
Function::Mduration => args_signature_scalars(arg_count, 5, 1),
|
||||||
Function::Pduration => args_signature_scalars(arg_count, 3, 0),
|
Function::Pduration => args_signature_scalars(arg_count, 3, 0),
|
||||||
Function::Pmt => args_signature_scalars(arg_count, 3, 2),
|
Function::Pmt => args_signature_scalars(arg_count, 3, 2),
|
||||||
Function::Ppmt => args_signature_scalars(arg_count, 4, 2),
|
Function::Ppmt => args_signature_scalars(arg_count, 4, 2),
|
||||||
@@ -1023,6 +1025,8 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult {
|
|||||||
Function::Nominal => not_implemented(args),
|
Function::Nominal => not_implemented(args),
|
||||||
Function::Nper => not_implemented(args),
|
Function::Nper => not_implemented(args),
|
||||||
Function::Npv => not_implemented(args),
|
Function::Npv => not_implemented(args),
|
||||||
|
Function::Duration => not_implemented(args),
|
||||||
|
Function::Mduration => not_implemented(args),
|
||||||
Function::Pduration => not_implemented(args),
|
Function::Pduration => not_implemented(args),
|
||||||
Function::Pmt => not_implemented(args),
|
Function::Pmt => not_implemented(args),
|
||||||
Function::Ppmt => not_implemented(args),
|
Function::Ppmt => not_implemented(args),
|
||||||
|
|||||||
@@ -1339,6 +1339,114 @@ impl Model {
|
|||||||
CalcResult::Number(result)
|
CalcResult::Number(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DURATION(settlement, maturity, coupon, yld, freq, [basis])
|
||||||
|
pub(crate) fn fn_duration(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
|
||||||
|
let arg_count = args.len();
|
||||||
|
if !(5..=6).contains(&arg_count) {
|
||||||
|
return CalcResult::new_args_number_error(cell);
|
||||||
|
}
|
||||||
|
let settlement = match self.get_number_no_bools(&args[0], cell) {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(s) => return s,
|
||||||
|
};
|
||||||
|
let maturity = match self.get_number_no_bools(&args[1], cell) {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(s) => return s,
|
||||||
|
};
|
||||||
|
let coupon = match self.get_number_no_bools(&args[2], cell) {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(s) => return s,
|
||||||
|
};
|
||||||
|
let yld = match self.get_number_no_bools(&args[3], cell) {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(s) => return s,
|
||||||
|
};
|
||||||
|
let freq = match self.get_number_no_bools(&args[4], cell) {
|
||||||
|
Ok(f) => f.trunc() as i32,
|
||||||
|
Err(s) => return s,
|
||||||
|
};
|
||||||
|
let basis = if arg_count > 5 {
|
||||||
|
match self.get_number_no_bools(&args[5], cell) {
|
||||||
|
Ok(f) => f.trunc() as i32,
|
||||||
|
Err(s) => return s,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
if settlement >= maturity || coupon < 0.0 || yld < 0.0 || !matches!(freq, 1 | 2 | 4) {
|
||||||
|
return CalcResult::new_error(Error::NUM, cell, "Invalid arguments".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let days_in_year = match basis {
|
||||||
|
0 | 2 | 4 => 360.0,
|
||||||
|
1 | 3 => 365.0,
|
||||||
|
_ => 360.0,
|
||||||
|
};
|
||||||
|
let diff_days = maturity - settlement;
|
||||||
|
if diff_days <= 0.0 {
|
||||||
|
return CalcResult::new_error(Error::NUM, cell, "Invalid arguments".to_string());
|
||||||
|
}
|
||||||
|
let yearfrac = diff_days / days_in_year;
|
||||||
|
let mut num_coupons = (yearfrac * freq as f64).ceil();
|
||||||
|
if num_coupons < 1.0 {
|
||||||
|
num_coupons = 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cf = coupon * 100.0 / freq as f64;
|
||||||
|
let y = 1.0 + yld / freq as f64;
|
||||||
|
let ndiff = yearfrac * freq as f64 - num_coupons;
|
||||||
|
let mut dur = 0.0;
|
||||||
|
for t in 1..(num_coupons as i32) {
|
||||||
|
let tt = t as f64 + ndiff;
|
||||||
|
dur += tt * cf / y.powf(tt);
|
||||||
|
}
|
||||||
|
let last_t = num_coupons + ndiff;
|
||||||
|
dur += last_t * (cf + 100.0) / y.powf(last_t);
|
||||||
|
|
||||||
|
let mut price = 0.0;
|
||||||
|
for t in 1..(num_coupons as i32) {
|
||||||
|
let tt = t as f64 + ndiff;
|
||||||
|
price += cf / y.powf(tt);
|
||||||
|
}
|
||||||
|
price += (cf + 100.0) / y.powf(last_t);
|
||||||
|
|
||||||
|
if price == 0.0 {
|
||||||
|
return CalcResult::new_error(Error::DIV, cell, "Division by 0".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = (dur / price) / freq as f64;
|
||||||
|
CalcResult::Number(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MDURATION(settlement, maturity, coupon, yld, freq, [basis])
|
||||||
|
pub(crate) fn fn_mduration(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
|
||||||
|
let mut res = self.fn_duration(args, cell);
|
||||||
|
if let CalcResult::Number(ref mut d) = res {
|
||||||
|
let yld = match self.get_number_no_bools(&args[3], cell) {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(_) => {
|
||||||
|
return CalcResult::new_error(
|
||||||
|
Error::VALUE,
|
||||||
|
cell,
|
||||||
|
"Invalid arguments".to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let freq = match self.get_number_no_bools(&args[4], cell) {
|
||||||
|
Ok(f) => f.trunc(),
|
||||||
|
Err(_) => {
|
||||||
|
return CalcResult::new_error(
|
||||||
|
Error::VALUE,
|
||||||
|
cell,
|
||||||
|
"Invalid arguments".to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
*d /= 1.0 + yld / freq;
|
||||||
|
}
|
||||||
|
res
|
||||||
|
}
|
||||||
|
|
||||||
// This next three functions deal with Treasure Bills or T-Bills for short
|
// This next three functions deal with Treasure Bills or T-Bills for short
|
||||||
// They are zero-coupon that mature in one year or less.
|
// They are zero-coupon that mature in one year or less.
|
||||||
// Definitions:
|
// Definitions:
|
||||||
|
|||||||
@@ -232,6 +232,8 @@ pub enum Function {
|
|||||||
Nominal,
|
Nominal,
|
||||||
Nper,
|
Nper,
|
||||||
Npv,
|
Npv,
|
||||||
|
Duration,
|
||||||
|
Mduration,
|
||||||
Pduration,
|
Pduration,
|
||||||
Pmt,
|
Pmt,
|
||||||
Ppmt,
|
Ppmt,
|
||||||
@@ -313,7 +315,7 @@ pub enum Function {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Function {
|
impl Function {
|
||||||
pub fn into_iter() -> IntoIter<Function, 256> {
|
pub fn into_iter() -> IntoIter<Function, 258> {
|
||||||
[
|
[
|
||||||
Function::And,
|
Function::And,
|
||||||
Function::False,
|
Function::False,
|
||||||
@@ -497,6 +499,8 @@ impl Function {
|
|||||||
Function::Syd,
|
Function::Syd,
|
||||||
Function::Nominal,
|
Function::Nominal,
|
||||||
Function::Effect,
|
Function::Effect,
|
||||||
|
Function::Duration,
|
||||||
|
Function::Mduration,
|
||||||
Function::Pduration,
|
Function::Pduration,
|
||||||
Function::Tbillyield,
|
Function::Tbillyield,
|
||||||
Function::Tbillprice,
|
Function::Tbillprice,
|
||||||
@@ -832,6 +836,8 @@ impl Function {
|
|||||||
"SYD" => Some(Function::Syd),
|
"SYD" => Some(Function::Syd),
|
||||||
"NOMINAL" => Some(Function::Nominal),
|
"NOMINAL" => Some(Function::Nominal),
|
||||||
"EFFECT" => Some(Function::Effect),
|
"EFFECT" => Some(Function::Effect),
|
||||||
|
"DURATION" => Some(Function::Duration),
|
||||||
|
"MDURATION" => Some(Function::Mduration),
|
||||||
"PDURATION" | "_XLFN.PDURATION" => Some(Function::Pduration),
|
"PDURATION" | "_XLFN.PDURATION" => Some(Function::Pduration),
|
||||||
|
|
||||||
"TBILLYIELD" => Some(Function::Tbillyield),
|
"TBILLYIELD" => Some(Function::Tbillyield),
|
||||||
@@ -1075,6 +1081,8 @@ impl fmt::Display for Function {
|
|||||||
Function::Syd => write!(f, "SYD"),
|
Function::Syd => write!(f, "SYD"),
|
||||||
Function::Nominal => write!(f, "NOMINAL"),
|
Function::Nominal => write!(f, "NOMINAL"),
|
||||||
Function::Effect => write!(f, "EFFECT"),
|
Function::Effect => write!(f, "EFFECT"),
|
||||||
|
Function::Duration => write!(f, "DURATION"),
|
||||||
|
Function::Mduration => write!(f, "MDURATION"),
|
||||||
Function::Pduration => write!(f, "PDURATION"),
|
Function::Pduration => write!(f, "PDURATION"),
|
||||||
Function::Tbillyield => write!(f, "TBILLYIELD"),
|
Function::Tbillyield => write!(f, "TBILLYIELD"),
|
||||||
Function::Tbillprice => write!(f, "TBILLPRICE"),
|
Function::Tbillprice => write!(f, "TBILLPRICE"),
|
||||||
@@ -1352,6 +1360,8 @@ impl Model {
|
|||||||
Function::Syd => self.fn_syd(args, cell),
|
Function::Syd => self.fn_syd(args, cell),
|
||||||
Function::Nominal => self.fn_nominal(args, cell),
|
Function::Nominal => self.fn_nominal(args, cell),
|
||||||
Function::Effect => self.fn_effect(args, cell),
|
Function::Effect => self.fn_effect(args, cell),
|
||||||
|
Function::Duration => self.fn_duration(args, cell),
|
||||||
|
Function::Mduration => self.fn_mduration(args, cell),
|
||||||
Function::Pduration => self.fn_pduration(args, cell),
|
Function::Pduration => self.fn_pduration(args, cell),
|
||||||
Function::Tbillyield => self.fn_tbillyield(args, cell),
|
Function::Tbillyield => self.fn_tbillyield(args, cell),
|
||||||
Function::Tbillprice => self.fn_tbillprice(args, cell),
|
Function::Tbillprice => self.fn_tbillprice(args, cell),
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ mod test_fn_choose;
|
|||||||
mod test_fn_concatenate;
|
mod test_fn_concatenate;
|
||||||
mod test_fn_count;
|
mod test_fn_count;
|
||||||
mod test_fn_day;
|
mod test_fn_day;
|
||||||
|
mod test_fn_duration;
|
||||||
mod test_fn_exact;
|
mod test_fn_exact;
|
||||||
mod test_fn_financial;
|
mod test_fn_financial;
|
||||||
mod test_fn_formulatext;
|
mod test_fn_formulatext;
|
||||||
|
|||||||
350
base/src/test/test_fn_duration.rs
Normal file
350
base/src/test/test_fn_duration.rs
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
#![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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
#![allow(clippy::unwrap_used)]
|
#![allow(clippy::unwrap_used)]
|
||||||
|
#![allow(clippy::panic)]
|
||||||
|
|
||||||
use crate::{cell::CellValue, test::util::new_empty_model};
|
use crate::{cell::CellValue, test::util::new_empty_model};
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir
|
|||||||
| DISC | <Badge type="info" text="Not implemented yet" /> | – |
|
| DISC | <Badge type="info" text="Not implemented yet" /> | – |
|
||||||
| DOLLARDE | <Badge type="tip" text="Available" /> | – |
|
| DOLLARDE | <Badge type="tip" text="Available" /> | – |
|
||||||
| DOLLARFR | <Badge type="tip" text="Available" /> | – |
|
| DOLLARFR | <Badge type="tip" text="Available" /> | – |
|
||||||
| DURATION | <Badge type="info" text="Not implemented yet" /> | – |
|
| DURATION | <Badge type="tip" text="Available" /> | – |
|
||||||
| EFFECT | <Badge type="tip" text="Available" /> | – |
|
| EFFECT | <Badge type="tip" text="Available" /> | – |
|
||||||
| FV | <Badge type="tip" text="Available" /> | [FV](financial/fv) |
|
| FV | <Badge type="tip" text="Available" /> | [FV](financial/fv) |
|
||||||
| FVSCHEDULE | <Badge type="info" text="Not implemented yet" /> | – |
|
| FVSCHEDULE | <Badge type="info" text="Not implemented yet" /> | – |
|
||||||
@@ -36,7 +36,7 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir
|
|||||||
| IPMT | <Badge type="tip" text="Available" /> | – |
|
| IPMT | <Badge type="tip" text="Available" /> | – |
|
||||||
| IRR | <Badge type="tip" text="Available" /> | – |
|
| IRR | <Badge type="tip" text="Available" /> | – |
|
||||||
| ISPMT | <Badge type="tip" text="Available" /> | – |
|
| ISPMT | <Badge type="tip" text="Available" /> | – |
|
||||||
| MDURATION | <Badge type="info" text="Not implemented yet" /> | – |
|
| MDURATION | <Badge type="tip" text="Available" /> | – |
|
||||||
| MIRR | <Badge type="tip" text="Available" /> | – |
|
| MIRR | <Badge type="tip" text="Available" /> | – |
|
||||||
| NOMINAL | <Badge type="tip" text="Available" /> | – |
|
| NOMINAL | <Badge type="tip" text="Available" /> | – |
|
||||||
| NPER | <Badge type="tip" text="Available" /> | – |
|
| NPER | <Badge type="tip" text="Available" /> | – |
|
||||||
|
|||||||
@@ -7,6 +7,5 @@ lang: en-US
|
|||||||
# DURATION
|
# DURATION
|
||||||
|
|
||||||
::: warning
|
::: warning
|
||||||
🚧 This function is not yet available in IronCalc.
|
🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb).
|
||||||
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
|
|
||||||
:::
|
:::
|
||||||
@@ -7,6 +7,5 @@ lang: en-US
|
|||||||
# MDURATION
|
# MDURATION
|
||||||
|
|
||||||
::: warning
|
::: warning
|
||||||
🚧 This function is not yet available in IronCalc.
|
🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb).
|
||||||
[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions)
|
|
||||||
:::
|
:::
|
||||||
Reference in New Issue
Block a user