@@ -764,6 +764,7 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec<Signatu
|
||||
Function::Pduration => args_signature_scalars(arg_count, 3, 0),
|
||||
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),
|
||||
@@ -772,6 +773,7 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec<Signatu
|
||||
Function::Tbilleq => 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::Xirr => args_signature_xirr(arg_count),
|
||||
Function::Xnpv => args_signature_xnpv(arg_count),
|
||||
Function::Besseli => args_signature_scalars(arg_count, 2, 0),
|
||||
@@ -1030,6 +1032,7 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult {
|
||||
Function::Pduration => 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),
|
||||
@@ -1038,6 +1041,7 @@ 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::Xirr => not_implemented(args),
|
||||
Function::Xnpv => not_implemented(args),
|
||||
Function::Besseli => scalar_arguments(args),
|
||||
|
||||
@@ -1623,6 +1623,140 @@ impl Model {
|
||||
CalcResult::Number(result)
|
||||
}
|
||||
|
||||
// PRICE(settlement, maturity, rate, yld, redemption, frequency, [basis])
|
||||
pub(crate) fn fn_price(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
|
||||
if !(6..=7).contains(&args.len()) {
|
||||
return CalcResult::new_args_number_error(cell);
|
||||
}
|
||||
let settlement = match self.get_number_no_bools(&args[0], cell) {
|
||||
Ok(f) => f,
|
||||
Err(s) => return s,
|
||||
};
|
||||
let maturity = match self.get_number_no_bools(&args[1], cell) {
|
||||
Ok(f) => f,
|
||||
Err(s) => return s,
|
||||
};
|
||||
let rate = match self.get_number_no_bools(&args[2], cell) {
|
||||
Ok(f) => f,
|
||||
Err(s) => return s,
|
||||
};
|
||||
let yld = match self.get_number_no_bools(&args[3], cell) {
|
||||
Ok(f) => f,
|
||||
Err(s) => return s,
|
||||
};
|
||||
let redemption = match self.get_number_no_bools(&args[4], cell) {
|
||||
Ok(f) => f,
|
||||
Err(s) => return s,
|
||||
};
|
||||
let frequency = match self.get_number_no_bools(&args[5], cell) {
|
||||
Ok(f) => f.round() as i32,
|
||||
Err(s) => return s,
|
||||
};
|
||||
if frequency != 1 && frequency != 2 && frequency != 4 {
|
||||
return CalcResult::new_error(
|
||||
Error::NUM,
|
||||
cell,
|
||||
"frequency should be 1, 2 or 4".to_string(),
|
||||
);
|
||||
}
|
||||
if settlement >= maturity {
|
||||
return CalcResult::new_error(
|
||||
Error::NUM,
|
||||
cell,
|
||||
"settlement should be < maturity".to_string(),
|
||||
);
|
||||
}
|
||||
if args.len() == 7 {
|
||||
let _basis = match self.get_number_no_bools(&args[6], cell) {
|
||||
Ok(f) => f,
|
||||
Err(s) => return s,
|
||||
};
|
||||
// basis is currently ignored
|
||||
}
|
||||
let days = maturity - settlement;
|
||||
let periods = ((days * frequency as f64) / 365.0).round();
|
||||
if periods <= 0.0 {
|
||||
return CalcResult::new_error(Error::NUM, cell, "invalid dates".to_string());
|
||||
}
|
||||
let coupon = redemption * rate / frequency as f64;
|
||||
let r = yld / frequency as f64;
|
||||
let mut price = 0.0;
|
||||
for i in 1..=(periods as i32) {
|
||||
price += coupon / (1.0 + r).powf(i as f64);
|
||||
}
|
||||
price += redemption / (1.0 + r).powf(periods);
|
||||
if price.is_nan() || price.is_infinite() {
|
||||
return CalcResult::new_error(Error::NUM, cell, "Invalid data".to_string());
|
||||
}
|
||||
CalcResult::Number(price)
|
||||
}
|
||||
|
||||
// YIELD(settlement, maturity, rate, pr, redemption, frequency, [basis])
|
||||
pub(crate) fn fn_yield(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
|
||||
if !(6..=7).contains(&args.len()) {
|
||||
return CalcResult::new_args_number_error(cell);
|
||||
}
|
||||
let settlement = match self.get_number_no_bools(&args[0], cell) {
|
||||
Ok(f) => f,
|
||||
Err(s) => return s,
|
||||
};
|
||||
let maturity = match self.get_number_no_bools(&args[1], cell) {
|
||||
Ok(f) => f,
|
||||
Err(s) => return s,
|
||||
};
|
||||
let rate = match self.get_number_no_bools(&args[2], cell) {
|
||||
Ok(f) => f,
|
||||
Err(s) => return s,
|
||||
};
|
||||
let price = match self.get_number_no_bools(&args[3], cell) {
|
||||
Ok(f) => f,
|
||||
Err(s) => return s,
|
||||
};
|
||||
let redemption = match self.get_number_no_bools(&args[4], cell) {
|
||||
Ok(f) => f,
|
||||
Err(s) => return s,
|
||||
};
|
||||
let frequency = match self.get_number_no_bools(&args[5], cell) {
|
||||
Ok(f) => f.round() as i32,
|
||||
Err(s) => return s,
|
||||
};
|
||||
if frequency != 1 && frequency != 2 && frequency != 4 {
|
||||
return CalcResult::new_error(
|
||||
Error::NUM,
|
||||
cell,
|
||||
"frequency should be 1, 2 or 4".to_string(),
|
||||
);
|
||||
}
|
||||
if settlement >= maturity {
|
||||
return CalcResult::new_error(
|
||||
Error::NUM,
|
||||
cell,
|
||||
"settlement should be < maturity".to_string(),
|
||||
);
|
||||
}
|
||||
if args.len() == 7 {
|
||||
let _basis = match self.get_number_no_bools(&args[6], cell) {
|
||||
Ok(f) => f,
|
||||
Err(s) => return s,
|
||||
};
|
||||
// basis ignored
|
||||
}
|
||||
let days = maturity - settlement;
|
||||
let periods = ((days * frequency as f64) / 365.0).round();
|
||||
if periods <= 0.0 {
|
||||
return CalcResult::new_error(Error::NUM, cell, "invalid dates".to_string());
|
||||
}
|
||||
let coupon = redemption * rate / frequency as f64;
|
||||
match compute_rate(-price, redemption, periods, coupon, 0, 0.1) {
|
||||
Ok(r) => CalcResult::Number(r * frequency as f64),
|
||||
Err(err) => CalcResult::Error {
|
||||
error: err.0,
|
||||
origin: cell,
|
||||
message: err.1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// DOLLARDE(fractional_dollar, fraction)
|
||||
pub(crate) fn fn_dollarde(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult {
|
||||
if args.len() != 2 {
|
||||
|
||||
@@ -237,6 +237,7 @@ pub enum Function {
|
||||
Pduration,
|
||||
Pmt,
|
||||
Ppmt,
|
||||
Price,
|
||||
Pv,
|
||||
Rate,
|
||||
Rri,
|
||||
@@ -247,6 +248,7 @@ pub enum Function {
|
||||
Tbillyield,
|
||||
Xirr,
|
||||
Xnpv,
|
||||
Yield,
|
||||
|
||||
// Engineering: Bessel and transcendental functions
|
||||
Besseli,
|
||||
@@ -315,7 +317,7 @@ pub enum Function {
|
||||
}
|
||||
|
||||
impl Function {
|
||||
pub fn into_iter() -> IntoIter<Function, 258> {
|
||||
pub fn into_iter() -> IntoIter<Function, 260> {
|
||||
[
|
||||
Function::And,
|
||||
Function::False,
|
||||
@@ -482,12 +484,14 @@ impl Function {
|
||||
Function::Nper,
|
||||
Function::Fv,
|
||||
Function::Ppmt,
|
||||
Function::Price,
|
||||
Function::Ipmt,
|
||||
Function::Npv,
|
||||
Function::Mirr,
|
||||
Function::Irr,
|
||||
Function::Xirr,
|
||||
Function::Xnpv,
|
||||
Function::Yield,
|
||||
Function::Rept,
|
||||
Function::Textafter,
|
||||
Function::Textbefore,
|
||||
@@ -823,9 +827,11 @@ impl Function {
|
||||
"NPER" => Some(Function::Nper),
|
||||
"FV" => Some(Function::Fv),
|
||||
"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),
|
||||
@@ -1064,12 +1070,14 @@ impl fmt::Display for Function {
|
||||
Function::Nper => write!(f, "NPER"),
|
||||
Function::Fv => write!(f, "FV"),
|
||||
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"),
|
||||
@@ -1343,12 +1351,14 @@ impl Model {
|
||||
Function::Nper => self.fn_nper(args, cell),
|
||||
Function::Fv => self.fn_fv(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),
|
||||
|
||||
@@ -20,6 +20,7 @@ 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;
|
||||
|
||||
239
base/src/test/test_fn_financial_bonds.rs
Normal file
239
base/src/test/test_fn_financial_bonds.rs
Normal file
@@ -0,0 +1,239 @@
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use crate::test::util::new_empty_model;
|
||||
|
||||
#[test]
|
||||
fn fn_price_yield() {
|
||||
let mut model = new_empty_model();
|
||||
model._set("A1", "=DATE(2023,1,1)");
|
||||
model._set("A2", "=DATE(2024,1,1)");
|
||||
model._set("A3", "5%");
|
||||
|
||||
model._set("B1", "=PRICE(A1,A2,A3,6%,100,1)");
|
||||
model._set("B2", "=YIELD(A1,A2,A3,B1,100,1)");
|
||||
|
||||
model.evaluate();
|
||||
assert_eq!(model._get_text("B1"), "99.056603774");
|
||||
assert_eq!(model._get_text("B2"), "0.06");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fn_price_frequencies() {
|
||||
let mut model = new_empty_model();
|
||||
model._set("A1", "=DATE(2023,1,1)");
|
||||
model._set("A2", "=DATE(2024,1,1)");
|
||||
|
||||
model._set("B1", "=PRICE(A1,A2,5%,6%,100,1)");
|
||||
model._set("B2", "=PRICE(A1,A2,5%,6%,100,2)");
|
||||
model._set("B3", "=PRICE(A1,A2,5%,6%,100,4)");
|
||||
|
||||
model.evaluate();
|
||||
|
||||
let annual: f64 = model._get_text("B1").parse().unwrap();
|
||||
let semi: f64 = model._get_text("B2").parse().unwrap();
|
||||
let quarterly: f64 = model._get_text("B3").parse().unwrap();
|
||||
|
||||
assert_ne!(annual, semi);
|
||||
assert_ne!(semi, quarterly);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fn_yield_frequencies() {
|
||||
let mut model = new_empty_model();
|
||||
model._set("A1", "=DATE(2023,1,1)");
|
||||
model._set("A2", "=DATE(2024,1,1)");
|
||||
|
||||
model._set("B1", "=YIELD(A1,A2,5%,99,100,1)");
|
||||
model._set("B2", "=YIELD(A1,A2,5%,99,100,2)");
|
||||
model._set("B3", "=YIELD(A1,A2,5%,99,100,4)");
|
||||
|
||||
model.evaluate();
|
||||
|
||||
let annual: f64 = model._get_text("B1").parse().unwrap();
|
||||
let semi: f64 = model._get_text("B2").parse().unwrap();
|
||||
let quarterly: f64 = model._get_text("B3").parse().unwrap();
|
||||
|
||||
assert_ne!(annual, semi);
|
||||
assert_ne!(semi, quarterly);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fn_price_argument_errors() {
|
||||
let mut model = new_empty_model();
|
||||
model._set("A1", "=DATE(2023,1,1)");
|
||||
model._set("A2", "=DATE(2024,1,1)");
|
||||
|
||||
model._set("B1", "=PRICE()");
|
||||
model._set("B2", "=PRICE(A1,A2,5%,6%,100)");
|
||||
model._set("B3", "=PRICE(A1,A2,5%,6%,100,2,0,99)");
|
||||
|
||||
model.evaluate();
|
||||
|
||||
assert_eq!(model._get_text("B1"), *"#ERROR!");
|
||||
assert_eq!(model._get_text("B2"), *"#ERROR!");
|
||||
assert_eq!(model._get_text("B3"), *"#ERROR!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fn_yield_argument_errors() {
|
||||
let mut model = new_empty_model();
|
||||
model._set("A1", "=DATE(2023,1,1)");
|
||||
model._set("A2", "=DATE(2024,1,1)");
|
||||
|
||||
model._set("B1", "=YIELD()");
|
||||
model._set("B2", "=YIELD(A1,A2,5%,99,100)");
|
||||
model._set("B3", "=YIELD(A1,A2,5%,99,100,2,0,99)");
|
||||
|
||||
model.evaluate();
|
||||
|
||||
assert_eq!(model._get_text("B1"), *"#ERROR!");
|
||||
assert_eq!(model._get_text("B2"), *"#ERROR!");
|
||||
assert_eq!(model._get_text("B3"), *"#ERROR!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fn_price_invalid_frequency() {
|
||||
let mut model = new_empty_model();
|
||||
model._set("A1", "=DATE(2023,1,1)");
|
||||
model._set("A2", "=DATE(2024,1,1)");
|
||||
|
||||
model._set("B1", "=PRICE(A1,A2,5%,6%,100,0)");
|
||||
model._set("B2", "=PRICE(A1,A2,5%,6%,100,3)");
|
||||
model._set("B3", "=PRICE(A1,A2,5%,6%,100,5)");
|
||||
|
||||
model.evaluate();
|
||||
|
||||
assert_eq!(model._get_text("B1"), *"#NUM!");
|
||||
assert_eq!(model._get_text("B2"), *"#NUM!");
|
||||
assert_eq!(model._get_text("B3"), *"#NUM!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fn_yield_invalid_frequency() {
|
||||
let mut model = new_empty_model();
|
||||
model._set("A1", "=DATE(2023,1,1)");
|
||||
model._set("A2", "=DATE(2024,1,1)");
|
||||
|
||||
model._set("B1", "=YIELD(A1,A2,5%,99,100,0)");
|
||||
model._set("B2", "=YIELD(A1,A2,5%,99,100,3)");
|
||||
model._set("B3", "=YIELD(A1,A2,5%,99,100,5)");
|
||||
|
||||
model.evaluate();
|
||||
|
||||
assert_eq!(model._get_text("B1"), *"#NUM!");
|
||||
assert_eq!(model._get_text("B2"), *"#NUM!");
|
||||
assert_eq!(model._get_text("B3"), *"#NUM!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fn_price_invalid_dates() {
|
||||
let mut model = new_empty_model();
|
||||
model._set("A1", "=DATE(2023,1,1)");
|
||||
model._set("A2", "=DATE(2024,1,1)");
|
||||
|
||||
model._set("B1", "=PRICE(A2,A1,5%,6%,100,2)");
|
||||
model._set("B2", "=PRICE(A1,A1,5%,6%,100,2)");
|
||||
|
||||
model.evaluate();
|
||||
|
||||
assert_eq!(model._get_text("B1"), *"#NUM!");
|
||||
assert_eq!(model._get_text("B2"), *"#NUM!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fn_yield_invalid_dates() {
|
||||
let mut model = new_empty_model();
|
||||
model._set("A1", "=DATE(2023,1,1)");
|
||||
model._set("A2", "=DATE(2024,1,1)");
|
||||
|
||||
model._set("B1", "=YIELD(A2,A1,5%,99,100,2)");
|
||||
model._set("B2", "=YIELD(A1,A1,5%,99,100,2)");
|
||||
|
||||
model.evaluate();
|
||||
|
||||
assert_eq!(model._get_text("B1"), *"#NUM!");
|
||||
assert_eq!(model._get_text("B2"), *"#NUM!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fn_price_with_basis() {
|
||||
let mut model = new_empty_model();
|
||||
model._set("A1", "=DATE(2023,1,1)");
|
||||
model._set("A2", "=DATE(2024,1,1)");
|
||||
|
||||
model._set("B1", "=PRICE(A1,A2,5%,6%,100,2,0)");
|
||||
model._set("B2", "=PRICE(A1,A2,5%,6%,100,2,1)");
|
||||
|
||||
model.evaluate();
|
||||
|
||||
assert!(model._get_text("B1").parse::<f64>().is_ok());
|
||||
assert!(model._get_text("B2").parse::<f64>().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::<f64>().is_ok());
|
||||
assert!(model._get_text("B2").parse::<f64>().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}"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user