tally/engine/src/financial.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));
}