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/drivers.py ADDED
@@ -0,0 +1,167 @@
1
+ """Assumption series.
2
+
3
+ Every line of an operating case is a number per period. Some are flat across the
4
+ hold, some are written out year by year, and a great many are a glide from one
5
+ value to another — growth stepping down from 9% to 3% as the business matures,
6
+ or margin building 150bp over five years as a cost programme lands.
7
+
8
+ Writing those out by hand for every line is where transcription errors get into
9
+ a model, so they are expressed once and expanded here.
10
+
11
+ An assumption is annual. That is a statement about the file rather than about
12
+ the grid: 8% growth means 8% over a year and 20% margin means 20% for the year,
13
+ and the same file has to describe the same business whether the year is reported
14
+ once or twelve times. Two things follow, and they are different for the two
15
+ kinds of assumption. A *rate of change* has to be converted before it is applied
16
+ more than once a year, which is what :func:`compounded_over` is for. A *level* —
17
+ a margin, a capital-intensity ratio — is already a share of something that has
18
+ itself been scaled to the period, so it is applied as written.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from collections.abc import Iterator, Sequence
24
+ from dataclasses import dataclass
25
+ from decimal import Decimal
26
+
27
+ from .money import ONE, Money, Numeric, money
28
+
29
+ __all__ = ["Driver", "compounded_over", "within_year_weights"]
30
+
31
+
32
+ def compounded_over(annual: Money, share: Money) -> Money:
33
+ """The rate that compounds to ``annual`` over the whole of a year.
34
+
35
+ ``share`` is the fraction of a year the period covers. Applying 8% in each
36
+ of four quarters grows a business by 36% over the year, which is not what
37
+ the file said. The rate that belongs in a quarter is the one that compounds
38
+ to 8% across four of them — ``1.08 ** 0.25 - 1``, or about 1.943%.
39
+
40
+ A ``share`` of exactly one returns ``annual`` unchanged rather than routing
41
+ it through a power. The result would agree to thirty-four digits either way;
42
+ returning it untouched makes an annual grid identical rather than merely
43
+ indistinguishable, which is the property the existing worked examples are
44
+ checked against.
45
+
46
+ A rate at or below -100% has no real root above zero. -100% itself is
47
+ admitted, because a business that ends the year at nothing is at nothing
48
+ however the year is divided. Anything below it is rejected rather than
49
+ quietly turned into a positive number by an even root.
50
+ """
51
+ if share <= 0:
52
+ raise ValueError("a period covers a positive share of a year")
53
+ if share == ONE:
54
+ return annual
55
+ base = ONE + annual
56
+ if base < 0:
57
+ raise ValueError(
58
+ f"a rate of {annual} shrinks the base past nothing, so it has no "
59
+ f"equivalent over part of a year"
60
+ )
61
+ return base**share - ONE
62
+
63
+
64
+ def within_year_weights(annual: Money, periods_per_year: int) -> tuple[Money, ...]:
65
+ """How a year's trading divides between the periods that report it.
66
+
67
+ A year's revenue is settled before this is asked: the annual line grows at
68
+ the annual rate, once, exactly as it does on an annual grid. What is left is
69
+ the question of where inside the year it was earned, and the answer cannot
70
+ be an equal split, because a business growing at 9% earns more in its fourth
71
+ quarter than in its first.
72
+
73
+ So the shares ramp at the rate that compounds to the annual one, and are
74
+ then normalised to sum to one. That gives both of the properties wanted at
75
+ once: the quarters of a year add back to the year exactly, so a file run
76
+ quarterly underwrites the same case it underwrote annually, and the quarters
77
+ still slope, so a covenant tested in the first quarter is tested against a
78
+ first quarter rather than against an average.
79
+
80
+ One period a year is the degenerate case and returns a single weight of one,
81
+ which keeps an annual grid untouched.
82
+ """
83
+ if periods_per_year < 1:
84
+ raise ValueError("a year is divided into at least one period")
85
+ if periods_per_year == 1:
86
+ return (ONE,)
87
+ step = ONE + compounded_over(annual, ONE / Decimal(periods_per_year))
88
+ if step <= 0:
89
+ # Only reachable at exactly -100% a year, where the business is at
90
+ # nothing from the first period on. All of the year's trading is in it.
91
+ return tuple(
92
+ ONE if k == 0 else Decimal(0) for k in range(periods_per_year)
93
+ )
94
+ shares = tuple(step**k for k in range(periods_per_year))
95
+ total = sum(shares, Decimal(0))
96
+ weights = [s / total for s in shares[:-1]]
97
+ # The last share is the residual rather than its own quotient, so the
98
+ # weights sum to exactly one and a year divided into twelve reassembles into
99
+ # the same year rather than into one a rounding unit away from it.
100
+ weights.append(ONE - sum(weights, Decimal(0)))
101
+ return tuple(weights)
102
+
103
+
104
+ @dataclass(frozen=True, slots=True)
105
+ class Driver:
106
+ """One assumption, resolved to a value for each period."""
107
+
108
+ values: tuple[Money, ...]
109
+
110
+ def __post_init__(self) -> None:
111
+ if not self.values:
112
+ raise ValueError("a driver needs at least one value")
113
+
114
+ @classmethod
115
+ def of(cls, values: Sequence[Numeric]) -> Driver:
116
+ """An explicit value per period."""
117
+ return cls(values=tuple(money(v) for v in values))
118
+
119
+ @classmethod
120
+ def constant(cls, value: Numeric, periods: int) -> Driver:
121
+ """The same value in every period."""
122
+ if periods <= 0:
123
+ raise ValueError("a driver must cover at least one period")
124
+ return cls(values=tuple([money(value)] * periods))
125
+
126
+ @classmethod
127
+ def ramp(cls, start: Numeric, end: Numeric, periods: int) -> Driver:
128
+ """A straight line from ``start`` to ``end``, inclusive of both.
129
+
130
+ The last period holds ``end`` exactly rather than approaching it, which
131
+ is what someone writing "growth tapers from 9% to 3%" means.
132
+ """
133
+ if periods <= 0:
134
+ raise ValueError("a driver must cover at least one period")
135
+ first, last = money(start), money(end)
136
+ if periods == 1:
137
+ return cls(values=(first,))
138
+ span = last - first
139
+ steps = Decimal(periods - 1)
140
+ return cls(values=tuple(first + span * Decimal(i) / steps for i in range(periods)))
141
+
142
+ def __len__(self) -> int:
143
+ return len(self.values)
144
+
145
+ def __iter__(self) -> Iterator[Money]:
146
+ return iter(self.values)
147
+
148
+ def __getitem__(self, index: int) -> Money:
149
+ return self.values[index]
150
+
151
+ def at(self, index: int) -> Money:
152
+ """Value for period ``index``, zero-based.
153
+
154
+ Reading past the end holds the final value rather than raising. A
155
+ five-year assumption applied to a six-year projection means the last
156
+ year repeats, which is what an analyst extending a case expects, and it
157
+ keeps a one-period mismatch from taking down the whole model.
158
+ """
159
+ if index < 0:
160
+ raise IndexError("period index must not be negative")
161
+ return self.values[min(index, len(self.values) - 1)]
162
+
163
+ def extended_to(self, periods: int) -> Driver:
164
+ """This driver stretched to ``periods``, holding the final value."""
165
+ if periods <= 0:
166
+ raise ValueError("a driver must cover at least one period")
167
+ return Driver(values=tuple(self.at(i) for i in range(periods)))