//! 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)); }