use std::ops::Deref;
use crate::util::error::{require_finite, require_rate_gt_minus_one, FinanceError, FinanceResult};
use crate::{columns_with_strings, print_table_locale_opt};
#[derive(Clone, Debug)]
pub struct AmortizationSolution {
rate: f64,
periods: u32,
present_value: f64,
future_value: f64,
due_at_beginning: bool,
payment: f64,
sum_of_payments: f64,
sum_of_interest: f64,
formula: String,
symbolic_formula: String,
}
impl AmortizationSolution {
pub(crate) fn new(
rate: f64,
periods: u32,
present_value: f64,
future_value: f64,
due_at_beginning: bool,
payment: f64,
formula: String,
symbolic_formula: String,
) -> Self {
let sum_of_payments = payment * periods as f64;
let sum_of_interest = sum_of_payments + present_value + future_value;
Self {
rate,
periods,
present_value,
future_value,
due_at_beginning,
payment,
sum_of_payments,
sum_of_interest,
formula,
symbolic_formula,
}
}
pub fn rate(&self) -> f64 {
self.rate
}
pub fn periods(&self) -> u32 {
self.periods
}
pub fn present_value(&self) -> f64 {
self.present_value
}
pub fn future_value(&self) -> f64 {
self.future_value
}
pub fn due_at_beginning(&self) -> bool {
self.due_at_beginning
}
pub fn payment(&self) -> f64 {
self.payment
}
pub fn sum_of_payments(&self) -> f64 {
self.sum_of_payments
}
pub fn sum_of_interest(&self) -> f64 {
self.sum_of_interest
}
pub fn formula(&self) -> &str {
&self.formula
}
pub fn symbolic_formula(&self) -> &str {
&self.symbolic_formula
}
pub fn ipmt(&self, period: u32) -> FinanceResult<f64> {
self.period_at(period).map(|p| p.interest())
}
pub fn ppmt(&self, period: u32) -> FinanceResult<f64> {
self.period_at(period).map(|p| p.principal())
}
pub fn cumipmt(&self, start_period: u32, end_period: u32) -> FinanceResult<f64> {
validate_range(start_period, end_period, self.periods)?;
let series = self.series();
let mut total = 0.0;
for p in start_period..=end_period {
total += series[(p - 1) as usize].interest();
}
Ok(total)
}
pub fn cumprinc(&self, start_period: u32, end_period: u32) -> FinanceResult<f64> {
validate_range(start_period, end_period, self.periods)?;
let series = self.series();
let mut total = 0.0;
for p in start_period..=end_period {
total += series[(p - 1) as usize].principal();
}
Ok(total)
}
fn period_at(&self, period: u32) -> FinanceResult<AmortizationPeriod> {
if period == 0 || period > self.periods {
return Err(FinanceError::InvalidPeriod {
period,
periods: self.periods,
message: "period must be in 1..=periods",
});
}
Ok(self.series()[(period - 1) as usize].clone())
}
pub fn series(&self) -> AmortizationSeries {
build_series(
self.rate,
self.periods,
self.present_value,
self.future_value,
self.due_at_beginning,
self.payment,
self.sum_of_payments,
self.sum_of_interest,
)
}
pub fn print_table(&self) {
self.series().print_table(true, true);
}
pub fn print_table_locale(&self, locale: &num_format::Locale, precision: usize) {
self.series()
.print_table_locale(true, true, locale, precision);
}
}
#[derive(Clone, Debug)]
pub struct AmortizationSeries(Vec<AmortizationPeriod>);
impl AmortizationSeries {
pub(crate) fn new(rows: Vec<AmortizationPeriod>) -> Self {
Self(rows)
}
pub fn filter<P>(&self, predicate: P) -> Self
where
P: Fn(&&AmortizationPeriod) -> bool,
{
Self(self.iter().filter(|x| predicate(x)).cloned().collect())
}
pub fn print_table(&self, include_running_totals: bool, include_remaining_amounts: bool) {
self.print_table_locale_opt(
include_running_totals,
include_remaining_amounts,
None,
None,
);
}
pub fn print_table_locale(
&self,
include_running_totals: bool,
include_remaining_amounts: bool,
locale: &num_format::Locale,
precision: usize,
) {
self.print_table_locale_opt(
include_running_totals,
include_remaining_amounts,
Some(locale),
Some(precision),
);
}
fn print_table_locale_opt(
&self,
include_running_totals: bool,
include_remaining_amounts: bool,
locale: Option<&num_format::Locale>,
precision: Option<usize>,
) {
let columns = columns_with_strings(&[
("period", "i", true),
("payment", "f", true),
("principal", "f", true),
("interest", "f", true),
("balance", "f", true),
("principal_to_date", "f", include_running_totals),
("interest_to_date", "f", include_running_totals),
("payments_to_date", "f", include_running_totals),
("principal_remaining", "f", include_remaining_amounts),
("interest_remaining", "f", include_remaining_amounts),
("payments_remaining", "f", include_remaining_amounts),
]);
let data = self
.iter()
.map(|e| {
vec![
e.period.to_string(),
e.payment.to_string(),
e.principal.to_string(),
e.interest.to_string(),
e.balance.to_string(),
e.principal_to_date.to_string(),
e.interest_to_date.to_string(),
e.payments_to_date.to_string(),
e.principal_remaining.to_string(),
e.interest_remaining.to_string(),
e.payments_remaining.to_string(),
]
})
.collect::<Vec<_>>();
print_table_locale_opt(&columns, data, locale, precision);
}
}
impl Deref for AmortizationSeries {
type Target = Vec<AmortizationPeriod>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[derive(Clone, Debug)]
pub struct AmortizationPeriod {
period: u32,
rate: f64,
payment: f64,
principal: f64,
interest: f64,
principal_to_date: f64,
interest_to_date: f64,
principal_remaining: f64,
interest_remaining: f64,
payments_to_date: f64,
payments_remaining: f64,
balance: f64,
formula: String,
symbolic_formula: String,
}
impl AmortizationPeriod {
pub fn period(&self) -> u32 {
self.period
}
pub fn rate(&self) -> f64 {
self.rate
}
pub fn payment(&self) -> f64 {
self.payment
}
pub fn principal(&self) -> f64 {
self.principal
}
pub fn interest(&self) -> f64 {
self.interest
}
pub fn principal_to_date(&self) -> f64 {
self.principal_to_date
}
pub fn interest_to_date(&self) -> f64 {
self.interest_to_date
}
pub fn principal_remaining(&self) -> f64 {
self.principal_remaining
}
pub fn interest_remaining(&self) -> f64 {
self.interest_remaining
}
pub fn payments_to_date(&self) -> f64 {
self.payments_to_date
}
pub fn payments_remaining(&self) -> f64 {
self.payments_remaining
}
pub fn balance(&self) -> f64 {
self.balance
}
pub fn formula(&self) -> &str {
&self.formula
}
pub fn symbolic_formula(&self) -> &str {
&self.symbolic_formula
}
}
pub fn amortization_solution<P, F, T>(
rate: f64,
periods: u32,
present_value: P,
future_value: F,
timing: T,
) -> FinanceResult<AmortizationSolution>
where
P: Into<f64> + Copy,
F: Into<f64> + Copy,
T: Into<crate::PaymentTiming>,
{
let present_value = present_value.into();
let future_value = future_value.into();
let due_at_beginning = timing.into().is_beginning();
require_rate_gt_minus_one(rate)?;
require_finite("present_value", present_value)?;
require_finite("future_value", future_value)?;
if periods == 0 {
return Err(FinanceError::InvalidPeriod {
period: 0,
periods,
message: "periods must be greater than zero for an amortization schedule",
});
}
let pmt = crate::payment(rate, periods, present_value, future_value, due_at_beginning)?;
let rate_mult = 1.0 + rate;
let (formula, symbolic_formula) = if rate == 0.0 {
(
format!(
"{:.4} = -({:.4} + {:.4}) / {}",
pmt, present_value, future_value, periods
),
"pmt = -(pv + fv) / n".to_string(),
)
} else if due_at_beginning {
(
format!(
"{:.4} = ((({:.4} * {:.6}^{}) + {:.4}) * {:.6}) / (({:.6}^{} - 1) * {:.6})",
pmt,
present_value,
rate_mult,
periods,
future_value,
-rate,
rate_mult,
periods,
rate_mult
),
"pmt = (((pv * (1+r)^n) + fv) * -r) / (((1+r)^n - 1) * (1+r))".to_string(),
)
} else {
(
format!(
"{:.4} = ((({:.4} * {:.6}^{}) + {:.4}) * {:.6}) / ({:.6}^{} - 1)",
pmt, present_value, rate_mult, periods, future_value, -rate, rate_mult, periods
),
"pmt = (((pv * (1+r)^n) + fv) * -r) / ((1+r)^n - 1)".to_string(),
)
};
Ok(AmortizationSolution::new(
rate,
periods,
present_value,
future_value,
due_at_beginning,
pmt,
formula,
symbolic_formula,
))
}
pub fn ipmt<P, F, T>(
rate: f64,
period: u32,
periods: u32,
present_value: P,
future_value: F,
timing: T,
) -> FinanceResult<f64>
where
P: Into<f64> + Copy,
F: Into<f64> + Copy,
T: Into<crate::PaymentTiming>,
{
amortization_solution(rate, periods, present_value, future_value, timing)?.ipmt(period)
}
pub fn ppmt<P, F, T>(
rate: f64,
period: u32,
periods: u32,
present_value: P,
future_value: F,
timing: T,
) -> FinanceResult<f64>
where
P: Into<f64> + Copy,
F: Into<f64> + Copy,
T: Into<crate::PaymentTiming>,
{
amortization_solution(rate, periods, present_value, future_value, timing)?.ppmt(period)
}
pub fn cumprinc<P, F, T>(
rate: f64,
periods: u32,
present_value: P,
future_value: F,
start_period: u32,
end_period: u32,
timing: T,
) -> FinanceResult<f64>
where
P: Into<f64> + Copy,
F: Into<f64> + Copy,
T: Into<crate::PaymentTiming>,
{
amortization_solution(rate, periods, present_value, future_value, timing)?
.cumprinc(start_period, end_period)
}
pub fn cumipmt<P, F, T>(
rate: f64,
periods: u32,
present_value: P,
future_value: F,
start_period: u32,
end_period: u32,
timing: T,
) -> FinanceResult<f64>
where
P: Into<f64> + Copy,
F: Into<f64> + Copy,
T: Into<crate::PaymentTiming>,
{
amortization_solution(rate, periods, present_value, future_value, timing)?
.cumipmt(start_period, end_period)
}
fn validate_range(start_period: u32, end_period: u32, periods: u32) -> FinanceResult<()> {
if periods == 0 {
return Err(FinanceError::InvalidPeriod {
period: 0,
periods,
message: "periods must be greater than zero",
});
}
if start_period == 0 || start_period > periods {
return Err(FinanceError::InvalidPeriod {
period: start_period,
periods,
message: "start_period must be in 1..=periods",
});
}
if end_period == 0 || end_period > periods {
return Err(FinanceError::InvalidPeriod {
period: end_period,
periods,
message: "end_period must be in 1..=periods",
});
}
if start_period > end_period {
return Err(FinanceError::InvalidPeriod {
period: start_period,
periods,
message: "start_period must be <= end_period",
});
}
Ok(())
}
fn build_series(
rate: f64,
periods: u32,
present_value: f64,
_future_value: f64,
due_at_beginning: bool,
pmt: f64,
sum_of_payments: f64,
sum_of_interest: f64,
) -> AmortizationSeries {
let mut rows = Vec::with_capacity(periods as usize);
let mut balance = present_value;
let mut payments_to_date = 0.0;
let mut principal_to_date = 0.0;
let mut interest_to_date = 0.0;
for period in 1..=periods {
let balance_at_start = balance;
let (interest, principal, formula, symbolic_formula) = if due_at_beginning && period == 1 {
(0.0, pmt, "0.0000".to_string(), "interest = 0".to_string())
} else {
let interest = -balance_at_start * rate;
let principal = pmt - interest;
let formula = format!(
"interest {:.4} = -({:.4} * {:.6}); principal {:.4} = pmt {:.4} - interest",
interest, balance_at_start, rate, principal, pmt
);
let symbolic =
"interest = -(balance_start * r); principal = pmt - interest".to_string();
(interest, principal, formula, symbolic)
};
balance += principal;
payments_to_date += pmt;
principal_to_date += principal;
interest_to_date += interest;
rows.push(AmortizationPeriod {
period,
rate,
payment: pmt,
principal,
interest,
principal_to_date,
interest_to_date,
principal_remaining: -(present_value + principal_to_date),
interest_remaining: sum_of_interest - interest_to_date,
payments_to_date,
payments_remaining: sum_of_payments - payments_to_date,
balance,
formula,
symbolic_formula,
});
}
AmortizationSeries::new(rows)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::*;
#[test]
fn test_ppmt_ipmt_sum_to_payment() {
let rate = 0.08 / 12.0;
let periods = 60;
let pv = 13_000.0;
let pmt = payment(rate, periods, pv, 0.0, false).unwrap();
for period in 1..=periods {
let princ = ppmt(rate, period, periods, pv, 0.0, false).unwrap();
let int = ipmt(rate, period, periods, pv, 0.0, false).unwrap();
assert_approx_equal!(princ + int, pmt);
}
}
#[test]
fn test_cumprinc_full_term_near_principal() {
let rate = 0.08 / 12.0;
let periods = 60;
let pv = 13_000.0;
let total_principal = cumprinc(rate, periods, pv, 0.0, 1, periods, false).unwrap();
assert_approx_equal!(total_principal, -pv);
}
#[test]
fn test_cumipmt_matches_sum_of_interest() {
let rate = 0.08 / 12.0;
let periods = 24;
let pv = 10_000.0;
let solution = amortization_solution(rate, periods, pv, 0.0, false).unwrap();
let cum_int = cumipmt(rate, periods, pv, 0.0, 1, periods, false).unwrap();
assert_approx_equal!(cum_int, solution.sum_of_interest());
}
#[test]
fn test_first_period_interest() {
assert_rounded_2!(ipmt(0.01, 1, 12, 10_000.0, 0.0, false).unwrap(), -100.0);
}
#[test]
fn test_ipmt_bad_period() {
assert!(matches!(
ipmt(0.01, 0, 12, 1000.0, 0.0, false),
Err(FinanceError::InvalidPeriod { .. })
));
assert!(matches!(
ipmt(0.01, 13, 12, 1000.0, 0.0, false),
Err(FinanceError::InvalidPeriod { .. })
));
}
#[test]
fn test_solution_series_len_and_formulas() {
let s = amortization_solution(0.01, 6, 1000.0, 0.0, false).unwrap();
let series = s.series();
assert_eq!(series.len(), 6);
assert!(!s.formula().is_empty());
assert!(!s.symbolic_formula().is_empty());
assert!(!series[0].formula().is_empty());
assert_approx_equal!(s.ipmt(1).unwrap(), series[0].interest());
assert_approx_equal!(s.ppmt(3).unwrap(), series[2].principal());
}
#[test]
fn test_amortization_invalid_rate() {
assert!(matches!(
amortization_solution(-1.0, 12, 1000.0, 0.0, false),
Err(FinanceError::InvalidRate { .. })
));
}
#[test]
fn test_amortization_zero_periods() {
assert!(matches!(
amortization_solution(0.01, 0, 1000.0, 0.0, false),
Err(FinanceError::InvalidPeriod { .. })
));
}
#[test]
fn test_cumipmt_invalid_range() {
let s = amortization_solution(0.01, 12, 1000.0, 0.0, false).unwrap();
assert!(s.cumipmt(5, 3).is_err());
assert!(s.cumipmt(0, 5).is_err());
assert!(s.cumipmt(1, 13).is_err());
}
#[test]
fn test_payment_timing_enum_matches_bool() {
let a = amortization_solution(0.01, 12, 5_000.0, 0.0, false).unwrap();
let b = amortization_solution(0.01, 12, 5_000.0, 0.0, PaymentTiming::EndOfPeriod).unwrap();
assert_approx_equal!(a.payment(), b.payment());
let due = amortization_solution(0.01, 12, 5_000.0, 0.0, true).unwrap();
assert!(due.payment().abs() < a.payment().abs());
}
}