# SPDX-License-Identifier: MIT # finance — compare loan offers, and calculate interest and value. # # THIS MODULE IS NOT FINANCIAL ADVICE. It states which of several offers costs # the least in total and which carry a payment over a limit you give it, and it # does arithmetic with published formulas. It does not say which offer to take: # the cheapest in total and an offer over a household's limit can be the same # offer, and the module reports both. Every rate, price and income is yours. # Ask a financial adviser. # # Two halves that share nothing but the subject. # # STRUCTURE. You supply two kinds of record as facts. # # Offers (`finance.Offer`), each a borrower, a principal, a monthly payment and # a number of months. `total` is the payment times the months and `interest` is # that total less the principal. The rule `cheapest` concludes that an offer is # the cheapest in total of those that share its borrower and its principal, # which is the absence of a cheaper one: it holds while no offer of that group # has a smaller total, and it is withdrawn when a cheaper one is written. It is # not "cheaper than some other offer", which would mark an offer that a third # one beats. Offers with EQUAL totals are each cheapest (the tie is measured in # the module's tests): neither has a cheaper one, and the module ranks nothing. # The rule `unaffordable` concludes an offer whose payment is over the limit of # the budget named by its borrower. An offer over a limit is marked and kept, # never dropped, and the same offer can be both cheapest and unaffordable: # # import finance # finance.quote("a", "ann", 200000.0, 0.05, 360.0) # finance.quote("c", "ann", 200000.0, 0.06, 120.0) # finance.budget("ann", 1500.0) # print(finance.cheapest_offers(), finance.unaffordable_offers()) # n = why_not(finance.Offer.a.cheapest) # # Budgets (`finance.Budget`), a payment limit named by the borrower it belongs # to. A borrower with no budget has no offer marked unaffordable: nothing is # recorded to be over, and `why_not` says so. # # Each conclusion answers `why`, and `why_not` names the premise that failed. # For `cheapest`, `why_not` names a helper the rule makes for its absence test # (it reads "... to be absent, and it is present"): the cheaper offer that is # present is the reason. A corrected fact withdraws what rested on it. An # empty read is a "no", not a missing answer. # # Where two sources disagree about a fact, such as an offer's payment, the # rules reason about both values and the names they return hold both verdicts. # `total` returns one number and refuses a cell that holds two values. # `unsettled()` names every fact you write that two sources give different # values. # # CALCULATION. Value scripts that return a value and write nothing: the # amortised payment, compound interest, the effective annual rate of a nominal # one and of continuous compounding, the exact doubling time beside the rule # of 72, present value, a value in today's money and the real rate by the # exact Fisher form. A rate is a fraction (0.05 for 5 percent) except in the # two doubling-time scripts, which take percent as the rule of 72 is written. # # RESULTS ARE REALS, NOT CENTS. A bank rounds each payment to the cent and a # schedule is built from those; this module does not, so a payment here is the # real number of currency units the formula gives and a total is that payment # times the months. Use it to compare, not to reproduce a lender's statement. # Results agree with CPython's bit for bit except where a logarithm is taken # (the doubling time), which is compared to the logarithm. # # What the rules and scripts do not cover: fees folded into an annual # percentage rate, taxes, a schedule row by row, variable rates, and any # statute beyond the words of the sources below. # # Sources, fetched 2026-11-09: # - A nominal annual rate multiplies the periodic rate by the periods in a # year, and the effective annual rate is EAR = (1 + APR/n)^n - 1; the page # works a card at 12.99 percent compounded monthly with the rate # 0.12994912, after allowing for rounding, and gets 13.7975 percent # (the formula at exactly 12.99 percent gives 13.7920), and says that 29.99 # percent compounded monthly carries an effective rate of 34.48 percent # (34.4758 by the formula). In the United States the Truth in Lending Act # governs the calculation and disclosure of the APR and the CFPB implements # it through Regulation Z; in the EU a single method of calculating the APR # was introduced in 1998 (directive 98/7/EC), setting the present value of # the lender's drawdowns equal to that of the borrower's repayments # (https://en.wikipedia.org/wiki/Annual_percentage_rate). # - A clay tablet from Babylon of about 2000 to 1700 BC "might be the first # display of the compound interest problem"; Jacob Bernoulli discovered the # constant e in 1683 studying a compound interest question; A = P(1 + # r/n)^(nt); the annual equivalent has the names effective annual rate, # AER and APY; as the periods grow without bound it approaches e^r - 1 # (https://en.wikipedia.org/wiki/Compound_interest). # - The exact doubling time is ln(2)/ln(1 + r/100) periods for r percent, and # the rule of 72 says 72/r; at 8 percent the exact time is 9.006 years and # the rule's 9.000; the rule suits rates from 6 to 10 percent and is less # accurate at higher ones; for continuous compounding 69 gives accurate # results for any rate; the rule is in Luca Pacioli's Summa de arithmetica, # Venice, 1494, which does not derive it # (https://en.wikipedia.org/wiki/Rule_of_72). # - The payment of an amortised loan is A = P i / (1 - (1 + i)^-n) for a # periodic rate i and n payments, valid for i greater than 0, and if i = 0 # then simply A = P/n; a rate stated as an APR is divided by 12 for the # monthly rate (https://en.wikipedia.org/wiki/Amortization_calculator). # - The present value of a future sum is that sum discounted at the periodic # rate i over n periods, so that # $1,000 in five years at 10 percent effective is worth $620.92 now; "a # dollar today is worth more than a dollar tomorrow" # (https://en.wikipedia.org/wiki/Present_value). # - The Fisher equation is (1 + i) = (1 + r)(1 + pi) for the nominal rate i, # the real rate r and inflation pi, with the approximation r = i - pi; # Irving Fisher's works The Rate of Interest (1907) and The Theory of # Interest (1930) are cited on the page # (https://en.wikipedia.org/wiki/Fisher_equation). # - Debt-to-income limits are written as a pair x/y, the first the share of # income to housing and the second to housing plus all other recurring debt: # at 28/36 on $45,000 a year ($3,750 a month) a lender allows $1,050 for # housing and $1,350 for housing plus other debt; FHA limits are given as # 31/43, and Fannie Mae's as a total of up to 36 percent, or 45 percent with # credit-score and reserve requirements # (https://en.wikipedia.org/wiki/Debt-to-income_ratio). The limits vary and # are the lender's; the module takes yours as a number. import math # ── Offers and budgets as facts ────────────────────────────────────────────── class Offer: # A loan offer, by a name you choose. `borrower` groups the offers that # are compared, with `principal`: the cheapest is judged among offers that # share both. `payment` is monthly, `months` the number of payments. # `total` is the payment times the months and `interest` the total less the # principal, taken of halves and doubled, which changes no digit and keeps # a total past the largest real from making it one. Concluded by the # rules: `cheapest`, `unaffordable`. borrower: string principal: real payment: real months: real total: real = payment * months interest: real = (0.5 * payment * months - 0.5 * principal) * 2.0 cheapest: bool unaffordable: bool class Budget: # A household's limit, by the borrower's name: `limit` is the largest # monthly payment it can bear. limit: real @rule def cheapest_offer(): for a in Offer: if none(b in Offer: b.borrower == a.borrower and b.principal == a.principal and b.total < a.total): a.cheapest = True @rule def unaffordable_offer(): for o in Offer, g in Budget: if o.borrower == g and o.payment > g.limit: o.unaffordable = True # ── Writing facts ──────────────────────────────────────────────────────────── def monthly_payment(principal: float, annual_rate: float, months: float) -> float: # The amortised payment, P i / (1 - (1 + i)^-n) with i the annual rate over # 12, and P / n for a rate of zero. Zero months refuses with a division by # zero, and fewer refuse. The result is not rounded to the cent. # 1 - (1 + i)^-n is taken as -expm1(-n log1p(i)), which keeps its digits # where (1 + i)^-n is near 1: the plain form lost up to 55,000 units in the # last digit, and refused a rate so small that 1 + i rounds to 1. Where # P i over it is not a normal real, it is worked on the mantissas and the # powers of two apart, as math.frexp gives them. if months < 0.0: raise ValueError("a number of months is not below 0") if annual_rate == 0.0: return principal / months i = annual_rate / 12.0 d = -math.expm1(-months * math.log1p(i)) v = principal * i / d if (math.isfinite(v) and abs(v) >= 2.2250738585072014e-308) or principal == 0.0 or d == 0.0: return v a = math.frexp(principal) b = math.frexp(i) c = math.frexp(d) return math.ldexp(a[0] * b[0] / c[0], a[1] + b[1] - c[1]) def offer(name: string, borrower: string, principal: float, payment: float, months: float): # An offer with its monthly payment as the lender states it. update(Offer(name).borrower, borrower, "offer") update(Offer(name).principal, principal, "offer") update(Offer(name).payment, payment, "offer") update(Offer(name).months, months, "offer") def quote(name: string, borrower: string, principal: float, annual_rate: float, months: float): # An offer from a rate: the payment is `monthly_payment`. offer(name, borrower, principal, monthly_payment(principal, annual_rate, months), months) def budget(borrower: string, limit: float): update(Budget(borrower).limit, limit, "budget") # ── Reading what the rules concluded ───────────────────────────────────────── # # The readers that return names read every value in force, so a fact two # sources disagree about gives each of its values a verdict and is reported # with both. `total` returns one number and reads it with `current()`, which # refuses a cell holding two different values rather than choose one; # `unsettled()` names the facts that hold two. def names_of(values: claim_list) -> list: out = [] for v in values: parts = v.path.split(".") x = parts[len(parts) - 2] if v.value == True and x not in out: out = out + [x] return sorted(out) def cheapest_offers() -> list: # The offers with no cheaper one among those of their borrower and # principal, sorted. Equal totals are each in the list. vs = list_values(Offer.cheapest) return names_of(vs) def unaffordable_offers() -> list: # The offers whose payment is over their borrower's limit, sorted. vs = list_values(Offer.unaffordable) return names_of(vs) def total(name: string) -> float: # An offer's total, payment times months; 0.0 before the facts are written. c = current(Offer.name.total) match c: case active_claim: match c.value: case real as x: return x case default: pass case empty: pass return 0.0 def unsettled() -> list: # Each offer and budget that holds two different values for a fact you # write, as "offer x payment", "budget x limit" and so on, sorted. A fact # is named only when its values differ. out = [] for f in ["o.borrower", "o.principal", "o.payment", "o.months", "b.limit"]: if f == "o.borrower": vs = list_values(Offer.borrower) noun = "offer " elif f == "o.principal": vs = list_values(Offer.principal) noun = "offer " elif f == "o.payment": vs = list_values(Offer.payment) noun = "offer " elif f == "o.months": vs = list_values(Offer.months) noun = "offer " else: vs = list_values(Budget.limit) noun = "budget " seen = {} for v in vs: parts = v.path.split(".") s = noun + parts[len(parts) - 2] + " " + parts[len(parts) - 1] x = str(v.value) if s in seen and seen[s] != x and s not in out: out = out + [s] seen[s] = x return sorted(out) # ── Calculation: interest and value ────────────────────────────────────────── def compound_amount(present: float, annual_rate: float, periods_per_year: float, years: float) -> float: # What a sum grows to, P (1 + r/n)^(n t), for n periods a year above 0. # Where n t is too small for a real, the power is taken as ((1 + r/n)^n)^t. # Where the sum is not a normal real while P is a real and 1 + r/n above 0, # it is P e^(log1p(r/n) n t), log1p(r/n) being ln r - ln n where r/n passes # the largest real, so a power past the range of a real still gives the sum # where the sum is a real. if not periods_per_year > 0.0: raise ValueError("periods_per_year is a number of periods a year, above 0") if present == 0.0: return present q = annual_rate / periods_per_year x = 1.0 + q e = periods_per_year * years if e == 0.0 and years != 0.0: g = (x ** periods_per_year) ** years else: g = x ** e v = present * g if (math.isfinite(v) and abs(v) >= 2.2250738585072014e-308) or not x > 0.0 or not math.isfinite(present): return v if math.isinf(q) and math.isfinite(annual_rate): lx = math.log(annual_rate) - math.log(periods_per_year) else: lx = math.log1p(q) return math.copysign(math.exp(math.log(abs(present)) + lx * periods_per_year * years), present) def effective_annual_rate(annual_rate: float, periods_per_year: float) -> float: # The annual rate that equals a nominal one compounded n times a year, # (1 + r/n)^n - 1, for n above 0, taken as expm1(n log1p(r/n)), which keeps # every digit where the power is near 1: 0.12 for n = 1 gives 0.12, where # the plain form gave 0.1200000000000001, and over 20,000 rates the plain # form lost up to a million units in the last digit and this one 2. A rate # that loses all of a sum in a period gives -1, and one that loses more # refuses with a math domain error. Where r/n passes the largest real, # log1p(r/n) is ln r - ln n, and where it is below the smallest normal # real, n log1p(r/n) is r to the last digit. if not periods_per_year > 0.0: raise ValueError("periods_per_year is a number of periods a year, above 0") q = annual_rate / periods_per_year if q == -1.0: return -1.0 if math.isinf(q) and math.isfinite(annual_rate): return math.expm1(periods_per_year * (math.log(annual_rate) - math.log(periods_per_year))) if abs(q) < 2.2250738585072014e-308: return math.expm1(annual_rate) return math.expm1(periods_per_year * math.log1p(q)) def continuous_annual_rate(annual_rate: float) -> float: # The limit as the periods grow without bound, e^r - 1, taken as expm1(r), # which keeps every digit: the plain form lost up to 10 units in the last # digit at an ordinary rate, and nearly every digit at a rate near 0. return math.expm1(annual_rate) def doubling_exact(percent: float) -> float: # The years to double at a rate in percent, ln(2) / ln(1 + r/100). A rate # of zero never doubles and refuses with a division by zero; -100 percent # refuses with a math domain error. return math.log(2.0) / math.log(1.0 + percent / 100.0) def doubling_rule_of_72(percent: float) -> float: # The rule of 72, 72 / r for a rate in percent. It suits 6 to 10 percent. return 72.0 / percent def present_value(future: float, annual_rate: float, years: float) -> float: # A future sum discounted to now, F / (1 + r)^t; where that is not a normal # real while F is a real and 1 + r above 0, F e^(-t log1p(r)), so a power # past the range of a real still gives the value where it is a real. v = future / (1.0 + annual_rate) ** years if (math.isfinite(v) and abs(v) >= 2.2250738585072014e-308) or future == 0.0 or not annual_rate > -1.0 or not math.isfinite(future): return v return math.copysign(math.exp(math.log(abs(future)) - years * math.log1p(annual_rate)), future) def todays_value(nominal: float, inflation: float, years: float) -> float: # A nominal sum in today's money after some years of a steady inflation, # N / (1 + pi)^t, kept in range as present_value is. v = nominal / (1.0 + inflation) ** years if (math.isfinite(v) and abs(v) >= 2.2250738585072014e-308) or nominal == 0.0 or not inflation > -1.0 or not math.isfinite(nominal): return v return math.copysign(math.exp(math.log(abs(nominal)) - years * math.log1p(inflation)), nominal) def real_rate(nominal_rate: float, inflation: float) -> float: # The real rate by the exact Fisher form, (1 + i) / (1 + pi) - 1; the # approximation i - pi is slightly higher. return (1.0 + nominal_rate) / (1.0 + inflation) - 1.0