use crate::util::error::{require_finite, require_rate, FinanceError, FinanceResult};
pub fn nper<C, P, F>(
periodic_rate: f64,
payment: C,
present_value: P,
future_value: F,
) -> FinanceResult<f64>
where
C: Into<f64> + Copy,
P: Into<f64> + Copy,
F: Into<f64> + Copy,
{
Ok(nper_solution(periodic_rate, payment, present_value, future_value)?.periods)
}
pub fn nper_solution<C, P, F>(
periodic_rate: f64,
payment: C,
present_value: P,
future_value: F,
) -> FinanceResult<NperSolution>
where
C: Into<f64> + Copy,
P: Into<f64> + Copy,
F: Into<f64> + Copy,
{
nper_solution_internal(
periodic_rate,
payment.into(),
present_value.into(),
future_value.into(),
false,
)
}
pub fn nper_due<C, P, F>(
periodic_rate: f64,
payment: C,
present_value: P,
future_value: F,
) -> FinanceResult<f64>
where
C: Into<f64> + Copy,
P: Into<f64> + Copy,
F: Into<f64> + Copy,
{
Ok(nper_due_solution(periodic_rate, payment, present_value, future_value)?.periods)
}
pub fn nper_due_solution<C, P, F>(
periodic_rate: f64,
payment: C,
present_value: P,
future_value: F,
) -> FinanceResult<NperSolution>
where
C: Into<f64> + Copy,
P: Into<f64> + Copy,
F: Into<f64> + Copy,
{
nper_solution_internal(
periodic_rate,
payment.into(),
present_value.into(),
future_value.into(),
true,
)
}
fn nper_solution_internal(
periodic_rate: f64,
payment: f64,
present_value: f64,
future_value: f64,
due_at_beginning: bool,
) -> FinanceResult<NperSolution> {
require_rate(periodic_rate)?;
require_finite("payment", payment)?;
require_finite("present_value", present_value)?;
require_finite("future_value", future_value)?;
if present_value < 0.0 || future_value < 0.0 {
return Err(FinanceError::InvalidCashflow {
message: "nper expects present_value and future_value >= 0 (Excel-style)",
});
}
if present_value + future_value <= 0.0 {
return Err(FinanceError::InvalidCashflow {
message: "either present_value and/or future_value must be greater than 0",
});
}
if payment >= 0.0 {
return Err(FinanceError::InvalidCashflow {
message: "payment must be negative (Excel / Google Sheets convention)",
});
}
if periodic_rate == -1.0 {
return Err(FinanceError::InvalidRate {
rate: periodic_rate,
});
}
let (num_periods, formula) = if periodic_rate == 0.0 {
let n = -(present_value + future_value) / payment;
if !n.is_finite() || n < 0.0 {
return Err(FinanceError::Unsolvable {
message: "nper with zero rate produced a non-finite or negative result",
});
}
let formula = format!("-({} + {}) / {}", present_value, future_value, payment);
(n, formula)
} else {
let (numer, denom_inner) = if due_at_beginning {
let pmt_adj = payment * (1.0 + periodic_rate);
(
pmt_adj - future_value * periodic_rate,
pmt_adj + present_value * periodic_rate,
)
} else {
(
payment - future_value * periodic_rate,
payment + present_value * periodic_rate,
)
};
if denom_inner == 0.0 || numer / denom_inner <= 0.0 {
return Err(FinanceError::Unsolvable {
message: "nper arguments do not admit a real solution (check signs and magnitudes)",
});
}
let ratio = numer / denom_inner;
let n = ratio.ln() / (1.0 + periodic_rate).ln();
if !n.is_finite() || n < 0.0 {
return Err(FinanceError::Unsolvable {
message: "nper produced a non-finite or negative period count",
});
}
let formula = if due_at_beginning {
format!(
"ln(({}*(1+{}) - {}*{}) / ({}*(1+{}) + {}*{})) / ln(1 + {})",
payment,
periodic_rate,
future_value,
periodic_rate,
payment,
periodic_rate,
present_value,
periodic_rate,
periodic_rate
)
} else {
format!(
"ln(({} - {}*{}) / ({} + {}*{})) / ln(1 + {})",
payment,
future_value,
periodic_rate,
payment,
present_value,
periodic_rate,
periodic_rate
)
};
(n, formula)
};
Ok(NperSolution::new(
periodic_rate,
num_periods,
payment,
present_value,
future_value,
due_at_beginning,
formula,
))
}
#[derive(Debug, Clone)]
pub struct NperSolution {
pub periodic_rate: f64,
pub periods: f64,
pub payment: f64,
pub present_value_total: f64,
pub future_value_total: f64,
pub due_at_beginning: bool,
pub formula: String,
}
impl NperSolution {
pub fn new(
periodic_rate: f64,
periods: f64,
payment: f64,
present_value_total: f64,
future_value_total: f64,
due_at_beginning: bool,
formula: String,
) -> Self {
Self {
periodic_rate,
periods,
payment,
present_value_total,
future_value_total,
due_at_beginning,
formula,
}
}
pub fn periods(&self) -> f64 {
self.periods
}
pub fn formula(&self) -> &str {
&self.formula
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{assert_approx_equal, round_6};
#[test]
fn test_nper_excel_values() {
assert_eq!(
round_6(27.7879559),
round_6(nper(0.034, -500, 1000, 20_000).unwrap())
);
assert_eq!(
round_6(59.76100743),
round_6(nper(0.034, -50, 1000, 2_000).unwrap())
);
assert_eq!(
round_6(25.68169193),
round_6(nper(0.034, -50, 0, 2_000).unwrap())
);
assert_eq!(
round_6(80.18661533),
round_6(nper(0.034, -5, 0, 2_000).unwrap())
);
assert_eq!(
round_6(106.3368288),
round_6(nper(0.034, -200, 0, 200_000).unwrap())
);
}
#[test]
fn test_nper_zero_rate() {
assert_approx_equal!(nper(0.0, -100.0, 0.0, 1000.0).unwrap(), 10.0);
}
#[test]
fn test_nper_due_less_or_equal_end() {
let end = nper(0.05, -100.0, 0.0, 1000.0).unwrap();
let due = nper_due(0.05, -100.0, 0.0, 1000.0).unwrap();
assert!(due <= end);
}
#[test]
fn test_nper_rejects_positive_payment() {
assert!(nper(0.05, 100.0, 0.0, 1000.0).is_err());
}
#[test]
fn test_nper_err_rate_inf() {
assert!(nper(1_f64 / 0_f64, -500, 1000, 20_000).is_err());
}
#[test]
fn test_nper_err_positive_payment() {
assert!(nper(0.034, 500, 1000, 20_000).is_err());
}
#[test]
fn test_nper_err_zero_payment() {
assert!(nper(0.034, 0, 1000, 20_000).is_err());
}
}