shadowprice 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- shadowprice/__init__.py +75 -0
- shadowprice/_basis.py +62 -0
- shadowprice/_version.py +1 -0
- shadowprice/benefits.py +65 -0
- shadowprice/conversion.py +168 -0
- shadowprice/discounting.py +147 -0
- shadowprice/economic.py +211 -0
- shadowprice/financial.py +228 -0
- shadowprice/financing.py +128 -0
- shadowprice/lines.py +99 -0
- shadowprice/methods.py +120 -0
- shadowprice/presets/__init__.py +1 -0
- shadowprice/presets/adb_2017.toml +22 -0
- shadowprice/presets/eu_2014.toml +48 -0
- shadowprice/presets/wb_2016.toml +24 -0
- shadowprice/project.py +259 -0
- shadowprice/report.py +171 -0
- shadowprice/risk.py +275 -0
- shadowprice/timeline.py +94 -0
- shadowprice-0.1.0.dist-info/METADATA +214 -0
- shadowprice-0.1.0.dist-info/RECORD +23 -0
- shadowprice-0.1.0.dist-info/WHEEL +4 -0
- shadowprice-0.1.0.dist-info/licenses/LICENSE +21 -0
shadowprice/__init__.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""shadowprice: economic cost-benefit analysis of investment projects.
|
|
2
|
+
|
|
3
|
+
Typical workflow::
|
|
4
|
+
|
|
5
|
+
import shadowprice as sp
|
|
6
|
+
|
|
7
|
+
p = sp.Project("Water network", start_year=2026, horizon=30, currency="KZT")
|
|
8
|
+
w = p.scenario("with")
|
|
9
|
+
w.investment("Pipes", {2026: 600, 2027: 400}, cf="traded", tax_share=0.12, group="capex")
|
|
10
|
+
w.om("Operation", 25, start=2028, group="opex")
|
|
11
|
+
w.revenue("Tariffs", 90, start=2028, group="demand")
|
|
12
|
+
w.benefit("Willingness to pay", 140, start=2028, group="demand")
|
|
13
|
+
|
|
14
|
+
fin = p.financial(rate=0.04)
|
|
15
|
+
eco = p.economic("adb_2017", national=sp.NationalParameters(serf=1.05))
|
|
16
|
+
sp.sensitivity(eco)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from . import benefits, conversion, discounting, report
|
|
20
|
+
from ._version import __version__
|
|
21
|
+
from .conversion import NationalParameters, ramsey_sdr, scf, shadow_wage_factor
|
|
22
|
+
from .discounting import irr, npv
|
|
23
|
+
from .economic import EconomicResult, economic_analysis
|
|
24
|
+
from .financial import FinancialResult, financial_analysis
|
|
25
|
+
from .financing import Financing, Loan
|
|
26
|
+
from .lines import Kind, Line
|
|
27
|
+
from .methods import Method, available_methods, method
|
|
28
|
+
from .project import Project, Scenario
|
|
29
|
+
from .risk import (
|
|
30
|
+
Discrete,
|
|
31
|
+
MonteCarloResult,
|
|
32
|
+
Normal,
|
|
33
|
+
Triangular,
|
|
34
|
+
Uniform,
|
|
35
|
+
monte_carlo,
|
|
36
|
+
scenario_npv,
|
|
37
|
+
sensitivity,
|
|
38
|
+
)
|
|
39
|
+
from .timeline import Timeline
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"Discrete",
|
|
43
|
+
"EconomicResult",
|
|
44
|
+
"FinancialResult",
|
|
45
|
+
"Financing",
|
|
46
|
+
"Kind",
|
|
47
|
+
"Line",
|
|
48
|
+
"Loan",
|
|
49
|
+
"Method",
|
|
50
|
+
"MonteCarloResult",
|
|
51
|
+
"NationalParameters",
|
|
52
|
+
"Normal",
|
|
53
|
+
"Project",
|
|
54
|
+
"Scenario",
|
|
55
|
+
"Timeline",
|
|
56
|
+
"Triangular",
|
|
57
|
+
"Uniform",
|
|
58
|
+
"__version__",
|
|
59
|
+
"available_methods",
|
|
60
|
+
"benefits",
|
|
61
|
+
"conversion",
|
|
62
|
+
"discounting",
|
|
63
|
+
"economic_analysis",
|
|
64
|
+
"financial_analysis",
|
|
65
|
+
"irr",
|
|
66
|
+
"method",
|
|
67
|
+
"monte_carlo",
|
|
68
|
+
"npv",
|
|
69
|
+
"ramsey_sdr",
|
|
70
|
+
"report",
|
|
71
|
+
"scenario_npv",
|
|
72
|
+
"scf",
|
|
73
|
+
"sensitivity",
|
|
74
|
+
"shadow_wage_factor",
|
|
75
|
+
]
|
shadowprice/_basis.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Common interface between analysis results and risk analysis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class RiskBasis:
|
|
12
|
+
"""Signed line flows of an analysis, as needed by sensitivity and Monte Carlo.
|
|
13
|
+
|
|
14
|
+
Because NPV is linear in each line's scale, the risk tools work directly on
|
|
15
|
+
the present value of each line (or group of lines).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
label: str
|
|
19
|
+
names: list[str]
|
|
20
|
+
groups: list[str]
|
|
21
|
+
flows: np.ndarray # shape (lines, years), signed, in analysis prices
|
|
22
|
+
rate: float
|
|
23
|
+
threshold: float = 0.01
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def discount(self) -> np.ndarray:
|
|
27
|
+
return (1.0 + self.rate) ** -np.arange(self.flows.shape[1], dtype=float)
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def pv_lines(self) -> np.ndarray:
|
|
31
|
+
return self.flows @ self.discount
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def npv(self) -> float:
|
|
35
|
+
return float(self.pv_lines.sum())
|
|
36
|
+
|
|
37
|
+
def keys(self, level: str = "group") -> list[str]:
|
|
38
|
+
"""Distinct groups (in order of appearance) or line names."""
|
|
39
|
+
if level == "line":
|
|
40
|
+
return list(self.names)
|
|
41
|
+
if level != "group":
|
|
42
|
+
raise ValueError("level must be 'group' or 'line'")
|
|
43
|
+
out: list[str] = []
|
|
44
|
+
for g in self.groups:
|
|
45
|
+
if g not in out:
|
|
46
|
+
out.append(g)
|
|
47
|
+
return out
|
|
48
|
+
|
|
49
|
+
def key_index(self, level: str = "group") -> np.ndarray:
|
|
50
|
+
"""For each line, the position of its key in :meth:`keys`."""
|
|
51
|
+
keys = self.keys(level)
|
|
52
|
+
labels = self.names if level == "line" else self.groups
|
|
53
|
+
return np.array([keys.index(k) for k in labels], dtype=int)
|
|
54
|
+
|
|
55
|
+
def by(self, level: str = "group") -> tuple[list[str], np.ndarray]:
|
|
56
|
+
"""Aggregate flows by ``"group"`` or by ``"line"``; returns (keys, flows)."""
|
|
57
|
+
keys = self.keys(level)
|
|
58
|
+
if level == "line":
|
|
59
|
+
return keys, self.flows
|
|
60
|
+
idx = self.key_index(level)
|
|
61
|
+
agg = np.vstack([self.flows[idx == j].sum(axis=0) for j in range(len(keys))])
|
|
62
|
+
return keys, agg
|
shadowprice/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
shadowprice/benefits.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Helpers to monetise common economic benefits and externalities.
|
|
2
|
+
|
|
3
|
+
Each helper returns a yearly array to pass to ``Scenario.benefit`` or
|
|
4
|
+
``Scenario.external_cost``. Inputs are scalars or arrays aligned with the
|
|
5
|
+
project timeline; unit values must already be in economic prices.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from .timeline import Timeline
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _arr(x) -> np.ndarray:
|
|
16
|
+
return np.asarray(x, dtype=float)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def willingness_to_pay(quantity, wtp_per_unit) -> np.ndarray:
|
|
20
|
+
"""Gross benefit ``quantity x WTP`` (e.g. m3 of water supplied x WTP per m3)."""
|
|
21
|
+
return _arr(quantity) * _arr(wtp_per_unit)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def consumer_surplus_change(
|
|
25
|
+
price_without, price_with, quantity_without, quantity_with
|
|
26
|
+
) -> np.ndarray:
|
|
27
|
+
"""Change in consumer surplus (rule of half) for a price reduction.
|
|
28
|
+
|
|
29
|
+
``0.5 x (P0 - P1) x (Q0 + Q1)``: benefits to existing users plus half the
|
|
30
|
+
benefit to generated demand.
|
|
31
|
+
"""
|
|
32
|
+
return (
|
|
33
|
+
0.5
|
|
34
|
+
* (_arr(price_without) - _arr(price_with))
|
|
35
|
+
* (_arr(quantity_without) + _arr(quantity_with))
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def time_savings(hours_saved, value_of_time) -> np.ndarray:
|
|
40
|
+
"""Value of travel or waiting time saved: ``hours x value per hour``."""
|
|
41
|
+
return _arr(hours_saved) * _arr(value_of_time)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def avoided_cost(cost) -> np.ndarray:
|
|
45
|
+
"""Resource costs avoided thanks to the project (e.g. coping costs, damages)."""
|
|
46
|
+
return _arr(cost)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def emissions(tonnes, price_per_tonne) -> np.ndarray:
|
|
50
|
+
"""Value of emissions: ``tonnes x shadow price``.
|
|
51
|
+
|
|
52
|
+
Use with ``benefit`` for reductions and with ``external_cost`` for increases.
|
|
53
|
+
"""
|
|
54
|
+
return _arr(tonnes) * _arr(price_per_tonne)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def price_path(
|
|
58
|
+
timeline: Timeline, value_in_base_year: float, growth: float = 0.0, base_year: int | None = None
|
|
59
|
+
) -> np.ndarray:
|
|
60
|
+
"""Unit value growing at a constant real rate (e.g. a shadow price of carbon or value of time).
|
|
61
|
+
|
|
62
|
+
``value_t = value_base x (1 + growth) ** (year - base_year)``.
|
|
63
|
+
"""
|
|
64
|
+
base = timeline.start_year if base_year is None else base_year
|
|
65
|
+
return value_in_base_year * (1.0 + growth) ** (timeline.years - base)
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""National economic parameters and conversion factors.
|
|
2
|
+
|
|
3
|
+
Conversion factors (CFs) turn market prices into economic (shadow/accounting)
|
|
4
|
+
prices: ``economic value = market value x (1 - tax share) x CF``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
from .methods import Method
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def scf(
|
|
16
|
+
imports: float,
|
|
17
|
+
exports: float,
|
|
18
|
+
import_duties: float,
|
|
19
|
+
export_duties: float = 0.0,
|
|
20
|
+
import_subsidies: float = 0.0,
|
|
21
|
+
export_subsidies: float = 0.0,
|
|
22
|
+
) -> float:
|
|
23
|
+
"""Standard conversion factor.
|
|
24
|
+
|
|
25
|
+
General form ``(M + X) / (M + Tm - Sm + X - Tx + Sx)`` with imports ``M`` at
|
|
26
|
+
CIF, exports ``X`` at FOB, trade taxes ``T`` and subsidies ``S``. With no
|
|
27
|
+
export taxes or subsidies it reduces to the EU guide's
|
|
28
|
+
``SCF = (M + X) / (M + X + TM)``.
|
|
29
|
+
"""
|
|
30
|
+
denom = imports + import_duties - import_subsidies + exports - export_duties + export_subsidies
|
|
31
|
+
if denom <= 0:
|
|
32
|
+
raise ValueError("denominator of the SCF must be positive")
|
|
33
|
+
return (imports + exports) / denom
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def serf_from_scf(scf_value: float) -> float:
|
|
37
|
+
"""Shadow exchange rate factor implied by an SCF (``SERF = 1 / SCF``)."""
|
|
38
|
+
return 1.0 / scf_value
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def traded_input_cf(import_tax: float = 0.0, vat: float = 0.0) -> float:
|
|
42
|
+
"""CF of a traded input bought at a tax-inclusive market price: ``(1 - i)(1 - VAT)``.
|
|
43
|
+
|
|
44
|
+
Use either this factor *or* ``tax_share`` on the line, not both.
|
|
45
|
+
"""
|
|
46
|
+
return (1.0 - import_tax) * (1.0 - vat)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def shadow_wage_factor(income_tax: float = 0.0, unemployment: float = 0.0) -> float:
|
|
50
|
+
"""Labour CF ``SW / W = (1 - t)(1 - u)`` (EU guide, unskilled labour).
|
|
51
|
+
|
|
52
|
+
``t`` is income taxation and ``u`` the regional unemployment rate. With
|
|
53
|
+
``unemployment=0`` it gives the shortcut ``SW = W (1 - t)``.
|
|
54
|
+
"""
|
|
55
|
+
for v, n in ((income_tax, "income_tax"), (unemployment, "unemployment")):
|
|
56
|
+
if not 0.0 <= v < 1.0:
|
|
57
|
+
raise ValueError(f"{n} must be in [0, 1)")
|
|
58
|
+
return (1.0 - income_tax) * (1.0 - unemployment)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def ramsey_sdr(growth: float, elasticity: float = 2.0, pure_time_preference: float = 0.0) -> float:
|
|
62
|
+
"""Social discount rate from the Ramsey formula ``SDR = delta + eta * g``.
|
|
63
|
+
|
|
64
|
+
The defaults (``eta = 2``, ``delta = 0``) reproduce the World Bank (2016)
|
|
65
|
+
mapping: 3% per-capita growth gives a 6% SDR.
|
|
66
|
+
"""
|
|
67
|
+
return pure_time_preference + elasticity * growth
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class NationalParameters:
|
|
72
|
+
"""Country-specific shadow-price parameters.
|
|
73
|
+
|
|
74
|
+
Parameters
|
|
75
|
+
----------
|
|
76
|
+
scf:
|
|
77
|
+
Standard conversion factor (non-traded items, world-price numeraire).
|
|
78
|
+
serf:
|
|
79
|
+
Shadow exchange rate factor (traded items, domestic-price numeraire).
|
|
80
|
+
If omitted and ``scf`` is given, ``1 / scf`` is used.
|
|
81
|
+
shadow_wage:
|
|
82
|
+
Labour CFs by tag, expressed in domestic prices, e.g.
|
|
83
|
+
``{"labour_unskilled": 0.75, "labour_skilled": 1.0}``.
|
|
84
|
+
factors:
|
|
85
|
+
Any other CFs by tag (e.g. ``{"electricity": 0.92, "land": 0.6}``).
|
|
86
|
+
source:
|
|
87
|
+
Where the values come from (kept in reports).
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
scf: float | None = None
|
|
91
|
+
serf: float | None = None
|
|
92
|
+
shadow_wage: Mapping[str, float] = field(default_factory=dict)
|
|
93
|
+
factors: Mapping[str, float] = field(default_factory=dict)
|
|
94
|
+
source: str = ""
|
|
95
|
+
|
|
96
|
+
def __post_init__(self) -> None:
|
|
97
|
+
if self.serf is None and self.scf is not None:
|
|
98
|
+
self.serf = serf_from_scf(self.scf)
|
|
99
|
+
if self.scf is None and self.serf is not None:
|
|
100
|
+
self.scf = 1.0 / self.serf
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class ConversionError(KeyError):
|
|
104
|
+
"""A conversion factor could not be resolved."""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def resolve_cf(
|
|
108
|
+
tag: str | float,
|
|
109
|
+
method: Method,
|
|
110
|
+
national: NationalParameters | None,
|
|
111
|
+
overrides: Mapping[str, float] | None = None,
|
|
112
|
+
) -> float:
|
|
113
|
+
"""Resolve a CF tag to a number under a method's numeraire.
|
|
114
|
+
|
|
115
|
+
Resolution order: explicit number, user overrides, built-in tags
|
|
116
|
+
(``none``, ``default``, ``traded``, ``non_traded``), labour tags, custom
|
|
117
|
+
factors in :class:`NationalParameters`.
|
|
118
|
+
|
|
119
|
+
* domestic numeraire: ``traded -> SERF``, ``non_traded -> 1``
|
|
120
|
+
* world numeraire: ``traded -> 1``, ``non_traded -> SCF``; labour CFs
|
|
121
|
+
(given in domestic prices) are multiplied by the SCF.
|
|
122
|
+
"""
|
|
123
|
+
if not isinstance(tag, str):
|
|
124
|
+
return float(tag)
|
|
125
|
+
if overrides and tag in overrides:
|
|
126
|
+
return float(overrides[tag])
|
|
127
|
+
nat = national or NationalParameters()
|
|
128
|
+
world = method.numeraire == "world"
|
|
129
|
+
if tag == "none":
|
|
130
|
+
return 1.0
|
|
131
|
+
if tag == "default":
|
|
132
|
+
return method.default_cf
|
|
133
|
+
if tag == "traded":
|
|
134
|
+
if not world:
|
|
135
|
+
if nat.serf is None:
|
|
136
|
+
raise ConversionError(
|
|
137
|
+
"tag 'traded' needs a shadow exchange rate factor under the domestic "
|
|
138
|
+
"numeraire: pass NationalParameters(serf=...) or scf=..."
|
|
139
|
+
)
|
|
140
|
+
return nat.serf
|
|
141
|
+
return 1.0
|
|
142
|
+
if tag == "non_traded":
|
|
143
|
+
if world:
|
|
144
|
+
if nat.scf is None:
|
|
145
|
+
raise ConversionError(
|
|
146
|
+
"tag 'non_traded' needs a standard conversion factor under the world "
|
|
147
|
+
"numeraire: pass NationalParameters(scf=...)"
|
|
148
|
+
)
|
|
149
|
+
return nat.scf
|
|
150
|
+
return 1.0
|
|
151
|
+
if tag.startswith("labour"):
|
|
152
|
+
if tag not in nat.shadow_wage:
|
|
153
|
+
raise ConversionError(
|
|
154
|
+
f"no shadow wage factor for '{tag}': pass NationalParameters("
|
|
155
|
+
f"shadow_wage={{'{tag}': ...}}) or use shadow_wage_factor()"
|
|
156
|
+
)
|
|
157
|
+
f = float(nat.shadow_wage[tag])
|
|
158
|
+
if world:
|
|
159
|
+
if nat.scf is None:
|
|
160
|
+
raise ConversionError("labour CFs under the world numeraire need an SCF")
|
|
161
|
+
f *= nat.scf
|
|
162
|
+
return f
|
|
163
|
+
if tag in nat.factors:
|
|
164
|
+
return float(nat.factors[tag])
|
|
165
|
+
raise ConversionError(
|
|
166
|
+
f"unknown conversion-factor tag '{tag}': define it in NationalParameters(factors=...) "
|
|
167
|
+
"or pass cf={tag: value}"
|
|
168
|
+
)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Discounting, NPV, IRR and price-basis helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import warnings
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
_IRR_LO = -0.99
|
|
10
|
+
_IRR_HI = 10.0
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MultipleIRRWarning(UserWarning):
|
|
14
|
+
"""Raised when a cash-flow profile has more than one internal rate of return."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def discount_factors(rate: float, n: int) -> np.ndarray:
|
|
18
|
+
"""Discount factors ``(1 + r)^-t`` for ``t = 0..n-1``."""
|
|
19
|
+
if rate <= -1:
|
|
20
|
+
raise ValueError("discount rate must be greater than -100%")
|
|
21
|
+
return (1.0 + rate) ** -np.arange(n, dtype=float)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def npv(rate: float, flows) -> float:
|
|
25
|
+
"""Net present value with the first flow at ``t = 0`` (undiscounted)."""
|
|
26
|
+
f = np.asarray(flows, dtype=float)
|
|
27
|
+
return float(f @ discount_factors(rate, f.size))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def present_value(rate: float, flows) -> float:
|
|
31
|
+
"""Alias of :func:`npv` for one-sided streams (costs or benefits)."""
|
|
32
|
+
return npv(rate, flows)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _bisect(fun, lo: float, hi: float, tol: float = 1e-12, maxiter: int = 200) -> float:
|
|
36
|
+
flo = fun(lo)
|
|
37
|
+
for _ in range(maxiter):
|
|
38
|
+
mid = 0.5 * (lo + hi)
|
|
39
|
+
fmid = fun(mid)
|
|
40
|
+
if fmid == 0 or (hi - lo) < tol:
|
|
41
|
+
return mid
|
|
42
|
+
if np.sign(fmid) == np.sign(flo):
|
|
43
|
+
lo, flo = mid, fmid
|
|
44
|
+
else:
|
|
45
|
+
hi = mid
|
|
46
|
+
return 0.5 * (lo + hi)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def irr(flows, lo: float = _IRR_LO, hi: float = _IRR_HI) -> float:
|
|
50
|
+
"""Internal rate of return.
|
|
51
|
+
|
|
52
|
+
Scans ``[lo, hi]`` for sign changes of the NPV and refines each by bisection.
|
|
53
|
+
Returns ``nan`` when no IRR exists in the interval. If several roots exist
|
|
54
|
+
(non-conventional flows) the lowest is returned and a
|
|
55
|
+
:class:`MultipleIRRWarning` is issued.
|
|
56
|
+
"""
|
|
57
|
+
f = np.asarray(flows, dtype=float)
|
|
58
|
+
if not (np.any(f > 0) and np.any(f < 0)):
|
|
59
|
+
return float("nan")
|
|
60
|
+
t = np.arange(f.size, dtype=float)
|
|
61
|
+
|
|
62
|
+
def fun(r: float) -> float:
|
|
63
|
+
return float(f @ (1.0 + r) ** -t)
|
|
64
|
+
|
|
65
|
+
grid = np.concatenate([np.linspace(lo, 0.0, 100, endpoint=False), np.geomspace(1e-4, hi, 400)])
|
|
66
|
+
grid = np.unique(np.concatenate([grid, [0.0]]))
|
|
67
|
+
vals = np.array([fun(r) for r in grid])
|
|
68
|
+
roots = []
|
|
69
|
+
for i in range(grid.size - 1):
|
|
70
|
+
if vals[i] == 0:
|
|
71
|
+
roots.append(float(grid[i]))
|
|
72
|
+
elif np.sign(vals[i]) != np.sign(vals[i + 1]):
|
|
73
|
+
roots.append(_bisect(fun, float(grid[i]), float(grid[i + 1])))
|
|
74
|
+
if not roots:
|
|
75
|
+
return float("nan")
|
|
76
|
+
if len(roots) > 1:
|
|
77
|
+
warnings.warn(
|
|
78
|
+
f"cash flows have {len(roots)} internal rates of return "
|
|
79
|
+
f"({', '.join(f'{r:.4f}' for r in roots)}); returning the lowest. "
|
|
80
|
+
"Rely on NPV for decisions.",
|
|
81
|
+
MultipleIRRWarning,
|
|
82
|
+
stacklevel=2,
|
|
83
|
+
)
|
|
84
|
+
return roots[0]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def irr_vectorized(flows: np.ndarray, lo: float = _IRR_LO, hi: float = _IRR_HI) -> np.ndarray:
|
|
88
|
+
"""IRR for each row of a 2-D array of cash flows (bisection, all rows at once).
|
|
89
|
+
|
|
90
|
+
Rows whose NPV does not change sign between ``lo`` and ``hi`` return ``nan``.
|
|
91
|
+
Intended for Monte Carlo draws of conventional profiles.
|
|
92
|
+
"""
|
|
93
|
+
f = np.atleast_2d(np.asarray(flows, dtype=float))
|
|
94
|
+
t = np.arange(f.shape[1], dtype=float)
|
|
95
|
+
|
|
96
|
+
def npv_rows(r: np.ndarray) -> np.ndarray:
|
|
97
|
+
return np.sum(f * (1.0 + r[:, None]) ** -t, axis=1)
|
|
98
|
+
|
|
99
|
+
a = np.full(f.shape[0], lo)
|
|
100
|
+
b = np.full(f.shape[0], hi)
|
|
101
|
+
fa = npv_rows(a)
|
|
102
|
+
fb = npv_rows(b)
|
|
103
|
+
ok = np.sign(fa) != np.sign(fb)
|
|
104
|
+
for _ in range(100):
|
|
105
|
+
m = 0.5 * (a + b)
|
|
106
|
+
fm = npv_rows(m)
|
|
107
|
+
left = np.sign(fm) == np.sign(fa)
|
|
108
|
+
a = np.where(left, m, a)
|
|
109
|
+
fa = np.where(left, fm, fa)
|
|
110
|
+
b = np.where(left, b, m)
|
|
111
|
+
out = 0.5 * (a + b)
|
|
112
|
+
out[~ok] = np.nan
|
|
113
|
+
return out
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def real_rate(nominal: float, inflation: float) -> float:
|
|
117
|
+
"""Fisher relation: real rate from a nominal rate and inflation."""
|
|
118
|
+
return (1.0 + nominal) / (1.0 + inflation) - 1.0
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def nominal_rate(real: float, inflation: float) -> float:
|
|
122
|
+
"""Fisher relation: nominal rate from a real rate and inflation."""
|
|
123
|
+
return (1.0 + real) * (1.0 + inflation) - 1.0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def price_index(inflation, n: int, base: int = 0) -> np.ndarray:
|
|
127
|
+
"""Cumulative price index equal to 1 in period ``base``.
|
|
128
|
+
|
|
129
|
+
``inflation`` is a scalar or an array of yearly rates of length ``n``
|
|
130
|
+
(the rate of period ``t`` applies between ``t-1`` and ``t``).
|
|
131
|
+
"""
|
|
132
|
+
infl = np.broadcast_to(np.asarray(inflation, dtype=float), (n,)).copy()
|
|
133
|
+
infl[0] = 0.0
|
|
134
|
+
idx = np.cumprod(1.0 + infl)
|
|
135
|
+
return idx / idx[base]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def deflate(nominal_values, inflation, base: int = 0) -> np.ndarray:
|
|
139
|
+
"""Convert nominal values to constant (real) prices of period ``base``."""
|
|
140
|
+
v = np.asarray(nominal_values, dtype=float)
|
|
141
|
+
return v / price_index(inflation, v.size, base)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def inflate(real_values, inflation, base: int = 0) -> np.ndarray:
|
|
145
|
+
"""Convert constant prices of period ``base`` to nominal values."""
|
|
146
|
+
v = np.asarray(real_values, dtype=float)
|
|
147
|
+
return v * price_index(inflation, v.size, base)
|