merge duration, mduration #44

This commit is contained in:
Brian Hung
2025-07-30 01:31:39 -07:00
committed by Nicolás Hatcher
parent d4f69f2ec2
commit dacf03d82d
9 changed files with 479 additions and 7 deletions

View File

@@ -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),

View File

@@ -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:

View File

@@ -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),

View File

@@ -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;

View 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");
}
}

View File

@@ -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};

View File

@@ -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" /> | |

View File

@@ -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)
::: :::

View File

@@ -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)
::: :::