Skip to content

Guard against rates at or below -100% and fix rounding in ytm - #28

Merged
tubedude merged 3 commits into
mainfrom
1.6.1
Jul 6, 2026
Merged

Guard against rates at or below -100% and fix rounding in ytm#28
tubedude merged 3 commits into
mainfrom
1.6.1

Conversation

@tubedude

@tubedude tubedude commented Jul 6, 2026

Copy link
Copy Markdown
Owner

Summary

This release hardens the library against edge cases where rates at or below -100% would cause arithmetic errors or nonsensical results. It also fixes a double-rounding bug in bond yield calculations and improves validation of input parameters.

Key Changes

Rate validation (the main fix)

  • All functions that compute (1 + rate)^t now validate that 1 + rate > 0 and return {:error, :undefined} instead of raising ArithmeticError or returning garbage. This includes:
    • CashFlow.xnpv, npv, mirr
    • TVM.fv, pv, pmt, amortization_schedule
    • Bonds.price and risk metrics (duration, modified_duration, convexity)
    • Returns.discounted_payback_period, profitability_index, twr
    • Rates.effective_annual_rate
    • Depreciation.sln (also now rejects non-positive life)
  • Rates.nominal_rate(-1.0, m) now correctly returns -m (total loss), staying the exact inverse of effective_annual_rate

Bond yield rounding fix

  • Bonds.ytm now solves the per-period rate at high precision (≥12 decimals) and rounds the annualized yield once, rather than rounding the period rate first and then multiplying by freq. The old path let freq amplify rounding error into the last reported digit (off by 1–3 units depending on frequency).
  • Returns.profitability_index similarly computes at full precision before the final round

Input validation improvements

  • :precision is now validated as 0..15 (the range Float.round/2 accepts) instead of any non-negative integer
  • :guess and :tolerance now accept integers as well as floats
  • Bonds functions now require an integer freq (matching the documented pos_integer type)
  • Depreciation.syd rejects fractional period values, matching ddb/db
  • xirr(dates, []) now correctly returns {:error, :mismatched_lengths} instead of crashing (empty amounts list is the two-list form, not options)

Discounting refactor

  • Centralized overflow-safe discounting in a new discount_factor(rate, t) helper that computes (1 + rate)^-t (negative exponent). This avoids the divide form 1 / (1 + rate)^t which would overflow its denominator at high rates over long horizons. All bond and returns metrics now route through this helper.
  • Moved the safely/1 wrapper (which catches ArithmeticError and maps it to :diverged) to Finance.Shared so both solvers use it consistently

Date parsing robustness

  • CashFlow.xirr now uses Date.from_erl/1 instead of a broad rescue, so malformed amounts raise immediately (a caller error) rather than being mislabeled as {:error, :invalid_date}

Test coverage

  • Added @solvers list to run fuzz properties and pathological corpus tests against both Newton and Brent solvers, ensuring both satisfy the same robustness contracts
  • Extended test suite with edge cases for rates at/below -100%, negative coupons, and degenerate inputs

Notable Implementation Details

  • The bracket scan in the solver now stops one step sooner when the root sits exactly at the guess, improving efficiency
  • Bond risk metrics (duration, modified_duration, convexity) now return {:error, :undefined} when a negative coupon drives the unit-face price to zero, rather than a nonsensical value or crash
  • TVM.amortization_schedule validates that the principal is positive and the rate is above -100% before building the schedule, preventing undefined payment calculations
  • All rate-dependent calculations now use a consistent guard: 1 + rate > 0 (or

https://claude.ai/code/session_01AVibXT2w1dEdVtMHqffFKn

Correctness:
- A rate at or below -100% returns {:error, :undefined} instead of raising
  ArithmeticError or returning garbage everywhere (1 + rate)^t is computed:
  xnpv/npv/mirr, TVM fv/pv/pmt/amortization_schedule, Bonds price + risk
  metrics, Returns dpp/profitability_index/twr, effective_annual_rate, sln.
  nominal_rate(-1.0, m) now returns -m so it stays the inverse of
  effective_annual_rate at total loss.
- Bonds duration/modified_duration/convexity return :undefined when a negative
  coupon zeroes the price, rather than a nonsensical value or a crash.
- ytm and profitability_index round once at full precision (ytm's periodic
  rounding was amplified by freq); a regression test pins ytm at the default
  precision.
- xirr(dates, []) returns :mismatched_lengths instead of crashing.
- Drop the broad rescue in normalize (dates parse via Date.from_erl/1); a
  malformed amount raises as a caller bug instead of being mislabeled
  :invalid_date.

Consistency and cleanup:
- Centralize discounting in one overflow-safe (1 + rate)^-t helper
  (Shared.discount_factor), used by present_value and the bond/returns metrics,
  which previously used a divide form that could overflow.
- Validate :precision as 0..15; accept integer :guess/:tolerance. Require an
  integer Bonds freq; reject a fractional syd period.
- Bracket scan stops one step sooner when the root sits at the guess. Hoist the
  solvers' safely/1 and rounding into Shared; dedupe the amortization row
  converters; trim comments that restated code.

Testing:
- Run the solver fuzz properties against both Newton and Brent, and add a
  dated, many-flow, random-sign XIRR property (only 2-flow dated series were
  fuzzed before). A real Newton/Brent robustness bug would surface here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AVibXT2w1dEdVtMHqffFKn
@coveralls

coveralls commented Jul 6, 2026

Copy link
Copy Markdown

Coverage Status

coverage: 100.0%. remained the same — 1.6.1 into main

tubedude and others added 2 commits July 6, 2026 03:55
The only feature gating < 1.18 was Enum.sum_by/2 (Elixir 1.18), introduced in
the 1.6.1 review pass; revert those six sites to Enum.reduce. The rest of the
code is ~1.12 vintage. Widen the CI matrix to 1.15.8/25 and 1.18.4/27 (test
only); format and Credo run on the newest version, whose formatter output is the
reference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AVibXT2w1dEdVtMHqffFKn
CLAUDE.md and the 1.6.1 changelog reflect the lowered requirement and the widened
CI matrix. The 1.0.0 changelog entry keeps ~> 1.18 as the historical record.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AVibXT2w1dEdVtMHqffFKn
@tubedude
tubedude merged commit ededb42 into main Jul 6, 2026
5 checks passed
@tubedude
tubedude deleted the 1.6.1 branch July 6, 2026 07:06
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