diff --git a/dcaf/metrics/npv.py b/dcaf/metrics/npv.py index 48a9642..b60f95f 100644 --- a/dcaf/metrics/npv.py +++ b/dcaf/metrics/npv.py @@ -6,9 +6,11 @@ from collections.abc import Iterable from datetime import date +from math import fsum from dcaf.shared.time import timedelta_fractional_years from dcaf.shared.types import DayCountConvention +from dcaf.shared.validation import validate_finite def npv( @@ -33,7 +35,8 @@ def npv( ``(amount, date)`` pairs to discount. Both financial amounts and physical quantities (e.g. MWh) are supported. rate : float - Annual discount rate as a decimal (e.g. ``0.10`` for 10%). + Annual discount rate as a decimal (e.g. ``0.10`` for 10%). Must be + finite and greater than ``-1``. valuation_date : date Reference date for discounting/compounding. convention : DayCountConvention, optional @@ -46,6 +49,11 @@ def npv( Sum of present values. Returns ``0.0`` for an empty *values* sequence. + Raises + ------ + ValueError + If *rate* is not finite or is less than or equal to ``-1``. + Examples -------- >>> from datetime import date @@ -56,9 +64,12 @@ def npv( ... ) # doctest: +SKIP 0.0 # approximately """ + validate_finite(rate, "rate") + if rate <= -1.0: + raise ValueError("rate must be greater than -1.0") + one_plus_r = 1.0 + rate - total = 0.0 - for amount, d in values: - t = timedelta_fractional_years(valuation_date, d, convention) - total += amount / one_plus_r**t - return total + return fsum( + amount / one_plus_r ** timedelta_fractional_years(valuation_date, d, convention) + for amount, d in values + ) diff --git a/dcaf/streams/cashflows.py b/dcaf/streams/cashflows.py index c38119d..0635808 100644 --- a/dcaf/streams/cashflows.py +++ b/dcaf/streams/cashflows.py @@ -840,7 +840,8 @@ def npv( Parameters ---------- rate : float - The annual discount rate as a decimal (e.g., 0.10 for 10%). + The annual discount rate as a decimal (e.g., 0.10 for 10%). Must be + finite and greater than ``-1``. valuation_date : date The date at which to calculate the present value. This is the reference point for all discounting/compounding calculations. @@ -854,6 +855,11 @@ def npv( The net present value of all cash cashflows in the stream, evaluated at the valuation date. + Raises + ------ + ValueError + If ``rate`` is not finite or is less than or equal to ``-1``. + Examples -------- >>> # Calculate NPV at project start with 10% discount rate diff --git a/dcaf/streams/generation.py b/dcaf/streams/generation.py index 32413fd..3c1880c 100644 --- a/dcaf/streams/generation.py +++ b/dcaf/streams/generation.py @@ -645,7 +645,7 @@ def discounted_sum( Parameters ---------- rate : float - Discount rate. + Annual discount rate. Must be finite and greater than ``-1``. valuation_date : date Reference date. convention : DayCountConvention, optional @@ -656,6 +656,11 @@ def discounted_sum( float Discounted total MWh. + Raises + ------ + ValueError + If ``rate`` is not finite or is less than or equal to ``-1``. + Examples -------- >>> stream = GenerationStream.from_capacity(100, 0.9, date(2030, 1, 1), 2) diff --git a/tests/unit/test_cashflow_stream.py b/tests/unit/test_cashflow_stream.py index cad8997..4556e3f 100644 --- a/tests/unit/test_cashflow_stream.py +++ b/tests/unit/test_cashflow_stream.py @@ -636,6 +636,29 @@ def test_npv_no_cashflows(): assert abs(npv) < tol +def test_npv_preserves_small_remainder_under_cancellation(): + """At zero rate, exact offsetting amounts leave the original small cashflow.""" + valuation_date = date(2026, 1, 1) + stream = CashFlowStream( + [ + CashFlow(1_000_000_000_000.0, valuation_date), + CashFlow(0.01, valuation_date), + CashFlow(-1_000_000_000_000.0, valuation_date), + ] + ) + + assert stream.npv(rate=0.0, valuation_date=valuation_date) == 0.01 + + +def test_npv_rejects_rate_at_minus_one(): + """The stream wrapper enforces the shared real-valued rate domain.""" + valuation_date = date(2026, 1, 1) + stream = CashFlowStream([CashFlow(100.0, date(2026, 7, 1))]) + + with pytest.raises(ValueError, match="rate must be greater than -1.0"): + stream.npv(rate=-1.0, valuation_date=valuation_date) + + # ---- filter by classification / is_cash keyword tests ---- diff --git a/tests/unit/test_generation.py b/tests/unit/test_generation.py index 2c0aeca..335b573 100644 --- a/tests/unit/test_generation.py +++ b/tests/unit/test_generation.py @@ -568,6 +568,14 @@ def test_discounted_sum_zero_rate(): assert abs(gs.discounted_sum(0.0, date(2030, 1, 1)) - gs.sum()) < 1e-6 +def test_discounted_sum_rejects_non_finite_rate(): + """The generation wrapper enforces the shared finite-rate requirement.""" + gs = GenerationStream([Generation(1000.0, date(2030, 1, 1))]) + + with pytest.raises(ValueError, match="rate must be finite"): + gs.discounted_sum(float("inf"), date(2030, 1, 1)) + + def test_discounted_sum_uses_constant_rate_escalation_for_discounting(): """Discounted sum matches evaluation through the shared constant-rate policy.""" valuation_date = date(2030, 1, 1) diff --git a/tests/unit/test_metrics.py b/tests/unit/test_metrics.py index 3ad0062..d2f6663 100644 --- a/tests/unit/test_metrics.py +++ b/tests/unit/test_metrics.py @@ -44,6 +44,28 @@ def test_empty_values(self): """Empty iterable returns 0.0.""" assert npv([], rate=0.10, valuation_date=date(2025, 1, 1)) == 0.0 + def test_allows_finite_negative_rate_above_minus_one(self): + """A finite rate above -1 remains in the real-valued NPV domain.""" + values = [(50.0, date(2026, 1, 1))] + + assert npv(values, rate=-0.5, valuation_date=date(2025, 1, 1)) == pytest.approx(100.0) + + @pytest.mark.parametrize("rate", [-1.0, -1.1]) + def test_rejects_rate_at_or_below_minus_one(self, rate): + """Rates at or below -1 are singular or complex for fractional periods.""" + values = [(100.0, date(2026, 7, 1))] + + with pytest.raises(ValueError, match="rate must be greater than -1.0"): + npv(values, rate=rate, valuation_date=date(2026, 1, 1)) + + @pytest.mark.parametrize("rate", [float("nan"), float("inf"), float("-inf")]) + def test_rejects_non_finite_rate(self, rate): + """NPV requires a finite discount rate.""" + values = [(100.0, date(2026, 7, 1))] + + with pytest.raises(ValueError, match="rate must be finite"): + npv(values, rate=rate, valuation_date=date(2026, 1, 1)) + def test_compounding_past_values(self): """Values before valuation_date are compounded forward.""" # 2025 is not a leap year: 2024-01-01 → 2025-01-01 = 366 days (2024 is leap)