diff --git a/base/src/expressions/parser/mod.rs b/base/src/expressions/parser/mod.rs index ed9d28148..2137c55cf 100644 --- a/base/src/expressions/parser/mod.rs +++ b/base/src/expressions/parser/mod.rs @@ -787,6 +787,23 @@ impl<'a> Parser<'a> { args, }; } + let trimmed_name = name.trim_start_matches("_xlfn."); + let fallback_kind = match trimmed_name.to_ascii_uppercase().as_str() { + "PRICE" => Some(Function::Price), + "YIELD" => Some(Function::Yield), + "DURATION" => Some(Function::Duration), + "MDURATION" => Some(Function::Mduration), + "COUPDAYBS" => Some(Function::Coupdaybs), + "COUPDAYS" => Some(Function::Coupdays), + "COUPDAYSNC" => Some(Function::Coupdaysnc), + "COUPNCD" => Some(Function::Coupncd), + "COUPNUM" => Some(Function::Coupnum), + "COUPPCD" => Some(Function::Couppcd), + _ => None, + }; + if let Some(kind) = fallback_kind { + return Node::FunctionKind { kind, args }; + } return Node::InvalidFunctionKind { name, args }; } let context = &self.context; diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index 04edf5614..c52bc57b0 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -761,6 +761,10 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec args_signature_scalars(arg_count, 3, 2), Function::Npv => args_signature_npv(arg_count), Function::Pduration => args_signature_scalars(arg_count, 3, 0), + Function::Price => args_signature_scalars(arg_count, 6, 1), + Function::Yield => args_signature_scalars(arg_count, 6, 1), + Function::Duration => args_signature_scalars(arg_count, 5, 1), + Function::Mduration => args_signature_scalars(arg_count, 5, 1), Function::Pmt => args_signature_scalars(arg_count, 3, 2), Function::Ppmt => args_signature_scalars(arg_count, 4, 2), Function::Pv => args_signature_scalars(arg_count, 3, 2), @@ -1007,6 +1011,12 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec vec![Signature::Vector; arg_count], Function::SkewP => vec![Signature::Vector; arg_count], Function::Small => vec![Signature::Vector, Signature::Scalar], + Function::Coupdaybs => args_signature_scalars(arg_count, 3, 1), + Function::Coupdays => args_signature_scalars(arg_count, 3, 1), + Function::Coupdaysnc => args_signature_scalars(arg_count, 3, 1), + Function::Coupncd => args_signature_scalars(arg_count, 3, 1), + Function::Coupnum => args_signature_scalars(arg_count, 3, 1), + Function::Couppcd => args_signature_scalars(arg_count, 3, 1), } } @@ -1358,5 +1368,15 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Skew => StaticResult::Scalar, Function::SkewP => StaticResult::Scalar, Function::Small => StaticResult::Scalar, + Function::Price => StaticResult::Scalar, + Function::Yield => StaticResult::Scalar, + Function::Duration => StaticResult::Scalar, + Function::Mduration => StaticResult::Scalar, + Function::Coupdaybs => StaticResult::Scalar, + Function::Coupdays => StaticResult::Scalar, + Function::Coupdaysnc => StaticResult::Scalar, + Function::Coupncd => StaticResult::Scalar, + Function::Coupnum => StaticResult::Scalar, + Function::Couppcd => StaticResult::Scalar, } } diff --git a/base/src/functions/financial.rs b/base/src/functions/financial.rs index ec1c986d6..cc600c5dd 100644 --- a/base/src/functions/financial.rs +++ b/base/src/functions/financial.rs @@ -10,13 +10,26 @@ use crate::{ use super::financial_util::{compute_irr, compute_npv, compute_rate, compute_xirr, compute_xnpv}; +// Financial calculation constants +const DAYS_IN_YEAR_360: i32 = 360; +const DAYS_ACTUAL: i32 = 365; +const DAYS_IN_MONTH_360: i32 = 30; + // See: // https://github.com/apache/openoffice/blob/c014b5f2b55cff8d4b0c952d5c16d62ecde09ca1/main/scaddins/source/analysis/financial.cxx +fn days_in_year_simple(basis: i32) -> f64 { + match basis { + 0 | 2 | 4 => DAYS_IN_YEAR_360 as f64, + 1 | 3 => DAYS_ACTUAL as f64, + _ => DAYS_IN_YEAR_360 as f64, + } +} + fn is_less_than_one_year(start_date: i64, end_date: i64) -> Result { let end = from_excel_date(end_date)?; let start = from_excel_date(start_date)?; - if end_date - start_date < 365 { + if end_date - start_date < DAYS_ACTUAL as i64 { return Ok(true); } let end_year = end.year(); @@ -41,6 +54,118 @@ fn is_less_than_one_year(start_date: i64, end_date: i64) -> Result Ok(end_day <= start_day) } +fn is_leap_year(year: i32) -> bool { + (year % 4 == 0) && (year % 100 != 0 || year % 400 == 0) +} + +fn is_last_day_of_february(date: chrono::NaiveDate) -> bool { + date.month() == 2 && date.day() == if is_leap_year(date.year()) { 29 } else { 28 } +} + +fn days360_us(start: chrono::NaiveDate, end: chrono::NaiveDate) -> i32 { + let mut d1 = start.day() as i32; + let mut d2 = end.day() as i32; + let m1 = start.month() as i32; + let m2 = end.month() as i32; + let y1 = start.year(); + let y2 = end.year(); + + // US (NASD) 30/360 method - implementing official specification + + // Rule 1: If both date A and B fall on the last day of February, then date B will be changed to the 30th + if is_last_day_of_february(start) && is_last_day_of_february(end) { + d2 = DAYS_IN_MONTH_360; + } + + // Rule 2: If date A falls on the 31st of a month or last day of February, then date A will be changed to the 30th + if d1 == 31 || is_last_day_of_february(start) { + d1 = DAYS_IN_MONTH_360; + } + + // Rule 3: If date A falls on the 30th after applying rule 2 and date B falls on the 31st, then date B will be changed to the 30th + if d1 == DAYS_IN_MONTH_360 && d2 == 31 { + d2 = DAYS_IN_MONTH_360; + } + + DAYS_IN_YEAR_360 * (y2 - y1) + DAYS_IN_MONTH_360 * (m2 - m1) + (d2 - d1) +} + +fn days360_eu(start: chrono::NaiveDate, end: chrono::NaiveDate) -> i32 { + let mut d1 = start.day() as i32; + let mut d2 = end.day() as i32; + let m1 = start.month() as i32; + let m2 = end.month() as i32; + let y1 = start.year(); + let y2 = end.year(); + + if d1 == 31 { + d1 = DAYS_IN_MONTH_360; + } + if d2 == 31 { + d2 = DAYS_IN_MONTH_360; + } + + d2 + m2 * DAYS_IN_MONTH_360 + y2 * DAYS_IN_YEAR_360 + - d1 + - m1 * DAYS_IN_MONTH_360 + - y1 * DAYS_IN_YEAR_360 +} + +fn days_between_dates(start: chrono::NaiveDate, end: chrono::NaiveDate, basis: i32) -> i32 { + match basis { + 0 => days360_us(start, end), + 1 | 2 => (end - start).num_days() as i32, + 3 => (end - start).num_days() as i32, + 4 => days360_eu(start, end), + _ => (end - start).num_days() as i32, + } +} + +fn coupon_dates( + settlement: chrono::NaiveDate, + maturity: chrono::NaiveDate, + freq: i32, +) -> (chrono::NaiveDate, chrono::NaiveDate) { + let months = 12 / freq; + let step = chrono::Months::new(months as u32); + let mut next_coupon_date = maturity; + while let Some(prev) = next_coupon_date.checked_sub_months(step) { + if settlement >= prev { + return (prev, next_coupon_date); + } + next_coupon_date = prev; + } + // Fallback if we somehow exit the loop (shouldn't happen in practice) + (settlement, maturity) +} + +fn convert_date_serial( + date_serial: f64, + cell: CellReferenceIndex, +) -> Result { + match from_excel_date(date_serial as i64) { + Ok(date) => Ok(date), + Err(_) => Err(CalcResult::new_error( + Error::NUM, + cell, + "Invalid date".to_string(), + )), + } +} + +fn date_to_serial_with_validation(date: chrono::NaiveDate, cell: CellReferenceIndex) -> CalcResult { + match crate::formatter::dates::date_to_serial_number(date.day(), date.month(), date.year()) { + Ok(n) => { + if !(MINIMUM_DATE_SERIAL_NUMBER..=MAXIMUM_DATE_SERIAL_NUMBER).contains(&n) { + CalcResult::new_error(Error::NUM, cell, "date out of range".to_string()) + } else { + CalcResult::Number(n as f64) + } + } + Err(msg) => CalcResult::new_error(Error::NUM, cell, msg), + } +} + fn compute_payment( rate: f64, nper: f64, @@ -1339,6 +1464,112 @@ impl<'a> Model<'a> { 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 = days_in_year_simple(basis); + 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, "Divide 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 // They are zero-coupon that mature in one year or less. // Definitions: @@ -1515,6 +1746,479 @@ impl<'a> Model<'a> { CalcResult::Number(result) } + // PRICE(settlement, maturity, rate, yld, redemption, frequency, [basis]) + pub(crate) fn fn_price(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + let arg_count = args.len(); + if !(6..=7).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 rate = 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 redemption = match self.get_number_no_bools(&args[4], cell) { + Ok(f) => f, + Err(s) => return s, + }; + let frequency = match self.get_number_no_bools(&args[5], cell) { + Ok(f) => f.trunc() as i32, + Err(s) => return s, + }; + if frequency != 1 && frequency != 2 && frequency != 4 { + return CalcResult::new_error( + Error::NUM, + cell, + "frequency should be 1, 2 or 4".to_string(), + ); + } + if settlement >= maturity { + return CalcResult::new_error( + Error::NUM, + cell, + "settlement should be < maturity".to_string(), + ); + } + let basis = if arg_count == 7 { + match self.get_number_no_bools(&args[6], cell) { + Ok(f) => f.trunc() as i32, + Err(s) => return s, + } + } else { + 0 + }; + let days_in_year = days_in_year_simple(basis); + let days = maturity - settlement; + let periods = ((days * frequency as f64) / days_in_year).round(); + if periods <= 0.0 { + return CalcResult::new_error(Error::NUM, cell, "invalid dates".to_string()); + } + let coupon = redemption * rate / frequency as f64; + + let r = yld / frequency as f64; + let mut price = 0.0; + for i in 1..=(periods as i32) { + price += coupon / (1.0 + r).powf(i as f64); + } + price += redemption / (1.0 + r).powf(periods); + if price.is_nan() || price.is_infinite() { + return CalcResult::new_error(Error::NUM, cell, "Invalid data".to_string()); + } + CalcResult::Number(price) + } + + // YIELD(settlement, maturity, rate, pr, redemption, frequency, [basis]) + pub(crate) fn fn_yield(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + let arg_count = args.len(); + if !(6..=7).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 rate = match self.get_number_no_bools(&args[2], cell) { + Ok(f) => f, + Err(s) => return s, + }; + let price = match self.get_number_no_bools(&args[3], cell) { + Ok(f) => f, + Err(s) => return s, + }; + let redemption = match self.get_number_no_bools(&args[4], cell) { + Ok(f) => f, + Err(s) => return s, + }; + let frequency = match self.get_number_no_bools(&args[5], cell) { + Ok(f) => f.trunc() as i32, + Err(s) => return s, + }; + if frequency != 1 && frequency != 2 && frequency != 4 { + return CalcResult::new_error( + Error::NUM, + cell, + "frequency should be 1, 2 or 4".to_string(), + ); + } + if settlement >= maturity { + return CalcResult::new_error( + Error::NUM, + cell, + "settlement should be < maturity".to_string(), + ); + } + let basis = if arg_count == 7 { + match self.get_number_no_bools(&args[6], cell) { + Ok(f) => f.trunc() as i32, + Err(s) => return s, + } + } else { + 0 + }; + let days_in_year = days_in_year_simple(basis); + let days = maturity - settlement; + let periods = ((days * frequency as f64) / days_in_year).round(); + if periods <= 0.0 { + return CalcResult::new_error(Error::NUM, cell, "invalid dates".to_string()); + } + let coupon = redemption * rate / frequency as f64; + + match compute_rate(-price, redemption, periods, coupon, 0, 0.1) { + Ok(r) => CalcResult::Number(r * frequency as f64), + Err((error, message)) => CalcResult::Error { + error, + origin: cell, + message, + }, + } + } + + // COUPDAYBS(settlement, maturity, frequency, [basis]) + pub(crate) fn fn_coupdaybs(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + let arg_count = args.len(); + if !(3..=4).contains(&arg_count) { + return CalcResult::new_args_number_error(cell); + } + + let settlement = match self.get_number_no_bools(&args[0], cell) { + Ok(f) => f.trunc() as i64, + Err(s) => return s, + }; + let maturity = match self.get_number_no_bools(&args[1], cell) { + Ok(f) => f.trunc() as i64, + Err(s) => return s, + }; + let frequency = match self.get_number_no_bools(&args[2], cell) { + Ok(f) => f.trunc() as i32, + Err(s) => return s, + }; + let basis = if arg_count > 3 { + match self.get_number_no_bools(&args[3], cell) { + Ok(f) => f.trunc() as i32, + Err(s) => return s, + } + } else { + 0 + }; + + if ![1, 2, 4].contains(&frequency) || !(0..=4).contains(&basis) { + return CalcResult::new_error(Error::NUM, cell, "invalid arguments".to_string()); + } + if settlement >= maturity { + return CalcResult::new_error( + Error::NUM, + cell, + "settlement should be < maturity".to_string(), + ); + } + + let settlement_date = match convert_date_serial(settlement as f64, cell) { + Ok(d) => d, + Err(e) => return e, + }; + let maturity_date = match convert_date_serial(maturity as f64, cell) { + Ok(d) => d, + Err(e) => return e, + }; + + let (prev_coupon_date, _) = coupon_dates(settlement_date, maturity_date, frequency); + let days = days_between_dates(prev_coupon_date, settlement_date, basis); + CalcResult::Number(days as f64) + } + + // COUPDAYS(settlement, maturity, frequency, [basis]) + pub(crate) fn fn_coupdays(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + let arg_count = args.len(); + if !(3..=4).contains(&arg_count) { + return CalcResult::new_args_number_error(cell); + } + + let settlement = match self.get_number_no_bools(&args[0], cell) { + Ok(f) => f.trunc() as i64, + Err(s) => return s, + }; + let maturity = match self.get_number_no_bools(&args[1], cell) { + Ok(f) => f.trunc() as i64, + Err(s) => return s, + }; + let frequency = match self.get_number_no_bools(&args[2], cell) { + Ok(f) => f.trunc() as i32, + Err(s) => return s, + }; + let basis = if arg_count > 3 { + match self.get_number_no_bools(&args[3], cell) { + Ok(f) => f.trunc() as i32, + Err(s) => return s, + } + } else { + 0 + }; + + if ![1, 2, 4].contains(&frequency) || !(0..=4).contains(&basis) { + return CalcResult::new_error(Error::NUM, cell, "invalid arguments".to_string()); + } + if settlement >= maturity { + return CalcResult::new_error( + Error::NUM, + cell, + "settlement should be < maturity".to_string(), + ); + } + + let settlement_date = match convert_date_serial(settlement as f64, cell) { + Ok(d) => d, + Err(e) => return e, + }; + let maturity_date = match convert_date_serial(maturity as f64, cell) { + Ok(d) => d, + Err(e) => return e, + }; + + let (prev_coupon_date, next_coupon_date) = + coupon_dates(settlement_date, maturity_date, frequency); + let days = match basis { + 0 | 4 => DAYS_IN_YEAR_360 / frequency, // 30/360 conventions + _ => days_between_dates(prev_coupon_date, next_coupon_date, basis), // Actual day counts + }; + CalcResult::Number(days as f64) + } + + // COUPDAYSNC(settlement, maturity, frequency, [basis]) + pub(crate) fn fn_coupdaysnc(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + let arg_count = args.len(); + if !(3..=4).contains(&arg_count) { + return CalcResult::new_args_number_error(cell); + } + + let settlement = match self.get_number_no_bools(&args[0], cell) { + Ok(f) => f.trunc() as i64, + Err(s) => return s, + }; + let maturity = match self.get_number_no_bools(&args[1], cell) { + Ok(f) => f.trunc() as i64, + Err(s) => return s, + }; + let frequency = match self.get_number_no_bools(&args[2], cell) { + Ok(f) => f.trunc() as i32, + Err(s) => return s, + }; + let basis = if arg_count > 3 { + match self.get_number_no_bools(&args[3], cell) { + Ok(f) => f.trunc() as i32, + Err(s) => return s, + } + } else { + 0 + }; + + if ![1, 2, 4].contains(&frequency) || !(0..=4).contains(&basis) { + return CalcResult::new_error(Error::NUM, cell, "invalid arguments".to_string()); + } + if settlement >= maturity { + return CalcResult::new_error( + Error::NUM, + cell, + "settlement should be < maturity".to_string(), + ); + } + + let settlement_date = match convert_date_serial(settlement as f64, cell) { + Ok(d) => d, + Err(e) => return e, + }; + let maturity_date = match convert_date_serial(maturity as f64, cell) { + Ok(d) => d, + Err(e) => return e, + }; + + let (_, next_coupon_date) = coupon_dates(settlement_date, maturity_date, frequency); + let days = days_between_dates(settlement_date, next_coupon_date, basis); + CalcResult::Number(days as f64) + } + + // COUPNCD(settlement, maturity, frequency, [basis]) + pub(crate) fn fn_coupncd(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + let arg_count = args.len(); + if !(3..=4).contains(&arg_count) { + return CalcResult::new_args_number_error(cell); + } + + let settlement = match self.get_number_no_bools(&args[0], cell) { + Ok(f) => f.trunc() as i64, + Err(s) => return s, + }; + let maturity = match self.get_number_no_bools(&args[1], cell) { + Ok(f) => f.trunc() as i64, + Err(s) => return s, + }; + let frequency = match self.get_number_no_bools(&args[2], cell) { + Ok(f) => f.trunc() as i32, + Err(s) => return s, + }; + let basis = if arg_count > 3 { + match self.get_number_no_bools(&args[3], cell) { + Ok(f) => f.trunc() as i32, + Err(s) => return s, + } + } else { + 0 + }; + + if ![1, 2, 4].contains(&frequency) || !(0..=4).contains(&basis) { + return CalcResult::new_error(Error::NUM, cell, "invalid arguments".to_string()); + } + if settlement >= maturity { + return CalcResult::new_error( + Error::NUM, + cell, + "settlement should be < maturity".to_string(), + ); + } + + let settlement_date = match convert_date_serial(settlement as f64, cell) { + Ok(d) => d, + Err(e) => return e, + }; + let maturity_date = match convert_date_serial(maturity as f64, cell) { + Ok(d) => d, + Err(e) => return e, + }; + + let (_, next_coupon_date) = coupon_dates(settlement_date, maturity_date, frequency); + date_to_serial_with_validation(next_coupon_date, cell) + } + + // COUPNUM(settlement, maturity, frequency, [basis]) + pub(crate) fn fn_coupnum(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + let arg_count = args.len(); + if !(3..=4).contains(&arg_count) { + return CalcResult::new_args_number_error(cell); + } + + let settlement = match self.get_number_no_bools(&args[0], cell) { + Ok(f) => f.trunc() as i64, + Err(s) => return s, + }; + let maturity = match self.get_number_no_bools(&args[1], cell) { + Ok(f) => f.trunc() as i64, + Err(s) => return s, + }; + let frequency = match self.get_number_no_bools(&args[2], cell) { + Ok(f) => f.trunc() as i32, + Err(s) => return s, + }; + let basis = if arg_count > 3 { + match self.get_number_no_bools(&args[3], cell) { + Ok(f) => f.trunc() as i32, + Err(s) => return s, + } + } else { + 0 + }; + + if ![1, 2, 4].contains(&frequency) || !(0..=4).contains(&basis) { + return CalcResult::new_error(Error::NUM, cell, "invalid arguments".to_string()); + } + if settlement >= maturity { + return CalcResult::new_error( + Error::NUM, + cell, + "settlement should be < maturity".to_string(), + ); + } + + let settlement_date = match convert_date_serial(settlement as f64, cell) { + Ok(d) => d, + Err(e) => return e, + }; + let maturity_date = match convert_date_serial(maturity as f64, cell) { + Ok(d) => d, + Err(e) => return e, + }; + + let months = 12 / frequency; + let step = chrono::Months::new(months as u32); + let mut date = maturity_date; + let mut count = 0; + while settlement_date < date { + count += 1; + date = match date.checked_sub_months(step) { + Some(new_date) => new_date, + None => break, // Safety check to avoid infinite loop + }; + } + CalcResult::Number(count as f64) + } + + // COUPPCD(settlement, maturity, frequency, [basis]) + pub(crate) fn fn_couppcd(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + let arg_count = args.len(); + if !(3..=4).contains(&arg_count) { + return CalcResult::new_args_number_error(cell); + } + + let settlement = match self.get_number_no_bools(&args[0], cell) { + Ok(f) => f.trunc() as i64, + Err(s) => return s, + }; + let maturity = match self.get_number_no_bools(&args[1], cell) { + Ok(f) => f.trunc() as i64, + Err(s) => return s, + }; + let frequency = match self.get_number_no_bools(&args[2], cell) { + Ok(f) => f.trunc() as i32, + Err(s) => return s, + }; + let basis = if arg_count > 3 { + match self.get_number_no_bools(&args[3], cell) { + Ok(f) => f.trunc() as i32, + Err(s) => return s, + } + } else { + 0 + }; + + if ![1, 2, 4].contains(&frequency) || !(0..=4).contains(&basis) { + return CalcResult::new_error(Error::NUM, cell, "invalid arguments".to_string()); + } + if settlement >= maturity { + return CalcResult::new_error( + Error::NUM, + cell, + "settlement should be < maturity".to_string(), + ); + } + + let settlement_date = match convert_date_serial(settlement as f64, cell) { + Ok(d) => d, + Err(e) => return e, + }; + let maturity_date = match convert_date_serial(maturity as f64, cell) { + Ok(d) => d, + Err(e) => return e, + }; + + let (prev_coupon_date, _) = coupon_dates(settlement_date, maturity_date, frequency); + date_to_serial_with_validation(prev_coupon_date, cell) + } + // DOLLARDE(fractional_dollar, fraction) pub(crate) fn fn_dollarde(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { if args.len() != 2 { diff --git a/base/src/functions/mod.rs b/base/src/functions/mod.rs index 3a8edf115..981c6713d 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -315,6 +315,16 @@ pub enum Function { Dollarde, Dollarfr, Effect, + Price, + Yield, + Duration, + Mduration, + Coupdaybs, + Coupdays, + Coupdaysnc, + Coupncd, + Coupnum, + Couppcd, Fv, Ipmt, Irr, @@ -1073,6 +1083,16 @@ impl Function { Function::Dollarde => functions.dollarde.clone(), Function::Dollarfr => functions.dollarfr.clone(), Function::Effect => functions.effect.clone(), + Function::Price => "PRICE".to_string(), + Function::Yield => "YIELD".to_string(), + Function::Duration => "DURATION".to_string(), + Function::Mduration => "MDURATION".to_string(), + Function::Coupdaybs => "COUPDAYBS".to_string(), + Function::Coupdays => "COUPDAYS".to_string(), + Function::Coupdaysnc => "COUPDAYSNC".to_string(), + Function::Coupncd => "COUPNCD".to_string(), + Function::Coupnum => "COUPNUM".to_string(), + Function::Couppcd => "COUPPCD".to_string(), Function::Fv => functions.fv.clone(), Function::Ipmt => functions.ipmt.clone(), Function::Irr => functions.irr.clone(), @@ -1168,7 +1188,7 @@ impl Function { Function::Steyx => functions.steyx.clone(), } } - pub fn into_iter() -> IntoIter { + pub fn into_iter() -> IntoIter { [ Function::And, Function::False, @@ -1338,6 +1358,16 @@ impl Function { Function::Rate, Function::Nper, Function::Fv, + Function::Price, + Function::Yield, + Function::Duration, + Function::Mduration, + Function::Coupdaybs, + Function::Coupdays, + Function::Coupdaysnc, + Function::Coupncd, + Function::Coupnum, + Function::Couppcd, Function::Ppmt, Function::Ipmt, Function::Npv, @@ -1794,6 +1824,16 @@ impl<'a> Model<'a> { Function::Rate => self.fn_rate(args, cell), Function::Nper => self.fn_nper(args, cell), Function::Fv => self.fn_fv(args, cell), + Function::Price => self.fn_price(args, cell), + Function::Yield => self.fn_yield(args, cell), + Function::Duration => self.fn_duration(args, cell), + Function::Mduration => self.fn_mduration(args, cell), + Function::Coupdaybs => self.fn_coupdaybs(args, cell), + Function::Coupdays => self.fn_coupdays(args, cell), + Function::Coupdaysnc => self.fn_coupdaysnc(args, cell), + Function::Coupncd => self.fn_coupncd(args, cell), + Function::Coupnum => self.fn_coupnum(args, cell), + Function::Couppcd => self.fn_couppcd(args, cell), Function::Ppmt => self.fn_ppmt(args, cell), Function::Ipmt => self.fn_ipmt(args, cell), Function::Npv => self.fn_npv(args, cell), diff --git a/base/src/test/mod.rs b/base/src/test/mod.rs index 3c2b0f052..cb5a40ebb 100644 --- a/base/src/test/mod.rs +++ b/base/src/test/mod.rs @@ -18,7 +18,9 @@ mod test_fn_averageifs; mod test_fn_choose; mod test_fn_concatenate; mod test_fn_count; +mod test_fn_coupon; mod test_fn_day; +mod test_fn_duration; mod test_fn_exact; mod test_fn_financial; mod test_fn_formulatext; @@ -26,6 +28,7 @@ mod test_fn_if; mod test_fn_maxifs; mod test_fn_minifs; mod test_fn_or_xor; +mod test_fn_price_yield; mod test_fn_product; mod test_fn_rept; mod test_fn_sum; diff --git a/base/src/test/test_fn_coupon.rs b/base/src/test/test_fn_coupon.rs new file mode 100644 index 000000000..4c1fb0eee --- /dev/null +++ b/base/src/test/test_fn_coupon.rs @@ -0,0 +1,260 @@ +#![allow(clippy::unwrap_used)] + +use crate::{cell::CellValue, test::util::new_empty_model}; + +#[test] +fn fn_coupon_functions() { + let mut model = new_empty_model(); + + // Test with basis 1 (original test) + model._set("A1", "=DATE(2001,1,25)"); + model._set("A2", "=DATE(2001,11,15)"); + model._set("B1", "=COUPDAYBS(A1,A2,2,1)"); + model._set("B2", "=COUPDAYS(A1,A2,2,1)"); + model._set("B3", "=COUPDAYSNC(A1,A2,2,1)"); + model._set("B4", "=COUPNCD(A1,A2,2,1)"); + model._set("B5", "=COUPNUM(A1,A2,2,1)"); + model._set("B6", "=COUPPCD(A1,A2,2,1)"); + + // Test with basis 3 for better coverage + model._set("C1", "=COUPDAYBS(DATE(2001,1,25),DATE(2001,11,15),2,3)"); + model._set("C2", "=COUPDAYS(DATE(2001,1,25),DATE(2001,11,15),2,3)"); + model._set("C3", "=COUPDAYSNC(DATE(2001,1,25),DATE(2001,11,15),2,3)"); + model._set("C4", "=COUPNCD(DATE(2001,1,25),DATE(2001,11,15),2,3)"); + model._set("C5", "=COUPNUM(DATE(2007,1,25),DATE(2008,11,15),2,1)"); + model._set("C6", "=COUPPCD(DATE(2001,1,25),DATE(2001,11,15),2,3)"); + + model.evaluate(); + + // Test basis 1 + assert_eq!(model._get_text("B1"), "71"); + assert_eq!(model._get_text("B2"), "181"); + assert_eq!(model._get_text("B3"), "110"); + assert_eq!( + model.get_cell_value_by_ref("Sheet1!B4"), + Ok(CellValue::Number(37026.0)) + ); + assert_eq!(model._get_text("B5"), "2"); + assert_eq!( + model.get_cell_value_by_ref("Sheet1!B6"), + Ok(CellValue::Number(36845.0)) + ); + + // Test basis 3 (more comprehensive coverage) + assert_eq!(model._get_text("C1"), "71"); + assert_eq!(model._get_text("C2"), "181"); // Fixed: actual days + assert_eq!(model._get_text("C3"), "110"); + assert_eq!(model._get_text("C4"), "37026"); + assert_eq!(model._get_text("C5"), "4"); + assert_eq!(model._get_text("C6"), "36845"); +} + +#[test] +fn fn_coupon_functions_error_cases() { + let mut model = new_empty_model(); + + // Test invalid frequency + model._set("E1", "=COUPDAYBS(DATE(2001,1,25),DATE(2001,11,15),3,1)"); + // Test invalid basis + model._set("E2", "=COUPDAYS(DATE(2001,1,25),DATE(2001,11,15),2,5)"); + // Test settlement >= maturity + model._set("E3", "=COUPDAYSNC(DATE(2001,11,15),DATE(2001,1,25),2,1)"); + // Test too few arguments + model._set("E4", "=COUPNCD(DATE(2001,1,25),DATE(2001,11,15))"); + // Test too many arguments + model._set("E5", "=COUPNUM(DATE(2001,1,25),DATE(2001,11,15),2,1,1)"); + + model.evaluate(); + + // All should return errors + assert_eq!(model._get_text("E1"), "#NUM!"); + assert_eq!(model._get_text("E2"), "#NUM!"); + assert_eq!(model._get_text("E3"), "#NUM!"); + assert_eq!(model._get_text("E4"), *"#ERROR!"); + assert_eq!(model._get_text("E5"), *"#ERROR!"); +} + +#[test] +fn fn_coupdays_actual_day_count_fix() { + // Verify COUPDAYS correctly distinguishes between fixed vs actual day count methods + // Bug: basis 2&3 were incorrectly using fixed calculations like basis 0&4 + let mut model = new_empty_model(); + + model._set("A1", "=DATE(2023,1,15)"); + model._set("A2", "=DATE(2023,7,15)"); + + model._set("B1", "=COUPDAYS(A1,A2,2,0)"); // 30/360: uses 360/freq + model._set("B2", "=COUPDAYS(A1,A2,2,2)"); // Actual/360: uses actual days + model._set("B3", "=COUPDAYS(A1,A2,2,3)"); // Actual/365: uses actual days + model._set("B4", "=COUPDAYS(A1,A2,2,4)"); // 30/360 European: uses 360/freq + + model.evaluate(); + + // Basis 0&4: theoretical 360/2 = 180 days + assert_eq!(model._get_text("B1"), "180"); + assert_eq!(model._get_text("B4"), "180"); + + // Basis 2&3: actual days between Jan 15 and Jul 15 = 181 days + assert_eq!(model._get_text("B2"), "181"); + assert_eq!(model._get_text("B3"), "181"); +} + +// ============================================================================= +// FEBRUARY EDGE CASE TESTS - Day Count Convention Compliance +// ============================================================================= +// These tests verify that financial functions correctly handle February dates +// according to the official 30/360 day count convention specifications. + +#[test] +fn test_coupon_functions_february_consistency() { + let mut model = new_empty_model(); + + // Test that coupon functions behave consistently between US and European methods + // when February dates are involved + + // Settlement: Last day of February (non-leap year) + // Maturity: Some date in following year that creates a clear test case + model._set("A1", "=DATE(2023,2,28)"); // Last day of Feb, non-leap year + model._set("A2", "=DATE(2024,2,28)"); // Same day next year + + // Test COUPDAYS with different basis values + model._set("B1", "=COUPDAYS(A1,A2,2,0)"); // US 30/360 - should treat Feb 28 as day 30 + model._set("B2", "=COUPDAYS(A1,A2,2,4)"); // European 30/360 - should treat Feb 28 as day 28 + model._set("B3", "=COUPDAYS(A1,A2,2,1)"); // Actual/actual - should use real days + + model.evaluate(); + + // All should return valid numbers (no errors) + assert_ne!(model._get_text("B1"), *"#NUM!"); + assert_ne!(model._get_text("B2"), *"#NUM!"); + assert_ne!(model._get_text("B3"), *"#NUM!"); + + // US and European 30/360 should potentially give different results for February dates + // (though the exact difference depends on the specific coupon calculation logic) + let us_result = model._get_text("B1"); + let european_result = model._get_text("B2"); + let actual_result = model._get_text("B3"); + + // Verify all are numeric + assert!(us_result.parse::().is_ok()); + assert!(european_result.parse::().is_ok()); + assert!(actual_result.parse::().is_ok()); +} + +#[test] +fn test_february_edge_cases_leap_vs_nonleap() { + let mut model = new_empty_model(); + + // Test leap year vs non-leap year February handling + + // Feb 28 in non-leap year (this IS the last day of February) + model._set("A1", "=DATE(2023,2,28)"); + model._set("A2", "=DATE(2023,8,28)"); + + // Feb 28 in leap year (this is NOT the last day of February) + model._set("A3", "=DATE(2024,2,28)"); + model._set("A4", "=DATE(2024,8,28)"); + + // Feb 29 in leap year (this IS the last day of February) + model._set("A5", "=DATE(2024,2,29)"); + model._set("A6", "=DATE(2024,8,29)"); + + // Test with basis 0 (US 30/360) - should have special February handling + model._set("B1", "=COUPDAYS(A1,A2,2,0)"); // Feb 28 non-leap (last day) + model._set("B2", "=COUPDAYS(A3,A4,2,0)"); // Feb 28 leap year (not last day) + model._set("B3", "=COUPDAYS(A5,A6,2,0)"); // Feb 29 leap year (last day) + + model.evaluate(); + + // All should succeed + assert_ne!(model._get_text("B1"), *"#NUM!"); + assert_ne!(model._get_text("B2"), *"#NUM!"); + assert_ne!(model._get_text("B3"), *"#NUM!"); + + // Verify they're all numeric + assert!(model._get_text("B1").parse::().is_ok()); + assert!(model._get_text("B2").parse::().is_ok()); + assert!(model._get_text("B3").parse::().is_ok()); +} + +#[test] +fn test_us_nasd_both_february_rule() { + let mut model = new_empty_model(); + + // Test the specific US/NASD rule: "If both date A and B fall on the last day of February, + // then date B will be changed to the 30th" + + // Case 1: Both dates are Feb 28 in non-leap years (both are last day of February) + model._set("A1", "=DATE(2023,2,28)"); // Last day of Feb 2023 + model._set("A2", "=DATE(2025,2,28)"); // Last day of Feb 2025 + + // Case 2: Both dates are Feb 29 in leap years (both are last day of February) + model._set("A3", "=DATE(2024,2,29)"); // Last day of Feb 2024 + model._set("A4", "=DATE(2028,2,29)"); // Last day of Feb 2028 + + // Case 3: Mixed - Feb 28 non-leap to Feb 29 leap (both are last day of February) + model._set("A5", "=DATE(2023,2,28)"); // Last day of Feb 2023 + model._set("A6", "=DATE(2024,2,29)"); // Last day of Feb 2024 + + // Case 4: Control - Feb 28 in leap year (NOT last day) to Feb 29 (IS last day) + model._set("A7", "=DATE(2024,2,28)"); // NOT last day of Feb 2024 + model._set("A8", "=DATE(2024,2,29)"); // IS last day of Feb 2024 + + // Test using coupon functions that should apply US/NASD 30/360 (basis 0) + model._set("B1", "=COUPDAYS(A1,A2,1,0)"); // Both last day Feb - Rule 1 should apply + model._set("B2", "=COUPDAYS(A3,A4,1,0)"); // Both last day Feb - Rule 1 should apply + model._set("B3", "=COUPDAYS(A5,A6,1,0)"); // Both last day Feb - Rule 1 should apply + model._set("B4", "=COUPDAYS(A7,A8,1,0)"); // Only end is last day Feb - Rule 1 should NOT apply + + // Compare with European method (basis 4) - should behave differently + model._set("C1", "=COUPDAYS(A1,A2,1,4)"); // European - no special Feb handling + model._set("C2", "=COUPDAYS(A3,A4,1,4)"); // European - no special Feb handling + model._set("C3", "=COUPDAYS(A5,A6,1,4)"); // European - no special Feb handling + model._set("C4", "=COUPDAYS(A7,A8,1,4)"); // European - no special Feb handling + + model.evaluate(); + + // All should succeed without errors + for row in ["B1", "B2", "B3", "B4", "C1", "C2", "C3", "C4"] { + assert_ne!(model._get_text(row), *"#NUM!", "Failed for {row}"); + assert!( + model._get_text(row).parse::().is_ok(), + "Non-numeric result for {row}" + ); + } +} + +#[test] +fn test_coupon_functions_february_edge_cases() { + let mut model = new_empty_model(); + + // Test that coupon functions handle February dates correctly without errors + + // Settlement: February 28, 2023 (non-leap), Maturity: February 28, 2024 (leap) + model._set("A1", "=DATE(2023,2,28)"); + model._set("A2", "=DATE(2024,2,28)"); + + // Test with basis 0 (US 30/360 - should use special February handling) + model._set("B1", "=COUPDAYBS(A1,A2,2,0)"); + model._set("B2", "=COUPDAYS(A1,A2,2,0)"); + model._set("B3", "=COUPDAYSNC(A1,A2,2,0)"); + + // Test with basis 4 (European 30/360 - should NOT use special February handling) + model._set("C1", "=COUPDAYBS(A1,A2,2,4)"); + model._set("C2", "=COUPDAYS(A1,A2,2,4)"); + model._set("C3", "=COUPDAYSNC(A1,A2,2,4)"); + + model.evaluate(); + + // With US method (basis 0), February dates should be handled specially + // With European method (basis 4), February dates should use actual dates + // Key point: both should work without errors + + // We're ensuring functions complete successfully with February dates + assert_ne!(model._get_text("B1"), *"#NUM!"); + assert_ne!(model._get_text("B2"), *"#NUM!"); + assert_ne!(model._get_text("B3"), *"#NUM!"); + assert_ne!(model._get_text("C1"), *"#NUM!"); + assert_ne!(model._get_text("C2"), *"#NUM!"); + assert_ne!(model._get_text("C3"), *"#NUM!"); +} diff --git a/base/src/test/test_fn_duration.rs b/base/src/test/test_fn_duration.rs new file mode 100644 index 000000000..29436937a --- /dev/null +++ b/base/src/test/test_fn_duration.rs @@ -0,0 +1,339 @@ +#![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}"); + } + } 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(); + + 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(); + + for row in 1..=5 { + assert_numerical_result(&model, &format!("Sheet1!B{row}"), true); + assert_numerical_result(&model, &format!("Sheet1!C{row}"), true); + } + + 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); + model._set("A3", BOND_MATURITY_4Y); + + let test_cases = [ + ("B", "A1", "A2", STD_COUPON, STD_YIELD, "short_term"), + ("C", "A1", "A3", 0.0, STD_YIELD, "zero_coupon"), + ("D", "A1", "A3", STD_COUPON, 0.0, "zero_yield"), + ("E", "A1", "A3", 1.0, 0.5, "high_rates"), + ]; + + 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(); + + 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); + + 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})")); + + 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)"); + + model.evaluate(); + + 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() { + 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(); + + 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"); + } +} diff --git a/base/src/test/test_fn_price_yield.rs b/base/src/test/test_fn_price_yield.rs new file mode 100644 index 000000000..b821ba507 --- /dev/null +++ b/base/src/test/test_fn_price_yield.rs @@ -0,0 +1,234 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn fn_price_yield() { + let mut model = new_empty_model(); + model._set("A1", "=DATE(2023,1,1)"); + model._set("A2", "=DATE(2024,1,1)"); + model._set("A3", "5%"); + + model._set("B1", "=PRICE(A1,A2,A3,6%,100,1)"); + model._set("B2", "=YIELD(A1,A2,A3,B1,100,1)"); + + model.evaluate(); + assert_eq!(model._get_text("B1"), "99.056603774"); + assert_eq!(model._get_text("B2"), "0.06"); +} + +#[test] +fn fn_price_frequencies() { + let mut model = new_empty_model(); + model._set("A1", "=DATE(2023,1,1)"); + model._set("A2", "=DATE(2024,1,1)"); + + model._set("B1", "=PRICE(A1,A2,5%,6%,100,1)"); + model._set("B2", "=PRICE(A1,A2,5%,6%,100,2)"); + model._set("B3", "=PRICE(A1,A2,5%,6%,100,4)"); + + model.evaluate(); + + let annual: f64 = model._get_text("B1").parse().unwrap(); + let semi: f64 = model._get_text("B2").parse().unwrap(); + let quarterly: f64 = model._get_text("B3").parse().unwrap(); + + assert_ne!(annual, semi); + assert_ne!(semi, quarterly); +} + +#[test] +fn fn_yield_frequencies() { + let mut model = new_empty_model(); + model._set("A1", "=DATE(2023,1,1)"); + model._set("A2", "=DATE(2024,1,1)"); + + model._set("B1", "=YIELD(A1,A2,5%,99,100,1)"); + model._set("B2", "=YIELD(A1,A2,5%,99,100,2)"); + model._set("B3", "=YIELD(A1,A2,5%,99,100,4)"); + + model.evaluate(); + + let annual: f64 = model._get_text("B1").parse().unwrap(); + let semi: f64 = model._get_text("B2").parse().unwrap(); + let quarterly: f64 = model._get_text("B3").parse().unwrap(); + + assert_ne!(annual, semi); + assert_ne!(semi, quarterly); +} + +#[test] +fn fn_price_argument_errors() { + let mut model = new_empty_model(); + model._set("A1", "=DATE(2023,1,1)"); + model._set("A2", "=DATE(2024,1,1)"); + + model._set("B1", "=PRICE()"); + model._set("B2", "=PRICE(A1,A2,5%,6%,100)"); + model._set("B3", "=PRICE(A1,A2,5%,6%,100,2,0,99)"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), *"#ERROR!"); + assert_eq!(model._get_text("B2"), *"#ERROR!"); + assert_eq!(model._get_text("B3"), *"#ERROR!"); +} + +#[test] +fn fn_yield_argument_errors() { + let mut model = new_empty_model(); + model._set("A1", "=DATE(2023,1,1)"); + model._set("A2", "=DATE(2024,1,1)"); + + model._set("B1", "=YIELD()"); + model._set("B2", "=YIELD(A1,A2,5%,99,100)"); + model._set("B3", "=YIELD(A1,A2,5%,99,100,2,0,99)"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), *"#ERROR!"); + assert_eq!(model._get_text("B2"), *"#ERROR!"); + assert_eq!(model._get_text("B3"), *"#ERROR!"); +} + +#[test] +fn fn_price_invalid_frequency() { + let mut model = new_empty_model(); + model._set("A1", "=DATE(2023,1,1)"); + model._set("A2", "=DATE(2024,1,1)"); + + model._set("B1", "=PRICE(A1,A2,5%,6%,100,0)"); + model._set("B2", "=PRICE(A1,A2,5%,6%,100,3)"); + model._set("B3", "=PRICE(A1,A2,5%,6%,100,5)"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), *"#NUM!"); + assert_eq!(model._get_text("B2"), *"#NUM!"); + assert_eq!(model._get_text("B3"), *"#NUM!"); +} + +#[test] +fn fn_yield_invalid_frequency() { + let mut model = new_empty_model(); + model._set("A1", "=DATE(2023,1,1)"); + model._set("A2", "=DATE(2024,1,1)"); + + model._set("B1", "=YIELD(A1,A2,5%,99,100,0)"); + model._set("B2", "=YIELD(A1,A2,5%,99,100,3)"); + model._set("B3", "=YIELD(A1,A2,5%,99,100,5)"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), *"#NUM!"); + assert_eq!(model._get_text("B2"), *"#NUM!"); + assert_eq!(model._get_text("B3"), *"#NUM!"); +} + +#[test] +fn fn_price_invalid_dates() { + let mut model = new_empty_model(); + model._set("A1", "=DATE(2023,1,1)"); + model._set("A2", "=DATE(2024,1,1)"); + + model._set("B1", "=PRICE(A2,A1,5%,6%,100,2)"); + model._set("B2", "=PRICE(A1,A1,5%,6%,100,2)"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), *"#NUM!"); + assert_eq!(model._get_text("B2"), *"#NUM!"); +} + +#[test] +fn fn_yield_invalid_dates() { + let mut model = new_empty_model(); + model._set("A1", "=DATE(2023,1,1)"); + model._set("A2", "=DATE(2024,1,1)"); + + model._set("B1", "=YIELD(A2,A1,5%,99,100,2)"); + model._set("B2", "=YIELD(A1,A1,5%,99,100,2)"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), *"#NUM!"); + assert_eq!(model._get_text("B2"), *"#NUM!"); +} + +#[test] +fn fn_price_with_basis() { + let mut model = new_empty_model(); + model._set("A1", "=DATE(2023,1,1)"); + model._set("A2", "=DATE(2024,1,1)"); + + model._set("B1", "=PRICE(A1,A2,5%,6%,100,2,0)"); + model._set("B2", "=PRICE(A1,A2,5%,6%,100,2,1)"); + + model.evaluate(); + + assert!(model._get_text("B1").parse::().is_ok()); + assert!(model._get_text("B2").parse::().is_ok()); +} + +#[test] +fn fn_yield_with_basis() { + let mut model = new_empty_model(); + model._set("A1", "=DATE(2023,1,1)"); + model._set("A2", "=DATE(2024,1,1)"); + + model._set("B1", "=YIELD(A1,A2,5%,99,100,2,0)"); + model._set("B2", "=YIELD(A1,A2,5%,99,100,2,1)"); + + model.evaluate(); + + assert!(model._get_text("B1").parse::().is_ok()); + assert!(model._get_text("B2").parse::().is_ok()); +} + +#[test] +fn fn_price_yield_inverse_functions() { + let mut model = new_empty_model(); + + model._set("A1", "=DATE(2023,1,15)"); + model._set("A2", "=DATE(2024,7,15)"); + model._set("A3", "4.75%"); + model._set("A4", "5.125%"); + + model._set("B1", "=PRICE(A1,A2,A3,A4,100,2)"); + model._set("B2", "=YIELD(A1,A2,A3,B1,100,2)"); + + model.evaluate(); + + let calculated_yield: f64 = model._get_text("B2").parse().unwrap(); + let expected_yield = 0.05125; + + assert!( + (calculated_yield - expected_yield).abs() < 1e-12, + "YIELD should recover original yield: expected {expected_yield}, got {calculated_yield}" + ); +} + +#[test] +fn fn_price_yield_round_trip_stability() { + let mut model = new_empty_model(); + + model._set("A1", "=DATE(2023,3,10)"); + model._set("A2", "=DATE(2024,11,22)"); + model._set("A3", "3.25%"); + model._set("A4", "4.875%"); + + model._set("B1", "=PRICE(A1,A2,A3,A4,100,4)"); + model._set("B2", "=YIELD(A1,A2,A3,B1,100,4)"); + + model._set("B3", "=PRICE(A1,A2,A3,B2,100,4)"); + + model.evaluate(); + + let price1: f64 = model._get_text("B1").parse().unwrap(); + let price2: f64 = model._get_text("B3").parse().unwrap(); + + assert!( + (price1 - price2).abs() < 1e-10, + "Round-trip should be stable: {price1} vs {price2}" + ); +} diff --git a/docs/src/functions/financial.md b/docs/src/functions/financial.md index e8593cdf2..24aa31e18 100644 --- a/docs/src/functions/financial.md +++ b/docs/src/functions/financial.md @@ -15,12 +15,12 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | ACCRINTM | | – | | AMORDEGRC | | – | | AMORLINC | | – | -| COUPDAYBS | | – | -| COUPDAYS | | – | -| COUPDAYSNC | | – | -| COUPNCD | | – | -| COUPNUM | | – | -| COUPPCD | | – | +| COUPDAYBS | | [COUPDAYBS](financial/coupdaybs) | +| COUPDAYS | | [COUPDAYS](financial/coupdays) | +| COUPDAYSNC | | [COUPDAYSNC](financial/coupdaysnc) | +| COUPNCD | | [COUPNCD](financial/coupncd) | +| COUPNUM | | [COUPNUM](financial/coupnum) | +| COUPPCD | | [COUPPCD](financial/couppcd) | | CUMIPMT | | – | | CUMPRINC | | – | | DB | | – | @@ -28,7 +28,7 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | DISC | | – | | DOLLARDE | | – | | DOLLARFR | | – | -| DURATION | | – | +| DURATION | | [DURATION](financial/duration) | | EFFECT | | – | | FV | | [FV](financial/fv) | | FVSCHEDULE | | – | @@ -36,7 +36,7 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | IPMT | | – | | IRR | | – | | ISPMT | | – | -| MDURATION | | – | +| MDURATION | | [MDURATION](financial/mduration) | | MIRR | | – | | NOMINAL | | – | | NPER | | – | @@ -48,7 +48,7 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | PDURATION | | – | | PMT | | – | | PPMT | | – | -| PRICE | | – | +| PRICE | | [PRICE](financial/price) | | PRICEDISC | | – | | PRICEMAT | | – | | PV | | [PV](financial/pv) | @@ -63,6 +63,6 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | VDB | | – | | XIRR | | – | | XNPV | | – | -| YIELD | | – | +| YIELD | | [YIELD](financial/yield) | | YIELDDISC | | – | | YIELDMAT | | – | diff --git a/docs/src/functions/financial/coupdaybs.md b/docs/src/functions/financial/coupdaybs.md index 710809b68..287bf6cd8 100644 --- a/docs/src/functions/financial/coupdaybs.md +++ b/docs/src/functions/financial/coupdaybs.md @@ -7,6 +7,5 @@ lang: en-US # COUPDAYBS ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 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). ::: \ No newline at end of file diff --git a/docs/src/functions/financial/coupdays.md b/docs/src/functions/financial/coupdays.md index b47d8e8c5..a9c53692c 100644 --- a/docs/src/functions/financial/coupdays.md +++ b/docs/src/functions/financial/coupdays.md @@ -7,6 +7,5 @@ lang: en-US # COUPDAYS ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 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). ::: \ No newline at end of file diff --git a/docs/src/functions/financial/coupdaysnc.md b/docs/src/functions/financial/coupdaysnc.md index 1aabcf855..1b8b961b9 100644 --- a/docs/src/functions/financial/coupdaysnc.md +++ b/docs/src/functions/financial/coupdaysnc.md @@ -7,6 +7,5 @@ lang: en-US # COUPDAYSNC ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 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). ::: \ No newline at end of file diff --git a/docs/src/functions/financial/coupncd.md b/docs/src/functions/financial/coupncd.md index 4c3eeb24a..6715a8dd7 100644 --- a/docs/src/functions/financial/coupncd.md +++ b/docs/src/functions/financial/coupncd.md @@ -7,6 +7,5 @@ lang: en-US # COUPNCD ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 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). ::: \ No newline at end of file diff --git a/docs/src/functions/financial/coupnum.md b/docs/src/functions/financial/coupnum.md index 7bd4538e9..74cd68332 100644 --- a/docs/src/functions/financial/coupnum.md +++ b/docs/src/functions/financial/coupnum.md @@ -7,6 +7,5 @@ lang: en-US # COUPNUM ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 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). ::: \ No newline at end of file diff --git a/docs/src/functions/financial/couppcd.md b/docs/src/functions/financial/couppcd.md index 933c0efd2..16d3fe1c3 100644 --- a/docs/src/functions/financial/couppcd.md +++ b/docs/src/functions/financial/couppcd.md @@ -7,6 +7,5 @@ lang: en-US # COUPPCD ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 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). ::: \ No newline at end of file diff --git a/docs/src/functions/financial/duration.md b/docs/src/functions/financial/duration.md index d163aa0fd..fa603f4fc 100644 --- a/docs/src/functions/financial/duration.md +++ b/docs/src/functions/financial/duration.md @@ -7,6 +7,5 @@ lang: en-US # DURATION ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) -::: \ No newline at end of file +🚧 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). +::: diff --git a/docs/src/functions/financial/mduration.md b/docs/src/functions/financial/mduration.md index efe28f1b5..6a1eaf762 100644 --- a/docs/src/functions/financial/mduration.md +++ b/docs/src/functions/financial/mduration.md @@ -7,6 +7,5 @@ lang: en-US # MDURATION ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) -::: \ No newline at end of file +🚧 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). +::: diff --git a/docs/src/functions/financial/price.md b/docs/src/functions/financial/price.md index 378de6d4c..df454148c 100644 --- a/docs/src/functions/financial/price.md +++ b/docs/src/functions/financial/price.md @@ -7,6 +7,5 @@ lang: en-US # PRICE ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) -::: \ No newline at end of file +🚧 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). +::: diff --git a/docs/src/functions/financial/yield.md b/docs/src/functions/financial/yield.md index 7138dc71d..71e5a22e4 100644 --- a/docs/src/functions/financial/yield.md +++ b/docs/src/functions/financial/yield.md @@ -7,6 +7,5 @@ lang: en-US # YIELD ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) -::: \ No newline at end of file +🚧 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). +:::