Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions app/interactives/present-value-calculator-v2/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -540,10 +540,6 @@ export default function PresentValueCalculator() {
)}
</SelectContent>
</Select>
<p className="text-sm mt-1">
The compounding frequency is equal to the payment
frequency.
</p>
<Button
type="button"
variant="lagunita"
Expand Down
106 changes: 60 additions & 46 deletions app/interactives/time-value-money-calculator/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -322,97 +322,103 @@ export default function Page() {
: ratePerPeriodCalc * compFreq * 100;
break
}
// Solve for the per-period rate: scan for a sign change, then bisect.
// Solve for the per-period rate (g): scan for a sign change, then
// bisect. This replaced an earlier Newton-Raphson solver, which was
// seed-sensitive and failed for high rates. Scan-then-bisect has no
// seed sensitivity and handles a root tucked against a peak; it
// reports M3 only when there is genuinely no crossing in the range.
//
// Replaces the earlier Newton-Raphson solver. Newton is seed-
// sensitive: for cash flows like PV < 0, PMT > 0, FV > 0 the
// objective can rise to a peak with its root sitting just below
// that peak (e.g. f is ~+14 at 280.23% and -101,700 at 300%), and
// a fixed seed can walk the wrong way off that slope. It also fails
// whenever the true rate lies outside the seed's basin, which is why
// "very high" rates specifically broke. Scanning the full supported
// annual-rate range (-99.99% to 1,000%) for the first sign change,
// then bisecting inside that bracket, has no seed sensitivity and
// handles a root tucked against a peak. It still reports M3 only when
// there is genuinely no crossing anywhere in the range.
// The search runs in per-period-rate space (g), not annual space.
// The per-period rate is frequency-independent, so the solver is
// equally capable at every compounding frequency and the scan
// resolution stays constant. The previous version scanned annual
// space and reused the input-field cap (CONSTRAINTS.annualRate.max,
// 1,000%) as its ceiling, which made valid high-frequency roots
// unreachable: PV 1,000, PMT -50, N 60 solves at 243.27% weekly but
// 1,707.54% daily, and the daily root sat above the 1,000% ceiling
// and was wrongly reported as unsolvable.
//
// NOTE: `objective` is written in the rate per PAYMENT period (g),
// applied against `n` (a count of payment periods). `annualToG`
// converts a candidate ANNUAL rate to g using the same annualization
// the rest of the calculator uses, so "same" and "different"
// frequency modes stay consistent.
// `objective` is written in the rate per PAYMENT period (g), applied
// against `n` (a count of payment periods).
const objective = (g: number): number => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: I think something more descriptive than g would be better

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think that the long comments absolves the short variable since there is so much math going on in the calculations? That is what I was leaning towards.

@majimmy88 majimmy88 Jul 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gotcha, i think its more a personal preference. I like reading code and not having to remember assignments to letters or acronyms esp if it gets long. I'm fine going either way since your comments are also very clear.

if (g === 0) return pv + pmt * n + fv
const cf = Math.pow(1 + g, n)
const tm = paymentTiming === "beginning" ? 1 + g : 1
return pv * cf + pmt * ((cf - 1) / g) * tm + fv
}

// annualToG converts a candidate ANNUAL rate to g; gToAnnual is its
// inverse. Both use the same annualization the rest of the calculator
// uses, so "same" and "different" frequency modes stay consistent.
const annualToG = (annual: number): number =>
paymentFrequencyMode === "different" && compFreq !== pmtFreq
? Math.pow(annual / (compFreq * 100) + 1, compFreq / pmtFreq) - 1
: annual / 100 / compFreq

const fAnnual = (annual: number): number => objective(annualToG(annual))

const RATE_LO = CONSTRAINTS.annualRate.min // -99.99
const RATE_HI = CONSTRAINTS.annualRate.max // 1,000
const RATE_STEPS = 2000 // ~0.55% resolution; a few thousand Math.pow calls, negligible cost
const gToAnnual = (g: number): number =>
paymentFrequencyMode === "different" && compFreq !== pmtFreq
? (Math.pow(1 + g, pmtFreq / compFreq) - 1) * compFreq * 100
: g * compFreq * 100

// Output range (per product decision): solved rate is uncapped on the
// high side (a computed 1,707% is a real answer) but floored at -100%
// (a rate at or below -100% is nonsensical). The scan floor is the g
// that maps to the -99.99% input floor; the post-switch guard
// enforces the hard -100% floor for the pmt === 0 path.
const G_LO = annualToG(CONSTRAINTS.annualRate.min) // maps to -99.99% annual
const G_HI = 10 // 1,000% per period; generous, frequency-independent
const G_STEPS = 2000

// Scan the whole range and keep the LAST (highest-rate) sign change.
// Some cash flows change sign twice (e.g. a loan received as +PV that
// is under-paid, leaving a +FV: the stream runs +, -, ..., -, +),
// which admits two valid rates. The economically meaningful answer is
// the higher one, and it matches what the previous Newton solver
// returned when seeded near 10%. For the common single-root case
// there is exactly one bracket, so this is identical to taking the
// first one.
// Some cash flows change sign twice and admit two valid rates; the
// economically meaningful answer is the higher one. For the common
// single-root case there is exactly one bracket, so this is identical
// to taking the first one.
let bracketLo: number | null = null
let bracketHi = 0
let bracketFLo = 0
let prevAnnual = RATE_LO
let prevF = fAnnual(prevAnnual)
let prevG = G_LO
let prevF = objective(prevG)

if (prevF === 0) { bracketLo = RATE_LO; bracketHi = RATE_LO; bracketFLo = 0 }
if (prevF === 0) { bracketLo = G_LO; bracketHi = G_LO; bracketFLo = 0 }

for (let i = 1; i <= RATE_STEPS; i++) {
const annual = RATE_LO + ((RATE_HI - RATE_LO) * i) / RATE_STEPS
const f = fAnnual(annual)
for (let i = 1; i <= G_STEPS; i++) {
const g = G_LO + ((G_HI - G_LO) * i) / G_STEPS
const f = objective(g)
if (f === 0) {
bracketLo = annual; bracketHi = annual; bracketFLo = 0
prevAnnual = annual; prevF = f
bracketLo = g; bracketHi = g; bracketFLo = 0
prevG = g; prevF = f
continue
}
// Sign change between prevAnnual and annual: remember this bracket.
// Sign change between prevG and g: remember this bracket.
if ((f > 0) !== (prevF > 0)) {
bracketLo = prevAnnual; bracketHi = annual; bracketFLo = prevF
bracketLo = prevG; bracketHi = g; bracketFLo = prevF
}
prevAnnual = annual
prevG = g
prevF = f
}

if (bracketLo === null)
// M3
throw new Error("No interest rate produces these values. Check that your cash flows include both a negative and a positive amount.")

let calculatedRate = (bracketLo + bracketHi) / 2
let solvedG = (bracketLo + bracketHi) / 2
if (bracketLo !== bracketHi) {
let lo = bracketLo
let hi = bracketHi
let fLo = bracketFLo
for (let j = 0; j < 100; j++) {
const mid = (lo + hi) / 2
const fMid = fAnnual(mid)
calculatedRate = mid
const fMid = objective(mid)
solvedG = mid
if (Math.abs(fMid) < 1e-7 || hi - lo < 1e-10) break
if ((fMid > 0) === (fLo > 0)) { lo = mid; fLo = fMid }
else { hi = mid }
}
}

// calculatedRate is already the annual rate (the search runs in
// annual space directly), so no re-annualization is needed.
calculatedValue = calculatedRate
// solvedG is a per-period rate; annualize it for display.
calculatedValue = gToAnnual(solvedG)
break
}

Expand Down Expand Up @@ -447,6 +453,14 @@ export default function Page() {
if (!isFinite(calculatedValue) || isNaN(calculatedValue))
throw new Error("Invalid result. Please check your inputs.")

// Output floor for RATE: never show a solved rate at or below -100%.
// Such a rate can arise via nominal annualization at high compounding
// frequencies (e.g. pmt = 0 with |fv| far below |pv|) but is nonsensical.
// The high side is intentionally uncapped. -100% exactly is allowed: it
// is the legitimate total-loss result when fv = 0.
if (solveFor === "RATE" && calculatedValue < -100)
throw new Error("No interest rate produces these values. Check that your cash flows include both a negative and a positive amount.")

// M5: output overflow ceiling — never render scientific notation
if (
(solveFor === "FV" || solveFor === "PV" || solveFor === "PMT") &&
Expand Down