Build1 publisher3 min readPublished
An upfront origination fee makes the annuity formula answer the wrong question
A $3,000 fee deducted from a $100,000 disbursement takes a quoted 10% to roughly 17% effective. Getting there needs a bracketing root-find, and the post's one-line description of its NPV curve points the wrong way.
The Engineer · Build desk
What happened
- A dev.to walkthrough works a 12-month $100,000 loan at 10% nominal where the lender deducts a $3,000 origination fee, so the borrower receives $97,000 and repays a schedule built on $100,000.
- The post argues the annuity formula only answers what payment amortizes the contractual balance, and recasts offer comparison as solving for the rate that equates the payments to the money actually received.
- Its TypeScript solver brackets the root between -0.9999 and 1, doubles the upper bound until the NPV sign flips, and then halves the interval 180 times before returning the midpoint.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- constraint The output is an effective annual rate, so a comparison screen that puts 17.0% next to a nominal 10% quote overstates the fee by half a point; the like-for-like baseline is 10.47%.
- decision Someone has to classify each fee as schedule or disbursement before the solve runs, and that call sits with whoever writes the product spec, not with the solver.
- exposure Several inputs come back null, including any schedule whose NPV never crosses zero, so a caller that formats the return without a null branch will print a number for exactly the offers that need a human.
The annuity formula gives the payment. A $100,000 balance at 10% nominal over twelve months amortises at $8,791.59 a month [4][2][1]. The borrower received $97,000 [2]. Twelve payments of $8,791.59 discounted back to $97,000 needs an annuity factor of 11.0336, which solves to a monthly rate of about 1.316% [2]. Compounded twelve times, that is an effective annual rate near 17.0% [3].
Do not set that beside the quoted 10%. The quote is nominal, and 10% nominal compounded monthly is 10.47% effective [4], so the fee is worth about 6.5 points of effective rate [5]. The exponent in the solver is (index + 1) / 12, and the post says the function returns an effective annual rate directly [11][12]. Its disclaimer notes that real products may have irregular dates, taxes, penalties, or rules that require a different disclosure method [16].
One sentence in the post runs against the code underneath it. For a conventional loan the post says "NPV falls as the discount rate rises" [8]. The shipped function computes actualReceived minus the sum of payment / (1 + annualRate)^((index + 1) / 12) [11]. Every term in that sum shrinks as the rate rises, so the function increases with the rate [8]. The code is consistent with itself: the expansion loop doubles `high` while `npv(high)` is still negative, and the bisection step assigns `high = mid` when `npv(mid) > 0` [10]. Reimplement from the prose, flip the comparison to match, and the search discards the half of the interval that contains the root at every pass.
The loop runs 180 times over a starting bracket of -0.9999 to 1 [10]. That interval is 1.9999 wide, and about 54 halvings take it below 1e-16, so roughly 126 passes narrow nothing [6]. Each pass discounts the whole schedule, which on a twelve-payment loan is 2,160 exponentiations per solve [7]. That costs nothing on a single quote in a form handler, and it is measurable on a nightly reprice of a book.
The guards are the part worth copying. `annualIrr` returns null when actualReceived is zero or negative, when the payment array is empty, and when every payment is non-positive [13]. It also returns null when npv(low) and npv(high) share a sign, so an unbracketable schedule fails loudly instead of converging on an endpoint [10]. `annuityPayment` special-cases a zero monthly rate and returns principal / months, which keeps a 0% promotional offer from dividing by (factor - 1) when factor is exactly 1 [14].
Classification has to come from outside the solver. A monthly service fee is added to every scheduled payment and a balloon only to the last, both inside the discounted sum; an upfront fee deducted before funding reduces actualReceived instead [7]. The post also keeps the solver reading a cash-flow schedule, and that is what makes the extension to monthly fees a change in one place [17]. On precision it is blunt: keep full precision internally and round only for display, because repeated early rounding can create a small residual balance and move the solved rate [15].
What to watch
- Whether the post corrects the NPV monotonicity sentence, since that line is what a reimplementation copies before the code.
- A follow-up that handles irregular payment dates, which the disclaimer puts outside the model's scope.
- A version that emits a nominal rate alongside the effective one, so the output can sit next to a quoted rate.