Visitar URL original
fix(returns): xirr returns None for identically-zero NPV (not the seed) by robcohen · Pull Request #1840 · rustledger/rustledger · GitHub
Skip to content

fix(returns): xirr returns None for identically-zero NPV (not the seed) - #1840

Merged
robcohen merged 1 commit into
mainfrom
fix/xirr-degenerate-npv
Jul 21, 2026
Merged

robcohen merged 1 commit into
mainfrom
fix/xirr-degenerate-npv

Conversation

@robcohen

Copy link
Copy Markdown
Member

What

Closes #1817. xirr could return a fabricated 0.10 (its Newton seed) for a degenerate series whose NPV is identically zero at every rate.

The bug

A series whose flows net to zero on every date — e.g. two same-day deposit/withdrawal washes on two different days:

[(-1000, 2020-01-01), (+1000, 2020-01-01), (-500, 2020-06-01), (+500, 2020-06-01)]

has NPV(r) = Σ Sₜ/(1+r)ᵗ with every date-net Sₜ = 0, so NPV is identically zero at every rate. This slips past both existing guards (the canceling flows are +/-, and there are ≥2 distinct dates), and Newton's iteration-0 |NPV| < tol check then succeeds at the seed — so xirr returned +10% for a genuinely-undefined series.

The fix

Detect it exactly: group the flows by date and return None if every date's net is zero, checked in Decimal. This is rigorous, not a heuristic — the functions (1+r)⁻ᵗ for distinct t are linearly independent, so identically-zero NPV ⟺ every Sₜ = 0. No rate-sampling, so no false positives. It complements the existing all-same-date guard (which catches the nonzero-constant NPV case).

Tests

  • The two-date wash now returns None (was 0.10).
  • Over-firing guard: a series whose undiscounted total is zero but whose dates differ (-1000 then +1000 a year later) is a genuine 0% return and must still be solved — asserted to return ~0%, not None.

50 engine tests pass; clippy/doc/fmt clean.

Found in the #1816 round-3 review; the extraction side already avoids emitting the trigger — this closes the underlying engine edge.

🤖 Generated with Claude Code

Closes #1817. A cash-flow series whose flows net to zero on EVERY date has
NPV(r) = Σ Sₜ/(1+r)ᵗ with every date-net Sₜ = 0 — i.e. NPV is identically
zero at every rate, so the IRR is undefined. With ≥2 distinct dates (two
same-day washes on different days) this slips past both the sign-change guard
(the canceling flows are +/-) and the all-same-date guard, and Newton's
iteration-0 `|NPV| < tol` check then succeeds at the 0.10 seed — so xirr
fabricated a +10% return for an undefined series.

Detect it exactly: group flows by date and, if every date's net is zero
(checked in Decimal — the (1+r)⁻ᵗ for distinct t are linearly independent, so
identically-zero NPV ⟺ every Sₜ = 0), return None. This complements the
existing all-same-date guard (which catches the nonzero-constant NPV case).

Tests: the two-date wash now returns None (was 0.10); and a guard against
over-firing — a series whose UNDISCOUNTED total is zero but whose dates differ
(-1000 then +1000 a year later) is a genuine 0% return and is still solved.

Found in the #1816 round-3 review; the extraction side already avoids emitting
the trigger, this closes the underlying engine edge.
Copilot AI review requested due to automatic review settings July 21, 2026 02:26
@robcohen
robcohen requested a review from a team as a code owner July 21, 2026 02:26

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Fixes a correctness edge case in rustledger-returns’s xirr solver where a cash-flow series with NPV identically zero for all rates could previously return the Newton seed (0.10) instead of indicating “undefined” via None.

Changes:

  • Adds an exact degeneracy guard in xirr: group cash flows by date and return None when every date’s net flow is zero (so NPV is identically zero).
  • Adds regression coverage for the two-date “wash” series that previously fabricated 0.10.
  • Adds a non-regression test ensuring the guard does not fire when the undiscounted sum is zero but the series is time-structured (a legitimate ~0% IRR).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@github-actions

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

Tool Time Memory
rustledger 37ms ± 1ms 23.9 MB
beancount 792.7ms ± 9.7ms 50.7 MB
Comparison 21.4x faster 2.1x less

vs main branch (2026-07-20)

Metric main this PR Change
Time 31.6ms 37ms +17.1% 🔴
10K transactions · 5 runs, 2 warmup · hyperfine
What do these numbers mean?
  • Time: Wall-clock time to parse and validate the ledger (± std dev)
  • Memory: Peak resident set size (RSS)
  • vs main: Compared to latest nightly benchmark on main branch

@codecov

codecov Bot commented Jul 21, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@github-actions

Copy link
Copy Markdown
Contributor

✅ Compatibility Test Results

Metric Result
Check Compatibility 100%
BQL Compatibility 100%
Full AST Match 100%
Total Files 812
What's tested
  • Check: Exit codes and error counts match bean-check
  • BQL: Query results match bean-query
  • Files: ~800 test files downloaded from 10+ beancount repositories

Full results available in workflow artifacts

@robcohen
robcohen merged commit 2347aa5 into main Jul 21, 2026
44 checks passed
@robcohen
robcohen deleted the fix/xirr-degenerate-npv branch July 21, 2026 02:50
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.

xirr: degenerate series with identically-zero NPV returns the Newton seed instead of None

2 participants