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.
@@ -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
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -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)