capstack-lbo 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.
- capstack/__init__.py +243 -0
- capstack/balance_sheet.py +295 -0
- capstack/cli.py +1995 -0
- capstack/covenants.py +594 -0
- capstack/daycount.py +121 -0
- capstack/debt.py +2003 -0
- capstack/drivers.py +167 -0
- capstack/events.py +914 -0
- capstack/fees.py +484 -0
- capstack/incentive.py +517 -0
- capstack/money.py +204 -0
- capstack/operating.py +677 -0
- capstack/outcome.py +958 -0
- capstack/periods.py +431 -0
- capstack/py.typed +0 -0
- capstack/report.py +1188 -0
- capstack/returns.py +426 -0
- capstack/sensitivity.py +940 -0
- capstack/spec.py +1686 -0
- capstack/tax.py +834 -0
- capstack/transaction.py +430 -0
- capstack_lbo-0.1.0.dist-info/METADATA +1260 -0
- capstack_lbo-0.1.0.dist-info/RECORD +27 -0
- capstack_lbo-0.1.0.dist-info/WHEEL +5 -0
- capstack_lbo-0.1.0.dist-info/entry_points.txt +3 -0
- capstack_lbo-0.1.0.dist-info/licenses/LICENSE +21 -0
- capstack_lbo-0.1.0.dist-info/top_level.txt +1 -0
capstack/returns.py
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
"""Discounting and the return measures.
|
|
2
|
+
|
|
3
|
+
The measure a sponsor is judged on is the internal rate of return, which is a
|
|
4
|
+
root of the net-present-value function. That function is a polynomial in the
|
|
5
|
+
discount factor, and finding its root is where naive implementations fail.
|
|
6
|
+
|
|
7
|
+
Two failure modes matter and both are handled explicitly here rather than
|
|
8
|
+
papered over:
|
|
9
|
+
|
|
10
|
+
*No root.* A stream that never changes sign has no internal rate of return at
|
|
11
|
+
all. There is no rate at which a series of pure outflows has zero present value.
|
|
12
|
+
The honest answer is to refuse, not to return whatever the iteration drifted to.
|
|
13
|
+
|
|
14
|
+
*More than one root.* Descartes' rule of signs bounds the number of positive
|
|
15
|
+
roots by the number of sign changes in the coefficient sequence. A stream that
|
|
16
|
+
goes out, comes back, goes out again — a bolt-on acquisition funded by a follow-
|
|
17
|
+
on equity cheque, say — can genuinely have two rates at which its present value
|
|
18
|
+
is zero, and neither is more the answer than the other. Reporting the first one
|
|
19
|
+
the solver happened to land on is how a model comes to claim a 40% return on a
|
|
20
|
+
deal that also returns 5%. This module finds every root in the search range and
|
|
21
|
+
refuses to pick between them.
|
|
22
|
+
|
|
23
|
+
The solver itself is Brent's method: bisection's guarantee of convergence, with
|
|
24
|
+
inverse quadratic interpolation to get there quickly. It cannot leave the
|
|
25
|
+
bracket, which is what makes it the right choice over Newton here.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from collections.abc import Callable, Iterable, Iterator, Sequence
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from datetime import date
|
|
33
|
+
from decimal import Decimal
|
|
34
|
+
|
|
35
|
+
from .daycount import DayCount, year_fraction
|
|
36
|
+
from .money import ZERO, Money, Numeric, money, to_float
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"AmbiguousIRRError",
|
|
40
|
+
"CashFlow",
|
|
41
|
+
"CashFlowStream",
|
|
42
|
+
"DidNotConvergeError",
|
|
43
|
+
"IRRError",
|
|
44
|
+
"NoSignChangeError",
|
|
45
|
+
"brent_root",
|
|
46
|
+
"cagr",
|
|
47
|
+
"irr_periodic",
|
|
48
|
+
"moic",
|
|
49
|
+
"npv_periodic",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class IRRError(ValueError):
|
|
54
|
+
"""Base for every reason an internal rate of return cannot be reported."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class NoSignChangeError(IRRError):
|
|
58
|
+
"""The stream never changes sign, so no rate sets its present value to zero."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AmbiguousIRRError(IRRError):
|
|
62
|
+
"""More than one rate sets the present value to zero.
|
|
63
|
+
|
|
64
|
+
Carries every root found so the caller can decide what to do — typically
|
|
65
|
+
quote the one nearest a hurdle, or fall back to a money multiple.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(self, roots: Sequence[float]) -> None:
|
|
69
|
+
self.roots = tuple(roots)
|
|
70
|
+
formatted = ", ".join(f"{r:.6%}" for r in self.roots)
|
|
71
|
+
super().__init__(
|
|
72
|
+
f"the stream has {len(self.roots)} internal rates of return ({formatted}); "
|
|
73
|
+
"none of them is the answer on its own"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class DidNotConvergeError(IRRError):
|
|
78
|
+
"""The solver exhausted its iteration budget inside a valid bracket."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# --------------------------------------------------------------------------
|
|
82
|
+
# Root finding
|
|
83
|
+
# --------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
def brent_root(
|
|
86
|
+
f: Callable[[float], float],
|
|
87
|
+
lower: float,
|
|
88
|
+
upper: float,
|
|
89
|
+
*,
|
|
90
|
+
xtol: float = 1e-12,
|
|
91
|
+
rtol: float = 8.9e-16,
|
|
92
|
+
max_iter: int = 200,
|
|
93
|
+
) -> float:
|
|
94
|
+
"""Find a root of ``f`` in ``[lower, upper]`` by Brent's method.
|
|
95
|
+
|
|
96
|
+
The interval must bracket a root: ``f(lower)`` and ``f(upper)`` must have
|
|
97
|
+
opposite signs. Given that, the method always converges, because it falls
|
|
98
|
+
back to bisection whenever interpolation would step outside the bracket or
|
|
99
|
+
fail to shrink it fast enough.
|
|
100
|
+
"""
|
|
101
|
+
xpre, xcur = float(lower), float(upper)
|
|
102
|
+
fpre, fcur = f(xpre), f(xcur)
|
|
103
|
+
|
|
104
|
+
if fpre == 0.0:
|
|
105
|
+
return xpre
|
|
106
|
+
if fcur == 0.0:
|
|
107
|
+
return xcur
|
|
108
|
+
if fpre * fcur > 0.0:
|
|
109
|
+
raise ValueError("the interval does not bracket a root")
|
|
110
|
+
|
|
111
|
+
xblk = 0.0
|
|
112
|
+
fblk = 0.0
|
|
113
|
+
spre = 0.0
|
|
114
|
+
scur = 0.0
|
|
115
|
+
|
|
116
|
+
for _ in range(max_iter):
|
|
117
|
+
if fpre * fcur < 0.0:
|
|
118
|
+
xblk, fblk = xpre, fpre
|
|
119
|
+
spre = scur = xcur - xpre
|
|
120
|
+
if abs(fblk) < abs(fcur):
|
|
121
|
+
xpre, xcur, xblk = xcur, xblk, xcur
|
|
122
|
+
fpre, fcur, fblk = fcur, fblk, fcur
|
|
123
|
+
|
|
124
|
+
delta = (xtol + rtol * abs(xcur)) / 2.0
|
|
125
|
+
sbis = (xblk - xcur) / 2.0
|
|
126
|
+
|
|
127
|
+
if fcur == 0.0 or abs(sbis) < delta:
|
|
128
|
+
return xcur
|
|
129
|
+
|
|
130
|
+
if abs(spre) > delta and abs(fcur) < abs(fpre):
|
|
131
|
+
if xpre == xblk:
|
|
132
|
+
# Two distinct points only: secant step.
|
|
133
|
+
stry = -fcur * (xcur - xpre) / (fcur - fpre)
|
|
134
|
+
else:
|
|
135
|
+
# Three points: inverse quadratic interpolation.
|
|
136
|
+
dpre = (fpre - fcur) / (xpre - xcur)
|
|
137
|
+
dblk = (fblk - fcur) / (xblk - xcur)
|
|
138
|
+
stry = -fcur * (fblk * dblk - fpre * dpre) / (dblk * dpre * (fblk - fpre))
|
|
139
|
+
if 2.0 * abs(stry) < min(abs(spre), 3.0 * abs(sbis) - delta):
|
|
140
|
+
spre, scur = scur, stry
|
|
141
|
+
else:
|
|
142
|
+
spre = scur = sbis
|
|
143
|
+
else:
|
|
144
|
+
spre = scur = sbis
|
|
145
|
+
|
|
146
|
+
xpre, fpre = xcur, fcur
|
|
147
|
+
if abs(scur) > delta:
|
|
148
|
+
xcur += scur
|
|
149
|
+
else:
|
|
150
|
+
xcur += delta if sbis > 0.0 else -delta
|
|
151
|
+
fcur = f(xcur)
|
|
152
|
+
|
|
153
|
+
raise DidNotConvergeError(f"no root after {max_iter} iterations")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# --------------------------------------------------------------------------
|
|
157
|
+
# Periodic measures
|
|
158
|
+
# --------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
#: The search range for an internal rate of return. The lower bound sits just
|
|
161
|
+
#: above -100%, where the discount factor is singular. The upper bound is
|
|
162
|
+
#: deliberately generous: venture-style outcomes can exceed 1000% a year, and a
|
|
163
|
+
#: bound that excludes them turns a real answer into a spurious "no root".
|
|
164
|
+
RATE_FLOOR = -0.999_999
|
|
165
|
+
RATE_CEILING = 100.0
|
|
166
|
+
|
|
167
|
+
#: The scan is graded rather than uniform. Two roots are only found separately
|
|
168
|
+
#: if the scan puts a point between them, so resolution has to be fine where
|
|
169
|
+
#: deal returns actually live. A uniform scan across a range this wide would
|
|
170
|
+
#: need tens of thousands of points to achieve the same thing below 100%.
|
|
171
|
+
_SCAN_BANDS: tuple[tuple[float, float, float], ...] = (
|
|
172
|
+
(RATE_FLOOR, 1.0, 0.002), # -100% to 100%, resolved to a fifth of a point
|
|
173
|
+
(1.0, 10.0, 0.05), # 100% to 1000%
|
|
174
|
+
(10.0, RATE_CEILING, 1.0), # beyond that, coarse
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _scan_grid() -> list[float]:
|
|
179
|
+
"""Rates at which the present value is sampled looking for sign changes."""
|
|
180
|
+
grid = [RATE_FLOOR]
|
|
181
|
+
for lower, upper, step in _SCAN_BANDS:
|
|
182
|
+
count = round((upper - lower) / step)
|
|
183
|
+
grid.extend(lower + i * step for i in range(1, count + 1))
|
|
184
|
+
return grid
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _sign_changes(amounts: Sequence[Money]) -> int:
|
|
188
|
+
"""Count sign changes in the sequence, skipping zeros."""
|
|
189
|
+
changes = 0
|
|
190
|
+
previous = 0
|
|
191
|
+
for amount in amounts:
|
|
192
|
+
current = 1 if amount > 0 else (-1 if amount < 0 else 0)
|
|
193
|
+
if current == 0:
|
|
194
|
+
continue
|
|
195
|
+
if previous != 0 and current != previous:
|
|
196
|
+
changes += 1
|
|
197
|
+
previous = current
|
|
198
|
+
return changes
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def npv_periodic(rate: Numeric, amounts: Sequence[Money], *, first_period_offset: int = 0) -> Money:
|
|
202
|
+
"""Net present value of evenly spaced ``amounts`` at ``rate``.
|
|
203
|
+
|
|
204
|
+
``amounts[0]`` falls at time ``first_period_offset``, which is zero by
|
|
205
|
+
convention: the first flow is the one happening now.
|
|
206
|
+
"""
|
|
207
|
+
r = money(rate)
|
|
208
|
+
if r <= -1:
|
|
209
|
+
raise ValueError("discount rate must exceed -100%")
|
|
210
|
+
base = Decimal(1) + r
|
|
211
|
+
total = ZERO
|
|
212
|
+
for i, amount in enumerate(amounts):
|
|
213
|
+
total += amount / (base ** (i + first_period_offset))
|
|
214
|
+
return total
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _npv_float(rate: float, amounts: Sequence[float], times: Sequence[float]) -> float:
|
|
218
|
+
"""Floating-point present value, for the solver's inner loop."""
|
|
219
|
+
base = 1.0 + rate
|
|
220
|
+
total = 0.0
|
|
221
|
+
for amount, t in zip(amounts, times, strict=True):
|
|
222
|
+
total += amount / (base**t)
|
|
223
|
+
return total
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _solve_rate(amounts: Sequence[Money], times: Sequence[float]) -> float:
|
|
227
|
+
"""Find the unique rate at which the stream's present value is zero.
|
|
228
|
+
|
|
229
|
+
Scans the search range for sign changes, then refines each one. Raises if
|
|
230
|
+
there is no root, or if there is more than one.
|
|
231
|
+
"""
|
|
232
|
+
if _sign_changes(amounts) == 0:
|
|
233
|
+
raise NoSignChangeError(
|
|
234
|
+
"the stream does not change sign, so it has no internal rate of return"
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
values = [to_float(a) for a in amounts]
|
|
238
|
+
|
|
239
|
+
def f(r: float) -> float:
|
|
240
|
+
return _npv_float(r, values, times)
|
|
241
|
+
|
|
242
|
+
brackets: list[tuple[float, float]] = []
|
|
243
|
+
exact: list[float] = []
|
|
244
|
+
|
|
245
|
+
grid = _scan_grid()
|
|
246
|
+
previous_r = grid[0]
|
|
247
|
+
previous_v = f(previous_r)
|
|
248
|
+
for current_r in grid[1:]:
|
|
249
|
+
current_v = f(current_r)
|
|
250
|
+
if current_v == 0.0:
|
|
251
|
+
exact.append(current_r)
|
|
252
|
+
elif previous_v != 0.0 and (previous_v > 0.0) != (current_v > 0.0):
|
|
253
|
+
brackets.append((previous_r, current_r))
|
|
254
|
+
previous_r, previous_v = current_r, current_v
|
|
255
|
+
|
|
256
|
+
roots = [brent_root(f, lo, hi) for lo, hi in brackets] + exact
|
|
257
|
+
roots.sort()
|
|
258
|
+
|
|
259
|
+
# Two brackets either side of a touching root can converge to the same
|
|
260
|
+
# place; collapse those before deciding the answer is ambiguous.
|
|
261
|
+
distinct: list[float] = []
|
|
262
|
+
for r in roots:
|
|
263
|
+
if not distinct or abs(r - distinct[-1]) > 1e-9:
|
|
264
|
+
distinct.append(r)
|
|
265
|
+
|
|
266
|
+
if not distinct:
|
|
267
|
+
raise NoSignChangeError(
|
|
268
|
+
"the stream changes sign but no rate in the search range sets its "
|
|
269
|
+
"present value to zero"
|
|
270
|
+
)
|
|
271
|
+
if len(distinct) > 1:
|
|
272
|
+
raise AmbiguousIRRError(distinct)
|
|
273
|
+
return distinct[0]
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def irr_periodic(amounts: Sequence[Money]) -> float:
|
|
277
|
+
"""Internal rate of return of evenly spaced cash flows, per period.
|
|
278
|
+
|
|
279
|
+
The first amount is at time zero. The result is a per-period rate, so a
|
|
280
|
+
quarterly stream returns a quarterly rate.
|
|
281
|
+
"""
|
|
282
|
+
if len(amounts) < 2:
|
|
283
|
+
raise NoSignChangeError("a single cash flow has no rate of return")
|
|
284
|
+
times = [float(i) for i in range(len(amounts))]
|
|
285
|
+
return _solve_rate(amounts, times)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def moic(amounts: Iterable[Money]) -> Money:
|
|
289
|
+
"""Multiple of invested capital: everything received over everything put in.
|
|
290
|
+
|
|
291
|
+
Unlike a rate of return this ignores timing entirely, which is exactly why
|
|
292
|
+
it is quoted alongside one rather than instead of one.
|
|
293
|
+
"""
|
|
294
|
+
contributed = ZERO
|
|
295
|
+
distributed = ZERO
|
|
296
|
+
for amount in amounts:
|
|
297
|
+
if amount < 0:
|
|
298
|
+
contributed -= amount
|
|
299
|
+
else:
|
|
300
|
+
distributed += amount
|
|
301
|
+
if contributed == 0:
|
|
302
|
+
raise ValueError("no capital was invested, so there is no multiple on it")
|
|
303
|
+
return distributed / contributed
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def cagr(begin: Money, end: Money, years: Numeric) -> float:
|
|
307
|
+
"""Compound annual growth rate taking ``begin`` to ``end`` over ``years``."""
|
|
308
|
+
y = float(money(years))
|
|
309
|
+
if y <= 0:
|
|
310
|
+
raise ValueError("the period must be positive")
|
|
311
|
+
b, e = to_float(begin), to_float(end)
|
|
312
|
+
if b <= 0:
|
|
313
|
+
raise ValueError("the opening value must be positive")
|
|
314
|
+
if e < 0:
|
|
315
|
+
raise ValueError("the closing value must not be negative")
|
|
316
|
+
return float((e / b) ** (1.0 / y) - 1.0)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
# --------------------------------------------------------------------------
|
|
320
|
+
# Dated streams
|
|
321
|
+
# --------------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
@dataclass(frozen=True, slots=True)
|
|
324
|
+
class CashFlow:
|
|
325
|
+
"""One dated movement of money.
|
|
326
|
+
|
|
327
|
+
Sign is from the perspective of the party whose return is being measured:
|
|
328
|
+
negative is money out of their pocket, positive is money coming back.
|
|
329
|
+
"""
|
|
330
|
+
|
|
331
|
+
when: date
|
|
332
|
+
amount: Money
|
|
333
|
+
label: str = ""
|
|
334
|
+
|
|
335
|
+
@classmethod
|
|
336
|
+
def of(cls, when: date, amount: Numeric, label: str = "") -> CashFlow:
|
|
337
|
+
return cls(when=when, amount=money(amount), label=label)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
@dataclass(frozen=True, slots=True)
|
|
341
|
+
class CashFlowStream:
|
|
342
|
+
"""A dated series of cash flows, held in date order."""
|
|
343
|
+
|
|
344
|
+
flows: tuple[CashFlow, ...]
|
|
345
|
+
convention: DayCount = DayCount.ACT_365F
|
|
346
|
+
|
|
347
|
+
def __post_init__(self) -> None:
|
|
348
|
+
if not self.flows:
|
|
349
|
+
raise ValueError("a cash-flow stream needs at least one flow")
|
|
350
|
+
object.__setattr__(self, "flows", tuple(sorted(self.flows, key=lambda f: f.when)))
|
|
351
|
+
|
|
352
|
+
@classmethod
|
|
353
|
+
def of(
|
|
354
|
+
cls,
|
|
355
|
+
items: Iterable[tuple[date, Numeric]] | Iterable[CashFlow],
|
|
356
|
+
convention: DayCount = DayCount.ACT_365F,
|
|
357
|
+
) -> CashFlowStream:
|
|
358
|
+
flows: list[CashFlow] = []
|
|
359
|
+
for item in items:
|
|
360
|
+
if isinstance(item, CashFlow):
|
|
361
|
+
flows.append(item)
|
|
362
|
+
else:
|
|
363
|
+
when, amount = item
|
|
364
|
+
flows.append(CashFlow.of(when, amount))
|
|
365
|
+
return cls(flows=tuple(flows), convention=convention)
|
|
366
|
+
|
|
367
|
+
def __len__(self) -> int:
|
|
368
|
+
return len(self.flows)
|
|
369
|
+
|
|
370
|
+
def __iter__(self) -> Iterator[CashFlow]:
|
|
371
|
+
return iter(self.flows)
|
|
372
|
+
|
|
373
|
+
@property
|
|
374
|
+
def start(self) -> date:
|
|
375
|
+
return self.flows[0].when
|
|
376
|
+
|
|
377
|
+
@property
|
|
378
|
+
def end(self) -> date:
|
|
379
|
+
return self.flows[-1].when
|
|
380
|
+
|
|
381
|
+
@property
|
|
382
|
+
def amounts(self) -> tuple[Money, ...]:
|
|
383
|
+
return tuple(f.amount for f in self.flows)
|
|
384
|
+
|
|
385
|
+
@property
|
|
386
|
+
def total(self) -> Money:
|
|
387
|
+
return sum(self.amounts, ZERO)
|
|
388
|
+
|
|
389
|
+
def _times(self, as_of: date | None = None) -> list[float]:
|
|
390
|
+
origin = as_of or self.start
|
|
391
|
+
return [float(year_fraction(origin, f.when, self.convention)) for f in self.flows]
|
|
392
|
+
|
|
393
|
+
def npv(self, rate: Numeric, *, as_of: date | None = None) -> Money:
|
|
394
|
+
"""Present value at ``rate``, discounted to ``as_of`` (default: the first flow).
|
|
395
|
+
|
|
396
|
+
Computed in exact decimal rather than floating point, because this is a
|
|
397
|
+
reported number rather than a solver intermediate.
|
|
398
|
+
"""
|
|
399
|
+
r = money(rate)
|
|
400
|
+
if r <= -1:
|
|
401
|
+
raise ValueError("discount rate must exceed -100%")
|
|
402
|
+
origin = as_of or self.start
|
|
403
|
+
base = Decimal(1) + r
|
|
404
|
+
total = ZERO
|
|
405
|
+
for flow in self.flows:
|
|
406
|
+
t = year_fraction(origin, flow.when, self.convention)
|
|
407
|
+
total += flow.amount / (base**t)
|
|
408
|
+
return total
|
|
409
|
+
|
|
410
|
+
def xirr(self) -> float:
|
|
411
|
+
"""Annualised internal rate of return over the actual dates.
|
|
412
|
+
|
|
413
|
+
Irregular spacing is handled by measuring each flow's time from the
|
|
414
|
+
first one under the stream's day-count convention, so the rate is a
|
|
415
|
+
genuine annual figure rather than a per-period one.
|
|
416
|
+
"""
|
|
417
|
+
if len(self.flows) < 2:
|
|
418
|
+
raise NoSignChangeError("a single cash flow has no rate of return")
|
|
419
|
+
return _solve_rate(self.amounts, self._times())
|
|
420
|
+
|
|
421
|
+
def moic(self) -> Money:
|
|
422
|
+
return moic(self.amounts)
|
|
423
|
+
|
|
424
|
+
def holding_period_years(self) -> Money:
|
|
425
|
+
"""Elapsed years from the first flow to the last."""
|
|
426
|
+
return year_fraction(self.start, self.end, self.convention)
|