diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index 6253d82..fcf0709 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -550,6 +550,14 @@ fn args_signature_irr(arg_count: usize) -> Vec { } } +fn args_signature_fvschedule(arg_count: usize) -> Vec { + if arg_count == 2 { + vec![Signature::Scalar, Signature::Vector] + } else { + vec![Signature::Error; arg_count] + } +} + fn args_signature_xirr(arg_count: usize) -> Vec { if arg_count == 2 { vec![Signature::Vector; arg_count] @@ -752,6 +760,7 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec args_signature_scalars(arg_count, 2, 0), Function::Effect => args_signature_scalars(arg_count, 2, 0), Function::Fv => args_signature_scalars(arg_count, 3, 2), + Function::Fvschedule => args_signature_fvschedule(arg_count), Function::Ipmt => args_signature_scalars(arg_count, 4, 2), Function::Irr => args_signature_irr(arg_count), Function::Ispmt => args_signature_scalars(arg_count, 4, 0), @@ -759,9 +768,20 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec args_signature_scalars(arg_count, 2, 0), Function::Nper => args_signature_scalars(arg_count, 3, 2), 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::Accrint => args_signature_scalars(arg_count, 6, 2), + Function::Accrintm => args_signature_scalars(arg_count, 4, 1), + 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), Function::Pmt => args_signature_scalars(arg_count, 3, 2), Function::Ppmt => args_signature_scalars(arg_count, 4, 2), + Function::Price => args_signature_scalars(arg_count, 6, 1), Function::Pv => args_signature_scalars(arg_count, 3, 2), Function::Rate => args_signature_scalars(arg_count, 3, 3), Function::Rri => args_signature_scalars(arg_count, 3, 0), @@ -770,6 +790,14 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec args_signature_scalars(arg_count, 3, 0), Function::Tbillprice => args_signature_scalars(arg_count, 3, 0), Function::Tbillyield => args_signature_scalars(arg_count, 3, 0), + Function::Yield => args_signature_scalars(arg_count, 6, 1), + Function::Pricedisc => args_signature_scalars(arg_count, 4, 1), + Function::Pricemat => args_signature_scalars(arg_count, 5, 1), + Function::Yielddisc => args_signature_scalars(arg_count, 4, 1), + Function::Yieldmat => args_signature_scalars(arg_count, 5, 1), + Function::Disc => args_signature_scalars(arg_count, 4, 1), + Function::Received => args_signature_scalars(arg_count, 4, 1), + Function::Intrate => args_signature_scalars(arg_count, 4, 1), Function::Xirr => args_signature_xirr(arg_count), Function::Xnpv => args_signature_xnpv(arg_count), Function::Besseli => args_signature_scalars(arg_count, 2, 0), @@ -1016,6 +1044,7 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Dollarfr => not_implemented(args), Function::Effect => not_implemented(args), Function::Fv => not_implemented(args), + Function::Fvschedule => not_implemented(args), Function::Ipmt => not_implemented(args), Function::Irr => not_implemented(args), Function::Ispmt => not_implemented(args), @@ -1023,9 +1052,20 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Nominal => not_implemented(args), Function::Nper => not_implemented(args), Function::Npv => not_implemented(args), + Function::Duration => not_implemented(args), + Function::Mduration => not_implemented(args), Function::Pduration => not_implemented(args), + Function::Accrint => not_implemented(args), + Function::Accrintm => not_implemented(args), + Function::Coupdaybs => not_implemented(args), + Function::Coupdays => not_implemented(args), + Function::Coupdaysnc => not_implemented(args), + Function::Coupncd => not_implemented(args), + Function::Coupnum => not_implemented(args), + Function::Couppcd => not_implemented(args), Function::Pmt => not_implemented(args), Function::Ppmt => not_implemented(args), + Function::Price => not_implemented(args), Function::Pv => not_implemented(args), Function::Rate => not_implemented(args), Function::Rri => not_implemented(args), @@ -1034,6 +1074,14 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Tbilleq => not_implemented(args), Function::Tbillprice => not_implemented(args), Function::Tbillyield => not_implemented(args), + Function::Yield => not_implemented(args), + Function::Pricedisc => not_implemented(args), + Function::Pricemat => not_implemented(args), + Function::Yielddisc => not_implemented(args), + Function::Yieldmat => not_implemented(args), + Function::Disc => not_implemented(args), + Function::Received => not_implemented(args), + Function::Intrate => not_implemented(args), Function::Xirr => not_implemented(args), Function::Xnpv => not_implemented(args), Function::Besseli => scalar_arguments(args), diff --git a/base/src/functions/financial.rs b/base/src/functions/financial.rs index 8e11ee4..d3aaab1 100644 --- a/base/src/functions/financial.rs +++ b/base/src/functions/financial.rs @@ -10,13 +10,20 @@ 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_LEAP_YEAR: i32 = 366; +const DAYS_IN_MONTH_360: i32 = 30; +const TBILL_MATURITY_THRESHOLD: f64 = 183.0; + // See: // https://github.com/apache/openoffice/blob/c014b5f2b55cff8d4b0c952d5c16d62ecde09ca1/main/scaddins/source/analysis/financial.cxx 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 +48,739 @@ 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_in_year(date: chrono::NaiveDate, basis: i32) -> Result { + Ok(match basis { + 0 | 2 | 4 => DAYS_IN_YEAR_360, + 1 => { + if is_leap_year(date.year()) { + DAYS_LEAP_YEAR + } else { + DAYS_ACTUAL + } + } + 3 => DAYS_ACTUAL, + _ => return Err("invalid basis".to_string()), + }) +} + +/// Returns days in year for financial calculations (simplified version without leap year checking) +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, + } +} + +/// Macro to reduce duplication in financial functions that follow the pattern: +/// 1. Parse settlement/maturity and two parameters with validation +/// 2. Calculate year fraction +/// 3. Apply formula and return result +macro_rules! financial_function_with_year_frac { + ( + $args:ident, $self:ident, $cell:ident, + param1_name: $param1_name:literal, + param2_name: $param2_name:literal, + validator: $validator:expr, + formula: |$settlement:ident, $maturity:ident, $param1:ident, $param2:ident, $basis:ident, $year_frac:ident| $formula:expr + ) => {{ + // Parse and validate arguments + let arg_count = $args.len(); + if !(4..=5).contains(&arg_count) { + return CalcResult::new_args_number_error($cell); + } + + let ($settlement, $maturity) = + match parse_and_validate_settlement_maturity($args, $self, $cell, true) { + Ok(sm) => sm, + Err(err) => return err, + }; + let $param1 = match $self.get_number_no_bools(&$args[2], $cell) { + Ok(p) => p, + Err(err) => return err, + }; + let $param2 = match $self.get_number_no_bools(&$args[3], $cell) { + Ok(p) => p, + Err(err) => return err, + }; + let $basis = match parse_optional_basis($args, 4, arg_count, $self, $cell) { + Ok(b) => b, + Err(err) => return err, + }; + + // Apply custom validation + if let Err(msg) = ($validator)($param1, $param2) { + return CalcResult::new_error( + Error::NUM, + $cell, + format!("{} and {}: {}", $param1_name, $param2_name, msg), + ); + } + + let $year_frac = match year_frac($settlement as i64, $maturity as i64, $basis) { + Ok(f) => f, + Err(_) => return CalcResult::new_error(Error::NUM, $cell, "Invalid date".to_string()), + }; + + let result = $formula; + CalcResult::Number(result) + }}; +} + +/// Helper function to convert date serial number to chrono date with error handling +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(), + )), + } +} + +/// Helper function to parse optional basis parameter (defaults to 0) +fn parse_optional_basis( + args: &[Node], + basis_arg_index: usize, + arg_count: usize, + model: &mut Model, + cell: CellReferenceIndex, +) -> Result { + if arg_count > basis_arg_index { + match model.get_number_no_bools(&args[basis_arg_index], cell) { + Ok(f) => Ok(f.trunc() as i32), + Err(s) => Err(s), + } + } else { + Ok(0) + } +} + +/// Enhanced helper function to parse, validate settlement/maturity with optional date range validation +fn parse_and_validate_settlement_maturity( + args: &[Node], + model: &mut Model, + cell: CellReferenceIndex, + check_date_range: bool, +) -> Result<(f64, f64), CalcResult> { + // Parse settlement and maturity + let settlement = model.get_number_no_bools(&args[0], cell)?; + let maturity = model.get_number_no_bools(&args[1], cell)?; + + // Validate settlement < maturity + if settlement >= maturity { + return Err(CalcResult::new_error( + Error::NUM, + cell, + "settlement should be < maturity".to_string(), + )); + } + + // Optionally validate date ranges + if check_date_range { + if settlement < MINIMUM_DATE_SERIAL_NUMBER as f64 + || settlement > MAXIMUM_DATE_SERIAL_NUMBER as f64 + { + return Err(CalcResult::new_error( + Error::NUM, + cell, + "Invalid number for date".to_string(), + )); + } + if maturity < MINIMUM_DATE_SERIAL_NUMBER as f64 + || maturity > MAXIMUM_DATE_SERIAL_NUMBER as f64 + { + return Err(CalcResult::new_error( + Error::NUM, + cell, + "Invalid number for date".to_string(), + )); + } + } + + Ok((settlement, maturity)) +} + +/// Helper function to parse multiple required numeric parameters efficiently +/// Returns a vector of parsed values in the same order as the indices +fn parse_required_params( + args: &[Node], + indices: &[usize], + model: &mut Model, + cell: CellReferenceIndex, + use_no_bools: bool, +) -> Result, CalcResult> { + let mut params = Vec::with_capacity(indices.len()); + for &index in indices { + let value = if use_no_bools { + model.get_number_no_bools(&args[index], cell)? + } else { + model.get_number(&args[index], cell)? + }; + params.push(value); + } + Ok(params) +} + +/// Helper function to validate argument count and return early if invalid +fn validate_arg_count_or_return( + arg_count: usize, + min: usize, + max: usize, + cell: CellReferenceIndex, +) -> Option { + if !(min..=max).contains(&arg_count) { + Some(CalcResult::new_args_number_error(cell)) + } else { + None + } +} + +/// Helper function to convert date to serial number with consistent error handling +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), + } +} + +/// Helper struct for common financial function optional parameters +struct FinancialOptionalParams { + pub optional_value: f64, + pub period_start: bool, +} + +/// Helper function to parse common optional financial parameters: [optional_value], [type] +/// optional_value defaults to 0.0, type defaults to false (end of period) +fn parse_financial_optional_params( + args: &[Node], + arg_count: usize, + optional_value_index: usize, + model: &mut Model, + cell: CellReferenceIndex, +) -> Result { + let optional_value = if arg_count > optional_value_index { + model.get_number(&args[optional_value_index], cell)? + } else { + 0.0 + }; + + let period_start = if arg_count > optional_value_index + 1 { + model.get_number(&args[optional_value_index + 1], cell)? != 0.0 + } else { + false // at the end of the period + }; + + Ok(FinancialOptionalParams { + optional_value, + period_start, + }) +} + +/// Helper struct for validated coupon function parameters +struct ValidatedCouponParams { + pub settlement_date: chrono::NaiveDate, + pub maturity_date: chrono::NaiveDate, + pub frequency: i32, + pub basis: i32, +} + +/// Helper function to validate T-Bill calculation results +fn validate_tbill_result(result: f64, cell: CellReferenceIndex) -> CalcResult { + if result.is_infinite() { + CalcResult::new_error(Error::DIV, cell, "Division by 0".to_string()) + } else if result.is_nan() { + CalcResult::new_error( + Error::NUM, + cell, + "Invalid data for T-Bill calculation".to_string(), + ) + } else { + CalcResult::Number(result) + } +} + +/// Helper function to validate and normalize fraction parameter for DOLLARDE/DOLLARFR functions +fn validate_and_normalize_fraction( + fraction: f64, + cell: CellReferenceIndex, +) -> Result { + if fraction < 0.0 { + return Err(CalcResult::new_error( + Error::NUM, + cell, + "fraction should be >= 1".to_string(), + )); + } + if fraction < 1.0 { + return Err(CalcResult::new_error( + Error::DIV, + cell, + "fraction should be >= 1".to_string(), + )); + } + + let mut normalized_fraction = fraction.trunc(); + while normalized_fraction > 10.0 { + normalized_fraction /= 10.0; + } + + Ok(normalized_fraction) +} + +/// Helper function to handle compute function errors consistently +fn handle_compute_error( + result: Result, + cell: CellReferenceIndex, +) -> Result { + match result { + Ok(value) => Ok(value), + Err(error) => Err(CalcResult::Error { + error: error.0, + origin: cell, + message: error.1, + }), + } +} + +/// Helper function to validate values and dates arrays for XNPV/XIRR functions +fn validate_values_dates_arrays( + values: &[f64], + dates: &[f64], + cell: CellReferenceIndex, +) -> Result, CalcResult> { + // Decimal points on dates are truncated + let normalized_dates: Vec = dates.iter().map(|s| s.floor()).collect(); + let values_count = values.len(); + + // If values and dates contain a different number of values, return error + if values_count != dates.len() { + return Err(CalcResult::new_error( + Error::NUM, + cell, + "Values and dates must be the same length".to_string(), + )); + } + + if values_count == 0 { + return Err(CalcResult::new_error( + Error::NUM, + cell, + "Not enough values".to_string(), + )); + } + + let first_date = normalized_dates[0]; + for date in &normalized_dates { + // Validate date range + if *date < MINIMUM_DATE_SERIAL_NUMBER as f64 || *date > MAXIMUM_DATE_SERIAL_NUMBER as f64 { + return Err(CalcResult::new_error( + Error::NUM, + cell, + "Invalid number for date".to_string(), + )); + } + + // If any date precedes the starting date, return error + if date < &first_date { + return Err(CalcResult::new_error( + Error::NUM, + cell, + "Date precedes the starting date".to_string(), + )); + } + } + + Ok(normalized_dates) +} + +/// Parse and validate T-Bill parameters, returning (days_to_maturity, param_value) +fn parse_tbill_params( + args: &[Node], + model: &mut Model, + cell: CellReferenceIndex, +) -> Result<(f64, f64), CalcResult> { + // Parse settlement, maturity, and third parameter + let settlement = model.get_number_no_bools(&args[0], cell)?; + let maturity = model.get_number_no_bools(&args[1], cell)?; + let param_value = model.get_number_no_bools(&args[2], cell)?; + + // Validate settlement <= maturity + if settlement > maturity { + return Err(CalcResult::new_error( + Error::NUM, + cell, + "settlement should be <= maturity".to_string(), + )); + } + + // Validate less than one year + let less_than_one_year = match is_less_than_one_year(settlement as i64, maturity as i64) { + Ok(f) => f, + Err(_) => { + return Err(CalcResult::new_error( + Error::NUM, + cell, + "Invalid date".to_string(), + )) + } + }; + if !less_than_one_year { + return Err(CalcResult::new_error( + Error::NUM, + cell, + "maturity <= settlement + year".to_string(), + )); + } + + // Validate parameter > 0 + if param_value <= 0.0 { + return Err(CalcResult::new_error( + Error::NUM, + cell, + "parameter should be >0".to_string(), + )); + } + + let days_to_maturity = maturity - settlement; + Ok((days_to_maturity, param_value)) +} + +/// Helper struct for validated bond pricing function parameters +struct BondPricingParams { + pub third_param: f64, // yld for PRICE, price for YIELD + pub redemption: f64, + pub frequency: i32, + pub periods: f64, + pub coupon: f64, +} + +/// Helper function to parse and validate common bond pricing function parameters +/// Used by PRICE and YIELD functions +fn parse_and_validate_bond_pricing_params( + args: &[Node], + model: &mut Model, + cell: CellReferenceIndex, +) -> Result { + if !(6..=7).contains(&args.len()) { + return Err(CalcResult::new_args_number_error(cell)); + } + + let settlement = model.get_number_no_bools(&args[0], cell)?; + let maturity = model.get_number_no_bools(&args[1], cell)?; + let rate = model.get_number_no_bools(&args[2], cell)?; + let third_param = model.get_number_no_bools(&args[3], cell)?; + let redemption = model.get_number_no_bools(&args[4], cell)?; + + let frequency = match model.get_number_no_bools(&args[5], cell) { + Ok(f) => f.trunc() as i32, + Err(s) => return Err(s), + }; + + if frequency != 1 && frequency != 2 && frequency != 4 { + return Err(CalcResult::new_error( + Error::NUM, + cell, + "frequency should be 1, 2 or 4".to_string(), + )); + } + if settlement >= maturity { + return Err(CalcResult::new_error( + Error::NUM, + cell, + "settlement should be < maturity".to_string(), + )); + } + + let basis = if args.len() == 7 { + match model.get_number_no_bools(&args[6], cell) { + Ok(f) => f.trunc() as i32, + Err(s) => return Err(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 Err(CalcResult::new_error( + Error::NUM, + cell, + "invalid dates".to_string(), + )); + } + let coupon = redemption * rate / frequency as f64; + + Ok(BondPricingParams { + third_param, + redemption, + frequency, + periods, + coupon, + }) +} + +/// Helper struct for validated cumulative payment function parameters +struct CumulativePaymentParams { + pub rate: f64, + pub nper: f64, + pub pv: f64, + pub start_period: i32, + pub end_period: i32, + pub period_type: bool, +} + +/// Helper function to parse and validate cumulative payment function parameters +fn parse_and_validate_cumulative_payment_params( + args: &[Node], + model: &mut Model, + cell: CellReferenceIndex, +) -> Result { + // Check argument count + if args.len() != 6 { + return Err(CalcResult::new_args_number_error(cell)); + } + + // Parse rate, nper, pv + let rate = model.get_number_no_bools(&args[0], cell)?; + let nper = model.get_number_no_bools(&args[1], cell)?; + let pv = model.get_number_no_bools(&args[2], cell)?; + + // Parse periods with appropriate rounding + let start_period = model.get_number_no_bools(&args[3], cell)?.ceil() as i32; + let end_period = model.get_number_no_bools(&args[4], cell)?.trunc() as i32; + + // Parse and validate period type (0 = end of period, 1 = beginning of period) + let period_type = match model.get_number_no_bools(&args[5], cell)? { + 0.0 => false, + 1.0 => true, + _ => { + return Err(CalcResult::new_error( + Error::NUM, + cell, + "invalid period type".to_string(), + )) + } + }; + + // Validate period order + if start_period > end_period { + return Err(CalcResult::new_error( + Error::NUM, + cell, + "start period should come before end period".to_string(), + )); + } + + // Validate positive parameters + if rate <= 0.0 || nper <= 0.0 || pv <= 0.0 || start_period < 1 { + return Err(CalcResult::new_error( + Error::NUM, + cell, + "invalid parameters".to_string(), + )); + } + + Ok(CumulativePaymentParams { + rate, + nper, + pv, + start_period, + end_period, + period_type, + }) +} + +/// Helper function to validate and parse common coupon function parameters +fn parse_and_validate_coupon_params( + args: &[Node], + arg_count: usize, + model: &mut Model, + cell: CellReferenceIndex, +) -> Result { + // Check argument count + if !(3..=4).contains(&arg_count) { + return Err(CalcResult::new_args_number_error(cell)); + } + + // Parse parameters + let settlement = match model.get_number_no_bools(&args[0], cell) { + Ok(f) => f.trunc() as i64, + Err(s) => return Err(s), + }; + let maturity = match model.get_number_no_bools(&args[1], cell) { + Ok(f) => f.trunc() as i64, + Err(s) => return Err(s), + }; + let frequency = match model.get_number_no_bools(&args[2], cell) { + Ok(f) => f.trunc() as i32, + Err(s) => return Err(s), + }; + let basis = if arg_count > 3 { + match model.get_number_no_bools(&args[3], cell) { + Ok(f) => f.trunc() as i32, + Err(s) => return Err(s), + } + } else { + 0 + }; + + // Validate frequency and basis + if ![1, 2, 4].contains(&frequency) || !(0..=4).contains(&basis) { + return Err(CalcResult::new_error( + Error::NUM, + cell, + "invalid arguments".to_string(), + )); + } + + // Validate settlement < maturity + if settlement as f64 >= maturity as f64 { + return Err(CalcResult::new_error( + Error::NUM, + cell, + "settlement should be < maturity".to_string(), + )); + } + + // Convert to dates + let settlement_date = convert_date_serial(settlement as f64, cell)?; + let maturity_date = convert_date_serial(maturity as f64, cell)?; + + Ok(ValidatedCouponParams { + settlement_date, + maturity_date, + frequency, + basis, + }) +} + +fn year_frac(start: i64, end: i64, basis: i32) -> Result { + let start_date = from_excel_date(start)?; + let end_date = from_excel_date(end)?; + let days = match basis { + 0 => days360_us(start_date, end_date), + 1..=3 => (end - start) as i32, + 4 => days360_eu(start_date, end_date), + _ => return Err("invalid basis".to_string()), + } as f64; + let year_days = days_in_year(start_date, basis)? as f64; + Ok(days / year_days) +} + +fn year_fraction( + start: chrono::NaiveDate, + end: chrono::NaiveDate, + basis: i32, +) -> Result { + let days = match basis { + 0 => days360_us(start, end) as f64 / DAYS_IN_YEAR_360 as f64, + 1 => (end - start).num_days() as f64 / DAYS_ACTUAL as f64, + 2 => (end - start).num_days() as f64 / DAYS_IN_YEAR_360 as f64, + 3 => (end - start).num_days() as f64 / DAYS_ACTUAL as f64, + 4 => days360_eu(start, end) as f64 / DAYS_IN_YEAR_360 as f64, + _ => return Err("Invalid basis".to_string()), + }; + Ok(days) +} + +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 compute_payment( rate: f64, nper: f64, @@ -191,6 +931,13 @@ fn compute_ppmt( // All, except for rate are easily solvable in terms of the others. // In these formulas the payment (pmt) is normally negative +/// Enum to define different array processing behaviors +enum ArrayProcessingMode { + Standard, // Accept single numbers, ignore empty/non-number cells + StrictWithError(Error), // Accept single numbers, error on empty/non-number with specified error type + RangeOnlyWithZeros, // Don't accept single numbers, treat empty as 0.0, error on non-number +} + impl Model { fn get_array_of_numbers_generic( &mut self, @@ -279,18 +1026,67 @@ impl Model { Ok(values) } + fn get_array_of_numbers_with_mode( + &mut self, + arg: &Node, + cell: &CellReferenceIndex, + mode: ArrayProcessingMode, + ) -> Result, CalcResult> { + match mode { + ArrayProcessingMode::Standard => { + self.get_array_of_numbers_generic( + arg, + cell, + true, // accept_number_node + || Ok(None), // Ignore empty cells + || Ok(None), // Ignore non-number cells + ) + } + ArrayProcessingMode::StrictWithError(error_type) => { + self.get_array_of_numbers_generic( + arg, + cell, + true, // accept_number_node + || { + Err(CalcResult::new_error( + Error::NUM, + *cell, + "Expected number".to_string(), + )) + }, + || { + Err(CalcResult::new_error( + error_type.clone(), + *cell, + "Expected number".to_string(), + )) + }, + ) + } + ArrayProcessingMode::RangeOnlyWithZeros => { + self.get_array_of_numbers_generic( + arg, + cell, + false, // Do not accept a single number node + || Ok(Some(0.0)), // Treat empty cells as zero + || { + Err(CalcResult::new_error( + Error::VALUE, + *cell, + "Expected number".to_string(), + )) + }, + ) + } + } + } + fn get_array_of_numbers( &mut self, arg: &Node, cell: &CellReferenceIndex, ) -> Result, CalcResult> { - self.get_array_of_numbers_generic( - arg, - cell, - true, // accept_number_node - || Ok(None), // Ignore empty cells - || Ok(None), // Ignore non-number cells - ) + self.get_array_of_numbers_with_mode(arg, cell, ArrayProcessingMode::Standard) } fn get_array_of_numbers_xpnv( @@ -299,25 +1095,7 @@ impl Model { cell: &CellReferenceIndex, error: Error, ) -> Result, CalcResult> { - self.get_array_of_numbers_generic( - arg, - cell, - true, // accept_number_node - || { - Err(CalcResult::new_error( - Error::NUM, - *cell, - "Expected number".to_string(), - )) - }, - || { - Err(CalcResult::new_error( - error.clone(), - *cell, - "Expected number".to_string(), - )) - }, - ) + self.get_array_of_numbers_with_mode(arg, cell, ArrayProcessingMode::StrictWithError(error)) } fn get_array_of_numbers_xirr( @@ -325,109 +1103,63 @@ impl Model { arg: &Node, cell: &CellReferenceIndex, ) -> Result, CalcResult> { - self.get_array_of_numbers_generic( - arg, - cell, - false, // Do not accept a single number node - || Ok(Some(0.0)), // Treat empty cells as zero - || { - Err(CalcResult::new_error( - Error::VALUE, - *cell, - "Expected number".to_string(), - )) - }, - ) + self.get_array_of_numbers_with_mode(arg, cell, ArrayProcessingMode::RangeOnlyWithZeros) } /// PMT(rate, nper, pv, [fv], [type]) pub(crate) fn fn_pmt(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { let arg_count = args.len(); - if !(3..=5).contains(&arg_count) { - return CalcResult::new_args_number_error(cell); + if let Some(err) = validate_arg_count_or_return(arg_count, 3, 5, cell) { + return err; } - let rate = match self.get_number(&args[0], cell) { - Ok(f) => f, - Err(s) => return s, + + let params = match parse_required_params(args, &[0, 1, 2], self, cell, false) { + Ok(p) => p, + Err(err) => return err, }; - // number of periods - let nper = match self.get_number(&args[1], cell) { - Ok(f) => f, - Err(s) => return s, + let (rate, nper, pv) = (params[0], params[1], params[2]); + + let optional_params = match parse_financial_optional_params(args, arg_count, 3, self, cell) + { + Ok(params) => params, + Err(err) => return err, }; - // present value - let pv = match self.get_number(&args[2], cell) { - Ok(f) => f, - Err(s) => return s, - }; - // future_value - let fv = if arg_count > 3 { - match self.get_number(&args[3], cell) { - Ok(f) => f, - Err(s) => return s, - } - } else { - 0.0 - }; - let period_start = if arg_count > 4 { - match self.get_number(&args[4], cell) { - Ok(f) => f != 0.0, - Err(s) => return s, - } - } else { - // at the end of the period - false - }; - match compute_payment(rate, nper, pv, fv, period_start) { + + match handle_compute_error( + compute_payment( + rate, + nper, + pv, + optional_params.optional_value, + optional_params.period_start, + ), + cell, + ) { Ok(p) => CalcResult::Number(p), - Err(error) => CalcResult::Error { - error: error.0, - origin: cell, - message: error.1, - }, + Err(err) => err, } } // PV(rate, nper, pmt, [fv], [type]) pub(crate) fn fn_pv(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { let arg_count = args.len(); - if !(3..=5).contains(&arg_count) { - return CalcResult::new_args_number_error(cell); + if let Some(err) = validate_arg_count_or_return(arg_count, 3, 5, cell) { + return err; } - let rate = match self.get_number(&args[0], cell) { - Ok(f) => f, - Err(s) => return s, + + let params = match parse_required_params(args, &[0, 1, 2], self, cell, false) { + Ok(p) => p, + Err(err) => return err, }; - // nper - let period_count = match self.get_number(&args[1], cell) { - Ok(f) => f, - Err(s) => return s, - }; - // pmt - let payment = match self.get_number(&args[2], cell) { - Ok(f) => f, - Err(s) => return s, - }; - // fv - let future_value = if arg_count > 3 { - match self.get_number(&args[3], cell) { - Ok(f) => f, - Err(s) => return s, - } - } else { - 0.0 - }; - let period_start = if arg_count > 4 { - match self.get_number(&args[4], cell) { - Ok(f) => f != 0.0, - Err(s) => return s, - } - } else { - // at the end of the period - false + let (rate, period_count, payment) = (params[0], params[1], params[2]); + + let optional_params = match parse_financial_optional_params(args, arg_count, 3, self, cell) + { + Ok(params) => params, + Err(err) => return err, }; if rate == 0.0 { - return CalcResult::Number(-future_value - payment * period_count); + return CalcResult::Number(-optional_params.optional_value - payment * period_count); } if rate == -1.0 { return CalcResult::Error { @@ -437,11 +1169,13 @@ impl Model { }; }; let rate_nper = (1.0 + rate).powf(period_count); - let result = if period_start { + let result = if optional_params.period_start { // type = 1 - -(future_value * rate + payment * (1.0 + rate) * (rate_nper - 1.0)) / (rate * rate_nper) + -(optional_params.optional_value * rate + payment * (1.0 + rate) * (rate_nper - 1.0)) + / (rate * rate_nper) } else { - (-future_value * rate - payment * (rate_nper - 1.0)) / (rate * rate_nper) + (-optional_params.optional_value * rate - payment * (rate_nper - 1.0)) + / (rate * rate_nper) }; if result.is_nan() || result.is_infinite() { return CalcResult::Error { @@ -454,24 +1188,180 @@ impl Model { CalcResult::Number(result) } + // ACCRINT(issue, first_interest, settlement, rate, par, freq, [basis], [calc]) + pub(crate) fn fn_accrint(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + let arg_count = args.len(); + if let Some(err) = validate_arg_count_or_return(arg_count, 6, 8, cell) { + return err; + } + + let params = match parse_required_params(args, &[0, 1, 2, 3, 4, 5], self, cell, true) { + Ok(p) => p, + Err(err) => return err, + }; + let (issue, first, settlement, rate, par, freq) = ( + params[0], + params[1], + params[2], + params[3], + params[4], + params[5] as i32, + ); + let basis = match parse_optional_basis(args, 6, arg_count, self, cell) { + Ok(b) => b, + Err(err) => return err, + }; + let calc = if arg_count > 7 { + match self.get_number(&args[7], cell) { + Ok(f) => f != 0.0, + Err(s) => return s, + } + } else { + true + }; + + if !(freq == 1 || freq == 2 || freq == 4) { + return CalcResult::new_error(Error::NUM, cell, "invalid frequency".to_string()); + } + if !(0..=4).contains(&basis) { + return CalcResult::new_error(Error::NUM, cell, "invalid basis".to_string()); + } + if par < 0.0 { + return CalcResult::new_error(Error::NUM, cell, "par cannot be negative".to_string()); + } + if rate < 0.0 { + return CalcResult::new_error(Error::NUM, cell, "rate cannot be negative".to_string()); + } + + let issue_d = match convert_date_serial(issue, cell) { + Ok(d) => d, + Err(err) => return err, + }; + let first_d = match from_excel_date(first as i64) { + Ok(d) => d, + Err(_) => return CalcResult::new_error(Error::NUM, cell, "Invalid date".to_string()), + }; + let settle_d = match convert_date_serial(settlement, cell) { + Ok(d) => d, + Err(err) => return err, + }; + + if settle_d < issue_d { + return CalcResult::new_error(Error::NUM, cell, "settlement < issue".to_string()); + } + if first_d < issue_d { + return CalcResult::new_error(Error::NUM, cell, "first_interest < issue".to_string()); + } + if settle_d < first_d { + return CalcResult::new_error( + Error::NUM, + cell, + "settlement < first_interest".to_string(), + ); + } + + let months = 12 / freq; + let mut prev = first_d; + if settle_d <= first_d { + prev = issue_d; + } else { + while prev <= settle_d { + let next = prev + chrono::Months::new(months as u32); + if next > settle_d { + break; + } + prev = next; + } + } + let next_coupon = prev + chrono::Months::new(months as u32); + + let mut result = 0.0; + if calc { + let mut next = first_d; + while next < prev { + result += rate * par / freq as f64; + next = next + chrono::Months::new(months as u32); + } + } + + let days_in_period = match year_fraction(prev, next_coupon, basis) { + Ok(f) => f, + Err(_) => return CalcResult::new_error(Error::NUM, cell, "invalid basis".to_string()), + }; + let days_elapsed = match year_fraction(prev, settle_d, basis) { + Ok(f) => f, + Err(_) => return CalcResult::new_error(Error::NUM, cell, "invalid basis".to_string()), + }; + + result += rate * par / freq as f64 + * if days_in_period == 0.0 { + 0.0 + } else { + days_elapsed / days_in_period + }; + CalcResult::Number(result) + } + + // ACCRINTM(issue, settlement, rate, par, [basis]) + pub(crate) fn fn_accrintm(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + let arg_count = args.len(); + if let Some(err) = validate_arg_count_or_return(arg_count, 4, 5, cell) { + return err; + } + + let params = match parse_required_params(args, &[0, 1, 2, 3], self, cell, true) { + Ok(p) => p, + Err(err) => return err, + }; + let (issue, settlement, rate, par) = (params[0], params[1], params[2], params[3]); + let basis = match parse_optional_basis(args, 4, arg_count, self, cell) { + Ok(b) => b, + Err(err) => return err, + }; + + if !(0..=4).contains(&basis) { + return CalcResult::new_error(Error::NUM, cell, "invalid basis".to_string()); + } + if par < 0.0 { + return CalcResult::new_error(Error::NUM, cell, "par cannot be negative".to_string()); + } + if rate < 0.0 { + return CalcResult::new_error(Error::NUM, cell, "rate cannot be negative".to_string()); + } + + let issue_d = match convert_date_serial(issue, cell) { + Ok(d) => d, + Err(err) => return err, + }; + let settle_d = match convert_date_serial(settlement, cell) { + Ok(d) => d, + Err(err) => return err, + }; + + if settle_d < issue_d { + return CalcResult::new_error(Error::NUM, cell, "settlement < issue".to_string()); + } + + let frac = match year_fraction(issue_d, settle_d, basis) { + Ok(f) => f, + Err(_) => return CalcResult::new_error(Error::NUM, cell, "invalid basis".to_string()), + }; + + CalcResult::Number(par * rate * frac) + } + // RATE(nper, pmt, pv, [fv], [type], [guess]) pub(crate) fn fn_rate(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { let arg_count = args.len(); - if !(3..=5).contains(&arg_count) { - return CalcResult::new_args_number_error(cell); + if let Some(err) = validate_arg_count_or_return(arg_count, 3, 5, cell) { + return err; } - let nper = match self.get_number(&args[0], cell) { - Ok(f) => f, - Err(s) => return s, - }; - let pmt = match self.get_number(&args[1], cell) { - Ok(f) => f, - Err(s) => return s, - }; - let pv = match self.get_number(&args[2], cell) { - Ok(f) => f, - Err(s) => return s, + + let params = match parse_required_params(args, &[0, 1, 2], self, cell, false) { + Ok(p) => p, + Err(err) => return err, }; + let (nper, pmt, pv) = (params[0], params[1], params[2]); // fv let fv = if arg_count > 3 { match self.get_number(&args[3], cell) { @@ -500,36 +1390,24 @@ impl Model { 0.1 }; - match compute_rate(pv, fv, nper, pmt, annuity_type, guess) { + match handle_compute_error(compute_rate(pv, fv, nper, pmt, annuity_type, guess), cell) { Ok(f) => CalcResult::Number(f), - Err(error) => CalcResult::Error { - error: error.0, - origin: cell, - message: error.1, - }, + Err(err) => err, } } // NPER(rate,pmt,pv,[fv],[type]) pub(crate) fn fn_nper(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { let arg_count = args.len(); - if !(3..=5).contains(&arg_count) { - return CalcResult::new_args_number_error(cell); + if let Some(err) = validate_arg_count_or_return(arg_count, 3, 5, cell) { + return err; } - let rate = match self.get_number(&args[0], cell) { - Ok(f) => f, - Err(s) => return s, - }; - // pmt - let payment = match self.get_number(&args[1], cell) { - Ok(f) => f, - Err(s) => return s, - }; - // pv - let present_value = match self.get_number(&args[2], cell) { - Ok(f) => f, - Err(s) => return s, + + let params = match parse_required_params(args, &[0, 1, 2], self, cell, false) { + Ok(p) => p, + Err(err) => return err, }; + let (rate, payment, present_value) = (params[0], params[1], params[2]); // fv let future_value = if arg_count > 3 { match self.get_number(&args[3], cell) { @@ -596,76 +1474,79 @@ impl Model { // FV(rate, nper, pmt, [pv], [type]) pub(crate) fn fn_fv(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { let arg_count = args.len(); - if !(3..=5).contains(&arg_count) { + if let Some(err) = validate_arg_count_or_return(arg_count, 3, 5, cell) { + return err; + } + + let params = match parse_required_params(args, &[0, 1, 2], self, cell, false) { + Ok(p) => p, + Err(err) => return err, + }; + let (rate, nper, pmt) = (params[0], params[1], params[2]); + + let optional_params = match parse_financial_optional_params(args, arg_count, 3, self, cell) + { + Ok(params) => params, + Err(err) => return err, + }; + + match handle_compute_error( + compute_future_value( + rate, + nper, + pmt, + optional_params.optional_value, + optional_params.period_start, + ), + cell, + ) { + Ok(f) => CalcResult::Number(f), + Err(err) => err, + } + } + + // FVSCHEDULE(principal, schedule) + pub(crate) fn fn_fvschedule(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() != 2 { return CalcResult::new_args_number_error(cell); } - let rate = match self.get_number(&args[0], cell) { + let principal = match self.get_number(&args[0], cell) { Ok(f) => f, Err(s) => return s, }; - // number of periods - let nper = match self.get_number(&args[1], cell) { - Ok(f) => f, - Err(s) => return s, + let schedule = match self.get_array_of_numbers(&args[1], &cell) { + Ok(s) => s, + Err(err) => return err, }; - // payment - let pmt = match self.get_number(&args[2], cell) { - Ok(f) => f, - Err(s) => return s, - }; - // present value - let pv = if arg_count > 3 { - match self.get_number(&args[3], cell) { - Ok(f) => f, - Err(s) => return s, + let mut result = principal; + for rate in schedule { + if rate <= -1.0 { + return CalcResult::new_error(Error::NUM, cell, "Rate must be > -1".to_string()); } - } else { - 0.0 - }; - let period_start = if arg_count > 4 { - match self.get_number(&args[4], cell) { - Ok(f) => f != 0.0, - Err(s) => return s, - } - } else { - // at the end of the period - false - }; - match compute_future_value(rate, nper, pmt, pv, period_start) { - Ok(f) => CalcResult::Number(f), - Err(error) => CalcResult::Error { - error: error.0, - origin: cell, - message: error.1, - }, + result *= 1.0 + rate; } + if result.is_infinite() { + return CalcResult::new_error(Error::DIV, cell, "Division by 0".to_string()); + } + if result.is_nan() { + return CalcResult::new_error(Error::NUM, cell, "Invalid result".to_string()); + } + CalcResult::Number(result) } // IPMT(rate, per, nper, pv, [fv], [type]) pub(crate) fn fn_ipmt(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { let arg_count = args.len(); - if !(4..=6).contains(&arg_count) { - return CalcResult::new_args_number_error(cell); + if let Some(err) = validate_arg_count_or_return(arg_count, 4, 6, cell) { + return err; } - let rate = match self.get_number(&args[0], cell) { - Ok(f) => f, - Err(s) => return s, - }; - // per - let period = match self.get_number(&args[1], cell) { - Ok(f) => f, - Err(s) => return s, - }; - // nper - let period_count = match self.get_number(&args[2], cell) { - Ok(f) => f, - Err(s) => return s, - }; - // pv - let present_value = match self.get_number(&args[3], cell) { - Ok(f) => f, - Err(s) => return s, + + let params = match parse_required_params(args, &[0, 1, 2, 3], self, cell, false) { + Ok(p) => p, + Err(err) => return err, }; + let (rate, period, period_count, present_value) = + (params[0], params[1], params[2], params[3]); // fv let future_value = if arg_count > 4 { match self.get_number(&args[4], cell) { @@ -684,22 +1565,19 @@ impl Model { // at the end of the period false }; - let ipmt = match compute_ipmt( - rate, - period, - period_count, - present_value, - future_value, - period_start, + let ipmt = match handle_compute_error( + compute_ipmt( + rate, + period, + period_count, + present_value, + future_value, + period_start, + ), + cell, ) { Ok(f) => f, - Err(error) => { - return CalcResult::Error { - error: error.0, - origin: cell, - message: error.1, - } - } + Err(err) => return err, }; CalcResult::Number(ipmt) } @@ -707,28 +1585,16 @@ impl Model { // PPMT(rate, per, nper, pv, [fv], [type]) pub(crate) fn fn_ppmt(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { let arg_count = args.len(); - if !(4..=6).contains(&arg_count) { - return CalcResult::new_args_number_error(cell); + if let Some(err) = validate_arg_count_or_return(arg_count, 4, 6, cell) { + return err; } - let rate = match self.get_number(&args[0], cell) { - Ok(f) => f, - Err(s) => return s, - }; - // per - let period = match self.get_number(&args[1], cell) { - Ok(f) => f, - Err(s) => return s, - }; - // nper - let period_count = match self.get_number(&args[2], cell) { - Ok(f) => f, - Err(s) => return s, - }; - // pv - let present_value = match self.get_number(&args[3], cell) { - Ok(f) => f, - Err(s) => return s, + + let params = match parse_required_params(args, &[0, 1, 2, 3], self, cell, false) { + Ok(p) => p, + Err(err) => return err, }; + let (rate, period, period_count, present_value) = + (params[0], params[1], params[2], params[3]); // fv let future_value = if arg_count > 4 { match self.get_number(&args[4], cell) { @@ -748,22 +1614,19 @@ impl Model { false }; - let ppmt = match compute_ppmt( - rate, - period, - period_count, - present_value, - future_value, - period_start, + let ppmt = match handle_compute_error( + compute_ppmt( + rate, + period, + period_count, + present_value, + future_value, + period_start, + ), + cell, ) { Ok(f) => f, - Err(error) => { - return CalcResult::Error { - error: error.0, - origin: cell, - message: error.1, - } - } + Err(err) => return err, }; CalcResult::Number(ppmt) } @@ -781,71 +1644,14 @@ impl Model { }; let mut values = Vec::new(); for arg in &args[1..] { - match self.evaluate_node_in_context(arg, cell) { - CalcResult::Number(value) => values.push(value), - CalcResult::Range { left, right } => { - if left.sheet != right.sheet { - return CalcResult::new_error( - Error::VALUE, - cell, - "Ranges are in different sheets".to_string(), - ); - } - let row1 = left.row; - let mut row2 = right.row; - let column1 = left.column; - let mut column2 = right.column; - if row1 == 1 && row2 == LAST_ROW { - row2 = match self.workbook.worksheet(left.sheet) { - Ok(s) => s.dimension().max_row, - Err(_) => { - return CalcResult::new_error( - Error::ERROR, - cell, - format!("Invalid worksheet index: '{}'", left.sheet), - ); - } - }; - } - if column1 == 1 && column2 == LAST_COLUMN { - column2 = match self.workbook.worksheet(left.sheet) { - Ok(s) => s.dimension().max_column, - Err(_) => { - return CalcResult::new_error( - Error::ERROR, - cell, - format!("Invalid worksheet index: '{}'", left.sheet), - ); - } - }; - } - for row in row1..row2 + 1 { - for column in column1..(column2 + 1) { - match self.evaluate_cell(CellReferenceIndex { - sheet: left.sheet, - row, - column, - }) { - CalcResult::Number(value) => { - values.push(value); - } - error @ CalcResult::Error { .. } => return error, - _ => { - // We ignore booleans and strings - } - } - } - } - } - error @ CalcResult::Error { .. } => return error, - _ => { - // We ignore booleans and strings - } - }; + match self.get_array_of_numbers(arg, &cell) { + Ok(mut arg_values) => values.append(&mut arg_values), + Err(err) => return err, + } } - match compute_npv(rate, &values) { + match handle_compute_error(compute_npv(rate, &values), cell) { Ok(f) => CalcResult::Number(f), - Err(error) => CalcResult::new_error(error.0, cell, error.1), + Err(err) => err, } } @@ -874,13 +1680,9 @@ impl Model { } else { 0.1 }; - match compute_irr(&values, guess) { + match handle_compute_error(compute_irr(&values, guess), cell) { Ok(f) => CalcResult::Number(f), - Err(error) => CalcResult::Error { - error: error.0, - origin: cell, - message: error.1, - }, + Err(err) => err, } } @@ -902,49 +1704,17 @@ impl Model { Ok(s) => s, Err(error) => return error, }; - // Decimal points on dates are truncated - let dates: Vec = dates.iter().map(|s| s.floor()).collect(); - let values_count = values.len(); - // If values and dates contain a different number of values, XNPV returns the #NUM! error value. - if values_count != dates.len() { - return CalcResult::new_error( - Error::NUM, - cell, - "Values and dates must be the same length".to_string(), - ); - } - if values_count == 0 { - return CalcResult::new_error(Error::NUM, cell, "Not enough values".to_string()); - } - let first_date = dates[0]; - for date in &dates { - if *date < MINIMUM_DATE_SERIAL_NUMBER as f64 - || *date > MAXIMUM_DATE_SERIAL_NUMBER as f64 - { - // Excel docs claim that if any number in dates is not a valid date, - // XNPV returns the #VALUE! error value, but it seems to return #VALUE! - return CalcResult::new_error( - Error::NUM, - cell, - "Invalid number for date".to_string(), - ); - } - // If any number in dates precedes the starting date, XNPV returns the #NUM! error value. - if date < &first_date { - return CalcResult::new_error( - Error::NUM, - cell, - "Date precedes the starting date".to_string(), - ); - } - } + let dates = match validate_values_dates_arrays(&values, &dates, cell) { + Ok(d) => d, + Err(err) => return err, + }; // It seems Excel returns #NUM! if rate < 0, this is only necessary if r <= -1 if rate <= 0.0 { return CalcResult::new_error(Error::NUM, cell, "rate needs to be > 0".to_string()); } - match compute_xnpv(rate, &values, &dates) { + match handle_compute_error(compute_xnpv(rate, &values, &dates), cell) { Ok(f) => CalcResult::Number(f), - Err((error, message)) => CalcResult::new_error(error, cell, message), + Err(err) => err, } } @@ -970,47 +1740,13 @@ impl Model { } else { 0.1 }; - // Decimal points on dates are truncated - let dates: Vec = dates.iter().map(|s| s.floor()).collect(); - let values_count = values.len(); - // If values and dates contain a different number of values, XNPV returns the #NUM! error value. - if values_count != dates.len() { - return CalcResult::new_error( - Error::NUM, - cell, - "Values and dates must be the same length".to_string(), - ); - } - if values_count == 0 { - return CalcResult::new_error(Error::NUM, cell, "Not enough values".to_string()); - } - let first_date = dates[0]; - for date in &dates { - if *date < MINIMUM_DATE_SERIAL_NUMBER as f64 - || *date > MAXIMUM_DATE_SERIAL_NUMBER as f64 - { - return CalcResult::new_error( - Error::NUM, - cell, - "Invalid number for date".to_string(), - ); - } - // If any number in dates precedes the starting date, XIRR returns the #NUM! error value. - if date < &first_date { - return CalcResult::new_error( - Error::NUM, - cell, - "Date precedes the starting date".to_string(), - ); - } - } - match compute_xirr(&values, &dates, guess) { + let dates = match validate_values_dates_arrays(&values, &dates, cell) { + Ok(d) => d, + Err(err) => return err, + }; + match handle_compute_error(compute_xirr(&values, &dates, guess), cell) { Ok(f) => CalcResult::Number(f), - Err((error, message)) => CalcResult::Error { - error, - origin: cell, - message, - }, + Err(err) => err, } } @@ -1023,21 +1759,19 @@ impl Model { // $v_n$ the vector of negative values // and $y$ is dimension of $v$ - 1 (number of years) pub(crate) fn fn_mirr(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { - if args.len() != 3 { - return CalcResult::new_args_number_error(cell); + if let Some(err) = validate_arg_count_or_return(args.len(), 3, 3, cell) { + return err; } + let values = match self.get_array_of_numbers(&args[0], &cell) { Ok(s) => s, Err(error) => return error, }; - let finance_rate = match self.get_number(&args[1], cell) { - Ok(f) => f, - Err(s) => return s, - }; - let reinvest_rate = match self.get_number(&args[2], cell) { - Ok(f) => f, - Err(s) => return s, + let params = match parse_required_params(args, &[1, 2], self, cell, false) { + Ok(p) => p, + Err(err) => return err, }; + let (finance_rate, reinvest_rate) = (params[0], params[1]); let mut positive_values = Vec::new(); let mut negative_values = Vec::new(); let mut last_negative_index = -1; @@ -1068,15 +1802,9 @@ impl Model { None => 0.0, } } else { - match compute_npv(reinvest_rate, &positive_values) { + match handle_compute_error(compute_npv(reinvest_rate, &positive_values), cell) { Ok(npv) => -npv * ((1.0 + reinvest_rate).powf(years)), - Err((error, message)) => { - return CalcResult::Error { - error, - origin: cell, - message, - } - } + Err(err) => return err, } }; let bottom = if finance_rate == -1.0 { @@ -1089,15 +1817,9 @@ impl Model { f64::INFINITY } } else { - match compute_npv(finance_rate, &negative_values) { + match handle_compute_error(compute_npv(finance_rate, &negative_values), cell) { Ok(npv) => npv * (1.0 + finance_rate), - Err((error, message)) => { - return CalcResult::Error { - error, - origin: cell, - message, - } - } + Err(err) => return err, } }; @@ -1115,25 +1837,15 @@ impl Model { // Formula is: // $$pv*rate*\left(\frac{per}{nper}-1\right)$$ pub(crate) fn fn_ispmt(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { - if args.len() != 4 { - return CalcResult::new_args_number_error(cell); + if let Some(err) = validate_arg_count_or_return(args.len(), 4, 4, cell) { + return err; } - let rate = match self.get_number(&args[0], cell) { - Ok(f) => f, - Err(s) => return s, - }; - let per = match self.get_number(&args[1], cell) { - Ok(f) => f, - Err(s) => return s, - }; - let nper = match self.get_number(&args[2], cell) { - Ok(f) => f, - Err(s) => return s, - }; - let pv = match self.get_number(&args[3], cell) { - Ok(f) => f, - Err(s) => return s, + + let params = match parse_required_params(args, &[0, 1, 2, 3], self, cell, false) { + Ok(p) => p, + Err(err) => return err, }; + let (rate, per, nper, pv) = (params[0], params[1], params[2], params[3]); if nper == 0.0 { return CalcResult::new_error(Error::DIV, cell, "Division by 0".to_string()); } @@ -1144,21 +1856,15 @@ impl Model { // Formula is // $$ \left(\frac{fv}{pv}\right)^{\frac{1}{nper}}-1 $$ pub(crate) fn fn_rri(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { - if args.len() != 3 { - return CalcResult::new_args_number_error(cell); + if let Some(err) = validate_arg_count_or_return(args.len(), 3, 3, cell) { + return err; } - let nper = match self.get_number(&args[0], cell) { - Ok(f) => f, - Err(s) => return s, - }; - let pv = match self.get_number(&args[1], cell) { - Ok(f) => f, - Err(s) => return s, - }; - let fv = match self.get_number(&args[2], cell) { - Ok(f) => f, - Err(s) => return s, + + let params = match parse_required_params(args, &[0, 1, 2], self, cell, false) { + Ok(p) => p, + Err(err) => return err, }; + let (nper, pv, fv) = (params[0], params[1], params[2]); if nper <= 0.0 { return CalcResult::new_error(Error::NUM, cell, "nper should be >0".to_string()); } @@ -1181,21 +1887,15 @@ impl Model { // Formula is: // $$ \frac{cost-salvage}{life} $$ pub(crate) fn fn_sln(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { - if args.len() != 3 { - return CalcResult::new_args_number_error(cell); + if let Some(err) = validate_arg_count_or_return(args.len(), 3, 3, cell) { + return err; } - let cost = match self.get_number(&args[0], cell) { - Ok(f) => f, - Err(s) => return s, - }; - let salvage = match self.get_number(&args[1], cell) { - Ok(f) => f, - Err(s) => return s, - }; - let life = match self.get_number(&args[2], cell) { - Ok(f) => f, - Err(s) => return s, + + let params = match parse_required_params(args, &[0, 1, 2], self, cell, false) { + Ok(p) => p, + Err(err) => return err, }; + let (cost, salvage, life) = (params[0], params[1], params[2]); if life == 0.0 { return CalcResult::new_error(Error::DIV, cell, "Division by 0".to_string()); } @@ -1208,25 +1908,15 @@ impl Model { // Formula is: // $$ \frac{(cost-salvage)*(life-per+1)*2}{life*(life+1)} $$ pub(crate) fn fn_syd(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { - if args.len() != 4 { - return CalcResult::new_args_number_error(cell); + if let Some(err) = validate_arg_count_or_return(args.len(), 4, 4, cell) { + return err; } - let cost = match self.get_number(&args[0], cell) { - Ok(f) => f, - Err(s) => return s, - }; - let salvage = match self.get_number(&args[1], cell) { - Ok(f) => f, - Err(s) => return s, - }; - let life = match self.get_number(&args[2], cell) { - Ok(f) => f, - Err(s) => return s, - }; - let per = match self.get_number(&args[3], cell) { - Ok(f) => f, - Err(s) => return s, + + let params = match parse_required_params(args, &[0, 1, 2, 3], self, cell, false) { + Ok(p) => p, + Err(err) => return err, }; + let (cost, salvage, life, per) = (params[0], params[1], params[2], params[3]); if life == 0.0 { return CalcResult::new_error(Error::NUM, cell, "Division by 0".to_string()); } @@ -1245,17 +1935,15 @@ impl Model { // $r$ is the effective interest rate // $n$ is the number of periods per year pub(crate) fn fn_nominal(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { - if args.len() != 2 { - return CalcResult::new_args_number_error(cell); + if let Some(err) = validate_arg_count_or_return(args.len(), 2, 2, cell) { + return err; } - let effect_rate = match self.get_number_no_bools(&args[0], cell) { - Ok(f) => f, - Err(s) => return s, - }; - let npery = match self.get_number_no_bools(&args[1], cell) { - Ok(f) => f.floor(), - Err(s) => return s, + + let params = match parse_required_params(args, &[0, 1], self, cell, true) { + Ok(p) => p, + Err(err) => return err, }; + let (effect_rate, npery) = (params[0], params[1].floor()); if effect_rate <= 0.0 || npery < 1.0 { return CalcResult::new_error(Error::NUM, cell, "Invalid arguments".to_string()); } @@ -1277,17 +1965,15 @@ impl Model { // $r$ is the nominal interest rate // $n$ is the number of periods per year pub(crate) fn fn_effect(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { - if args.len() != 2 { - return CalcResult::new_args_number_error(cell); + if let Some(err) = validate_arg_count_or_return(args.len(), 2, 2, cell) { + return err; } - let nominal_rate = match self.get_number_no_bools(&args[0], cell) { - Ok(f) => f, - Err(s) => return s, - }; - let npery = match self.get_number_no_bools(&args[1], cell) { - Ok(f) => f.floor(), - Err(s) => return s, + + let params = match parse_required_params(args, &[0, 1], self, cell, true) { + Ok(p) => p, + Err(err) => return err, }; + let (nominal_rate, npery) = (params[0], params[1].floor()); if nominal_rate <= 0.0 || npery < 1.0 { return CalcResult::new_error(Error::NUM, cell, "Invalid arguments".to_string()); } @@ -1310,21 +1996,15 @@ impl Model { // * $pv$ is the present value of the investment // * $fv$ is the desired future value of the investment pub(crate) fn fn_pduration(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { - if args.len() != 3 { - return CalcResult::new_args_number_error(cell); + if let Some(err) = validate_arg_count_or_return(args.len(), 3, 3, cell) { + return err; } - let rate = match self.get_number(&args[0], cell) { - Ok(f) => f, - Err(s) => return s, - }; - let pv = match self.get_number(&args[1], cell) { - Ok(f) => f, - Err(s) => return s, - }; - let fv = match self.get_number(&args[2], cell) { - Ok(f) => f, - Err(s) => return s, + + let params = match parse_required_params(args, &[0, 1, 2], self, cell, false) { + Ok(p) => p, + Err(err) => return err, }; + let (rate, pv, fv) = (params[0], params[1], params[2]); if fv <= 0.0 || pv <= 0.0 || rate <= 0.0 { return CalcResult::new_error(Error::NUM, cell, "Invalid arguments".to_string()); } @@ -1339,6 +2019,98 @@ impl Model { 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 let Some(err) = validate_arg_count_or_return(arg_count, 5, 6, cell) { + return err; + } + + let params = match parse_required_params(args, &[0, 1, 2, 3, 4], self, cell, true) { + Ok(p) => p, + Err(err) => return err, + }; + let (settlement, maturity, coupon, yld, freq) = ( + params[0], + params[1], + params[2], + params[3], + params[4].trunc() as i32, + ); + let basis = match parse_optional_basis(args, 5, arg_count, self, cell) { + Ok(b) => b, + Err(err) => return err, + }; + 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, "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 // They are zero-coupon that mature in one year or less. // Definitions: @@ -1360,49 +2132,24 @@ impl Model { if args.len() != 3 { 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 (days_to_maturity, discount) = match parse_tbill_params(args, self, cell) { + Ok(params) => params, + Err(err) => return err, }; - let maturity = match self.get_number_no_bools(&args[1], cell) { - Ok(f) => f, - Err(s) => return s, - }; - let discount = match self.get_number_no_bools(&args[2], cell) { - Ok(f) => f, - Err(s) => return s, - }; - let less_than_one_year = match is_less_than_one_year(settlement as i64, maturity as i64) { - Ok(f) => f, - Err(_) => return CalcResult::new_error(Error::NUM, cell, "Invalid date".to_string()), - }; - if settlement > maturity { - return CalcResult::new_error( - Error::NUM, - cell, - "settlement should be <= maturity".to_string(), - ); - } - if !less_than_one_year { - return CalcResult::new_error( - Error::NUM, - cell, - "maturity <= settlement + year".to_string(), - ); - } - if discount <= 0.0 { - return CalcResult::new_error(Error::NUM, cell, "discount should be >0".to_string()); - } - // days to maturity - let d_m = maturity - settlement; - let result = if d_m < 183.0 { - 365.0 * discount / (360.0 - discount * d_m) + + let result = if days_to_maturity < TBILL_MATURITY_THRESHOLD { + DAYS_ACTUAL as f64 * discount / (DAYS_IN_YEAR_360 as f64 - discount * days_to_maturity) } else { // Equation here is: // (1-days*rate/360)*(1+y/2)*(1+d_extra*y/year)=1 - let year = if d_m == 366.0 { 366.0 } else { 365.0 }; - let d_extra = d_m - year / 2.0; - let alpha = 1.0 - d_m * discount / 360.0; + let year = if days_to_maturity == DAYS_LEAP_YEAR as f64 { + DAYS_LEAP_YEAR as f64 + } else { + DAYS_ACTUAL as f64 + }; + let d_extra = days_to_maturity - year / 2.0; + let alpha = 1.0 - days_to_maturity * discount / DAYS_IN_YEAR_360 as f64; let beta = 0.5 + d_extra / year; // ay^2+by+c=0 let a = d_extra * alpha / (year * 2.0); @@ -1410,14 +2157,8 @@ impl Model { let c = alpha - 1.0; (-b + (b * b - 4.0 * a * c).sqrt()) / (2.0 * a) }; - if result.is_infinite() { - return CalcResult::new_error(Error::DIV, cell, "Division by 0".to_string()); - } - if result.is_nan() { - return CalcResult::new_error(Error::NUM, cell, "Invalid data for RRI".to_string()); - } - CalcResult::Number(result) + validate_tbill_result(result, cell) } // TBILLPRICE(settlement, maturity, discount) @@ -1425,50 +2166,20 @@ impl Model { if args.len() != 3 { 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 (days_to_maturity, discount) = match parse_tbill_params(args, self, cell) { + Ok(params) => params, + Err(err) => return err, }; - let maturity = match self.get_number_no_bools(&args[1], cell) { - Ok(f) => f, - Err(s) => return s, - }; - let discount = match self.get_number_no_bools(&args[2], cell) { - Ok(f) => f, - Err(s) => return s, - }; - let less_than_one_year = match is_less_than_one_year(settlement as i64, maturity as i64) { - Ok(f) => f, - Err(_) => return CalcResult::new_error(Error::NUM, cell, "Invalid date".to_string()), - }; - if settlement > maturity { - return CalcResult::new_error( - Error::NUM, - cell, - "settlement should be <= maturity".to_string(), - ); - } - if !less_than_one_year { - return CalcResult::new_error( - Error::NUM, - cell, - "maturity <= settlement + year".to_string(), - ); - } - if discount <= 0.0 { - return CalcResult::new_error(Error::NUM, cell, "discount should be >0".to_string()); - } - // days to maturity - let d_m = maturity - settlement; - let result = 100.0 * (1.0 - discount * d_m / 360.0); - if result.is_infinite() { - return CalcResult::new_error(Error::DIV, cell, "Division by 0".to_string()); - } - if result.is_nan() || result < 0.0 { + + let result = 100.0 * (1.0 - discount * days_to_maturity / DAYS_IN_YEAR_360 as f64); + + // TBILLPRICE specifically checks for negative results (prices can't be negative) + if result < 0.0 { return CalcResult::new_error(Error::NUM, cell, "Invalid data for RRI".to_string()); } - CalcResult::Number(result) + validate_tbill_result(result, cell) } // TBILLYIELD(settlement, maturity, pr) @@ -1476,69 +2187,382 @@ impl Model { if args.len() != 3 { 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 (days, price) = match parse_tbill_params(args, self, cell) { + Ok(params) => params, + Err(err) => return err, }; - let maturity = match self.get_number_no_bools(&args[1], cell) { - Ok(f) => f, - Err(s) => return s, + + let result = (100.0 - price) * DAYS_IN_YEAR_360 as f64 / (price * days); + + validate_tbill_result(result, cell) + } + + pub(crate) fn fn_price(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + let params = match parse_and_validate_bond_pricing_params(args, self, cell) { + Ok(p) => p, + Err(err) => return err, }; - let pr = match self.get_number_no_bools(&args[2], cell) { - Ok(f) => f, - Err(s) => return s, + + let r = params.third_param / params.frequency as f64; // yld / frequency + let mut price = 0.0; + for i in 1..=(params.periods as i32) { + price += params.coupon / (1.0 + r).powf(i as f64); + } + price += params.redemption / (1.0 + r).powf(params.periods); + if price.is_nan() || price.is_infinite() { + return CalcResult::new_error(Error::NUM, cell, "Invalid data".to_string()); + } + CalcResult::Number(price) + } + + pub(crate) fn fn_pricedisc(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + financial_function_with_year_frac!( + args, self, cell, + param1_name: "discount rate", + param2_name: "redemption value", + validator: |discount_rate, redemption_value| { + if discount_rate <= 0.0 || redemption_value <= 0.0 { + Err("values must be positive".to_string()) + } else { + Ok(()) + } + }, + formula: |_settlement, _maturity, discount_rate, redemption_value, _basis, year_frac| { + redemption_value * (1.0 - discount_rate * year_frac) + } + ) + } + + pub(crate) fn fn_pricemat(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + let arg_count = args.len(); + if let Some(err) = validate_arg_count_or_return(arg_count, 5, 6, cell) { + return err; + } + + let params = match parse_required_params(args, &[0, 1, 2, 3, 4], self, cell, true) { + Ok(p) => p, + Err(err) => return err, }; - let less_than_one_year = match is_less_than_one_year(settlement as i64, maturity as i64) { + let (settlement, maturity, issue, rate, yld) = + (params[0], params[1], params[2], params[3], params[4]); + let basis = if arg_count == 6 { + match self.get_number_no_bools(&args[5], cell) { + Ok(f) => f, + Err(s) => return s, + } + } else { + 0.0 + }; + if rate < 0.0 || yld < 0.0 || settlement >= maturity { + return CalcResult::new_error(Error::NUM, cell, "invalid parameters".to_string()); + } + if settlement < MINIMUM_DATE_SERIAL_NUMBER as f64 + || maturity > MAXIMUM_DATE_SERIAL_NUMBER as f64 + || settlement > MAXIMUM_DATE_SERIAL_NUMBER as f64 + || maturity < MINIMUM_DATE_SERIAL_NUMBER as f64 + || issue < MINIMUM_DATE_SERIAL_NUMBER as f64 + || issue > MAXIMUM_DATE_SERIAL_NUMBER as f64 + { + return CalcResult::new_error(Error::NUM, cell, "Invalid number for date".to_string()); + } + let issue_to_maturity_frac = match year_frac(issue as i64, maturity as i64, basis as i32) { Ok(f) => f, Err(_) => return CalcResult::new_error(Error::NUM, cell, "Invalid date".to_string()), }; - if settlement > maturity { - return CalcResult::new_error( - Error::NUM, - cell, - "settlement should be <= maturity".to_string(), - ); - } - if !less_than_one_year { - return CalcResult::new_error( - Error::NUM, - cell, - "maturity <= settlement + year".to_string(), - ); - } - if pr <= 0.0 { - return CalcResult::new_error(Error::NUM, cell, "discount should be >0".to_string()); - } - let days = maturity - settlement; - let result = (100.0 - pr) * 360.0 / (pr * days); - + let issue_to_settlement_frac = + match year_frac(issue as i64, settlement as i64, basis as i32) { + Ok(f) => f, + Err(_) => { + return CalcResult::new_error(Error::NUM, cell, "Invalid date".to_string()) + } + }; + let settlement_to_maturity_frac = + match year_frac(settlement as i64, maturity as i64, basis as i32) { + Ok(f) => f, + Err(_) => { + return CalcResult::new_error(Error::NUM, cell, "Invalid date".to_string()) + } + }; + let mut result = 1.0 + issue_to_maturity_frac * rate; + result /= 1.0 + settlement_to_maturity_frac * yld; + result -= issue_to_settlement_frac * rate; + result *= 100.0; CalcResult::Number(result) } + pub(crate) fn fn_yield(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + let params = match parse_and_validate_bond_pricing_params(args, self, cell) { + Ok(p) => p, + Err(err) => return err, + }; + + match handle_compute_error( + compute_rate( + -params.third_param, + params.redemption, + params.periods, + params.coupon, + 0, + 0.1, + ), + cell, + ) { + Ok(r) => CalcResult::Number(r * params.frequency as f64), + Err(err) => err, + } + } + + pub(crate) fn fn_yielddisc(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + financial_function_with_year_frac!( + args, self, cell, + param1_name: "price", + param2_name: "redemption value", + validator: |price, redemption_value| { + if price <= 0.0 || redemption_value <= 0.0 { + Err("values must be positive".to_string()) + } else { + Ok(()) + } + }, + formula: |_settlement, _maturity, price, redemption_value, _basis, year_frac| { + (redemption_value / price - 1.0) / year_frac + } + ) + } + + pub(crate) fn fn_yieldmat(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + let arg_count = args.len(); + if let Some(err) = validate_arg_count_or_return(arg_count, 5, 6, cell) { + return err; + } + + let params = match parse_required_params(args, &[0, 1, 2, 3, 4], self, cell, true) { + Ok(p) => p, + Err(err) => return err, + }; + let (settlement, maturity, issue, rate, price) = + (params[0], params[1], params[2], params[3], params[4]); + let basis = if arg_count == 6 { + match self.get_number_no_bools(&args[5], cell) { + Ok(f) => f, + Err(s) => return s, + } + } else { + 0.0 + }; + if price <= 0.0 || rate < 0.0 || settlement >= maturity || settlement < issue { + return CalcResult::new_error(Error::NUM, cell, "invalid parameters".to_string()); + } + if settlement < MINIMUM_DATE_SERIAL_NUMBER as f64 + || maturity > MAXIMUM_DATE_SERIAL_NUMBER as f64 + || settlement > MAXIMUM_DATE_SERIAL_NUMBER as f64 + || maturity < MINIMUM_DATE_SERIAL_NUMBER as f64 + || issue < MINIMUM_DATE_SERIAL_NUMBER as f64 + || issue > MAXIMUM_DATE_SERIAL_NUMBER as f64 + { + return CalcResult::new_error(Error::NUM, cell, "Invalid number for date".to_string()); + } + let issue_to_maturity_frac = match year_frac(issue as i64, maturity as i64, basis as i32) { + Ok(f) => f, + Err(_) => return CalcResult::new_error(Error::NUM, cell, "Invalid date".to_string()), + }; + let issue_to_settlement_frac = + match year_frac(issue as i64, settlement as i64, basis as i32) { + Ok(f) => f, + Err(_) => { + return CalcResult::new_error(Error::NUM, cell, "Invalid date".to_string()) + } + }; + let settlement_to_maturity_frac = + match year_frac(settlement as i64, maturity as i64, basis as i32) { + Ok(f) => f, + Err(_) => { + return CalcResult::new_error(Error::NUM, cell, "Invalid date".to_string()) + } + }; + let mut y = 1.0 + issue_to_maturity_frac * rate; + y /= price / 100.0 + issue_to_settlement_frac * rate; + y -= 1.0; + y /= settlement_to_maturity_frac; + CalcResult::Number(y) + } + + // DISC(settlement, maturity, pr, redemption, [basis]) + pub(crate) fn fn_disc(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + financial_function_with_year_frac!( + args, self, cell, + param1_name: "price", + param2_name: "redemption value", + validator: |price, redemption_value| { + if price <= 0.0 || redemption_value <= 0.0 { + Err("values must be positive".to_string()) + } else { + Ok(()) + } + }, + formula: |_settlement, _maturity, price, redemption_value, _basis, year_frac| { + (1.0 - price / redemption_value) / year_frac + } + ) + } + + // RECEIVED(settlement, maturity, investment, discount, [basis]) + pub(crate) fn fn_received(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + financial_function_with_year_frac!( + args, self, cell, + param1_name: "investment", + param2_name: "discount rate", + validator: |investment, discount_rate| { + if investment <= 0.0 || discount_rate <= 0.0 { + Err("values must be positive".to_string()) + } else { + Ok(()) + } + }, + formula: |_settlement, _maturity, investment, discount_rate, _basis, year_frac| { + investment / (1.0 - discount_rate * year_frac) + } + ) + } + + // INTRATE(settlement, maturity, investment, redemption, [basis]) + pub(crate) fn fn_intrate(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + financial_function_with_year_frac!( + args, self, cell, + param1_name: "investment", + param2_name: "redemption value", + validator: |investment, redemption_value| { + if investment <= 0.0 || redemption_value <= 0.0 { + Err("values must be positive".to_string()) + } else { + Ok(()) + } + }, + formula: |_settlement, _maturity, investment, redemption_value, _basis, year_frac| { + ((redemption_value / investment) - 1.0) / year_frac + } + ) + } + + // COUPDAYBS(settlement, maturity, frequency, [basis]) + pub(crate) fn fn_coupdaybs(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + let params = match parse_and_validate_coupon_params(args, args.len(), self, cell) { + Ok(p) => p, + Err(err) => return err, + }; + + let (prev_coupon_date, _) = coupon_dates( + params.settlement_date, + params.maturity_date, + params.frequency, + ); + let days = days_between_dates(prev_coupon_date, params.settlement_date, params.basis); + CalcResult::Number(days as f64) + } + + // COUPDAYS(settlement, maturity, frequency, [basis]) + pub(crate) fn fn_coupdays(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + let params = match parse_and_validate_coupon_params(args, args.len(), self, cell) { + Ok(p) => p, + Err(err) => return err, + }; + + let (prev_coupon_date, next_coupon_date) = coupon_dates( + params.settlement_date, + params.maturity_date, + params.frequency, + ); + let days = match params.basis { + 0 | 4 => DAYS_IN_YEAR_360 / params.frequency, // 30/360 conventions + _ => days_between_dates(prev_coupon_date, next_coupon_date, params.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 params = match parse_and_validate_coupon_params(args, args.len(), self, cell) { + Ok(p) => p, + Err(err) => return err, + }; + + let (_, next_coupon_date) = coupon_dates( + params.settlement_date, + params.maturity_date, + params.frequency, + ); + let days = days_between_dates(params.settlement_date, next_coupon_date, params.basis); + CalcResult::Number(days as f64) + } + + // COUPNCD(settlement, maturity, frequency, [basis]) + pub(crate) fn fn_coupncd(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + let params = match parse_and_validate_coupon_params(args, args.len(), self, cell) { + Ok(p) => p, + Err(err) => return err, + }; + + let (_, next_coupon_date) = coupon_dates( + params.settlement_date, + params.maturity_date, + params.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 params = match parse_and_validate_coupon_params(args, args.len(), self, cell) { + Ok(p) => p, + Err(err) => return err, + }; + + let months = 12 / params.frequency; + let step = chrono::Months::new(months as u32); + let mut date = params.maturity_date; + let mut count = 0; + while params.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 params = match parse_and_validate_coupon_params(args, args.len(), self, cell) { + Ok(p) => p, + Err(err) => return err, + }; + + let (prev_coupon_date, _) = coupon_dates( + params.settlement_date, + params.maturity_date, + params.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 { - return CalcResult::new_args_number_error(cell); + if let Some(err) = validate_arg_count_or_return(args.len(), 2, 2, cell) { + return err; } - let fractional_dollar = match self.get_number_no_bools(&args[0], cell) { - Ok(f) => f, - Err(s) => return s, + + let params = match parse_required_params(args, &[0, 1], self, cell, true) { + Ok(p) => p, + Err(err) => return err, }; - let mut fraction = match self.get_number_no_bools(&args[1], cell) { + let (fractional_dollar, raw_fraction) = (params[0], params[1]); + let fraction = match validate_and_normalize_fraction(raw_fraction, cell) { Ok(f) => f, - Err(s) => return s, + Err(err) => return err, }; - if fraction < 0.0 { - return CalcResult::new_error(Error::NUM, cell, "fraction should be >= 1".to_string()); - } - if fraction < 1.0 { - // this is not necessarily DIV/0 - return CalcResult::new_error(Error::DIV, cell, "fraction should be >= 1".to_string()); - } - fraction = fraction.trunc(); - while fraction > 10.0 { - fraction /= 10.0; - } + let t = fractional_dollar.trunc(); let result = t + (fractional_dollar - t) * 10.0 / fraction; CalcResult::Number(result) @@ -1546,28 +2570,20 @@ impl Model { // DOLLARFR(decimal_dollar, fraction) pub(crate) fn fn_dollarfr(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { - if args.len() != 2 { - return CalcResult::new_args_number_error(cell); + if let Some(err) = validate_arg_count_or_return(args.len(), 2, 2, cell) { + return err; } - let decimal_dollar = match self.get_number_no_bools(&args[0], cell) { - Ok(f) => f, - Err(s) => return s, + + let params = match parse_required_params(args, &[0, 1], self, cell, true) { + Ok(p) => p, + Err(err) => return err, }; - let mut fraction = match self.get_number_no_bools(&args[1], cell) { + let (decimal_dollar, raw_fraction) = (params[0], params[1]); + let fraction = match validate_and_normalize_fraction(raw_fraction, cell) { Ok(f) => f, - Err(s) => return s, + Err(err) => return err, }; - if fraction < 0.0 { - return CalcResult::new_error(Error::NUM, cell, "fraction should be >= 1".to_string()); - } - if fraction < 1.0 { - // this is not necessarily DIV/0 - return CalcResult::new_error(Error::DIV, cell, "fraction should be >= 1".to_string()); - } - fraction = fraction.trunc(); - while fraction > 10.0 { - fraction /= 10.0; - } + let t = decimal_dollar.trunc(); let result = t + (decimal_dollar - t) * fraction / 10.0; CalcResult::Number(result) @@ -1575,67 +2591,25 @@ impl Model { // CUMIPMT(rate, nper, pv, start_period, end_period, type) pub(crate) fn fn_cumipmt(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { - if args.len() != 6 { - return CalcResult::new_args_number_error(cell); - } - let rate = match self.get_number_no_bools(&args[0], cell) { - Ok(f) => f, - Err(s) => return s, + let params = match parse_and_validate_cumulative_payment_params(args, self, cell) { + Ok(p) => p, + Err(err) => return err, }; - let nper = match self.get_number_no_bools(&args[1], cell) { - Ok(f) => f, - Err(s) => return s, - }; - let pv = match self.get_number_no_bools(&args[2], cell) { - Ok(f) => f, - Err(s) => return s, - }; - let start_period = match self.get_number_no_bools(&args[3], cell) { - Ok(f) => f.ceil() as i32, - Err(s) => return s, - }; - let end_period = match self.get_number_no_bools(&args[4], cell) { - Ok(f) => f.trunc() as i32, - Err(s) => return s, - }; - // 0 at the end of the period, 1 at the beginning of the period - let period_type = match self.get_number_no_bools(&args[5], cell) { - Ok(f) => { - if f == 0.0 { - false - } else if f == 1.0 { - true - } else { - return CalcResult::new_error( - Error::NUM, - cell, - "invalid period type".to_string(), - ); - } - } - Err(s) => return s, - }; - if start_period > end_period { - return CalcResult::new_error( - Error::NUM, - cell, - "start period should come before end period".to_string(), - ); - } - if rate <= 0.0 || nper <= 0.0 || pv <= 0.0 || start_period < 1 { - return CalcResult::new_error(Error::NUM, cell, "invalid parameters".to_string()); - } let mut result = 0.0; - for period in start_period..=end_period { - result += match compute_ipmt(rate, period as f64, nper, pv, 0.0, period_type) { + for period in params.start_period..=params.end_period { + result += match handle_compute_error( + compute_ipmt( + params.rate, + period as f64, + params.nper, + params.pv, + 0.0, + params.period_type, + ), + cell, + ) { Ok(f) => f, - Err(error) => { - return CalcResult::Error { - error: error.0, - origin: cell, - message: error.1, - } - } + Err(err) => return err, } } CalcResult::Number(result) @@ -1643,67 +2617,25 @@ impl Model { // CUMPRINC(rate, nper, pv, start_period, end_period, type) pub(crate) fn fn_cumprinc(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { - if args.len() != 6 { - return CalcResult::new_args_number_error(cell); - } - let rate = match self.get_number_no_bools(&args[0], cell) { - Ok(f) => f, - Err(s) => return s, + let params = match parse_and_validate_cumulative_payment_params(args, self, cell) { + Ok(p) => p, + Err(err) => return err, }; - let nper = match self.get_number_no_bools(&args[1], cell) { - Ok(f) => f, - Err(s) => return s, - }; - let pv = match self.get_number_no_bools(&args[2], cell) { - Ok(f) => f, - Err(s) => return s, - }; - let start_period = match self.get_number_no_bools(&args[3], cell) { - Ok(f) => f.ceil() as i32, - Err(s) => return s, - }; - let end_period = match self.get_number_no_bools(&args[4], cell) { - Ok(f) => f.trunc() as i32, - Err(s) => return s, - }; - // 0 at the end of the period, 1 at the beginning of the period - let period_type = match self.get_number_no_bools(&args[5], cell) { - Ok(f) => { - if f == 0.0 { - false - } else if f == 1.0 { - true - } else { - return CalcResult::new_error( - Error::NUM, - cell, - "invalid period type".to_string(), - ); - } - } - Err(s) => return s, - }; - if start_period > end_period { - return CalcResult::new_error( - Error::NUM, - cell, - "start period should come before end period".to_string(), - ); - } - if rate <= 0.0 || nper <= 0.0 || pv <= 0.0 || start_period < 1 { - return CalcResult::new_error(Error::NUM, cell, "invalid parameters".to_string()); - } let mut result = 0.0; - for period in start_period..=end_period { - result += match compute_ppmt(rate, period as f64, nper, pv, 0.0, period_type) { + for period in params.start_period..=params.end_period { + result += match handle_compute_error( + compute_ppmt( + params.rate, + period as f64, + params.nper, + params.pv, + 0.0, + params.period_type, + ), + cell, + ) { Ok(f) => f, - Err(error) => { - return CalcResult::Error { - error: error.0, - origin: cell, - message: error.1, - } - } + Err(err) => return err, } } CalcResult::Number(result) @@ -1712,25 +2644,15 @@ impl Model { // DDB(cost, salvage, life, period, [factor]) pub(crate) fn fn_ddb(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { let arg_count = args.len(); - if !(4..=5).contains(&arg_count) { - return CalcResult::new_args_number_error(cell); + if let Some(err) = validate_arg_count_or_return(arg_count, 4, 5, cell) { + return err; } - let cost = match self.get_number(&args[0], cell) { - Ok(f) => f, - Err(s) => return s, - }; - let salvage = match self.get_number(&args[1], cell) { - Ok(f) => f, - Err(s) => return s, - }; - let life = match self.get_number(&args[2], cell) { - Ok(f) => f, - Err(s) => return s, - }; - let period = match self.get_number(&args[3], cell) { - Ok(f) => f, - Err(s) => return s, + + let params = match parse_required_params(args, &[0, 1, 2, 3], self, cell, false) { + Ok(p) => p, + Err(err) => return err, }; + let (cost, salvage, life, period) = (params[0], params[1], params[2], params[3]); // The rate at which the balance declines. let factor = if arg_count > 4 { match self.get_number_no_bools(&args[4], cell) { @@ -1766,25 +2688,15 @@ impl Model { // DB(cost, salvage, life, period, [month]) pub(crate) fn fn_db(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { let arg_count = args.len(); - if !(4..=5).contains(&arg_count) { - return CalcResult::new_args_number_error(cell); + if let Some(err) = validate_arg_count_or_return(arg_count, 4, 5, cell) { + return err; } - let cost = match self.get_number(&args[0], cell) { - Ok(f) => f, - Err(s) => return s, - }; - let salvage = match self.get_number(&args[1], cell) { - Ok(f) => f, - Err(s) => return s, - }; - let life = match self.get_number(&args[2], cell) { - Ok(f) => f, - Err(s) => return s, - }; - let period = match self.get_number(&args[3], cell) { - Ok(f) => f, - Err(s) => return s, + + let params = match parse_required_params(args, &[0, 1, 2, 3], self, cell, false) { + Ok(p) => p, + Err(err) => return err, }; + let (cost, salvage, life, period) = (params[0], params[1], params[2], params[3]); let month = if arg_count > 4 { match self.get_number_no_bools(&args[4], cell) { Ok(f) => f.trunc(), diff --git a/base/src/functions/mod.rs b/base/src/functions/mod.rs index 53a2868..8c9edc1 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -217,6 +217,14 @@ pub enum Function { Isoweeknum, // Financial + Accrint, + Accrintm, + Coupdaybs, + Coupdays, + Coupdaysnc, + Coupncd, + Coupnum, + Couppcd, Cumipmt, Cumprinc, Db, @@ -225,6 +233,7 @@ pub enum Function { Dollarfr, Effect, Fv, + Fvschedule, Ipmt, Irr, Ispmt, @@ -232,9 +241,12 @@ pub enum Function { Nominal, Nper, Npv, + Duration, + Mduration, Pduration, Pmt, Ppmt, + Price, Pv, Rate, Rri, @@ -243,8 +255,16 @@ pub enum Function { Tbilleq, Tbillprice, Tbillyield, + Pricedisc, + Pricemat, + Yielddisc, + Yieldmat, + Disc, + Received, + Intrate, Xirr, Xnpv, + Yield, // Engineering: Bessel and transcendental functions Besseli, @@ -313,7 +333,7 @@ pub enum Function { } impl Function { - pub fn into_iter() -> IntoIter { + pub fn into_iter() -> IntoIter { [ Function::And, Function::False, @@ -474,18 +494,23 @@ impl Function { Function::WorkdayIntl, Function::Yearfrac, Function::Isoweeknum, + Function::Accrint, + Function::Accrintm, Function::Pmt, Function::Pv, Function::Rate, Function::Nper, Function::Fv, + Function::Fvschedule, Function::Ppmt, + Function::Price, Function::Ipmt, Function::Npv, Function::Mirr, Function::Irr, Function::Xirr, Function::Xnpv, + Function::Yield, Function::Rept, Function::Textafter, Function::Textbefore, @@ -497,10 +522,25 @@ impl Function { Function::Syd, Function::Nominal, Function::Effect, + Function::Duration, + Function::Mduration, Function::Pduration, + Function::Coupdaybs, + Function::Coupdays, + Function::Coupdaysnc, + Function::Coupncd, + Function::Coupnum, + Function::Couppcd, Function::Tbillyield, Function::Tbillprice, Function::Tbilleq, + Function::Pricedisc, + Function::Pricemat, + Function::Yielddisc, + Function::Yieldmat, + Function::Disc, + Function::Received, + Function::Intrate, Function::Dollarde, Function::Dollarfr, Function::Ddb, @@ -813,15 +853,20 @@ impl Function { "YEARFRAC" => Some(Function::Yearfrac), "ISOWEEKNUM" | "_XLFN.ISOWEEKNUM" => Some(Function::Isoweeknum), // Financial + "ACCRINT" => Some(Function::Accrint), + "ACCRINTM" => Some(Function::Accrintm), "PMT" => Some(Function::Pmt), "PV" => Some(Function::Pv), "RATE" => Some(Function::Rate), "NPER" => Some(Function::Nper), "FV" => Some(Function::Fv), + "FVSCHEDULE" => Some(Function::Fvschedule), "PPMT" => Some(Function::Ppmt), + "PRICE" => Some(Function::Price), "IPMT" => Some(Function::Ipmt), "NPV" => Some(Function::Npv), "XNPV" => Some(Function::Xnpv), + "YIELD" => Some(Function::Yield), "MIRR" => Some(Function::Mirr), "IRR" => Some(Function::Irr), "XIRR" => Some(Function::Xirr), @@ -832,11 +877,27 @@ impl Function { "SYD" => Some(Function::Syd), "NOMINAL" => Some(Function::Nominal), "EFFECT" => Some(Function::Effect), + "DURATION" => Some(Function::Duration), + "MDURATION" => Some(Function::Mduration), "PDURATION" | "_XLFN.PDURATION" => Some(Function::Pduration), + "COUPDAYBS" => Some(Function::Coupdaybs), + "COUPDAYS" => Some(Function::Coupdays), + "COUPDAYSNC" => Some(Function::Coupdaysnc), + "COUPNCD" => Some(Function::Coupncd), + "COUPNUM" => Some(Function::Coupnum), + "COUPPCD" => Some(Function::Couppcd), + "TBILLYIELD" => Some(Function::Tbillyield), "TBILLPRICE" => Some(Function::Tbillprice), "TBILLEQ" => Some(Function::Tbilleq), + "PRICEDISC" => Some(Function::Pricedisc), + "PRICEMAT" => Some(Function::Pricemat), + "YIELDDISC" => Some(Function::Yielddisc), + "YIELDMAT" => Some(Function::Yieldmat), + "DISC" => Some(Function::Disc), + "RECEIVED" => Some(Function::Received), + "INTRATE" => Some(Function::Intrate), "DOLLARDE" => Some(Function::Dollarde), "DOLLARFR" => Some(Function::Dollarfr), @@ -1052,18 +1113,23 @@ impl fmt::Display for Function { Function::WorkdayIntl => write!(f, "WORKDAY.INTL"), Function::Yearfrac => write!(f, "YEARFRAC"), Function::Isoweeknum => write!(f, "ISOWEEKNUM"), + Function::Accrint => write!(f, "ACCRINT"), + Function::Accrintm => write!(f, "ACCRINTM"), Function::Pmt => write!(f, "PMT"), Function::Pv => write!(f, "PV"), Function::Rate => write!(f, "RATE"), Function::Nper => write!(f, "NPER"), Function::Fv => write!(f, "FV"), + Function::Fvschedule => write!(f, "FVSCHEDULE"), Function::Ppmt => write!(f, "PPMT"), + Function::Price => write!(f, "PRICE"), Function::Ipmt => write!(f, "IPMT"), Function::Npv => write!(f, "NPV"), Function::Mirr => write!(f, "MIRR"), Function::Irr => write!(f, "IRR"), Function::Xirr => write!(f, "XIRR"), Function::Xnpv => write!(f, "XNPV"), + Function::Yield => write!(f, "YIELD"), Function::Rept => write!(f, "REPT"), Function::Textafter => write!(f, "TEXTAFTER"), Function::Textbefore => write!(f, "TEXTBEFORE"), @@ -1075,10 +1141,25 @@ impl fmt::Display for Function { Function::Syd => write!(f, "SYD"), Function::Nominal => write!(f, "NOMINAL"), Function::Effect => write!(f, "EFFECT"), + Function::Duration => write!(f, "DURATION"), + Function::Mduration => write!(f, "MDURATION"), Function::Pduration => write!(f, "PDURATION"), + Function::Coupdaybs => write!(f, "COUPDAYBS"), + Function::Coupdays => write!(f, "COUPDAYS"), + Function::Coupdaysnc => write!(f, "COUPDAYSNC"), + Function::Coupncd => write!(f, "COUPNCD"), + Function::Coupnum => write!(f, "COUPNUM"), + Function::Couppcd => write!(f, "COUPPCD"), Function::Tbillyield => write!(f, "TBILLYIELD"), Function::Tbillprice => write!(f, "TBILLPRICE"), Function::Tbilleq => write!(f, "TBILLEQ"), + Function::Pricedisc => write!(f, "PRICEDISC"), + Function::Pricemat => write!(f, "PRICEMAT"), + Function::Yielddisc => write!(f, "YIELDDISC"), + Function::Yieldmat => write!(f, "YIELDMAT"), + Function::Disc => write!(f, "DISC"), + Function::Received => write!(f, "RECEIVED"), + Function::Intrate => write!(f, "INTRATE"), Function::Dollarde => write!(f, "DOLLARDE"), Function::Dollarfr => write!(f, "DOLLARFR"), Function::Ddb => write!(f, "DDB"), @@ -1329,18 +1410,24 @@ impl Model { Function::WorkdayIntl => self.fn_workday_intl(args, cell), Function::Yearfrac => self.fn_yearfrac(args, cell), Function::Isoweeknum => self.fn_isoweeknum(args, cell), + // Financial + Function::Accrint => self.fn_accrint(args, cell), + Function::Accrintm => self.fn_accrintm(args, cell), Function::Pmt => self.fn_pmt(args, cell), Function::Pv => self.fn_pv(args, cell), Function::Rate => self.fn_rate(args, cell), Function::Nper => self.fn_nper(args, cell), Function::Fv => self.fn_fv(args, cell), + Function::Fvschedule => self.fn_fvschedule(args, cell), Function::Ppmt => self.fn_ppmt(args, cell), + Function::Price => self.fn_price(args, cell), Function::Ipmt => self.fn_ipmt(args, cell), Function::Npv => self.fn_npv(args, cell), Function::Mirr => self.fn_mirr(args, cell), Function::Irr => self.fn_irr(args, cell), Function::Xirr => self.fn_xirr(args, cell), Function::Xnpv => self.fn_xnpv(args, cell), + Function::Yield => self.fn_yield(args, cell), Function::Rept => self.fn_rept(args, cell), Function::Textafter => self.fn_textafter(args, cell), Function::Textbefore => self.fn_textbefore(args, cell), @@ -1352,10 +1439,25 @@ impl Model { Function::Syd => self.fn_syd(args, cell), Function::Nominal => self.fn_nominal(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::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::Tbillyield => self.fn_tbillyield(args, cell), Function::Tbillprice => self.fn_tbillprice(args, cell), Function::Tbilleq => self.fn_tbilleq(args, cell), + Function::Pricedisc => self.fn_pricedisc(args, cell), + Function::Pricemat => self.fn_pricemat(args, cell), + Function::Yielddisc => self.fn_yielddisc(args, cell), + Function::Yieldmat => self.fn_yieldmat(args, cell), + Function::Disc => self.fn_disc(args, cell), + Function::Received => self.fn_received(args, cell), + Function::Intrate => self.fn_intrate(args, cell), Function::Dollarde => self.fn_dollarde(args, cell), Function::Dollarfr => self.fn_dollarfr(args, cell), Function::Ddb => self.fn_ddb(args, cell), diff --git a/base/src/test/mod.rs b/base/src/test/mod.rs index f48604f..7f3f394 100644 --- a/base/src/test/mod.rs +++ b/base/src/test/mod.rs @@ -11,14 +11,19 @@ mod test_datedif_leap_month_end; mod test_days360_month_end; mod test_degrees_radians; mod test_error_propagation; +mod test_fn_accrint; +mod test_fn_accrintm; mod test_fn_average; 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_financial_bonds; mod test_fn_formulatext; mod test_fn_if; mod test_fn_maxifs; @@ -63,6 +68,7 @@ mod test_escape_quotes; mod test_extend; mod test_fn_fv; mod test_fn_round; +mod test_fn_fvschedule; mod test_fn_type; mod test_frozen_rows_and_columns; mod test_geomean; diff --git a/base/src/test/test_fn_accrint.rs b/base/src/test/test_fn_accrint.rs new file mode 100644 index 0000000..2ffde00 --- /dev/null +++ b/base/src/test/test_fn_accrint.rs @@ -0,0 +1,134 @@ +#![allow(clippy::unwrap_used)] + +use crate::{cell::CellValue, test::util::new_empty_model}; + +#[test] +fn fn_accrint() { + let mut model = new_empty_model(); + model._set("A1", "=DATE(2020,1,1)"); + model._set("A2", "=DATE(2020,1,1)"); + model._set("A3", "=DATE(2020,1,31)"); + model._set("A4", "10%"); + model._set("A5", "$1,000"); + model._set("A6", "2"); + + model._set("B1", "=ACCRINT(A1,A2,A3,A4,A5,A6)"); + model._set("C1", "=ACCRINT(A1)"); + model._set("C2", "=ACCRINT(A1,A2,A3,A4,A5,3)"); + + model.evaluate(); + + match model.get_cell_value_by_ref("Sheet1!B1") { + Ok(CellValue::Number(v)) => { + assert!((v - 8.333333333333334).abs() < 1e-9); + } + other => unreachable!("Expected number for B1, got {:?}", other), + } + assert_eq!(model._get_text("C1"), *"#ERROR!"); + assert_eq!(model._get_text("C2"), *"#NUM!"); +} + +#[test] +fn fn_accrint_parameters() { + let mut model = new_empty_model(); + + model._set("A1", "=DATE(2020,1,1)"); + model._set("A2", "=DATE(2020,1,1)"); + model._set("A3", "=DATE(2020,7,1)"); + model._set("A4", "8%"); + model._set("A5", "1000"); + + model._set("B1", "=ACCRINT(A1,A2,A3,A4,A5,2,0,TRUE)"); + model._set("B2", "=ACCRINT(A1,A2,A3,A4,A5,2,1,TRUE)"); + model._set("B3", "=ACCRINT(A1,A2,A3,A4,A5,2,4,TRUE)"); + model._set("B4", "=ACCRINT(A1,A2,A3,A4,A5,1)"); + model._set("B5", "=ACCRINT(A1,A2,A3,A4,A5,4)"); + model._set("B6", "=ACCRINT(A1,A2,A3,A4,A5,2)"); + model._set("B7", "=ACCRINT(A1,A2,A3,A4,A5,2,0)"); + + model.evaluate(); + + match model.get_cell_value_by_ref("Sheet1!B1") { + Ok(CellValue::Number(v)) => { + assert!((v - 40.0).abs() < 1e-9); + } + other => unreachable!("Expected number for B1, got {:?}", other), + } + + match ( + model.get_cell_value_by_ref("Sheet1!B1"), + model.get_cell_value_by_ref("Sheet1!B6"), + ) { + (Ok(CellValue::Number(v1)), Ok(CellValue::Number(v2))) => { + assert!((v1 - v2).abs() < 1e-12); + } + other => unreachable!("Expected matching numbers, got {:?}", other), + } +} + +#[test] +fn fn_accrint_errors() { + let mut model = new_empty_model(); + + model._set("A1", "=DATE(2020,1,1)"); + model._set("A2", "=DATE(2020,1,1)"); + model._set("A3", "=DATE(2020,7,1)"); + model._set("A4", "8%"); + model._set("A5", "1000"); + + model._set("B1", "=ACCRINT()"); + model._set("B2", "=ACCRINT(A1,A2,A3,A4,A5)"); + model._set("B3", "=ACCRINT(A1,A2,A3,A4,A5,2,0,TRUE,1)"); + model._set("C1", "=ACCRINT(A1,A2,A3,A4,A5,0)"); + model._set("C2", "=ACCRINT(A1,A2,A3,A4,A5,3)"); + model._set("C3", "=ACCRINT(A1,A2,A3,A4,A5,-1)"); + model._set("D1", "=ACCRINT(A1,A2,A3,A4,A5,2,-1)"); + model._set("D2", "=ACCRINT(A1,A2,A3,A4,A5,2,5)"); + model._set("E1", "=ACCRINT(A3,A2,A1,A4,A5,2)"); + model._set("E2", "=ACCRINT(A1,A3,A1,A4,A5,2)"); + model._set("F1", "=ACCRINT(A1,A2,A3,A4,0,2)"); + model._set("F2", "=ACCRINT(A1,A2,A3,A4,-1000,2)"); + model._set("F3", "=ACCRINT(A1,A2,A3,-8%,A5,2)"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), *"#ERROR!"); + assert_eq!(model._get_text("B2"), *"#ERROR!"); + assert_eq!(model._get_text("B3"), *"#ERROR!"); + assert_eq!(model._get_text("C1"), *"#NUM!"); + assert_eq!(model._get_text("C2"), *"#NUM!"); + assert_eq!(model._get_text("C3"), *"#NUM!"); + assert_eq!(model._get_text("D1"), *"#NUM!"); + assert_eq!(model._get_text("D2"), *"#NUM!"); + assert_eq!(model._get_text("E1"), *"#NUM!"); + assert_eq!(model._get_text("E2"), *"#NUM!"); + assert_eq!(model._get_text("F2"), *"#NUM!"); + assert_eq!(model._get_text("F3"), *"#NUM!"); + + match model.get_cell_value_by_ref("Sheet1!F1") { + Ok(CellValue::Number(v)) => { + assert!((v - 0.0).abs() < 1e-9); + } + other => unreachable!("Expected 0 for F1, got {:?}", other), + } +} + +#[test] +fn fn_accrint_combined() { + let mut model = new_empty_model(); + model._set("A1", "=DATE(2018,10,15)"); + model._set("A2", "=DATE(2019,2,1)"); + model._set("A3", "5%"); + model._set("A4", "1000"); + + model._set("B1", "=ACCRINT(A1,A1,A2,A3,A4,2)"); + + model.evaluate(); + + match model.get_cell_value_by_ref("Sheet1!B1") { + Ok(CellValue::Number(v)) => { + assert!((v - 14.722222222222221).abs() < 1e-9); + } + other => unreachable!("Expected number for B1, got {:?}", other), + } +} diff --git a/base/src/test/test_fn_accrintm.rs b/base/src/test/test_fn_accrintm.rs new file mode 100644 index 0000000..a85a3a4 --- /dev/null +++ b/base/src/test/test_fn_accrintm.rs @@ -0,0 +1,122 @@ +#![allow(clippy::unwrap_used)] + +use crate::{cell::CellValue, test::util::new_empty_model}; + +#[test] +fn fn_accrintm() { + let mut model = new_empty_model(); + model._set("A1", "=DATE(2020,1,1)"); + model._set("A2", "=DATE(2020,7,1)"); + model._set("A3", "10%"); + model._set("A4", "$1,000"); + + model._set("B1", "=ACCRINTM(A1,A2,A3,A4)"); + model._set("C1", "=ACCRINTM(A1)"); + + model.evaluate(); + + match model.get_cell_value_by_ref("Sheet1!B1") { + Ok(CellValue::Number(v)) => { + assert!((v - 50.0).abs() < 1e-9); + } + other => unreachable!("Expected number for B1, got {:?}", other), + } + assert_eq!(model._get_text("C1"), *"#ERROR!"); +} + +#[test] +fn fn_accrintm_parameters() { + let mut model = new_empty_model(); + + model._set("A1", "=DATE(2020,1,1)"); + model._set("A2", "=DATE(2020,7,1)"); + model._set("A3", "8%"); + model._set("A4", "1000"); + + model._set("B1", "=ACCRINTM(A1,A2,A3,A4,0)"); + model._set("B2", "=ACCRINTM(A1,A2,A3,A4,1)"); + model._set("B3", "=ACCRINTM(A1,A2,A3,A4,4)"); + model._set("C1", "=ACCRINTM(A1,A2,A3,A4)"); + + model.evaluate(); + + match ( + model.get_cell_value_by_ref("Sheet1!B1"), + model.get_cell_value_by_ref("Sheet1!B2"), + ) { + (Ok(CellValue::Number(v1)), Ok(CellValue::Number(v2))) => { + assert!(v1 > 0.0 && v2 > 0.0); + } + other => unreachable!("Expected numbers for basis test, got {:?}", other), + } + + match ( + model.get_cell_value_by_ref("Sheet1!B1"), + model.get_cell_value_by_ref("Sheet1!C1"), + ) { + (Ok(CellValue::Number(v1)), Ok(CellValue::Number(v2))) => { + assert!((v1 - v2).abs() < 1e-12); + } + other => unreachable!( + "Expected matching numbers for default test, got {:?}", + other + ), + } +} + +#[test] +fn fn_accrintm_errors() { + let mut model = new_empty_model(); + + model._set("A1", "=DATE(2020,1,1)"); + model._set("A2", "=DATE(2020,7,1)"); + model._set("A3", "8%"); + model._set("A4", "1000"); + + model._set("B1", "=ACCRINTM()"); + model._set("B2", "=ACCRINTM(A1,A2,A3)"); + model._set("B3", "=ACCRINTM(A1,A2,A3,A4,0,1)"); + model._set("C1", "=ACCRINTM(A1,A2,A3,A4,-1)"); + model._set("C2", "=ACCRINTM(A1,A2,A3,A4,5)"); + model._set("D1", "=ACCRINTM(A2,A1,A3,A4)"); + model._set("E1", "=ACCRINTM(A1,A2,A3,0)"); + model._set("E2", "=ACCRINTM(A1,A2,A3,-1000)"); + model._set("E3", "=ACCRINTM(A1,A2,-8%,A4)"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), *"#ERROR!"); + assert_eq!(model._get_text("B2"), *"#ERROR!"); + assert_eq!(model._get_text("B3"), *"#ERROR!"); + assert_eq!(model._get_text("C1"), *"#NUM!"); + assert_eq!(model._get_text("C2"), *"#NUM!"); + assert_eq!(model._get_text("D1"), *"#NUM!"); + assert_eq!(model._get_text("E2"), *"#NUM!"); + assert_eq!(model._get_text("E3"), *"#NUM!"); + + match model.get_cell_value_by_ref("Sheet1!E1") { + Ok(CellValue::Number(v)) => { + assert!((v - 0.0).abs() < 1e-9); + } + other => unreachable!("Expected 0 for E1, got {:?}", other), + } +} + +#[test] +fn fn_accrintm_combined() { + let mut model = new_empty_model(); + model._set("C1", "=DATE(2016,4,5)"); + model._set("C2", "=DATE(2019,2,1)"); + model._set("A3", "5%"); + model._set("A4", "1000"); + model._set("B2", "=ACCRINTM(C1,C2,A3,A4)"); + + model.evaluate(); + + match model.get_cell_value_by_ref("Sheet1!B2") { + Ok(CellValue::Number(v)) => { + assert!((v - 141.11111111111111).abs() < 1e-9); + } + other => unreachable!("Expected number for B2, got {:?}", other), + } +} diff --git a/base/src/test/test_fn_coupon.rs b/base/src/test/test_fn_coupon.rs new file mode 100644 index 0000000..4c1fb0e --- /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 0000000..14accbc --- /dev/null +++ b/base/src/test/test_fn_duration.rs @@ -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"); + } +} diff --git a/base/src/test/test_fn_financial.rs b/base/src/test/test_fn_financial.rs index a5f31f0..4120997 100644 --- a/base/src/test/test_fn_financial.rs +++ b/base/src/test/test_fn_financial.rs @@ -1,4 +1,5 @@ #![allow(clippy::unwrap_used)] +#![allow(clippy::panic)] use crate::{cell::CellValue, test::util::new_empty_model}; @@ -25,6 +26,10 @@ fn fn_arguments() { model._set("E2", "=RATE(1,1)"); model._set("E3", "=RATE(1,1,1,1,1,1)"); + model._set("F1", "=FVSCHEDULE()"); + model._set("F2", "=FVSCHEDULE(1)"); + model._set("F3", "=FVSCHEDULE(1,1,1)"); + model.evaluate(); assert_eq!(model._get_text("A1"), *"#ERROR!"); @@ -46,6 +51,10 @@ fn fn_arguments() { assert_eq!(model._get_text("E1"), *"#ERROR!"); assert_eq!(model._get_text("E2"), *"#ERROR!"); assert_eq!(model._get_text("E3"), *"#ERROR!"); + + assert_eq!(model._get_text("F1"), *"#ERROR!"); + assert_eq!(model._get_text("F2"), *"#ERROR!"); + assert_eq!(model._get_text("F3"), *"#ERROR!"); } #[test] @@ -468,3 +477,18 @@ fn fn_db_misc() { assert_eq!(model._get_text("B1"), "$0.00"); } + +#[test] +fn fn_fvschedule() { + let mut model = new_empty_model(); + model._set("A1", "1000"); + model._set("A2", "0.08"); + model._set("A3", "0.09"); + model._set("A4", "0.1"); + + model._set("B1", "=FVSCHEDULE(A1, A2:A4)"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), "1294.92"); +} diff --git a/base/src/test/test_fn_financial_bonds.rs b/base/src/test/test_fn_financial_bonds.rs new file mode 100644 index 0000000..54f42b0 --- /dev/null +++ b/base/src/test/test_fn_financial_bonds.rs @@ -0,0 +1,615 @@ +#![allow(clippy::unwrap_used)] + +use crate::{cell::CellValue, 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_pricedisc() { + let mut model = new_empty_model(); + model._set("A2", "=DATE(2022,1,25)"); + model._set("A3", "=DATE(2022,11,15)"); + model._set("A4", "3.75%"); + model._set("A5", "100"); + + model._set("B1", "=PRICEDISC(A2,A3,A4,A5)"); + model._set("C1", "=PRICEDISC(A2,A3)"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), "96.979166667"); + assert_eq!(model._get_text("C1"), *"#ERROR!"); +} + +#[test] +fn fn_pricemat() { + let mut model = new_empty_model(); + model._set("A1", "=DATE(2019,2,15)"); + model._set("A2", "=DATE(2025,4,13)"); + model._set("A3", "=DATE(2018,11,11)"); + model._set("A4", "5.75%"); + model._set("A5", "6.5%"); + + model._set("B1", "=PRICEMAT(A1,A2,A3,A4,A5)"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), "96.271187821"); +} + +#[test] +fn fn_yielddisc() { + let mut model = new_empty_model(); + model._set("A1", "=DATE(2022,1,25)"); + model._set("A2", "=DATE(2022,11,15)"); + model._set("A3", "97"); + model._set("A4", "100"); + + model._set("B1", "=YIELDDISC(A1,A2,A3,A4)"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), "0.038393175"); +} + +#[test] +fn fn_yieldmat() { + let mut model = new_empty_model(); + model._set("A1", "=DATE(2019,2,15)"); + model._set("A2", "=DATE(2025,4,13)"); + model._set("A3", "=DATE(2018,11,11)"); + model._set("A4", "5.75%"); + model._set("A5", "96.27"); + + model._set("B1", "=YIELDMAT(A1,A2,A3,A4,A5)"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), "0.065002762"); +} + +#[test] +fn fn_disc() { + let mut model = new_empty_model(); + model._set("A1", "=DATE(2022,1,25)"); + model._set("A2", "=DATE(2022,11,15)"); + model._set("A3", "97"); + model._set("A4", "100"); + + model._set("B1", "=DISC(A1,A2,A3,A4)"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), "0.037241379"); +} + +#[test] +fn fn_received() { + let mut model = new_empty_model(); + model._set("A1", "=DATE(2020,1,1)"); + model._set("A2", "=DATE(2023,6,30)"); + model._set("A3", "20000"); + model._set("A4", "5%"); + model._set("A5", "3"); + + model._set("B1", "=RECEIVED(A1,A2,A3,A4,A5)"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), "24236.387782205"); +} + +#[test] +fn fn_intrate() { + let mut model = new_empty_model(); + model._set("A1", "=DATE(2020,1,1)"); + model._set("A2", "=DATE(2023,6,30)"); + model._set("A3", "10000"); + model._set("A4", "12000"); + model._set("A5", "3"); + + model._set("B1", "=INTRATE(A1,A2,A3,A4,A5)"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), "0.057210031"); +} + +#[test] +fn fn_bond_functions_arguments() { + let mut model = new_empty_model(); + + // PRICEDISC: 4-5 args + model._set("A1", "=PRICEDISC()"); + model._set("A2", "=PRICEDISC(1,2,3)"); + model._set("A3", "=PRICEDISC(1,2,3,4,5,6)"); + + // PRICEMAT: 5-6 args + model._set("B1", "=PRICEMAT()"); + model._set("B2", "=PRICEMAT(1,2,3,4)"); + model._set("B3", "=PRICEMAT(1,2,3,4,5,6,7)"); + + // YIELDDISC: 4-5 args + model._set("C1", "=YIELDDISC()"); + model._set("C2", "=YIELDDISC(1,2,3)"); + model._set("C3", "=YIELDDISC(1,2,3,4,5,6)"); + + // YIELDMAT: 5-6 args + model._set("D1", "=YIELDMAT()"); + model._set("D2", "=YIELDMAT(1,2,3,4)"); + model._set("D3", "=YIELDMAT(1,2,3,4,5,6,7)"); + + // DISC: 4-5 args + model._set("E1", "=DISC()"); + model._set("E2", "=DISC(1,2,3)"); + model._set("E3", "=DISC(1,2,3,4,5,6)"); + + // RECEIVED: 4-5 args + model._set("F1", "=RECEIVED()"); + model._set("F2", "=RECEIVED(1,2,3)"); + model._set("F3", "=RECEIVED(1,2,3,4,5,6)"); + + // INTRATE: 4-5 args + model._set("G1", "=INTRATE()"); + model._set("G2", "=INTRATE(1,2,3)"); + model._set("G3", "=INTRATE(1,2,3,4,5,6)"); + + model.evaluate(); + + 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!"); + + assert_eq!(model._get_text("C1"), *"#ERROR!"); + assert_eq!(model._get_text("C2"), *"#ERROR!"); + assert_eq!(model._get_text("C3"), *"#ERROR!"); + + assert_eq!(model._get_text("D1"), *"#ERROR!"); + assert_eq!(model._get_text("D2"), *"#ERROR!"); + assert_eq!(model._get_text("D3"), *"#ERROR!"); + + assert_eq!(model._get_text("E1"), *"#ERROR!"); + assert_eq!(model._get_text("E2"), *"#ERROR!"); + assert_eq!(model._get_text("E3"), *"#ERROR!"); + + assert_eq!(model._get_text("F1"), *"#ERROR!"); + assert_eq!(model._get_text("F2"), *"#ERROR!"); + assert_eq!(model._get_text("F3"), *"#ERROR!"); + + assert_eq!(model._get_text("G1"), *"#ERROR!"); + assert_eq!(model._get_text("G2"), *"#ERROR!"); + assert_eq!(model._get_text("G3"), *"#ERROR!"); +} + +#[test] +fn fn_bond_functions_date_boundaries() { + let mut model = new_empty_model(); + + // Date boundary values + model._set("A1", "0"); // Below MINIMUM_DATE_SERIAL_NUMBER + model._set("A2", "1"); // MINIMUM_DATE_SERIAL_NUMBER + model._set("A3", "2958465"); // MAXIMUM_DATE_SERIAL_NUMBER + model._set("A4", "2958466"); // Above MAXIMUM_DATE_SERIAL_NUMBER + + // Test settlement < minimum + model._set("B1", "=PRICEDISC(A1,A2,0.05,100)"); + model._set("B2", "=YIELDDISC(A1,A2,95,100)"); + model._set("B3", "=DISC(A1,A2,95,100)"); + model._set("B4", "=RECEIVED(A1,A2,1000,0.05)"); + model._set("B5", "=INTRATE(A1,A2,1000,1050)"); + + // Test maturity > maximum + model._set("C1", "=PRICEDISC(A2,A4,0.05,100)"); + model._set("C2", "=YIELDDISC(A2,A4,95,100)"); + model._set("C3", "=DISC(A2,A4,95,100)"); + model._set("C4", "=RECEIVED(A2,A4,1000,0.05)"); + model._set("C5", "=INTRATE(A2,A4,1000,1050)"); + + // Test PRICEMAT/YIELDMAT with issue < minimum + model._set("D1", "=PRICEMAT(A2,A3,A1,0.06,0.05)"); + model._set("D2", "=YIELDMAT(A2,A3,A1,0.06,99)"); + + // Test PRICEMAT/YIELDMAT with issue > maximum + model._set("E1", "=PRICEMAT(A2,A3,A4,0.06,0.05)"); + model._set("E2", "=YIELDMAT(A2,A3,A4,0.06,99)"); + + 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_bond_functions_date_ordering() { + let mut model = new_empty_model(); + + model._set("A1", "=DATE(2022,1,1)"); // settlement + model._set("A2", "=DATE(2021,12,31)"); // maturity (before settlement) + model._set("A3", "=DATE(2020,1,1)"); // issue + + // Test settlement >= maturity + model._set("B1", "=PRICEDISC(A1,A2,0.05,100)"); + model._set("B2", "=YIELDDISC(A1,A2,95,100)"); + model._set("B3", "=DISC(A1,A2,95,100)"); + model._set("B4", "=RECEIVED(A1,A2,1000,0.05)"); + model._set("B5", "=INTRATE(A1,A2,1000,1050)"); + model._set("B6", "=PRICEMAT(A1,A2,A3,0.06,0.05)"); + model._set("B7", "=YIELDMAT(A1,A2,A3,0.06,99)"); + + // Test settlement < issue for YIELDMAT/PRICEMAT + model._set("A4", "=DATE(2023,1,1)"); // later issue date + model._set("C1", "=PRICEMAT(A1,A2,A4,0.06,0.05)"); // settlement < issue + model._set("C2", "=YIELDMAT(A1,A2,A4,0.06,99)"); // settlement < issue + + 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_bond_functions_parameter_validation() { + let mut model = new_empty_model(); + + model._set("A1", "=DATE(2022,1,1)"); + model._set("A2", "=DATE(2022,12,31)"); + model._set("A3", "=DATE(2021,1,1)"); + + // Test negative/zero prices and redemptions + model._set("B1", "=PRICEDISC(A1,A2,0.05,0)"); // zero redemption + model._set("B2", "=PRICEDISC(A1,A2,0,100)"); // zero discount + model._set("B3", "=PRICEDISC(A1,A2,-0.05,100)"); // negative discount + + model._set("C1", "=YIELDDISC(A1,A2,0,100)"); // zero price + model._set("C2", "=YIELDDISC(A1,A2,95,0)"); // zero redemption + model._set("C3", "=YIELDDISC(A1,A2,-95,100)"); // negative price + + model._set("D1", "=DISC(A1,A2,0,100)"); // zero price + model._set("D2", "=DISC(A1,A2,95,0)"); // zero redemption + model._set("D3", "=DISC(A1,A2,-95,100)"); // negative price + + model._set("E1", "=RECEIVED(A1,A2,0,0.05)"); // zero investment + model._set("E2", "=RECEIVED(A1,A2,1000,0)"); // zero discount + model._set("E3", "=RECEIVED(A1,A2,-1000,0.05)"); // negative investment + + model._set("F1", "=INTRATE(A1,A2,0,1050)"); // zero investment + model._set("F2", "=INTRATE(A1,A2,1000,0)"); // zero redemption + model._set("F3", "=INTRATE(A1,A2,-1000,1050)"); // negative investment + + model._set("G1", "=PRICEMAT(A1,A2,A3,-0.06,0.05)"); // negative rate + model._set("G2", "=PRICEMAT(A1,A2,A3,0.06,-0.05)"); // negative yield + + model._set("H1", "=YIELDMAT(A1,A2,A3,0.06,0)"); // zero price + model._set("H2", "=YIELDMAT(A1,A2,A3,-0.06,99)"); // negative rate + + 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() { + // Verifies PRICE and YIELD are mathematical inverses + // Regression test for periods calculation type mismatch + let mut model = new_empty_model(); + + model._set("A1", "=DATE(2023,1,15)"); + model._set("A2", "=DATE(2024,7,15)"); // ~1.5 years, fractional periods + model._set("A3", "4.75%"); // coupon + model._set("A4", "5.125%"); // yield + + 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() { + // Tests numerical stability through multiple PRICE->YIELD->PRICE cycles + let mut model = new_empty_model(); + + model._set("A1", "=DATE(2023,3,10)"); + model._set("A2", "=DATE(2024,11,22)"); // Irregular period length + model._set("A3", "3.25%"); // coupon rate + model._set("A4", "4.875%"); // initial yield + + // First round-trip + model._set("B1", "=PRICE(A1,A2,A3,A4,100,4)"); + model._set("B2", "=YIELD(A1,A2,A3,B1,100,4)"); + + // Second round-trip + 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}" + ); +} + +#[test] +fn fn_bond_functions_basis_validation() { + let mut model = new_empty_model(); + + model._set("A1", "=DATE(2022,1,1)"); + model._set("A2", "=DATE(2022,12,31)"); + model._set("A3", "=DATE(2021,1,1)"); + + // Test valid basis values (0-4) + model._set("B1", "=PRICEDISC(A1,A2,0.05,100,0)"); + model._set("B2", "=PRICEDISC(A1,A2,0.05,100,1)"); + model._set("B3", "=PRICEDISC(A1,A2,0.05,100,2)"); + model._set("B4", "=PRICEDISC(A1,A2,0.05,100,3)"); + model._set("B5", "=PRICEDISC(A1,A2,0.05,100,4)"); + + // Test invalid basis values + model._set("C1", "=PRICEDISC(A1,A2,0.05,100,-1)"); + model._set("C2", "=PRICEDISC(A1,A2,0.05,100,5)"); + model._set("C3", "=YIELDDISC(A1,A2,95,100,10)"); + model._set("C4", "=DISC(A1,A2,95,100,-5)"); + model._set("C5", "=RECEIVED(A1,A2,1000,0.05,99)"); + model._set("C6", "=INTRATE(A1,A2,1000,1050,-2)"); + model._set("C7", "=PRICEMAT(A1,A2,A3,0.06,0.05,7)"); + model._set("C8", "=YIELDMAT(A1,A2,A3,0.06,99,-3)"); + + model.evaluate(); + + // Valid basis should work + assert_ne!(model._get_text("B1"), *"#ERROR!"); + assert_ne!(model._get_text("B2"), *"#ERROR!"); + assert_ne!(model._get_text("B3"), *"#ERROR!"); + assert_ne!(model._get_text("B4"), *"#ERROR!"); + assert_ne!(model._get_text("B5"), *"#ERROR!"); + + // Invalid basis should error + assert_eq!(model._get_text("C1"), *"#NUM!"); + assert_eq!(model._get_text("C2"), *"#NUM!"); + assert_eq!(model._get_text("C3"), *"#NUM!"); + assert_eq!(model._get_text("C4"), *"#NUM!"); + assert_eq!(model._get_text("C5"), *"#NUM!"); + assert_eq!(model._get_text("C6"), *"#NUM!"); + assert_eq!(model._get_text("C7"), *"#NUM!"); + assert_eq!(model._get_text("C8"), *"#NUM!"); +} + +#[test] +fn fn_bond_functions_relationships() { + // Test mathematical relationships between functions + let mut model = new_empty_model(); + model._set("A1", "=DATE(2021,1,1)"); + model._set("A2", "=DATE(2021,7,1)"); + + model._set("B1", "=PRICEDISC(A1,A2,5%,100)"); + model._set("B2", "=YIELDDISC(A1,A2,B1,100)"); + model._set("B3", "=DISC(A1,A2,B1,100)"); + model._set("B4", "=RECEIVED(A1,A2,1000,5%)"); + model._set("B5", "=INTRATE(A1,A2,1000,1050)"); + model._set("B6", "=PRICEMAT(A1,A2,DATE(2020,7,1),6%,5%)"); + model._set("B7", "=YIELDMAT(A1,A2,DATE(2020,7,1),6%,99)"); + + model.evaluate(); + + assert_eq!( + model.get_cell_value_by_ref("Sheet1!B1"), + Ok(CellValue::Number(97.5)) + ); + if let Ok(CellValue::Number(v)) = model.get_cell_value_by_ref("Sheet1!B2") { + assert!((v - 0.051282051).abs() < 1e-6); + } + if let Ok(CellValue::Number(v)) = model.get_cell_value_by_ref("Sheet1!B3") { + assert!((v - 0.05).abs() < 1e-6); + } + if let Ok(CellValue::Number(v)) = model.get_cell_value_by_ref("Sheet1!B4") { + assert!((v - 1025.641025).abs() < 1e-6); + } + if let Ok(CellValue::Number(v)) = model.get_cell_value_by_ref("Sheet1!B5") { + assert!((v - 0.10).abs() < 1e-6); + } + if let Ok(CellValue::Number(v)) = model.get_cell_value_by_ref("Sheet1!B6") { + assert!((v - 100.414634).abs() < 1e-6); + } + if let Ok(CellValue::Number(v)) = model.get_cell_value_by_ref("Sheet1!B7") { + assert!((v - 0.078431372).abs() < 1e-6); + } +} diff --git a/base/src/test/test_fn_fvschedule.rs b/base/src/test/test_fn_fvschedule.rs new file mode 100644 index 0000000..b2e7467 --- /dev/null +++ b/base/src/test/test_fn_fvschedule.rs @@ -0,0 +1,127 @@ +#![allow(clippy::unwrap_used)] + +use crate::{cell::CellValue, test::util::new_empty_model}; + +#[test] +fn computation() { + let mut model = new_empty_model(); + model._set("B1", "0.1"); + model._set("B2", "0.2"); + model._set("A1", "=FVSCHEDULE(100,B1:B2)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), "132"); +} + +#[test] +fn fvschedule_basic_with_precise_assertion() { + let mut model = new_empty_model(); + model._set("A1", "1000"); + model._set("B1", "0.09"); + model._set("B2", "0.11"); + model._set("B3", "0.1"); + + model._set("C1", "=FVSCHEDULE(A1,B1:B3)"); + model.evaluate(); + + assert_eq!( + model.get_cell_value_by_ref("Sheet1!C1"), + Ok(CellValue::Number(1330.89)) + ); +} + +#[test] +fn fvschedule_compound_rates() { + let mut model = new_empty_model(); + model._set("A1", "1"); + model._set("A2", "0.1"); + model._set("A3", "0.2"); + model._set("A4", "0.3"); + + model._set("B1", "=FVSCHEDULE(A1, A2:A4)"); + + model.evaluate(); + + // 1 * (1+0.1) * (1+0.2) * (1+0.3) = 1 * 1.1 * 1.2 * 1.3 = 1.716 + assert_eq!(model._get_text("B1"), "1.716"); +} + +#[test] +fn fvschedule_ignore_non_numbers() { + let mut model = new_empty_model(); + model._set("A1", "1"); + model._set("A2", "0.1"); + model._set("A3", "foo"); // non-numeric value should be ignored + model._set("A4", "0.2"); + + model._set("B1", "=FVSCHEDULE(A1, A2:A4)"); + + model.evaluate(); + + // 1 * (1+0.1) * (1+0.2) = 1 * 1.1 * 1.2 = 1.32 + assert_eq!(model._get_text("B1"), "1.32"); +} + +#[test] +fn fvschedule_argument_count() { + let mut model = new_empty_model(); + model._set("A1", "=FVSCHEDULE()"); + model._set("A2", "=FVSCHEDULE(1)"); + model._set("A3", "=FVSCHEDULE(1,1,1)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"#ERROR!"); + assert_eq!(model._get_text("A2"), *"#ERROR!"); + assert_eq!(model._get_text("A3"), *"#ERROR!"); +} + +#[test] +fn fvschedule_edge_cases() { + let mut model = new_empty_model(); + + // Test with zero principal + model._set("A1", "0"); + model._set("A2", "0.1"); + model._set("A3", "0.2"); + model._set("B1", "=FVSCHEDULE(A1, A2:A3)"); + + // Test with negative principal + model._set("C1", "-100"); + model._set("D1", "=FVSCHEDULE(C1, A2:A3)"); + + // Test with zero rates + model._set("E1", "100"); + model._set("E2", "0"); + model._set("E3", "0"); + model._set("F1", "=FVSCHEDULE(E1, E2:E3)"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), "0"); // 0 * anything = 0 + assert_eq!(model._get_text("D1"), "-132"); // -100 * 1.1 * 1.2 = -132 + assert_eq!(model._get_text("F1"), "100"); // 100 * 1 * 1 = 100 +} + +#[test] +fn fvschedule_rate_validation() { + let mut model = new_empty_model(); + + // Test with rate exactly -1 (should cause error due to validation in patch 1) + model._set("A1", "100"); + model._set("A2", "-1"); + model._set("A3", "0.1"); + model._set("B1", "=FVSCHEDULE(A1, A2:A3)"); + + // Test with rate less than -1 (should cause error) + model._set("C1", "100"); + model._set("C2", "-1.5"); + model._set("C3", "0.1"); + model._set("D1", "=FVSCHEDULE(C1, C2:C3)"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), "#NUM!"); + assert_eq!(model._get_text("D1"), "#NUM!"); +} diff --git a/docs/src/functions/financial.md b/docs/src/functions/financial.md index e8593cd..ef81721 100644 --- a/docs/src/functions/financial.md +++ b/docs/src/functions/financial.md @@ -11,32 +11,32 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | Function | Status | Documentation | | ---------- | ---------------------------------------------- | ------------------ | -| ACCRINT | | – | -| ACCRINTM | | – | +| ACCRINT | | [ACCRINT](financial/accrint) | +| ACCRINTM | | [ACCRINTM](financial/accrintm) | | AMORDEGRC | | – | | AMORLINC | | – | -| COUPDAYBS | | – | -| COUPDAYS | | – | -| COUPDAYSNC | | – | -| COUPNCD | | – | -| COUPNUM | | – | -| COUPPCD | | – | +| COUPDAYBS | | – | +| COUPDAYS | | – | +| COUPDAYSNC | | – | +| COUPNCD | | – | +| COUPNUM | | – | +| COUPPCD | | – | | CUMIPMT | | – | | CUMPRINC | | – | | DB | | – | | DDB | | – | -| DISC | | – | +| DISC | | – | | DOLLARDE | | – | | DOLLARFR | | – | -| DURATION | | – | +| DURATION | | – | | EFFECT | | – | | FV | | [FV](financial/fv) | -| FVSCHEDULE | | – | -| INTRATE | | – | +| FVSCHEDULE | | [FVSCHEDULE](financial/fvschedule) | +| INTRATE | | – | | IPMT | | – | | IRR | | – | | ISPMT | | – | -| MDURATION | | – | +| MDURATION | | – | | MIRR | | – | | NOMINAL | | – | | NPER | | – | @@ -48,12 +48,12 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | PDURATION | | – | | PMT | | – | | PPMT | | – | -| PRICE | | – | -| PRICEDISC | | – | -| PRICEMAT | | – | +| PRICE | | – | +| PRICEDISC | | – | +| PRICEMAT | | – | | PV | | [PV](financial/pv) | | RATE | | – | -| RECEIVED | | – | +| RECEIVED | | – | | RRI | | - | | SLN | | – | | SYD | | – | @@ -63,6 +63,9 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | VDB | | – | | XIRR | | – | | XNPV | | – | -| YIELD | | – | +| YIELD | | – | | YIELDDISC | | – | | YIELDMAT | | – | +| YIELD | | – | +| YIELDDISC | | – | +| YIELDMAT | | – | diff --git a/docs/src/functions/financial/accrint.md b/docs/src/functions/financial/accrint.md index 453b66c..908b5a7 100644 --- a/docs/src/functions/financial/accrint.md +++ b/docs/src/functions/financial/accrint.md @@ -7,6 +7,5 @@ lang: en-US # ACCRINT ::: 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/accrintm.md b/docs/src/functions/financial/accrintm.md index 0de1e3f..e4150e2 100644 --- a/docs/src/functions/financial/accrintm.md +++ b/docs/src/functions/financial/accrintm.md @@ -7,6 +7,5 @@ lang: en-US # ACCRINTM ::: 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/coupdaybs.md b/docs/src/functions/financial/coupdaybs.md index 710809b..287bf6c 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 b47d8e8..a9c5369 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 1aabcf8..1b8b961 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 4c3eeb2..6715a8d 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 7bd4538..74cd683 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 933c0ef..16d3fe1 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/disc.md b/docs/src/functions/financial/disc.md index ace252e..779b882 100644 --- a/docs/src/functions/financial/disc.md +++ b/docs/src/functions/financial/disc.md @@ -7,6 +7,5 @@ lang: en-US # DISC ::: 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 d163aa0..6b86251 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) +🚧 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/fvschedule.md b/docs/src/functions/financial/fvschedule.md index 797ca7d..bb860a1 100644 --- a/docs/src/functions/financial/fvschedule.md +++ b/docs/src/functions/financial/fvschedule.md @@ -7,6 +7,5 @@ lang: en-US # FVSCHEDULE ::: 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/intrate.md b/docs/src/functions/financial/intrate.md index 4e393f3..f26227b 100644 --- a/docs/src/functions/financial/intrate.md +++ b/docs/src/functions/financial/intrate.md @@ -7,6 +7,5 @@ lang: en-US # INTRATE ::: 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/mduration.md b/docs/src/functions/financial/mduration.md index efe28f1..49fee2c 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) +🚧 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/price.md b/docs/src/functions/financial/price.md index 378de6d..a3178ed 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) +🚧 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/pricedisc.md b/docs/src/functions/financial/pricedisc.md index 9955d05..7a639bf 100644 --- a/docs/src/functions/financial/pricedisc.md +++ b/docs/src/functions/financial/pricedisc.md @@ -7,6 +7,5 @@ lang: en-US # PRICEDISC ::: 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/pricemat.md b/docs/src/functions/financial/pricemat.md index 9cc61c9..360f1b2 100644 --- a/docs/src/functions/financial/pricemat.md +++ b/docs/src/functions/financial/pricemat.md @@ -7,6 +7,5 @@ lang: en-US # PRICEMAT ::: 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/received.md b/docs/src/functions/financial/received.md index 4dd1e7d..fb8ca17 100644 --- a/docs/src/functions/financial/received.md +++ b/docs/src/functions/financial/received.md @@ -7,6 +7,5 @@ lang: en-US # RECEIVED ::: 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/yield.md b/docs/src/functions/financial/yield.md index 7138dc7..0bd9e9a 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) +🚧 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/yielddisc.md b/docs/src/functions/financial/yielddisc.md index 547f1c2..f6720bf 100644 --- a/docs/src/functions/financial/yielddisc.md +++ b/docs/src/functions/financial/yielddisc.md @@ -7,6 +7,5 @@ lang: en-US # YIELDDISC ::: 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/yieldmat.md b/docs/src/functions/financial/yieldmat.md index 483fef3..530631b 100644 --- a/docs/src/functions/financial/yieldmat.md +++ b/docs/src/functions/financial/yieldmat.md @@ -7,6 +7,5 @@ lang: en-US # YIELDMAT ::: 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