Skip to content

Fix: TVM interest-rate solver returns high and mixed-frequency rates correctly#154

Merged
jenbreese merged 4 commits into
devfrom
TVM-0710
Jul 10, 2026
Merged

Fix: TVM interest-rate solver returns high and mixed-frequency rates correctly#154
jenbreese merged 4 commits into
devfrom
TVM-0710

Conversation

@jenbreese

Copy link
Copy Markdown
Collaborator

Summary

The interest-rate solver returned a "no interest rate produces these values" warning for some solvable inputs, specifically when the correct rate exceeded 1,000% annual. This happened most often at high compounding frequencies. This PR switches the rate search to per-period space so the solver is frequency-independent, and adds a hard floor so solved rates at or below -100% are never displayed.

Problem

When solving for the interest rate, identical cash flows would solve at one compounding frequency and fail at another. Example:

  • PV 1,000, PMT -50, FV 0, N 60, weekly: returns 243.27% (correct)
  • Same inputs, daily: returned the M3 warning even though the answer is 1,707.54%

Root cause

The solver scanned for a rate by looking for a sign change across a range, then bisecting. It scanned in annual-rate space and reused the input-field validation cap (CONSTRAINTS.annualRate.max, 1,000%) as its search ceiling. That cap limits what a user may type into the rate field; it should never have limited what the solver can compute. The same per-period rate annualizes to a larger number at higher frequencies (243.27% weekly is the same underlying rate as 1,707.54% daily), so any correct rate above 1,000% fell outside the search window and was reported as unsolvable.

Changes

  1. The rate search now runs in per-period-rate space (g) rather than 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 solved g is annualized for display via a new gToAnnual helper (the inverse of the existing annualToG). Both "same" and "different" payment-frequency modes are covered.
  2. Added a hard output floor for RATE: a solved rate below -100% is now suppressed and returns the standard warning instead. A rate at or below -100% is nonsensical to display but can arise through nominal annualization at high frequencies. Exactly -100% is still allowed, since it is the legitimate total-loss result when FV is 0.

Product decision

Per stakeholder sign-off: the rate input field stays capped at 1,000% to guard against typos, but the solved output is uncapped on the high side. A typed 1,707% is likely a mistake; a computed 1,707% is a real answer.

Note on the floor

The floor also closes a gap that predated this PR. The scan branch was implicitly bounded, but the pmt === 0 branch was not, so it could display rates below -100% at high compounding frequencies (for example around -1,374% for a daily case). Both paths are now covered.

Testing

Verified against representative cases:

  • Weekly 243.27% and daily 1,707.54% for the reported inputs both solve correctly
  • Standard saving and borrowing rate examples unchanged
  • Different payment-frequency mode solves correctly
  • pmt = 0 sub-100% case at daily compounding is now suppressed (previously showed roughly -1,374%)
  • pmt = 0, FV 0 total-loss case still shows -100.00%

No other calculator behavior changes. The G_HI per-period ceiling (1,000% per period) preserves the solver's prior reach and is tunable if a tighter bound is ever wanted.

Want this as a .md file to attach to the PR, or is this good to paste into the description?

// 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.

@majimmy88 majimmy88 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Added a minor NIT, but LGTM

@jenbreese jenbreese merged commit 1646fd3 into dev Jul 10, 2026
@jenbreese jenbreese mentioned this pull request Jul 10, 2026
14 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants