Skip to content

Scan the bracket interior so even-crossing series find a root - #24

Merged
tubedude merged 1 commit into
mainfrom
bracket-interior-scan
Jul 5, 2026
Merged

Scan the bracket interior so even-crossing series find a root#24
tubedude merged 1 commit into
mainfrom
bracket-interior-scan

Conversation

@tubedude

@tubedude tubedude commented Jul 5, 2026

Copy link
Copy Markdown
Owner

The bug

Finance.Shared.bracket/1 compared only the two ends of the rate domain: it fixed the NPV at the floor and doubled high, checking those two endpoints for a sign change. When the NPV crosses zero an even number of times — a series with more than one IRR, where both ends share a sign — no bracket was ever found and the solve returned {:error, :did_not_converge}, even though a real, economically meaningful rate exists.

Reproduced against the numpy-financial #39 series (IRRs near −1.8% and 12%, NPV negative at both ends of the domain): Finance.CashFlow.irr/1 returned :did_not_converge on main where Excel/Sheets compute 12%.

The fix

Bracketing now scans the interior of the domain on a geometric grid (5% steps in 1 + rate) and, among every adjacent-sample sign change, returns the interval whose nearer edge is closest to :guess. So:

  • numpy #39 now converges to 0.12 (was :did_not_converge).
  • Root selection is guess-anchored: a multi-IRR series resolves to the rate a guess-driven spreadsheet XIRR would return, not always the lowest. The default guess (0.1) reproduces [-50,-100,600,300,-100] → -76.9% (numpy Guard against rates at or below -100% and fix rounding in ytm #28) and [-1600,10000,-10000] → 0.25; guess: 3.0 on the latter selects the far root 4.0.
  • Both Finance.Solver.Newton and Finance.Solver.Brent share the bracket, so they select the same root (parity-tested).
  • Single-rate series are unchanged; the two did_not_converge cases (net-positive / no sign change) still correctly diverge.

The floor logic, overflow/underflow safety, and expansion cap are preserved — only sign-change detection changed, not the domain math.

Tests

New regression coverage: the numpy #39 even-crossing case, guess-anchored root selection (both solvers), and real-world cash flows that broke other implementations (nodejs-xirr #2, the Ruby finance gem's 56y/23y horizons, peliot's negative-base crash class), plus the zero-payout / same-date / leap-year day-count conventions.

233 tests across seeds, credo --strict and dialyzer clean, 100% coverage, 0 doc warnings. Ships under 1.6.0 alongside Finance.Solver.Brent.

🤖 Generated with Claude Code

Bracketing compared only the two ends of the rate domain, so a series with
more than one IRR — where both ends share a sign — reported did_not_converge
even though a real rate existed (numpy-financial #39). The bracket now scans
the interior on a geometric grid and returns the sign-change interval nearest
:guess, so a multi-IRR series resolves to the rate a guess-driven spreadsheet
XIRR returns. Both solvers share the bracket. Single-rate series are unchanged.

Ships under 1.6.0 alongside Finance.Solver.Brent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AVibXT2w1dEdVtMHqffFKn
@tubedude
tubedude merged commit 4c2ee03 into main Jul 5, 2026
4 checks passed
@tubedude
tubedude deleted the bracket-interior-scan branch July 5, 2026 19:25
@coveralls

Copy link
Copy Markdown

Coverage Status

coverage: 100.0%. remained the same — bracket-interior-scan into main

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