Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions dcaf/metrics/npv.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
)
8 changes: 7 additions & 1 deletion dcaf/streams/cashflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
7 changes: 6 additions & 1 deletion dcaf/streams/generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
23 changes: 23 additions & 0 deletions tests/unit/test_cashflow_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ----


Expand Down
8 changes: 8 additions & 0 deletions tests/unit/test_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
22 changes: 22 additions & 0 deletions tests/unit/test_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down