1530 lines
58 KiB
Zig
1530 lines
58 KiB
Zig
//! Financial calculations for Tally.
|
|
//!
|
|
//! CAGR, compound interest, present value, and a five-variable TVM solver.
|
|
//!
|
|
//! ## Why this module is f64 rather than exact
|
|
//!
|
|
//! Every formula here needs a non-integer power or a logarithm: CAGR raises to
|
|
//! `1/n`, the period solver takes `ln`, and the rate solver iterates. Those
|
|
//! escape the rationals by definition (see design.md 2.7.4), so results are
|
|
//! inexact and there is nothing for the exact tier to preserve.
|
|
//!
|
|
//! What money actually needs is not exactness but *controlled rounding*, which
|
|
//! is a separate concern handled by `roundToScale` at the point a figure is
|
|
//! committed or displayed.
|
|
//!
|
|
//! ## Sign convention
|
|
//!
|
|
//! TVM uses the cash-flow convention shared by financial calculators: money
|
|
//! received is positive, money paid out is negative. A borrower taking a
|
|
//! 200,000 loan has `pv = 200000` and a negative `pmt`. The five variables
|
|
//! satisfy
|
|
//!
|
|
//! pv * (1+r)^n + pmt * annuityFactor(r, n) + fv = 0
|
|
//!
|
|
//! Getting this backwards is the most common source of sign confusion, so the
|
|
//! solver never silently flips signs for the caller.
|
|
|
|
const std = @import("std");
|
|
const math = std.math;
|
|
const grouping = @import("grouping.zig");
|
|
/// What the financial calculations can fail with.
|
|
///
|
|
/// `InsufficientParameters` and `ConvergenceFailure` are theirs alone: no other part
|
|
/// of the engine can produce either, and under the old single error set every
|
|
/// function in the engine claimed both.
|
|
pub const Error = error{
|
|
InsufficientParameters,
|
|
ConvergenceFailure,
|
|
DomainError,
|
|
DivisionByZero,
|
|
OutOfMemory,
|
|
};
|
|
|
|
/// Iteration cap for the rate solver.
|
|
pub const max_iterations: usize = 1000;
|
|
/// Convergence tolerance for the rate solver, on the residual of the TVM
|
|
/// equation.
|
|
pub const tolerance: f64 = 1e-10;
|
|
|
|
// -- Compound growth --
|
|
|
|
/// Compound annual growth rate, as a decimal fraction (0.2011 means 20.11%).
|
|
///
|
|
/// cagr = (end / start)^(1/periods) - 1
|
|
pub fn cagr(start_value: f64, end_value: f64, periods: f64) Error!f64 {
|
|
if (periods <= 0) return Error.DomainError;
|
|
// A zero or negative starting value has no meaningful growth rate, and a
|
|
// negative ending value would need a complex root.
|
|
if (start_value <= 0 or end_value < 0) return Error.DomainError;
|
|
return math.pow(f64, end_value / start_value, 1.0 / periods) - 1.0;
|
|
}
|
|
|
|
/// Future value under compound interest.
|
|
///
|
|
/// fv = pv * (1 + rate/m)^(m * years)
|
|
///
|
|
/// `annual_rate` is a percentage (5 means 5%). `compounds_per_year` is the
|
|
/// compounding frequency; use 1 for annual, 12 for monthly.
|
|
pub fn compoundFutureValue(
|
|
present_value: f64,
|
|
annual_rate: f64,
|
|
years: f64,
|
|
compounds_per_year: f64,
|
|
) Error!f64 {
|
|
if (compounds_per_year <= 0) return Error.DomainError;
|
|
if (years < 0) return Error.DomainError;
|
|
const periodic = annual_rate / 100.0 / compounds_per_year;
|
|
if (periodic <= -1.0) return Error.DomainError;
|
|
return present_value * math.pow(f64, 1.0 + periodic, compounds_per_year * years);
|
|
}
|
|
|
|
/// Present value of a future amount under compound interest: the inverse of
|
|
/// `compoundFutureValue`.
|
|
pub fn compoundPresentValue(
|
|
future_value: f64,
|
|
annual_rate: f64,
|
|
years: f64,
|
|
compounds_per_year: f64,
|
|
) Error!f64 {
|
|
if (compounds_per_year <= 0) return Error.DomainError;
|
|
if (years < 0) return Error.DomainError;
|
|
const periodic = annual_rate / 100.0 / compounds_per_year;
|
|
if (periodic <= -1.0) return Error.DomainError;
|
|
const factor = math.pow(f64, 1.0 + periodic, compounds_per_year * years);
|
|
if (factor == 0) return Error.DivisionByZero;
|
|
return future_value / factor;
|
|
}
|
|
|
|
/// The NOMINAL annual rate, as a percentage, that grows `present_value` into
|
|
/// `future_value` over `years` with `compounds_per_year` compounding periods.
|
|
///
|
|
/// rate = m * ((fv/pv)^(1/(m*t)) - 1) * 100
|
|
///
|
|
/// Nominal, not effective: at m = 12 this is the APR a lender would quote, which
|
|
/// is lower than what the money actually earns. Use `effectiveAnnualRate` to
|
|
/// convert. The distinction only bites when solving, because everywhere else the
|
|
/// rate is an input the caller already understands.
|
|
///
|
|
/// A negative result is a legitimate answer (the value shrank), so it is returned
|
|
/// rather than rejected.
|
|
pub fn compoundRate(
|
|
present_value: f64,
|
|
future_value: f64,
|
|
years: f64,
|
|
compounds_per_year: f64,
|
|
) Error!f64 {
|
|
if (compounds_per_year <= 0) return Error.DomainError;
|
|
// With no time elapsed, any rate satisfies pv == fv and none satisfies
|
|
// pv != fv, so there is no answer to give.
|
|
if (years <= 0) return Error.DomainError;
|
|
if (present_value == 0) return Error.DomainError;
|
|
|
|
const ratio = future_value / present_value;
|
|
// A sign change has no real root: no rate turns 1000 into -500.
|
|
if (!(ratio > 0)) return Error.DomainError;
|
|
|
|
const periods = compounds_per_year * years;
|
|
const periodic = math.pow(f64, ratio, 1.0 / periods) - 1.0;
|
|
const rate = periodic * compounds_per_year * 100.0;
|
|
if (!math.isFinite(rate)) return Error.DomainError;
|
|
return rate;
|
|
}
|
|
|
|
/// Years needed to grow `present_value` into `future_value` at a nominal annual
|
|
/// rate.
|
|
///
|
|
/// years = ln(fv/pv) / (m * ln(1 + rate/100/m))
|
|
pub fn compoundPeriods(
|
|
present_value: f64,
|
|
future_value: f64,
|
|
annual_rate: f64,
|
|
compounds_per_year: f64,
|
|
) Error!f64 {
|
|
if (compounds_per_year <= 0) return Error.DomainError;
|
|
if (present_value == 0) return Error.DomainError;
|
|
|
|
const ratio = future_value / present_value;
|
|
if (!(ratio > 0)) return Error.DomainError;
|
|
// Already there, whatever the rate.
|
|
if (ratio == 1) return 0;
|
|
|
|
const periodic = annual_rate / 100.0 / compounds_per_year;
|
|
if (periodic <= -1.0) return Error.DomainError;
|
|
// A zero rate never moves the balance, so no amount of time reaches a
|
|
// different future value.
|
|
if (periodic == 0) return Error.DomainError;
|
|
|
|
const years = @log(ratio) / @log(1.0 + periodic) / compounds_per_year;
|
|
if (!math.isFinite(years)) return Error.DomainError;
|
|
return years;
|
|
}
|
|
|
|
/// Effective annual rate (APY) for a nominal rate compounded `compounds_per_year`
|
|
/// times a year, both as percentages.
|
|
///
|
|
/// effective = ((1 + nominal/100/m)^m - 1) * 100
|
|
///
|
|
/// 18% compounded monthly is 19.56% effective. Reporting a solved nominal rate
|
|
/// without this is how rate comparisons go wrong.
|
|
pub fn effectiveAnnualRate(annual_rate: f64, compounds_per_year: f64) Error!f64 {
|
|
if (compounds_per_year <= 0) return Error.DomainError;
|
|
const periodic = annual_rate / 100.0 / compounds_per_year;
|
|
if (periodic <= -1.0) return Error.DomainError;
|
|
const grown = math.pow(f64, 1.0 + periodic, compounds_per_year);
|
|
if (!math.isFinite(grown)) return Error.DomainError;
|
|
return (grown - 1.0) * 100.0;
|
|
}
|
|
|
|
// -- Time value of money --
|
|
|
|
pub const TvmVariable = enum {
|
|
periods,
|
|
rate,
|
|
present_value,
|
|
payment,
|
|
future_value,
|
|
|
|
pub fn label(self: TvmVariable) []const u8 {
|
|
return switch (self) {
|
|
.periods => "N",
|
|
.rate => "I/Y",
|
|
.present_value => "PV",
|
|
.payment => "PMT",
|
|
.future_value => "FV",
|
|
};
|
|
}
|
|
};
|
|
|
|
/// The five TVM variables. Exactly one must be null: that is the one solved for.
|
|
pub const TvmParams = struct {
|
|
/// Number of periods.
|
|
periods: ?f64 = null,
|
|
/// Interest rate per period, as a percentage (0.5 means 0.5% per period).
|
|
rate: ?f64 = null,
|
|
present_value: ?f64 = null,
|
|
payment: ?f64 = null,
|
|
future_value: ?f64 = null,
|
|
/// True when payments occur at the START of each period (annuity due, "BGN"
|
|
/// on a financial calculator). Default is end of period (ordinary annuity).
|
|
due: bool = false,
|
|
};
|
|
|
|
pub const TvmSolution = struct {
|
|
variable: TvmVariable,
|
|
value: f64,
|
|
/// Iterations used by the rate solver; null for the closed-form cases.
|
|
iterations: ?usize = null,
|
|
};
|
|
|
|
/// (1+r)^n, the growth factor over `n` periods.
|
|
fn growth(rate: f64, periods: f64) f64 {
|
|
return math.pow(f64, 1.0 + rate, periods);
|
|
}
|
|
|
|
/// The annuity factor multiplying PMT.
|
|
///
|
|
/// At rate zero the usual `((1+r)^n - 1) / r` is 0/0; its limit is simply `n`,
|
|
/// which is also the intuitive answer (n equal payments, no interest).
|
|
fn annuityFactor(rate: f64, periods: f64, due: bool) f64 {
|
|
if (rate == 0) return periods;
|
|
const base = (growth(rate, periods) - 1.0) / rate;
|
|
return if (due) base * (1.0 + rate) else base;
|
|
}
|
|
|
|
/// Residual of the TVM equation. Zero when the five variables are consistent.
|
|
fn tvmResidual(rate: f64, periods: f64, pv: f64, pmt: f64, fv: f64, due: bool) f64 {
|
|
return pv * growth(rate, periods) + pmt * annuityFactor(rate, periods, due) + fv;
|
|
}
|
|
|
|
/// Solve for whichever variable is null.
|
|
pub fn solveTvm(params: TvmParams) Error!TvmSolution {
|
|
// Exactly one unknown.
|
|
var unknowns: usize = 0;
|
|
var which: TvmVariable = .future_value;
|
|
if (params.periods == null) {
|
|
unknowns += 1;
|
|
which = .periods;
|
|
}
|
|
if (params.rate == null) {
|
|
unknowns += 1;
|
|
which = .rate;
|
|
}
|
|
if (params.present_value == null) {
|
|
unknowns += 1;
|
|
which = .present_value;
|
|
}
|
|
if (params.payment == null) {
|
|
unknowns += 1;
|
|
which = .payment;
|
|
}
|
|
if (params.future_value == null) {
|
|
unknowns += 1;
|
|
which = .future_value;
|
|
}
|
|
if (unknowns != 1) return Error.InsufficientParameters;
|
|
|
|
return switch (which) {
|
|
.future_value => .{ .variable = which, .value = try solveFutureValue(params) },
|
|
.present_value => .{ .variable = which, .value = try solvePresentValue(params) },
|
|
.payment => .{ .variable = which, .value = try solvePayment(params) },
|
|
.periods => .{ .variable = which, .value = try solvePeriods(params) },
|
|
.rate => try solveRate(params),
|
|
};
|
|
}
|
|
|
|
fn solveFutureValue(p: TvmParams) Error!f64 {
|
|
const r = p.rate.? / 100.0;
|
|
const n = p.periods.?;
|
|
if (r <= -1.0) return Error.DomainError;
|
|
return -(p.present_value.? * growth(r, n) + p.payment.? * annuityFactor(r, n, p.due));
|
|
}
|
|
|
|
fn solvePresentValue(p: TvmParams) Error!f64 {
|
|
const r = p.rate.? / 100.0;
|
|
const n = p.periods.?;
|
|
if (r <= -1.0) return Error.DomainError;
|
|
const g = growth(r, n);
|
|
if (g == 0) return Error.DivisionByZero;
|
|
return -(p.future_value.? + p.payment.? * annuityFactor(r, n, p.due)) / g;
|
|
}
|
|
|
|
fn solvePayment(p: TvmParams) Error!f64 {
|
|
const r = p.rate.? / 100.0;
|
|
const n = p.periods.?;
|
|
if (r <= -1.0) return Error.DomainError;
|
|
const af = annuityFactor(r, n, p.due);
|
|
if (af == 0) return Error.DivisionByZero;
|
|
return -(p.present_value.? * growth(r, n) + p.future_value.?) / af;
|
|
}
|
|
|
|
fn solvePeriods(p: TvmParams) Error!f64 {
|
|
const r = p.rate.? / 100.0;
|
|
const pv = p.present_value.?;
|
|
const pmt = p.payment.?;
|
|
const fv = p.future_value.?;
|
|
if (r <= -1.0) return Error.DomainError;
|
|
|
|
// With no interest the equation is linear: pv + pmt*n + fv = 0.
|
|
if (r == 0) {
|
|
if (pmt == 0) return Error.InsufficientParameters;
|
|
return -(pv + fv) / pmt;
|
|
}
|
|
|
|
// pv*g + pmt*d*(g-1)/r + fv = 0, with d = (1+r) for annuity due.
|
|
// Let a = pmt*d/r. Then g*(pv + a) = a - fv.
|
|
const d: f64 = if (p.due) 1.0 + r else 1.0;
|
|
const a = pmt * d / r;
|
|
const denominator = pv + a;
|
|
if (denominator == 0) return Error.DivisionByZero;
|
|
|
|
const g = (a - fv) / denominator;
|
|
// A non-positive growth factor has no real logarithm: the cash flows cannot
|
|
// reach the requested future value at this rate.
|
|
if (g <= 0) return Error.DomainError;
|
|
const base = 1.0 + r;
|
|
if (base <= 0) return Error.DomainError;
|
|
return @log(g) / @log(base);
|
|
}
|
|
|
|
/// Solve for the periodic rate with Newton's method.
|
|
///
|
|
/// There is no closed form, and the analytic derivative of the annuity-due
|
|
/// variant is unwieldy, so the derivative is taken by central difference. That
|
|
/// makes this a quasi-Newton iteration in the strict sense; convergence is
|
|
/// verified rather than assumed.
|
|
///
|
|
/// Convergence is judged on the STEP SIZE in the rate, with the residual checked
|
|
/// only relative to the magnitude of the cash flows. An absolute residual
|
|
/// tolerance does not work here: for a 200,000 mortgage the residual is scaled by
|
|
/// the principal, so floating-point noise alone exceeds any fixed epsilon and a
|
|
/// perfectly good root looks like a failure.
|
|
///
|
|
/// Several starting points are tried because the residual can be flat or have a
|
|
/// bad slope near a poor initial guess, and a single seed makes the solver fail
|
|
/// on otherwise well-posed inputs.
|
|
///
|
|
/// Two known limits, both left as they are for now:
|
|
///
|
|
/// 1. When a root exists at more than one rate, the seed order decides which one
|
|
/// is returned, with no indication that another exists. `tvm_rate(2, -1, 5, -11)`
|
|
/// has roots at 100% and 200% and reports the first the sequence reaches.
|
|
/// 2. The scale-relative tolerance covers noise proportional to the cash flows but
|
|
/// not noise proportional to `(1+r)^n`, so a large growth factor can put the
|
|
/// residual permanently above the limit and report `ConvergenceFailure` for an
|
|
/// input that does have an answer (n=360 with r near 6% and a matching payment
|
|
/// is one). Fixing that means scaling by the computed terms rather than by the
|
|
/// inputs, which changes acceptance for every case and needs its own testing.
|
|
fn solveRate(p: TvmParams) Error!TvmSolution {
|
|
const n = p.periods.?;
|
|
const pv = p.present_value.?;
|
|
const pmt = p.payment.?;
|
|
const fv = p.future_value.?;
|
|
if (n <= 0) return Error.DomainError;
|
|
|
|
// A sign change in the cash flows is necessary for a solution to exist.
|
|
if (pv == 0 and pmt == 0 and fv == 0) return Error.InsufficientParameters;
|
|
|
|
// Residuals are proportional to the size of the cash flows, so the
|
|
// acceptance threshold has to be too.
|
|
const scale = @max(@max(@abs(pv), @abs(fv)), @max(@abs(pmt) * n, 1.0));
|
|
const residual_limit = tolerance * scale;
|
|
|
|
const seeds = [_]f64{ 0.05, 0.01, 0.1, 0.005, 0.25, -0.05, 0.5 };
|
|
var total_iterations: usize = 0;
|
|
|
|
for (seeds) |seed| {
|
|
var r = seed;
|
|
var i: usize = 0;
|
|
while (i < max_iterations) : (i += 1) {
|
|
total_iterations += 1;
|
|
const f = tvmResidual(r, n, pv, pmt, fv, p.due);
|
|
if (@abs(f) <= residual_limit) {
|
|
return .{ .variable = .rate, .value = r * 100.0, .iterations = total_iterations };
|
|
}
|
|
|
|
// Central difference, scaled to the magnitude of r so the step stays
|
|
// meaningful for both tiny and large rates.
|
|
const h = @max(1e-9, @abs(r) * 1e-6);
|
|
const f_hi = tvmResidual(r + h, n, pv, pmt, fv, p.due);
|
|
const f_lo = tvmResidual(r - h, n, pv, pmt, fv, p.due);
|
|
const slope = (f_hi - f_lo) / (2.0 * h);
|
|
if (slope == 0 or !math.isFinite(slope)) break;
|
|
|
|
var next = r - f / slope;
|
|
if (!math.isFinite(next)) break;
|
|
// Keep the iterate in the region where (1+r)^n is defined.
|
|
if (next <= -1.0) next = (r - 1.0) / 2.0;
|
|
|
|
// The step has stopped moving: this is the root to the precision the
|
|
// arithmetic allows, provided the residual is small for its scale.
|
|
if (@abs(next - r) <= 1e-14 * @max(1.0, @abs(r))) {
|
|
const residual = tvmResidual(next, n, pv, pmt, fv, p.due);
|
|
if (@abs(residual) <= residual_limit) {
|
|
return .{ .variable = .rate, .value = next * 100.0, .iterations = total_iterations };
|
|
}
|
|
break;
|
|
}
|
|
r = next;
|
|
}
|
|
}
|
|
return Error.ConvergenceFailure;
|
|
}
|
|
|
|
// -- Money rounding --
|
|
|
|
pub const RoundingMode = enum {
|
|
/// Halves go to the nearest even digit. The default for money because it does
|
|
/// not bias totals upward the way half-up does across many roundings.
|
|
///
|
|
/// It is half-even on the BINARY value, which is not always half-even on the
|
|
/// decimal one. `roundToScale` multiplies by a power of ten and rounds the
|
|
/// result, and most decimal halves are not exactly representable: 0.545
|
|
/// becomes 54.499999999999996 once scaled and rounds down, where exact decimal
|
|
/// half-even would round up. Sweeping every value `k/1000` whose last digit is
|
|
/// 5 shows 573 of 10000 going the other way, in both directions, so the
|
|
/// no-bias property this exists for still holds. Matching decimal half-even
|
|
/// exactly would mean a decimal type in the money path, which is a larger
|
|
/// change than the discrepancy warrants.
|
|
half_even,
|
|
/// Halves go away from zero. What most people mean by "round".
|
|
half_up,
|
|
};
|
|
|
|
/// Round to a fixed number of decimal places.
|
|
///
|
|
/// This is the "money boundary" from design.md 2.7.3: rather than introducing a
|
|
/// decimal numeric type, financial figures are computed in binary floating point
|
|
/// and rounded explicitly at the point they become an amount.
|
|
pub fn roundToScale(value: f64, decimals: u8, mode: RoundingMode) f64 {
|
|
if (!math.isFinite(value)) return value;
|
|
if (decimals > 17) return value;
|
|
|
|
const scale = math.pow(f64, 10.0, @floatFromInt(decimals));
|
|
const scaled = value * scale;
|
|
if (!math.isFinite(scaled)) return value;
|
|
|
|
const rounded = switch (mode) {
|
|
.half_up => @round(scaled),
|
|
.half_even => blk: {
|
|
const floor = @floor(scaled);
|
|
const diff = scaled - floor;
|
|
if (diff > 0.5) break :blk floor + 1.0;
|
|
if (diff < 0.5) break :blk floor;
|
|
// Exactly halfway: pick the even neighbour.
|
|
break :blk if (@mod(floor, 2.0) == 0.0) floor else floor + 1.0;
|
|
},
|
|
};
|
|
return rounded / scale;
|
|
}
|
|
|
|
/// Round to whole cents, the common case.
|
|
pub fn roundToCents(value: f64) f64 {
|
|
return roundToScale(value, 2, .half_even);
|
|
}
|
|
|
|
// -- Money display --
|
|
//
|
|
// Two decimal places and a grouped integer part is a property of money, not of a
|
|
// screen, so unlike `Number.FormatOptions` there is no budget to pass: cents are
|
|
// cents on every frontend. This used to live in `formatter.zig`, and before that
|
|
// character for character in `src/main.zig` and `src/tui/financial.zig`, both of
|
|
// which also reimplemented the thousands grouping.
|
|
|
|
/// Widest fixed-point rendering an f64 has (about 310 integer digits), plus
|
|
/// separators and cents.
|
|
const money_text_max = 512;
|
|
|
|
/// An amount ready to print: grouped integer part, exactly two decimal places.
|
|
pub const Money = struct {
|
|
value: f64,
|
|
|
|
pub fn format(self: Money, w: *std.Io.Writer) std.Io.Writer.Error!void {
|
|
// Grouping asserts numeric text, and "inf" is not. A non-finite amount
|
|
// prints as itself rather than as a placeholder that reads like data.
|
|
if (!math.isFinite(self.value)) {
|
|
return w.print("{d}", .{self.value});
|
|
}
|
|
var plain: [money_text_max]u8 = undefined;
|
|
const text = std.fmt.bufPrint(&plain, "{d:.2}", .{self.value}) catch
|
|
return error.WriteFailed;
|
|
return grouping.print(w, text);
|
|
}
|
|
|
|
/// For a caller that must measure or pad the text, such as a table column.
|
|
/// `error.WriteFailed` when `buf` is too small: the amount is reported as a
|
|
/// failure rather than truncated or replaced with "?".
|
|
pub fn render(self: Money, buf: []u8) std.Io.Writer.Error![]const u8 {
|
|
var w = std.Io.Writer.fixed(buf);
|
|
try self.format(&w);
|
|
return w.buffered();
|
|
}
|
|
};
|
|
|
|
/// An amount at two decimal places, the money case.
|
|
pub fn money(value: f64) Money {
|
|
return .{ .value = value };
|
|
}
|
|
|
|
// -- Amortization --
|
|
|
|
/// Upper bound on schedule length. 12,000 monthly periods is a thousand years,
|
|
/// so anything past this is a typo rather than a loan, and the cap keeps a bad
|
|
/// input from asking for an enormous allocation.
|
|
pub const max_schedule_periods: usize = 12_000;
|
|
|
|
/// One row of an amortization schedule.
|
|
///
|
|
/// Unlike TVM, the figures here are all positive: a schedule is read from the
|
|
/// borrower's side, where `payment` is an amount paid and `balance` an amount
|
|
/// still owed. Mixing the TVM sign convention into a table only makes it harder
|
|
/// to read.
|
|
pub const AmortizationEntry = struct {
|
|
/// 1-based period number.
|
|
period: usize,
|
|
payment: f64,
|
|
interest: f64,
|
|
principal: f64,
|
|
/// Balance remaining AFTER this payment.
|
|
balance: f64,
|
|
};
|
|
|
|
pub const AmortizationParams = struct {
|
|
/// Loan amount, as a positive number.
|
|
principal: f64,
|
|
/// Interest rate per period, as a percentage (0.5 means 0.5% per period).
|
|
rate: f64,
|
|
/// Number of payments.
|
|
periods: usize,
|
|
/// Level payment per period, as a positive amount. When null it is derived
|
|
/// from the loan terms with the TVM solver.
|
|
payment: ?f64 = null,
|
|
/// Round every figure to whole cents, the way a lender's schedule does.
|
|
/// The last period absorbs whatever residue the rounding leaves, which is
|
|
/// why the final payment often differs by a cent or two.
|
|
round_cents: bool = true,
|
|
};
|
|
|
|
pub const AmortizationTotals = struct {
|
|
/// Periods actually generated. Less than `periods` when a payment larger
|
|
/// than the level payment retires the loan early.
|
|
periods: usize,
|
|
paid: f64,
|
|
interest: f64,
|
|
principal: f64,
|
|
};
|
|
|
|
/// The level payment implied by a loan, as a positive amount.
|
|
pub fn amortizationPayment(p: AmortizationParams) Error!f64 {
|
|
if (p.principal <= 0) return Error.DomainError;
|
|
if (p.periods == 0 or p.periods > max_schedule_periods) return Error.DomainError;
|
|
// A negative rate would mean the balance shrinks on its own, which is not
|
|
// something an amortization table describes.
|
|
if (p.rate < 0) return Error.DomainError;
|
|
|
|
if (p.payment) |given| {
|
|
if (given <= 0) return Error.DomainError;
|
|
return if (p.round_cents) roundToCents(given) else given;
|
|
}
|
|
|
|
const solution = try solveTvm(.{
|
|
.periods = @floatFromInt(p.periods),
|
|
.rate = p.rate,
|
|
.present_value = p.principal,
|
|
.future_value = 0,
|
|
});
|
|
// solveTvm returns the payment as a cash outflow; a schedule wants the
|
|
// magnitude.
|
|
const amount = -solution.value;
|
|
if (!math.isFinite(amount) or amount <= 0) return Error.DomainError;
|
|
return if (p.round_cents) roundToCents(amount) else amount;
|
|
}
|
|
|
|
/// Walks a schedule one period at a time.
|
|
///
|
|
/// Both the single-row and whole-table entry points go through this, so a row
|
|
/// fetched on its own can never disagree with the same row inside a full
|
|
/// schedule.
|
|
const AmortizationCursor = struct {
|
|
params: AmortizationParams,
|
|
payment: f64,
|
|
rate: f64,
|
|
balance: f64,
|
|
period: usize = 0,
|
|
|
|
fn init(p: AmortizationParams) Error!AmortizationCursor {
|
|
const payment = try amortizationPayment(p);
|
|
const rate = p.rate / 100.0;
|
|
const balance = if (p.round_cents) roundToCents(p.principal) else p.principal;
|
|
|
|
// A payment that does not even cover the first period's interest never
|
|
// reduces the balance: the loan grows forever, and there is no schedule
|
|
// to print.
|
|
const first_interest = balance * rate;
|
|
if (payment <= first_interest) return Error.DomainError;
|
|
|
|
return .{ .params = p, .payment = payment, .rate = rate, .balance = balance };
|
|
}
|
|
|
|
fn scale(self: AmortizationCursor, value: f64) f64 {
|
|
return if (self.params.round_cents) roundToCents(value) else value;
|
|
}
|
|
|
|
fn next(self: *AmortizationCursor) ?AmortizationEntry {
|
|
if (self.period >= self.params.periods or self.balance <= 0) return null;
|
|
self.period += 1;
|
|
|
|
const interest = self.scale(self.balance * self.rate);
|
|
var principal = self.payment - interest;
|
|
var payment = self.payment;
|
|
|
|
// The final period, or any period whose scheduled principal would
|
|
// overshoot, settles the balance exactly instead. On the last period of
|
|
// an underfunded loan this is a balloon payment rather than a rounding
|
|
// adjustment, which is the honest thing to show.
|
|
if (self.period == self.params.periods or principal >= self.balance) {
|
|
principal = self.balance;
|
|
payment = self.scale(self.balance + interest);
|
|
}
|
|
|
|
self.balance = self.scale(self.balance - principal);
|
|
return .{
|
|
.period = self.period,
|
|
.payment = payment,
|
|
.interest = interest,
|
|
.principal = principal,
|
|
.balance = self.balance,
|
|
};
|
|
}
|
|
};
|
|
|
|
/// A single period of a schedule, without building the whole table.
|
|
pub fn amortizationEntry(p: AmortizationParams, period: usize) Error!AmortizationEntry {
|
|
if (period == 0) return Error.DomainError;
|
|
var cursor = try AmortizationCursor.init(p);
|
|
while (cursor.next()) |entry| {
|
|
if (entry.period == period) return entry;
|
|
}
|
|
// The loan was retired before this period, so the period does not exist.
|
|
return Error.DomainError;
|
|
}
|
|
|
|
/// The full schedule. Caller owns the returned slice.
|
|
pub fn amortizationSchedule(
|
|
allocator: std.mem.Allocator,
|
|
p: AmortizationParams,
|
|
) Error![]AmortizationEntry {
|
|
var cursor = try AmortizationCursor.init(p);
|
|
var rows: std.ArrayList(AmortizationEntry) = .empty;
|
|
errdefer rows.deinit(allocator);
|
|
while (cursor.next()) |entry| {
|
|
try rows.append(allocator, entry);
|
|
}
|
|
return rows.toOwnedSlice(allocator);
|
|
}
|
|
|
|
/// Schedule totals, computed without allocating a table.
|
|
pub fn amortizationTotals(p: AmortizationParams) Error!AmortizationTotals {
|
|
var cursor = try AmortizationCursor.init(p);
|
|
var totals: AmortizationTotals = .{ .periods = 0, .paid = 0, .interest = 0, .principal = 0 };
|
|
while (cursor.next()) |entry| {
|
|
totals.periods = entry.period;
|
|
totals.paid += entry.payment;
|
|
totals.interest += entry.interest;
|
|
totals.principal += entry.principal;
|
|
}
|
|
if (p.round_cents) {
|
|
totals.paid = roundToCents(totals.paid);
|
|
totals.interest = roundToCents(totals.interest);
|
|
totals.principal = roundToCents(totals.principal);
|
|
}
|
|
return totals;
|
|
}
|
|
|
|
// -- Tests --
|
|
|
|
const testing = std.testing;
|
|
|
|
// Textbook and well-known reference values throughout. The mortgage figure in
|
|
// particular (200,000 at 6% over 30 years giving 1199.10 a month) is a standard
|
|
// check that any TVM implementation should reproduce.
|
|
|
|
test "cagr: textbook 10000 to 25000 over 5 years" {
|
|
const result = try cagr(10000, 25000, 5);
|
|
try testing.expectApproxEqAbs(@as(f64, 0.2011244), result, 1e-7);
|
|
}
|
|
|
|
test "cagr: doubling in 10 years" {
|
|
const result = try cagr(1000, 2000, 10);
|
|
// 2^(1/10) - 1
|
|
try testing.expectApproxEqAbs(@as(f64, 0.0717734625), result, 1e-9);
|
|
}
|
|
|
|
test "cagr: no change is zero growth" {
|
|
try testing.expectApproxEqAbs(@as(f64, 0.0), try cagr(500, 500, 3), 1e-15);
|
|
}
|
|
|
|
test "cagr: a decline is negative" {
|
|
const result = try cagr(1000, 500, 5);
|
|
try testing.expect(result < 0);
|
|
// (0.5)^(1/5) - 1
|
|
try testing.expectApproxEqAbs(@as(f64, -0.1294494), result, 1e-7);
|
|
}
|
|
|
|
test "cagr: single period is the simple return" {
|
|
try testing.expectApproxEqAbs(@as(f64, 0.25), try cagr(100, 125, 1), 1e-12);
|
|
}
|
|
|
|
test "cagr: domain errors" {
|
|
try testing.expectError(Error.DomainError, cagr(1000, 2000, 0));
|
|
try testing.expectError(Error.DomainError, cagr(1000, 2000, -5));
|
|
try testing.expectError(Error.DomainError, cagr(0, 2000, 5));
|
|
try testing.expectError(Error.DomainError, cagr(-1000, 2000, 5));
|
|
try testing.expectError(Error.DomainError, cagr(1000, -1, 5));
|
|
}
|
|
|
|
test "compound interest: annual compounding" {
|
|
// 1000 at 5% for 10 years, compounded annually
|
|
const fv = try compoundFutureValue(1000, 5, 10, 1);
|
|
try testing.expectApproxEqAbs(@as(f64, 1628.894627), fv, 1e-6);
|
|
}
|
|
|
|
test "compound interest: monthly compounding beats annual" {
|
|
const monthly = try compoundFutureValue(1000, 5, 10, 12);
|
|
const annual = try compoundFutureValue(1000, 5, 10, 1);
|
|
try testing.expectApproxEqAbs(@as(f64, 1647.009498), monthly, 1e-6);
|
|
try testing.expect(monthly > annual);
|
|
}
|
|
|
|
test "compound interest: daily compounding" {
|
|
const fv = try compoundFutureValue(1000, 5, 10, 365);
|
|
try testing.expectApproxEqAbs(@as(f64, 1648.6648), fv, 1e-4);
|
|
}
|
|
|
|
test "compound interest: zero years is the principal" {
|
|
try testing.expectApproxEqAbs(@as(f64, 1000.0), try compoundFutureValue(1000, 5, 0, 12), 1e-12);
|
|
}
|
|
|
|
test "compound interest: zero rate is the principal" {
|
|
try testing.expectApproxEqAbs(@as(f64, 1000.0), try compoundFutureValue(1000, 0, 10, 12), 1e-12);
|
|
}
|
|
|
|
test "compound interest: present value inverts future value" {
|
|
const fv = try compoundFutureValue(1000, 7, 15, 4);
|
|
const pv = try compoundPresentValue(fv, 7, 15, 4);
|
|
try testing.expectApproxEqAbs(@as(f64, 1000.0), pv, 1e-9);
|
|
}
|
|
|
|
test "compound interest: present value textbook figure" {
|
|
// What is 10000 in 5 years worth today at 8% compounded annually?
|
|
const pv = try compoundPresentValue(10000, 8, 5, 1);
|
|
try testing.expectApproxEqAbs(@as(f64, 6805.83), pv, 0.01);
|
|
}
|
|
|
|
test "compound interest: domain errors" {
|
|
try testing.expectError(Error.DomainError, compoundFutureValue(1000, 5, 10, 0));
|
|
try testing.expectError(Error.DomainError, compoundFutureValue(1000, 5, -1, 12));
|
|
try testing.expectError(Error.DomainError, compoundPresentValue(1000, 5, 10, 0));
|
|
// A rate of -100% per period wipes the base out entirely.
|
|
try testing.expectError(Error.DomainError, compoundFutureValue(1000, -1200, 10, 12));
|
|
}
|
|
|
|
test "tvm: solve payment for a classic 30-year mortgage" {
|
|
// 200,000 borrowed at 6% a year over 360 monthly periods.
|
|
const solution = try solveTvm(.{
|
|
.periods = 360,
|
|
.rate = 0.5, // 6% / 12
|
|
.present_value = 200000,
|
|
.future_value = 0,
|
|
});
|
|
try testing.expectEqual(TvmVariable.payment, solution.variable);
|
|
// Payment is negative: money leaving the borrower.
|
|
// 200000 * 0.005 / (1 - 1.005^-360)
|
|
try testing.expectApproxEqAbs(@as(f64, -1199.10105030), solution.value, 1e-6);
|
|
}
|
|
|
|
test "tvm: solve future value of a savings plan" {
|
|
// 100 deposited at the end of each period for 10 periods at 5%.
|
|
const solution = try solveTvm(.{
|
|
.periods = 10,
|
|
.rate = 5,
|
|
.present_value = 0,
|
|
.payment = -100,
|
|
});
|
|
try testing.expectEqual(TvmVariable.future_value, solution.variable);
|
|
try testing.expectApproxEqAbs(@as(f64, 1257.789254), solution.value, 1e-6);
|
|
}
|
|
|
|
test "tvm: solve present value" {
|
|
const solution = try solveTvm(.{
|
|
.periods = 10,
|
|
.rate = 7,
|
|
.payment = 0,
|
|
.future_value = 2000,
|
|
});
|
|
try testing.expectEqual(TvmVariable.present_value, solution.variable);
|
|
// 2000 discounted 10 periods at 7%: -2000 / 1.07^10
|
|
try testing.expectApproxEqAbs(@as(f64, -1016.69858427), solution.value, 1e-6);
|
|
}
|
|
|
|
test "tvm: solve periods to double at 7 percent" {
|
|
const solution = try solveTvm(.{
|
|
.rate = 7,
|
|
.present_value = -1000,
|
|
.payment = 0,
|
|
.future_value = 2000,
|
|
});
|
|
try testing.expectEqual(TvmVariable.periods, solution.variable);
|
|
// ln(2) / ln(1.07)
|
|
try testing.expectApproxEqAbs(@as(f64, 10.244768), solution.value, 1e-6);
|
|
}
|
|
|
|
test "tvm: solve rate to double in 10 periods" {
|
|
const solution = try solveTvm(.{
|
|
.periods = 10,
|
|
.present_value = -1000,
|
|
.payment = 0,
|
|
.future_value = 2000,
|
|
});
|
|
try testing.expectEqual(TvmVariable.rate, solution.variable);
|
|
// 2^(1/10) - 1, as a percentage
|
|
try testing.expectApproxEqAbs(@as(f64, 7.17734625), solution.value, 1e-6);
|
|
try testing.expect(solution.iterations != null);
|
|
}
|
|
|
|
test "tvm: solve rate for a mortgage payment" {
|
|
// Recover the 0.5% periodic rate from the payment it produces.
|
|
const solution = try solveTvm(.{
|
|
.periods = 360,
|
|
.present_value = 200000,
|
|
.payment = -1199.101083,
|
|
.future_value = 0,
|
|
});
|
|
try testing.expectApproxEqAbs(@as(f64, 0.5), solution.value, 1e-6);
|
|
}
|
|
|
|
test "tvm: solve rate with both a payment and a future value" {
|
|
const solution = try solveTvm(.{
|
|
.periods = 20,
|
|
.present_value = -5000,
|
|
.payment = -100,
|
|
.future_value = 12000,
|
|
});
|
|
// Verify by substituting back into the equation rather than hardcoding a
|
|
// figure: the residual is the definition of a correct answer.
|
|
const residual = tvmResidual(solution.value / 100.0, 20, -5000, -100, 12000, false);
|
|
try testing.expectApproxEqAbs(@as(f64, 0.0), residual, 1e-6);
|
|
}
|
|
|
|
test "tvm: every solved variable reproduces the others" {
|
|
// Round-trip: solve each variable from the other four and confirm the
|
|
// original value comes back.
|
|
const n: f64 = 120;
|
|
const rate: f64 = 0.75;
|
|
const pv: f64 = 50000;
|
|
const pmt: f64 = -600;
|
|
|
|
const fv_solution = try solveTvm(.{ .periods = n, .rate = rate, .present_value = pv, .payment = pmt });
|
|
const fv = fv_solution.value;
|
|
|
|
const back_pv = try solveTvm(.{ .periods = n, .rate = rate, .payment = pmt, .future_value = fv });
|
|
try testing.expectApproxEqAbs(pv, back_pv.value, 1e-6);
|
|
|
|
const back_pmt = try solveTvm(.{ .periods = n, .rate = rate, .present_value = pv, .future_value = fv });
|
|
try testing.expectApproxEqAbs(pmt, back_pmt.value, 1e-6);
|
|
|
|
const back_n = try solveTvm(.{ .rate = rate, .present_value = pv, .payment = pmt, .future_value = fv });
|
|
try testing.expectApproxEqAbs(n, back_n.value, 1e-6);
|
|
|
|
const back_rate = try solveTvm(.{ .periods = n, .present_value = pv, .payment = pmt, .future_value = fv });
|
|
try testing.expectApproxEqAbs(rate, back_rate.value, 1e-6);
|
|
}
|
|
|
|
test "tvm: zero rate is handled as the linear case" {
|
|
// No interest: 10 payments of 100 exactly repay 1000.
|
|
const solution = try solveTvm(.{
|
|
.periods = 10,
|
|
.rate = 0,
|
|
.present_value = 1000,
|
|
.future_value = 0,
|
|
});
|
|
try testing.expectApproxEqAbs(@as(f64, -100.0), solution.value, 1e-12);
|
|
|
|
const periods = try solveTvm(.{
|
|
.rate = 0,
|
|
.present_value = 1000,
|
|
.payment = -100,
|
|
.future_value = 0,
|
|
});
|
|
try testing.expectApproxEqAbs(@as(f64, 10.0), periods.value, 1e-12);
|
|
}
|
|
|
|
test "tvm: annuity due pays less than an ordinary annuity" {
|
|
// Paying at the start of each period means every payment earns interest for
|
|
// one extra period, so a smaller payment settles the same loan.
|
|
const ordinary = try solveTvm(.{
|
|
.periods = 360,
|
|
.rate = 0.5,
|
|
.present_value = 200000,
|
|
.future_value = 0,
|
|
.due = false,
|
|
});
|
|
const due = try solveTvm(.{
|
|
.periods = 360,
|
|
.rate = 0.5,
|
|
.present_value = 200000,
|
|
.future_value = 0,
|
|
.due = true,
|
|
});
|
|
try testing.expect(@abs(due.value) < @abs(ordinary.value));
|
|
// Exactly a factor of (1 + r) smaller.
|
|
try testing.expectApproxEqAbs(ordinary.value / 1.005, due.value, 1e-6);
|
|
}
|
|
|
|
test "tvm: annuity due round-trips too" {
|
|
const solution = try solveTvm(.{
|
|
.periods = 24,
|
|
.rate = 1,
|
|
.present_value = 10000,
|
|
.due = true,
|
|
.future_value = 0,
|
|
});
|
|
const residual = tvmResidual(0.01, 24, 10000, solution.value, 0, true);
|
|
try testing.expectApproxEqAbs(@as(f64, 0.0), residual, 1e-6);
|
|
}
|
|
|
|
test "tvm: requires exactly one unknown" {
|
|
// All five supplied.
|
|
try testing.expectError(Error.InsufficientParameters, solveTvm(.{
|
|
.periods = 10,
|
|
.rate = 5,
|
|
.present_value = 100,
|
|
.payment = -10,
|
|
.future_value = 0,
|
|
}));
|
|
// Two unknowns.
|
|
try testing.expectError(Error.InsufficientParameters, solveTvm(.{
|
|
.periods = 10,
|
|
.rate = 5,
|
|
.present_value = 100,
|
|
}));
|
|
// Nothing supplied at all.
|
|
try testing.expectError(Error.InsufficientParameters, solveTvm(.{}));
|
|
}
|
|
|
|
test "tvm: unsolvable cash flows report convergence failure, not a wrong answer" {
|
|
// All cash flows the same sign: no rate can balance the equation.
|
|
const result = solveTvm(.{
|
|
.periods = 10,
|
|
.present_value = 1000,
|
|
.payment = 100,
|
|
.future_value = 5000,
|
|
});
|
|
try testing.expectError(Error.ConvergenceFailure, result);
|
|
}
|
|
|
|
test "tvm: rate solver rejects degenerate input" {
|
|
try testing.expectError(Error.InsufficientParameters, solveTvm(.{
|
|
.periods = 10,
|
|
.present_value = 0,
|
|
.payment = 0,
|
|
.future_value = 0,
|
|
}));
|
|
try testing.expectError(Error.DomainError, solveTvm(.{
|
|
.periods = 0,
|
|
.present_value = -100,
|
|
.payment = 0,
|
|
.future_value = 200,
|
|
}));
|
|
}
|
|
|
|
test "tvm: unreachable future value has no real period count" {
|
|
// Paying nothing can never grow 1000 into 5000.
|
|
try testing.expectError(Error.DomainError, solveTvm(.{
|
|
.rate = 5,
|
|
.present_value = 1000,
|
|
.payment = 0,
|
|
.future_value = 5000,
|
|
}));
|
|
}
|
|
|
|
test "tvm: periods with zero rate and zero payment is unsolvable" {
|
|
try testing.expectError(Error.InsufficientParameters, solveTvm(.{
|
|
.rate = 0,
|
|
.present_value = 1000,
|
|
.payment = 0,
|
|
.future_value = -1000,
|
|
}));
|
|
}
|
|
|
|
test "roundToScale: half-even avoids the upward bias of half-up" {
|
|
// 0.025 scales to exactly 2.5, a true tie, and half-even takes it down.
|
|
try testing.expectEqual(@as(f64, 0.02), roundToScale(0.025, 2, .half_even));
|
|
// 0.035 is NOT a tie once scaled: 0.035*100 is 3.5000000000000004, so this
|
|
// rounds up on magnitude rather than by the tie rule. It is here as the
|
|
// companion figure people expect, not as a second demonstration of half-even.
|
|
try testing.expectEqual(@as(f64, 0.04), roundToScale(0.035, 2, .half_even));
|
|
// Half-up sends both the same direction.
|
|
try testing.expectEqual(@as(f64, 0.03), roundToScale(0.025, 2, .half_up));
|
|
try testing.expectEqual(@as(f64, 0.04), roundToScale(0.035, 2, .half_up));
|
|
}
|
|
|
|
test "roundToScale: ordinary cases" {
|
|
try testing.expectApproxEqAbs(@as(f64, 1.23), roundToScale(1.2345, 2, .half_even), 1e-12);
|
|
try testing.expectApproxEqAbs(@as(f64, 1.24), roundToScale(1.2355, 2, .half_even), 1e-12);
|
|
try testing.expectApproxEqAbs(@as(f64, 1.0), roundToScale(1.4, 0, .half_even), 1e-12);
|
|
try testing.expectApproxEqAbs(@as(f64, 2.0), roundToScale(1.6, 0, .half_even), 1e-12);
|
|
}
|
|
|
|
test "roundToScale: negatives round symmetrically for half-up" {
|
|
try testing.expectApproxEqAbs(@as(f64, -1.24), roundToScale(-1.235, 2, .half_up), 1e-12);
|
|
try testing.expectApproxEqAbs(@as(f64, -1.23), roundToScale(-1.2345, 2, .half_up), 1e-12);
|
|
}
|
|
|
|
test "roundToScale: non-finite values pass through" {
|
|
try testing.expect(math.isNan(roundToScale(math.nan(f64), 2, .half_even)));
|
|
try testing.expect(math.isPositiveInf(roundToScale(math.inf(f64), 2, .half_even)));
|
|
}
|
|
|
|
test "roundToScale: absurd scales are left alone rather than overflowing" {
|
|
const huge = 1.5e300;
|
|
try testing.expectEqual(huge, roundToScale(huge, 2, .half_even));
|
|
try testing.expectEqual(@as(f64, 1.2345), roundToScale(1.2345, 18, .half_even));
|
|
}
|
|
|
|
test "roundToCents: mortgage payment to whole cents" {
|
|
const solution = try solveTvm(.{
|
|
.periods = 360,
|
|
.rate = 0.5,
|
|
.present_value = 200000,
|
|
.future_value = 0,
|
|
});
|
|
try testing.expectApproxEqAbs(@as(f64, -1199.1), roundToCents(solution.value), 1e-12);
|
|
}
|
|
|
|
test "TvmVariable labels match calculator conventions" {
|
|
try testing.expectEqualStrings("N", TvmVariable.periods.label());
|
|
try testing.expectEqualStrings("I/Y", TvmVariable.rate.label());
|
|
try testing.expectEqualStrings("PV", TvmVariable.present_value.label());
|
|
try testing.expectEqualStrings("PMT", TvmVariable.payment.label());
|
|
try testing.expectEqualStrings("FV", TvmVariable.future_value.label());
|
|
}
|
|
|
|
test "annuityFactor: rate zero is the period count, not a division by zero" {
|
|
try testing.expectEqual(@as(f64, 10.0), annuityFactor(0, 10, false));
|
|
try testing.expectEqual(@as(f64, 10.0), annuityFactor(0, 10, true));
|
|
}
|
|
|
|
test "annuityFactor: due is an extra period of interest" {
|
|
const ordinary = annuityFactor(0.05, 10, false);
|
|
const due = annuityFactor(0.05, 10, true);
|
|
try testing.expectApproxEqAbs(ordinary * 1.05, due, 1e-12);
|
|
}
|
|
|
|
// -- Amortization tests --
|
|
|
|
test "amortization: payment matches the TVM solution rounded to cents" {
|
|
const payment = try amortizationPayment(.{ .principal = 200000, .rate = 0.5, .periods = 360 });
|
|
try testing.expectEqual(@as(f64, 1199.10), payment);
|
|
}
|
|
|
|
test "amortization: an explicit payment is used as given" {
|
|
const payment = try amortizationPayment(.{
|
|
.principal = 200000,
|
|
.rate = 0.5,
|
|
.periods = 360,
|
|
.payment = 1500,
|
|
});
|
|
try testing.expectEqual(@as(f64, 1500.0), payment);
|
|
}
|
|
|
|
test "amortization: first row of a classic 30-year mortgage" {
|
|
const entry = try amortizationEntry(.{ .principal = 200000, .rate = 0.5, .periods = 360 }, 1);
|
|
try testing.expectEqual(@as(usize, 1), entry.period);
|
|
// 200,000 * 0.5% = exactly 1000 of interest, so the rest reduces principal.
|
|
try testing.expectEqual(@as(f64, 1000.0), entry.interest);
|
|
try testing.expectEqual(@as(f64, 1199.10), entry.payment);
|
|
try testing.expectApproxEqAbs(@as(f64, 199.10), entry.principal, 1e-9);
|
|
try testing.expectApproxEqAbs(@as(f64, 199800.90), entry.balance, 1e-9);
|
|
}
|
|
|
|
test "amortization: interest falls and principal rises over the life of the loan" {
|
|
const params: AmortizationParams = .{ .principal = 200000, .rate = 0.5, .periods = 360 };
|
|
const first = try amortizationEntry(params, 1);
|
|
const middle = try amortizationEntry(params, 180);
|
|
const last = try amortizationEntry(params, 360);
|
|
|
|
try testing.expect(first.interest > middle.interest);
|
|
try testing.expect(middle.interest > last.interest);
|
|
try testing.expect(first.principal < middle.principal);
|
|
try testing.expect(middle.principal < last.principal);
|
|
}
|
|
|
|
test "amortization: the schedule ends at a zero balance" {
|
|
const rows = try amortizationSchedule(testing.allocator, .{
|
|
.principal = 200000,
|
|
.rate = 0.5,
|
|
.periods = 360,
|
|
});
|
|
defer testing.allocator.free(rows);
|
|
|
|
try testing.expectEqual(@as(usize, 360), rows.len);
|
|
try testing.expectEqual(@as(f64, 0.0), rows[rows.len - 1].balance);
|
|
}
|
|
|
|
test "amortization: principal repaid sums to the loan amount" {
|
|
const rows = try amortizationSchedule(testing.allocator, .{
|
|
.principal = 200000,
|
|
.rate = 0.5,
|
|
.periods = 360,
|
|
});
|
|
defer testing.allocator.free(rows);
|
|
|
|
var sum: f64 = 0;
|
|
for (rows) |row| sum += row.principal;
|
|
// Cent rounding is absorbed by the final payment, so this is exact to the
|
|
// cent rather than merely close.
|
|
try testing.expectApproxEqAbs(@as(f64, 200000.0), sum, 0.005);
|
|
}
|
|
|
|
test "amortization: every row is payment = interest + principal" {
|
|
const rows = try amortizationSchedule(testing.allocator, .{
|
|
.principal = 25000,
|
|
.rate = 0.375,
|
|
.periods = 60,
|
|
});
|
|
defer testing.allocator.free(rows);
|
|
|
|
for (rows) |row| {
|
|
try testing.expectApproxEqAbs(row.payment, row.interest + row.principal, 1e-9);
|
|
}
|
|
}
|
|
|
|
test "amortization: the balance never increases" {
|
|
const rows = try amortizationSchedule(testing.allocator, .{
|
|
.principal = 15000,
|
|
.rate = 1.0,
|
|
.periods = 48,
|
|
});
|
|
defer testing.allocator.free(rows);
|
|
|
|
var previous: f64 = 15000;
|
|
for (rows) |row| {
|
|
try testing.expect(row.balance <= previous);
|
|
previous = row.balance;
|
|
}
|
|
}
|
|
|
|
test "amortization: totals agree with the generated schedule" {
|
|
const params: AmortizationParams = .{ .principal = 200000, .rate = 0.5, .periods = 360 };
|
|
const rows = try amortizationSchedule(testing.allocator, params);
|
|
defer testing.allocator.free(rows);
|
|
|
|
var paid: f64 = 0;
|
|
var interest: f64 = 0;
|
|
for (rows) |row| {
|
|
paid += row.payment;
|
|
interest += row.interest;
|
|
}
|
|
|
|
const totals = try amortizationTotals(params);
|
|
try testing.expectEqual(@as(usize, 360), totals.periods);
|
|
try testing.expectApproxEqAbs(roundToCents(paid), totals.paid, 0.005);
|
|
try testing.expectApproxEqAbs(roundToCents(interest), totals.interest, 0.005);
|
|
// A 30-year 6% mortgage costs more in interest than the house.
|
|
try testing.expect(totals.interest > 231000 and totals.interest < 232000);
|
|
try testing.expectApproxEqAbs(totals.paid, totals.interest + totals.principal, 0.02);
|
|
}
|
|
|
|
test "amortization: zero rate splits the principal evenly" {
|
|
const rows = try amortizationSchedule(testing.allocator, .{
|
|
.principal = 1200,
|
|
.rate = 0,
|
|
.periods = 12,
|
|
});
|
|
defer testing.allocator.free(rows);
|
|
|
|
try testing.expectEqual(@as(usize, 12), rows.len);
|
|
for (rows) |row| {
|
|
try testing.expectEqual(@as(f64, 0.0), row.interest);
|
|
try testing.expectEqual(@as(f64, 100.0), row.payment);
|
|
}
|
|
try testing.expectEqual(@as(f64, 0.0), rows[11].balance);
|
|
}
|
|
|
|
test "amortization: a larger payment retires the loan early" {
|
|
const params: AmortizationParams = .{
|
|
.principal = 200000,
|
|
.rate = 0.5,
|
|
.periods = 360,
|
|
.payment = 2000,
|
|
};
|
|
const rows = try amortizationSchedule(testing.allocator, params);
|
|
defer testing.allocator.free(rows);
|
|
|
|
try testing.expect(rows.len < 360);
|
|
try testing.expectEqual(@as(f64, 0.0), rows[rows.len - 1].balance);
|
|
// The last payment is the one that clears the balance, so it is no larger
|
|
// than a full payment.
|
|
try testing.expect(rows[rows.len - 1].payment <= 2000);
|
|
|
|
const totals = try amortizationTotals(params);
|
|
try testing.expectEqual(rows.len, totals.periods);
|
|
// Paying faster costs less interest than the scheduled payment would.
|
|
const scheduled = try amortizationTotals(.{ .principal = 200000, .rate = 0.5, .periods = 360 });
|
|
try testing.expect(totals.interest < scheduled.interest);
|
|
}
|
|
|
|
test "amortization: an underfunded term ends in a balloon payment" {
|
|
// 200,000 at 6% needs 1199.10 a month. 1100 covers the interest but does not
|
|
// retire the loan in 360 periods, so the last period carries the remainder.
|
|
const params: AmortizationParams = .{
|
|
.principal = 200000,
|
|
.rate = 0.5,
|
|
.periods = 360,
|
|
.payment = 1100,
|
|
};
|
|
const rows = try amortizationSchedule(testing.allocator, params);
|
|
defer testing.allocator.free(rows);
|
|
|
|
try testing.expectEqual(@as(usize, 360), rows.len);
|
|
const last = rows[rows.len - 1];
|
|
try testing.expectEqual(@as(f64, 0.0), last.balance);
|
|
// Roughly 100,000 still owed at the end, paid in one lump.
|
|
try testing.expect(last.payment > 90000 and last.payment < 110000);
|
|
}
|
|
|
|
test "amortization: a payment below the first interest charge is rejected" {
|
|
// 1000 of interest in month one, so 500 never touches principal.
|
|
try testing.expectError(Error.DomainError, amortizationPayment(.{
|
|
.principal = 200000,
|
|
.rate = 0.5,
|
|
.periods = 360,
|
|
.payment = -1,
|
|
}));
|
|
try testing.expectError(Error.DomainError, amortizationEntry(.{
|
|
.principal = 200000,
|
|
.rate = 0.5,
|
|
.periods = 360,
|
|
.payment = 500,
|
|
}, 1));
|
|
}
|
|
|
|
test "amortization: rejects nonsense loan terms" {
|
|
try testing.expectError(Error.DomainError, amortizationPayment(.{
|
|
.principal = 0,
|
|
.rate = 0.5,
|
|
.periods = 12,
|
|
}));
|
|
try testing.expectError(Error.DomainError, amortizationPayment(.{
|
|
.principal = 1000,
|
|
.rate = 0.5,
|
|
.periods = 0,
|
|
}));
|
|
try testing.expectError(Error.DomainError, amortizationPayment(.{
|
|
.principal = 1000,
|
|
.rate = -1,
|
|
.periods = 12,
|
|
}));
|
|
try testing.expectError(Error.DomainError, amortizationPayment(.{
|
|
.principal = 1000,
|
|
.rate = 0.5,
|
|
.periods = max_schedule_periods + 1,
|
|
}));
|
|
}
|
|
|
|
test "amortization: periods outside the schedule are an error" {
|
|
const params: AmortizationParams = .{ .principal = 1200, .rate = 0, .periods = 12 };
|
|
try testing.expectError(Error.DomainError, amortizationEntry(params, 0));
|
|
try testing.expectError(Error.DomainError, amortizationEntry(params, 13));
|
|
}
|
|
|
|
test "amortization: unrounded mode keeps full precision" {
|
|
const params: AmortizationParams = .{
|
|
.principal = 200000,
|
|
.rate = 0.5,
|
|
.periods = 360,
|
|
.round_cents = false,
|
|
};
|
|
const payment = try amortizationPayment(params);
|
|
try testing.expectApproxEqAbs(@as(f64, 1199.10105030), payment, 1e-6);
|
|
|
|
const rows = try amortizationSchedule(testing.allocator, params);
|
|
defer testing.allocator.free(rows);
|
|
// With the exact payment there is no residue for the last period to absorb,
|
|
// so every payment is identical.
|
|
try testing.expectApproxEqAbs(rows[0].payment, rows[rows.len - 1].payment, 1e-6);
|
|
}
|
|
|
|
test "amortization: schedule allocation failure frees the partial table" {
|
|
// Sweeps the failure point across every allocation the schedule makes, which
|
|
// is what exercises the errdefer that releases a half-built table.
|
|
var fail_index: usize = 0;
|
|
while (fail_index < 64) : (fail_index += 1) {
|
|
var failing = std.testing.FailingAllocator.init(testing.allocator, .{ .fail_index = fail_index });
|
|
const allocator = failing.allocator();
|
|
if (amortizationSchedule(allocator, .{ .principal = 1200, .rate = 1, .periods = 12 })) |rows| {
|
|
allocator.free(rows);
|
|
return;
|
|
} else |err| {
|
|
try testing.expectEqual(Error.OutOfMemory, err);
|
|
}
|
|
}
|
|
return error.AllocationSweepNeverCompleted;
|
|
}
|
|
|
|
// -- Solving compound interest for rate and time --
|
|
|
|
test "compoundRate: inverts compoundFutureValue" {
|
|
// 1000 grows to 1628.894627 at 5% over 10 years, annually.
|
|
const rate = try compoundRate(1000, 1628.894627, 10, 1);
|
|
try testing.expectApproxEqAbs(@as(f64, 5.0), rate, 1e-6);
|
|
|
|
// And with monthly compounding, where the answer is the nominal rate.
|
|
const monthly_fv = try compoundFutureValue(1000, 5, 10, 12);
|
|
const monthly_rate = try compoundRate(1000, monthly_fv, 10, 12);
|
|
try testing.expectApproxEqAbs(@as(f64, 5.0), monthly_rate, 1e-9);
|
|
}
|
|
|
|
test "compoundRate: at annual compounding it agrees with CAGR" {
|
|
// Same question, two entry points: they must not disagree.
|
|
const rate = try compoundRate(10000, 25000, 5, 1);
|
|
const growth_rate = try cagr(10000, 25000, 5);
|
|
try testing.expectApproxEqAbs(growth_rate * 100.0, rate, 1e-12);
|
|
try testing.expectApproxEqAbs(@as(f64, 20.11244), rate, 1e-5);
|
|
}
|
|
|
|
test "compoundRate: doubling money" {
|
|
// 2^(1/10) - 1 as a percentage.
|
|
try testing.expectApproxEqAbs(@as(f64, 7.17734625), try compoundRate(1000, 2000, 10, 1), 1e-8);
|
|
// Compounded monthly the nominal rate needed is lower: 12*(2^(1/120) - 1).
|
|
const monthly = try compoundRate(1000, 2000, 10, 12);
|
|
try testing.expect(monthly < 7.17734625);
|
|
try testing.expectApproxEqAbs(@as(f64, 6.95152928), monthly, 1e-8);
|
|
}
|
|
|
|
test "compoundRate: a loss is a negative rate, not an error" {
|
|
const rate = try compoundRate(1000, 500, 5, 1);
|
|
try testing.expect(rate < 0);
|
|
try testing.expectApproxEqAbs(@as(f64, -12.94494), rate, 1e-5);
|
|
}
|
|
|
|
test "compoundRate: unchanged value is a zero rate" {
|
|
try testing.expectApproxEqAbs(@as(f64, 0), try compoundRate(1000, 1000, 5, 4), 1e-12);
|
|
}
|
|
|
|
test "compoundRate: domain errors" {
|
|
// No time elapsed: nothing to solve.
|
|
try testing.expectError(Error.DomainError, compoundRate(1000, 2000, 0, 1));
|
|
try testing.expectError(Error.DomainError, compoundRate(1000, 2000, -5, 1));
|
|
// No compounding frequency.
|
|
try testing.expectError(Error.DomainError, compoundRate(1000, 2000, 10, 0));
|
|
try testing.expectError(Error.DomainError, compoundRate(1000, 2000, 10, -12));
|
|
// Nothing to grow from.
|
|
try testing.expectError(Error.DomainError, compoundRate(0, 2000, 10, 1));
|
|
// A sign change has no real root.
|
|
try testing.expectError(Error.DomainError, compoundRate(1000, -500, 10, 1));
|
|
try testing.expectError(Error.DomainError, compoundRate(-1000, 500, 10, 1));
|
|
// Reaching exactly zero would need a rate of -100%, which is a limit.
|
|
try testing.expectError(Error.DomainError, compoundRate(1000, 0, 10, 1));
|
|
}
|
|
|
|
test "compoundPeriods: inverts compoundFutureValue" {
|
|
const years = try compoundPeriods(1000, 1628.894627, 5, 1);
|
|
try testing.expectApproxEqAbs(@as(f64, 10.0), years, 1e-6);
|
|
|
|
const monthly_fv = try compoundFutureValue(1000, 5, 10, 12);
|
|
const monthly_years = try compoundPeriods(1000, monthly_fv, 5, 12);
|
|
try testing.expectApproxEqAbs(@as(f64, 10.0), monthly_years, 1e-9);
|
|
}
|
|
|
|
test "compoundPeriods: doubling at 7 percent takes about ten years" {
|
|
// ln(2)/ln(1.07), the same figure the TVM period solver produces.
|
|
const years = try compoundPeriods(1000, 2000, 7, 1);
|
|
try testing.expectApproxEqAbs(@as(f64, 10.244768), years, 1e-6);
|
|
}
|
|
|
|
test "compoundPeriods: more frequent compounding gets there sooner" {
|
|
const annual = try compoundPeriods(1000, 2000, 7, 1);
|
|
const monthly = try compoundPeriods(1000, 2000, 7, 12);
|
|
try testing.expect(monthly < annual);
|
|
}
|
|
|
|
test "compoundPeriods: a shrinking balance takes time to fall" {
|
|
const years = try compoundPeriods(1000, 500, -10, 1);
|
|
try testing.expect(years > 0);
|
|
// ln(0.5)/ln(0.9)
|
|
try testing.expectApproxEqAbs(@as(f64, 6.5788), years, 1e-4);
|
|
}
|
|
|
|
test "compoundPeriods: already there takes no time at all" {
|
|
try testing.expectEqual(@as(f64, 0), try compoundPeriods(1000, 1000, 5, 1));
|
|
// Even at a zero rate, since no growth is needed.
|
|
try testing.expectEqual(@as(f64, 0), try compoundPeriods(1000, 1000, 0, 1));
|
|
}
|
|
|
|
test "compoundPeriods: domain errors" {
|
|
// A zero rate never reaches a different value.
|
|
try testing.expectError(Error.DomainError, compoundPeriods(1000, 2000, 0, 1));
|
|
// -100% or worse is not a rate.
|
|
try testing.expectError(Error.DomainError, compoundPeriods(1000, 2000, -100, 1));
|
|
try testing.expectError(Error.DomainError, compoundPeriods(1000, 2000, -150, 1));
|
|
try testing.expectError(Error.DomainError, compoundPeriods(1000, 2000, 5, 0));
|
|
try testing.expectError(Error.DomainError, compoundPeriods(0, 2000, 5, 1));
|
|
try testing.expectError(Error.DomainError, compoundPeriods(1000, -2000, 5, 1));
|
|
}
|
|
|
|
test "effectiveAnnualRate: monthly compounding beats its nominal rate" {
|
|
try testing.expectApproxEqAbs(@as(f64, 19.5618), try effectiveAnnualRate(18, 12), 1e-4);
|
|
try testing.expectApproxEqAbs(@as(f64, 5.11619), try effectiveAnnualRate(5, 12), 1e-5);
|
|
}
|
|
|
|
test "effectiveAnnualRate: annual compounding is its own effective rate" {
|
|
try testing.expectApproxEqAbs(@as(f64, 5.0), try effectiveAnnualRate(5, 1), 1e-12);
|
|
try testing.expectApproxEqAbs(@as(f64, 0.0), try effectiveAnnualRate(0, 12), 1e-12);
|
|
}
|
|
|
|
test "effectiveAnnualRate: a negative nominal rate stays negative" {
|
|
const effective = try effectiveAnnualRate(-10, 12);
|
|
try testing.expect(effective < 0);
|
|
try testing.expect(effective > -10);
|
|
}
|
|
|
|
test "effectiveAnnualRate: domain errors" {
|
|
try testing.expectError(Error.DomainError, effectiveAnnualRate(5, 0));
|
|
try testing.expectError(Error.DomainError, effectiveAnnualRate(-100, 1));
|
|
}
|
|
|
|
test "compound interest: the four variables round-trip through each other" {
|
|
// One consistent set, solved for each variable in turn.
|
|
const pv: f64 = 5000;
|
|
const rate: f64 = 6.5;
|
|
const years: f64 = 8;
|
|
const per_year: f64 = 4;
|
|
|
|
const fv = try compoundFutureValue(pv, rate, years, per_year);
|
|
try testing.expectApproxEqAbs(pv, try compoundPresentValue(fv, rate, years, per_year), 1e-9);
|
|
try testing.expectApproxEqAbs(rate, try compoundRate(pv, fv, years, per_year), 1e-9);
|
|
try testing.expectApproxEqAbs(years, try compoundPeriods(pv, fv, rate, per_year), 1e-9);
|
|
}
|
|
|
|
// -- Money display tests --
|
|
//
|
|
// These moved here with `Money`, from `formatter.zig`. The old version returned
|
|
// `?[]const u8` and both frontends had copies returning the string "?", so
|
|
// `tally amort 1e40 0.5 3` printed a table of question marks and exited 0.
|
|
|
|
fn expectMoney(expected: []const u8, value: f64) !void {
|
|
var buf: [money_text_max]u8 = undefined;
|
|
try testing.expectEqualStrings(expected, try money(value).render(&buf));
|
|
}
|
|
|
|
test "money: grouping, sign and two decimals" {
|
|
try expectMoney("0.00", 0);
|
|
try expectMoney("199.10", 199.1);
|
|
try expectMoney("1,199.10", 1199.1);
|
|
try expectMoney("200,000.00", 200000);
|
|
try expectMoney("231,677.04", 231677.04);
|
|
try expectMoney("1,234,567.89", 1234567.89);
|
|
try expectMoney("-1,199.10", -1199.1);
|
|
try expectMoney("-0.01", -0.01);
|
|
}
|
|
|
|
test "money: rounds to the cent" {
|
|
try expectMoney("1,199.10", 1199.101050305518);
|
|
try expectMoney("2.00", 1.995);
|
|
}
|
|
|
|
test "money: a buffer too small is a failure, not a placeholder" {
|
|
var tiny: [4]u8 = undefined;
|
|
try testing.expectError(error.WriteFailed, money(1234567.89).render(&tiny));
|
|
}
|
|
|
|
test "money: non-finite amounts print as themselves" {
|
|
// Not reachable from the solvers, which reject the inputs that would produce
|
|
// one, but a formatter that silently emitted "?" here would be worse than one
|
|
// that says "inf".
|
|
try expectMoney("inf", math.inf(f64));
|
|
try expectMoney("-inf", -math.inf(f64));
|
|
try expectMoney("nan", math.nan(f64));
|
|
}
|
|
|
|
test "money: very large amounts render or fail cleanly, never partially" {
|
|
// 1e40 needs 41 integer digits, 13 separators and cents: 57 bytes.
|
|
var buf: [64]u8 = undefined;
|
|
const forty = try money(1e40).render(&buf);
|
|
try testing.expectEqual(@as(usize, 57), forty.len);
|
|
try testing.expect(std.mem.startsWith(u8, forty, "10,000,000,000"));
|
|
try testing.expect(std.mem.endsWith(u8, forty, ".00"));
|
|
|
|
// 1e300 needs 404 bytes, so the same buffer must refuse rather than truncate.
|
|
try testing.expectError(error.WriteFailed, money(1e300).render(&buf));
|
|
var wide: [money_text_max]u8 = undefined;
|
|
const huge = try money(1e300).render(&wide);
|
|
try testing.expect(std.mem.endsWith(u8, huge, ".00"));
|
|
}
|
|
|
|
test "money: prints through a writer without a caller buffer" {
|
|
var out: [64]u8 = undefined;
|
|
var w = std.Io.Writer.fixed(&out);
|
|
try w.print("Payment {f} per period", .{money(1199.1)});
|
|
try testing.expectEqualStrings("Payment 1,199.10 per period", w.buffered());
|
|
}
|
|
|
|
test "money: groups the same way an ordinary result does" {
|
|
// The point of collapsing the copies: an amount and a plain result group
|
|
// identically, differing only in the fixed decimal places.
|
|
var money_buf: [64]u8 = undefined;
|
|
const as_money = try money(231677).render(&money_buf);
|
|
var value = @import("number.zig").Number.fromFloat(231677);
|
|
const as_value = try value.render(testing.allocator, .{
|
|
.fraction_digits = 20,
|
|
.max_integer_digits = 40,
|
|
.significant_digits = 17,
|
|
.separators = true,
|
|
});
|
|
defer as_value.deinit(testing.allocator);
|
|
try testing.expectEqualStrings("231,677.00", as_money);
|
|
try testing.expectEqualStrings("231,677", as_value.text);
|
|
try testing.expect(std.mem.startsWith(u8, as_money, as_value.text));
|
|
}
|