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/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)