merge price, yield #29

# Conflicts:
#	base/src/functions/mod.rs
This commit is contained in:
Nicolás Hatcher
2025-11-06 21:43:23 +01:00
parent dacf03d82d
commit 9c68fcc8ec
8 changed files with 393 additions and 7 deletions

View File

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

View File

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

View File

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

View File

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

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