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