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/operating.py
ADDED
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
"""The operating case: drivers through to unlevered free cash flow.
|
|
2
|
+
|
|
3
|
+
What the business earns, and what it converts into cash, before any of it is
|
|
4
|
+
claimed by lenders. Everything here is unlevered on purpose — the capital
|
|
5
|
+
structure has no bearing on how many widgets get sold, and keeping the two
|
|
6
|
+
apart means the debt schedule in the next layer has a clean input rather than a
|
|
7
|
+
circular one.
|
|
8
|
+
|
|
9
|
+
Two things are easy to get wrong and are handled explicitly.
|
|
10
|
+
|
|
11
|
+
*Working capital is a movement, not a level.* A business growing at 8% with
|
|
12
|
+
working capital steady at 15% of revenue consumes cash every single period, even
|
|
13
|
+
though the ratio never budges, because the balance is rising and the increase
|
|
14
|
+
has to be funded. Subtracting the balance rather than the change is the classic
|
|
15
|
+
error and it understates cash flow by an order of magnitude.
|
|
16
|
+
|
|
17
|
+
*Losses carry forward, but they do not shelter everything.* A loss in one period
|
|
18
|
+
reduces tax in a later one, so the tax line depends on the whole history rather
|
|
19
|
+
than on the period in front of you. The usable amount is also capped at a
|
|
20
|
+
percentage of taxable income, which means a company with large historic losses
|
|
21
|
+
still writes a cheque to the revenue authority the moment it turns profitable.
|
|
22
|
+
Modelling the pool without the cap overstates cash in precisely the years a
|
|
23
|
+
sponsor is counting on it.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from collections.abc import Iterator, Sequence
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from decimal import Decimal
|
|
31
|
+
|
|
32
|
+
from .daycount import DayCount
|
|
33
|
+
from .drivers import Driver, compounded_over, within_year_weights
|
|
34
|
+
from .money import ONE, ZERO, Money, Numeric, money, safe_div
|
|
35
|
+
from .periods import Period, PeriodGrid, TrailingWindow, trailing_window
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"AcquiredStream",
|
|
39
|
+
"OperatingAssumptions",
|
|
40
|
+
"OperatingModel",
|
|
41
|
+
"OperatingPeriod",
|
|
42
|
+
"TaxResult",
|
|
43
|
+
"apply_carryforward",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
#: Default cap on how much of a period's taxable income a carried-forward loss
|
|
47
|
+
#: may shelter. Eighty per cent matches the limitation that applies to losses
|
|
48
|
+
#: arising in US tax years from 2018 onward; set it to 1 to model a regime with
|
|
49
|
+
#: no such cap.
|
|
50
|
+
DEFAULT_NOL_USAGE_LIMIT = money("0.80")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class TaxResult:
|
|
55
|
+
"""What a period's tax computation produced, with the pool movement shown."""
|
|
56
|
+
|
|
57
|
+
taxable_income: Money
|
|
58
|
+
loss_relief_used: Money
|
|
59
|
+
taxable_after_relief: Money
|
|
60
|
+
cash_tax: Money
|
|
61
|
+
closing_carryforward: Money
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def apply_carryforward(
|
|
65
|
+
taxable_income: Money,
|
|
66
|
+
opening_carryforward: Money,
|
|
67
|
+
tax_rate: Money,
|
|
68
|
+
*,
|
|
69
|
+
usage_limit: Money = DEFAULT_NOL_USAGE_LIMIT,
|
|
70
|
+
) -> TaxResult:
|
|
71
|
+
"""Compute cash tax for one period against a loss carryforward.
|
|
72
|
+
|
|
73
|
+
A loss adds to the pool and pays nothing. A profit draws on the pool, but
|
|
74
|
+
only up to ``usage_limit`` of that profit, so the remainder is taxed even
|
|
75
|
+
when the pool is large enough to cover it several times over.
|
|
76
|
+
"""
|
|
77
|
+
if tax_rate < 0 or tax_rate > 1:
|
|
78
|
+
raise ValueError("the tax rate must be between 0 and 1")
|
|
79
|
+
if usage_limit < 0 or usage_limit > 1:
|
|
80
|
+
raise ValueError("the loss usage limit must be between 0 and 1")
|
|
81
|
+
if opening_carryforward < 0:
|
|
82
|
+
raise ValueError("the carryforward pool must not be negative")
|
|
83
|
+
|
|
84
|
+
if taxable_income <= 0:
|
|
85
|
+
# The loss itself joins the pool; nothing is payable.
|
|
86
|
+
return TaxResult(
|
|
87
|
+
taxable_income=taxable_income,
|
|
88
|
+
loss_relief_used=ZERO,
|
|
89
|
+
taxable_after_relief=ZERO,
|
|
90
|
+
cash_tax=ZERO,
|
|
91
|
+
closing_carryforward=opening_carryforward - taxable_income,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
shelterable = taxable_income * usage_limit
|
|
95
|
+
used = min(opening_carryforward, shelterable)
|
|
96
|
+
after_relief = taxable_income - used
|
|
97
|
+
return TaxResult(
|
|
98
|
+
taxable_income=taxable_income,
|
|
99
|
+
loss_relief_used=used,
|
|
100
|
+
taxable_after_relief=after_relief,
|
|
101
|
+
cash_tax=after_relief * tax_rate,
|
|
102
|
+
closing_carryforward=opening_carryforward - used,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True, slots=True)
|
|
107
|
+
class OperatingAssumptions:
|
|
108
|
+
"""The operating case, one driver per line.
|
|
109
|
+
|
|
110
|
+
Rates are expressed against revenue because that is how an operating case is
|
|
111
|
+
negotiated and how it is sanity-checked. Capital expenditure at 4% of
|
|
112
|
+
revenue is a statement someone can argue with; 47.2 of capital expenditure
|
|
113
|
+
is not.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
revenue_growth: Driver
|
|
117
|
+
ebitda_margin: Driver
|
|
118
|
+
da_rate: Driver
|
|
119
|
+
capex_rate: Driver
|
|
120
|
+
nwc_rate: Driver
|
|
121
|
+
tax_rate: Money
|
|
122
|
+
opening_carryforward: Money = ZERO
|
|
123
|
+
nol_usage_limit: Money = DEFAULT_NOL_USAGE_LIMIT
|
|
124
|
+
|
|
125
|
+
@classmethod
|
|
126
|
+
def of(
|
|
127
|
+
cls,
|
|
128
|
+
*,
|
|
129
|
+
revenue_growth: Driver,
|
|
130
|
+
ebitda_margin: Driver,
|
|
131
|
+
da_rate: Driver,
|
|
132
|
+
capex_rate: Driver,
|
|
133
|
+
nwc_rate: Driver,
|
|
134
|
+
tax_rate: Numeric,
|
|
135
|
+
opening_carryforward: Numeric = 0,
|
|
136
|
+
nol_usage_limit: Numeric = DEFAULT_NOL_USAGE_LIMIT,
|
|
137
|
+
) -> OperatingAssumptions:
|
|
138
|
+
return cls(
|
|
139
|
+
revenue_growth=revenue_growth,
|
|
140
|
+
ebitda_margin=ebitda_margin,
|
|
141
|
+
da_rate=da_rate,
|
|
142
|
+
capex_rate=capex_rate,
|
|
143
|
+
nwc_rate=nwc_rate,
|
|
144
|
+
tax_rate=money(tax_rate),
|
|
145
|
+
opening_carryforward=money(opening_carryforward),
|
|
146
|
+
nol_usage_limit=money(nol_usage_limit),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def __post_init__(self) -> None:
|
|
150
|
+
if self.tax_rate < 0 or self.tax_rate > 1:
|
|
151
|
+
raise ValueError("the tax rate must be between 0 and 1")
|
|
152
|
+
if self.nol_usage_limit < 0 or self.nol_usage_limit > 1:
|
|
153
|
+
raise ValueError("the loss usage limit must be between 0 and 1")
|
|
154
|
+
if self.opening_carryforward < 0:
|
|
155
|
+
raise ValueError("the opening carryforward must not be negative")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass(frozen=True, slots=True)
|
|
159
|
+
class AcquiredStream:
|
|
160
|
+
"""Earnings bought part-way through the hold and carried from then on.
|
|
161
|
+
|
|
162
|
+
A platform that buys three businesses over five years is not the business
|
|
163
|
+
that was underwritten, and modelling it as though a bolt-on simply lifted
|
|
164
|
+
the margin loses the thing that makes buy-and-build work: the acquired
|
|
165
|
+
revenue arrives at its own margin, on a base that did not compound from
|
|
166
|
+
close, and it is paid for at a different multiple from the platform's.
|
|
167
|
+
|
|
168
|
+
So an acquisition is carried as a separate stream rather than folded into
|
|
169
|
+
the platform's revenue line. ``revenue`` and ``margin`` are the run-rate the
|
|
170
|
+
business is bought on, and both are struck at the moment it closes: the
|
|
171
|
+
stream contributes nothing to the period it is acquired in and a full
|
|
172
|
+
period's trading to every period after.
|
|
173
|
+
|
|
174
|
+
Growth defaults to the platform's own driver, which is the assumption a
|
|
175
|
+
model makes unless it has a reason not to. Passing ``growth`` states the
|
|
176
|
+
reason — a tuck-in bought precisely because it is growing faster than the
|
|
177
|
+
platform is the usual one.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
period: int
|
|
181
|
+
revenue: Money
|
|
182
|
+
margin: Money
|
|
183
|
+
synergies: Money = ZERO
|
|
184
|
+
synergy_phase_in: int = 1
|
|
185
|
+
growth: Driver | None = None
|
|
186
|
+
label: str = "Add-on"
|
|
187
|
+
|
|
188
|
+
@classmethod
|
|
189
|
+
def of(
|
|
190
|
+
cls,
|
|
191
|
+
period: int,
|
|
192
|
+
*,
|
|
193
|
+
revenue: Numeric,
|
|
194
|
+
margin: Numeric,
|
|
195
|
+
synergies: Numeric = 0,
|
|
196
|
+
synergy_phase_in: int = 1,
|
|
197
|
+
growth: Driver | None = None,
|
|
198
|
+
label: str = "Add-on",
|
|
199
|
+
) -> AcquiredStream:
|
|
200
|
+
return cls(
|
|
201
|
+
period=int(period),
|
|
202
|
+
revenue=money(revenue),
|
|
203
|
+
margin=money(margin),
|
|
204
|
+
synergies=money(synergies),
|
|
205
|
+
synergy_phase_in=int(synergy_phase_in),
|
|
206
|
+
growth=growth,
|
|
207
|
+
label=label,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
def __post_init__(self) -> None:
|
|
211
|
+
if self.period < 1:
|
|
212
|
+
raise ValueError(
|
|
213
|
+
f"periods are numbered from one, so period {self.period} is not one "
|
|
214
|
+
f"of them"
|
|
215
|
+
)
|
|
216
|
+
if self.revenue <= 0:
|
|
217
|
+
raise ValueError(f"{self.label}: an acquired stream has to earn something")
|
|
218
|
+
if not (-1 < self.margin <= 1):
|
|
219
|
+
raise ValueError(
|
|
220
|
+
f"{self.label}: a margin of {self.margin} is not a share of revenue"
|
|
221
|
+
)
|
|
222
|
+
if self.synergy_phase_in < 1:
|
|
223
|
+
raise ValueError(
|
|
224
|
+
f"{self.label}: synergies phase in over at least one period"
|
|
225
|
+
)
|
|
226
|
+
if self.synergies < 0:
|
|
227
|
+
raise ValueError(
|
|
228
|
+
f"{self.label}: a negative synergy is a dis-synergy, which belongs in "
|
|
229
|
+
f"the margin the business is carried at"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def ebitda(self) -> Money:
|
|
234
|
+
"""Run-rate EBITDA at the moment of purchase, before any synergy."""
|
|
235
|
+
return self.revenue * self.margin
|
|
236
|
+
|
|
237
|
+
def _elapsed(self, index: int) -> int:
|
|
238
|
+
"""Whole periods traded under ownership by the end of period ``index``."""
|
|
239
|
+
return index - (self.period - 1)
|
|
240
|
+
|
|
241
|
+
def revenue_at(
|
|
242
|
+
self,
|
|
243
|
+
index: int,
|
|
244
|
+
default_growth: Driver,
|
|
245
|
+
*,
|
|
246
|
+
periods_per_year: int = 1,
|
|
247
|
+
) -> Money:
|
|
248
|
+
"""Annualised revenue contributed in period ``index``, zero-based.
|
|
249
|
+
|
|
250
|
+
Compounded from the run-rate at purchase across every period since,
|
|
251
|
+
which is why a business bought at the end of period two is at one
|
|
252
|
+
period's growth in period three rather than at its purchase run-rate.
|
|
253
|
+
|
|
254
|
+
The figure is a run-rate: what the stream earns over a year at the point
|
|
255
|
+
this period reaches. The caller scales it to the length of the period,
|
|
256
|
+
for the same reason the platform's own revenue is scaled there — an
|
|
257
|
+
acquisition contributes a quarter of its run-rate to a quarter.
|
|
258
|
+
|
|
259
|
+
``periods_per_year`` divides the growth assumption, which is annual, so
|
|
260
|
+
an add-on on a quarterly grid grows at the quarterly equivalent of its
|
|
261
|
+
rate rather than at the whole rate four times over. The growth entry is
|
|
262
|
+
read by the year each elapsed period falls in.
|
|
263
|
+
"""
|
|
264
|
+
elapsed = self._elapsed(index)
|
|
265
|
+
if elapsed <= 0:
|
|
266
|
+
return ZERO
|
|
267
|
+
driver = self.growth if self.growth is not None else default_growth
|
|
268
|
+
share = ONE / Decimal(periods_per_year)
|
|
269
|
+
revenue = self.revenue
|
|
270
|
+
for i in range(self.period, self.period + elapsed):
|
|
271
|
+
revenue = revenue * (
|
|
272
|
+
ONE + compounded_over(driver.at(i // periods_per_year), share)
|
|
273
|
+
)
|
|
274
|
+
return revenue
|
|
275
|
+
|
|
276
|
+
def synergies_at(self, index: int) -> Money:
|
|
277
|
+
"""Synergy realised in period ``index``, phased straight-line to run-rate.
|
|
278
|
+
|
|
279
|
+
Phasing is the difference between an add-on that pays for itself in year
|
|
280
|
+
one and one that pays for itself in year three, and underwriting the
|
|
281
|
+
first when the second is what happens is how a buy-and-build case gets
|
|
282
|
+
into trouble. The default of one period is full realisation immediately,
|
|
283
|
+
which is the right assumption only for a synergy that is a signature —
|
|
284
|
+
a duplicated licence, a lease not renewed.
|
|
285
|
+
"""
|
|
286
|
+
elapsed = self._elapsed(index)
|
|
287
|
+
if elapsed <= 0 or self.synergies == 0:
|
|
288
|
+
return ZERO
|
|
289
|
+
if elapsed >= self.synergy_phase_in:
|
|
290
|
+
return self.synergies
|
|
291
|
+
return self.synergies * Decimal(elapsed) / Decimal(self.synergy_phase_in)
|
|
292
|
+
|
|
293
|
+
def ebitda_at(
|
|
294
|
+
self,
|
|
295
|
+
index: int,
|
|
296
|
+
default_growth: Driver,
|
|
297
|
+
*,
|
|
298
|
+
periods_per_year: int = 1,
|
|
299
|
+
) -> Money:
|
|
300
|
+
"""Annualised EBITDA this stream contributes in period ``index``."""
|
|
301
|
+
return self.revenue_at(
|
|
302
|
+
index, default_growth, periods_per_year=periods_per_year
|
|
303
|
+
) * self.margin + self.synergies_at(index)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@dataclass(frozen=True, slots=True)
|
|
307
|
+
class OperatingPeriod:
|
|
308
|
+
"""One column of the operating case, from revenue down to cash."""
|
|
309
|
+
|
|
310
|
+
period: Period
|
|
311
|
+
revenue: Money
|
|
312
|
+
ebitda: Money
|
|
313
|
+
depreciation_and_amortisation: Money
|
|
314
|
+
ebit: Money
|
|
315
|
+
tax: TaxResult
|
|
316
|
+
nopat: Money
|
|
317
|
+
capital_expenditure: Money
|
|
318
|
+
net_working_capital: Money
|
|
319
|
+
change_in_net_working_capital: Money
|
|
320
|
+
unlevered_free_cash_flow: Money
|
|
321
|
+
acquired_revenue: Money = ZERO
|
|
322
|
+
acquired_ebitda: Money = ZERO
|
|
323
|
+
|
|
324
|
+
@property
|
|
325
|
+
def index(self) -> int:
|
|
326
|
+
return self.period.index
|
|
327
|
+
|
|
328
|
+
@property
|
|
329
|
+
def organic_revenue(self) -> Money:
|
|
330
|
+
"""Revenue from the business that was bought at close.
|
|
331
|
+
|
|
332
|
+
Kept separate because the two grow for different reasons, and a case
|
|
333
|
+
that reports only the total cannot answer the one question an investment
|
|
334
|
+
committee always asks of a buy-and-build: how much of this is the
|
|
335
|
+
platform, and how much of it did we pay for again.
|
|
336
|
+
"""
|
|
337
|
+
return self.revenue - self.acquired_revenue
|
|
338
|
+
|
|
339
|
+
@property
|
|
340
|
+
def organic_ebitda(self) -> Money:
|
|
341
|
+
return self.ebitda - self.acquired_ebitda
|
|
342
|
+
|
|
343
|
+
@property
|
|
344
|
+
def ebitda_margin(self) -> Money:
|
|
345
|
+
return safe_div(self.ebitda, self.revenue, default=ZERO)
|
|
346
|
+
|
|
347
|
+
@property
|
|
348
|
+
def cash_conversion(self) -> Money:
|
|
349
|
+
"""Unlevered free cash flow as a share of EBITDA.
|
|
350
|
+
|
|
351
|
+
The number that decides how much leverage a business can carry. Two
|
|
352
|
+
companies with identical EBITDA and different capital intensity support
|
|
353
|
+
very different capital structures.
|
|
354
|
+
"""
|
|
355
|
+
return safe_div(self.unlevered_free_cash_flow, self.ebitda, default=ZERO)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
@dataclass(frozen=True, slots=True)
|
|
359
|
+
class OperatingModel:
|
|
360
|
+
"""A projected operating case across a period grid."""
|
|
361
|
+
|
|
362
|
+
periods: tuple[OperatingPeriod, ...]
|
|
363
|
+
opening_revenue: Money
|
|
364
|
+
opening_net_working_capital: Money
|
|
365
|
+
|
|
366
|
+
def __len__(self) -> int:
|
|
367
|
+
return len(self.periods)
|
|
368
|
+
|
|
369
|
+
def __iter__(self) -> Iterator[OperatingPeriod]:
|
|
370
|
+
return iter(self.periods)
|
|
371
|
+
|
|
372
|
+
def __getitem__(self, index: int) -> OperatingPeriod:
|
|
373
|
+
return self.periods[index]
|
|
374
|
+
|
|
375
|
+
@classmethod
|
|
376
|
+
def project(
|
|
377
|
+
cls,
|
|
378
|
+
grid: PeriodGrid,
|
|
379
|
+
assumptions: OperatingAssumptions,
|
|
380
|
+
*,
|
|
381
|
+
opening_revenue: Numeric,
|
|
382
|
+
opening_net_working_capital: Numeric | None = None,
|
|
383
|
+
acquisitions: Sequence[AcquiredStream] = (),
|
|
384
|
+
day_count: DayCount = DayCount.ACT_365F,
|
|
385
|
+
) -> OperatingModel:
|
|
386
|
+
"""Roll the case forward across ``grid``.
|
|
387
|
+
|
|
388
|
+
``opening_net_working_capital`` defaults to the opening revenue at the
|
|
389
|
+
first period's working-capital rate. That default matters: it is the
|
|
390
|
+
base the first period's movement is measured against, and assuming a
|
|
391
|
+
zero opening balance would charge the entire working-capital balance to
|
|
392
|
+
period one as though the business had been founded at close.
|
|
393
|
+
|
|
394
|
+
``acquisitions`` are earnings bought during the hold. Each one grows on
|
|
395
|
+
its own base, at its own margin, and joins the combined revenue that the
|
|
396
|
+
capital-intensity lines are then struck against — so a business acquired
|
|
397
|
+
in period two carries working capital and capital expenditure at the
|
|
398
|
+
platform's rates from period three onward. That is a simplification, and
|
|
399
|
+
the honest form of one: a bolt-on that needs materially more capital per
|
|
400
|
+
pound of revenue than the platform is a different business, and belongs
|
|
401
|
+
in the file as a second case rather than as a line in this one.
|
|
402
|
+
|
|
403
|
+
``day_count`` measures a stub period only. It is separate from the one
|
|
404
|
+
the debt schedule accrues on, and defaults to actual days over 365
|
|
405
|
+
because a business trades on calendar days rather than on whatever the
|
|
406
|
+
credit agreement negotiated for its interest.
|
|
407
|
+
"""
|
|
408
|
+
revenue = money(opening_revenue)
|
|
409
|
+
if revenue < 0:
|
|
410
|
+
raise ValueError("opening revenue must not be negative")
|
|
411
|
+
|
|
412
|
+
count = len(grid)
|
|
413
|
+
streams = tuple(acquisitions)
|
|
414
|
+
for stream in streams:
|
|
415
|
+
if stream.period > count:
|
|
416
|
+
raise ValueError(
|
|
417
|
+
f"{stream.label}: closes at the end of period {stream.period}, "
|
|
418
|
+
f"which is beyond the {count} periods the case covers"
|
|
419
|
+
)
|
|
420
|
+
nwc = (
|
|
421
|
+
money(opening_net_working_capital)
|
|
422
|
+
if opening_net_working_capital is not None
|
|
423
|
+
else revenue * assumptions.nwc_rate.at(0)
|
|
424
|
+
)
|
|
425
|
+
opening_nwc = nwc
|
|
426
|
+
carryforward = assumptions.opening_carryforward
|
|
427
|
+
ppy = grid.frequency.periods_per_year
|
|
428
|
+
|
|
429
|
+
# The organic revenue line, one figure per year of the case. It is grown
|
|
430
|
+
# once a year at the annual rate, which is what the file says and what
|
|
431
|
+
# an annual grid has always done, and the periods inside a year then
|
|
432
|
+
# divide that figure between them. Growing it once per period instead
|
|
433
|
+
# would compound an annual rate as many times as the year is reported.
|
|
434
|
+
years = max((grid[i].driver_index for i in range(count)), default=0) + 1
|
|
435
|
+
annual_revenue: list[Money] = []
|
|
436
|
+
base = revenue
|
|
437
|
+
for y in range(years):
|
|
438
|
+
base = base * (ONE + assumptions.revenue_growth.at(y))
|
|
439
|
+
annual_revenue.append(base)
|
|
440
|
+
weights = {
|
|
441
|
+
y: within_year_weights(assumptions.revenue_growth.at(y), ppy)
|
|
442
|
+
for y in range(years)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
rows: list[OperatingPeriod] = []
|
|
446
|
+
traded: list[Money] = []
|
|
447
|
+
covered: list[Money] = []
|
|
448
|
+
for i in range(count):
|
|
449
|
+
period = grid[i]
|
|
450
|
+
j = period.driver_index
|
|
451
|
+
# How much of a year this column trades, and where it sits in the
|
|
452
|
+
# run of columns. The two are different questions on a sub-annual
|
|
453
|
+
# grid: the assumptions are read by year and the events — an add-on
|
|
454
|
+
# closing, a synergy phasing in — happen in a period.
|
|
455
|
+
elapsed = period.share_of_year(day_count)
|
|
456
|
+
position = max(period.index - 1, 0)
|
|
457
|
+
|
|
458
|
+
# A stub trades at the rate the business is running at when the deal
|
|
459
|
+
# closes, for as much of the year as it owns. Growth is a whole
|
|
460
|
+
# year's assumption and is not applied inside it, so the base the
|
|
461
|
+
# first whole period compounds from is the base the deal was
|
|
462
|
+
# underwritten on — six weeks of ownership does not advance the
|
|
463
|
+
# operating case by a year.
|
|
464
|
+
if period.is_stub:
|
|
465
|
+
organic = money(opening_revenue) * elapsed
|
|
466
|
+
else:
|
|
467
|
+
organic = annual_revenue[j] * weights[j][position % ppy]
|
|
468
|
+
|
|
469
|
+
# The organic line is the platform. Acquired revenue is added to the
|
|
470
|
+
# period's total but never to the platform, because each stream
|
|
471
|
+
# compounds from its own purchase run-rate; folding it back in would
|
|
472
|
+
# grow it a second time next period.
|
|
473
|
+
#
|
|
474
|
+
# A stream ramps period by period rather than being distributed
|
|
475
|
+
# across a year like the platform is: it is bought on a date that
|
|
476
|
+
# owes nothing to a year end, so there is no year of its own to
|
|
477
|
+
# divide. The two conventions differ by a fraction of a period's
|
|
478
|
+
# growth and only for the streams, not for the underwritten case.
|
|
479
|
+
acquired_run_rate = ZERO
|
|
480
|
+
acquired_run_rate_ebitda = ZERO
|
|
481
|
+
for stream in streams:
|
|
482
|
+
contributed = stream.revenue_at(
|
|
483
|
+
position,
|
|
484
|
+
assumptions.revenue_growth,
|
|
485
|
+
periods_per_year=period.periods_per_year,
|
|
486
|
+
)
|
|
487
|
+
acquired_run_rate += contributed
|
|
488
|
+
acquired_run_rate_ebitda += (
|
|
489
|
+
contributed * stream.margin + stream.synergies_at(position)
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
acquired_revenue = acquired_run_rate * elapsed
|
|
493
|
+
acquired_ebitda = acquired_run_rate_ebitda * elapsed
|
|
494
|
+
revenue = organic + acquired_revenue
|
|
495
|
+
ebitda = organic * assumptions.ebitda_margin.at(j) + acquired_ebitda
|
|
496
|
+
traded.append(revenue)
|
|
497
|
+
covered.append(elapsed)
|
|
498
|
+
|
|
499
|
+
# The run-rate the balance-sheet lines are struck against: the
|
|
500
|
+
# revenue of the twelve months to this date, annualised if fewer
|
|
501
|
+
# than twelve months have been traded. A ratio like working capital
|
|
502
|
+
# is a stock over a year of sales, so a year of sales is what it
|
|
503
|
+
# divides — taking one period and multiplying by four instead would
|
|
504
|
+
# make the balance jump about with the seasonality of a quarter, and
|
|
505
|
+
# would leave a year-end balance that disagreed with the same case
|
|
506
|
+
# run annually.
|
|
507
|
+
window = {p.index for p in grid.trailing(i)}
|
|
508
|
+
seen = [k for k in range(i + 1) if grid[k].index in window]
|
|
509
|
+
annualised = safe_div(
|
|
510
|
+
sum((traded[k] for k in seen), ZERO),
|
|
511
|
+
sum((covered[k] for k in seen), ZERO),
|
|
512
|
+
default=ZERO,
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
da = revenue * assumptions.da_rate.at(j)
|
|
516
|
+
ebit = ebitda - da
|
|
517
|
+
|
|
518
|
+
tax = apply_carryforward(
|
|
519
|
+
ebit,
|
|
520
|
+
carryforward,
|
|
521
|
+
assumptions.tax_rate,
|
|
522
|
+
usage_limit=assumptions.nol_usage_limit,
|
|
523
|
+
)
|
|
524
|
+
carryforward = tax.closing_carryforward
|
|
525
|
+
|
|
526
|
+
nopat = ebit - tax.cash_tax
|
|
527
|
+
capex = revenue * assumptions.capex_rate.at(j)
|
|
528
|
+
|
|
529
|
+
# Working capital is a balance, not a flow, and it is the one line
|
|
530
|
+
# a short period gets catastrophically wrong if it is treated as
|
|
531
|
+
# one. Six weeks of revenue at a 15% working-capital rate implies a
|
|
532
|
+
# balance an eighth of the real one, and the difference is released
|
|
533
|
+
# into the stub as cash the business never had. So the balance is
|
|
534
|
+
# struck against the annualised figure and only the *movement* in it
|
|
535
|
+
# reaches the cash flow, which is what a movement was always for.
|
|
536
|
+
closing_nwc = annualised * assumptions.nwc_rate.at(j)
|
|
537
|
+
change_in_nwc = closing_nwc - nwc
|
|
538
|
+
nwc = closing_nwc
|
|
539
|
+
|
|
540
|
+
# Depreciation is added back because it never left; the increase in
|
|
541
|
+
# working capital is subtracted because it did.
|
|
542
|
+
ufcf = nopat + da - capex - change_in_nwc
|
|
543
|
+
|
|
544
|
+
rows.append(
|
|
545
|
+
OperatingPeriod(
|
|
546
|
+
period=period,
|
|
547
|
+
revenue=revenue,
|
|
548
|
+
ebitda=ebitda,
|
|
549
|
+
depreciation_and_amortisation=da,
|
|
550
|
+
ebit=ebit,
|
|
551
|
+
tax=tax,
|
|
552
|
+
nopat=nopat,
|
|
553
|
+
capital_expenditure=capex,
|
|
554
|
+
net_working_capital=closing_nwc,
|
|
555
|
+
change_in_net_working_capital=change_in_nwc,
|
|
556
|
+
unlevered_free_cash_flow=ufcf,
|
|
557
|
+
acquired_revenue=acquired_revenue,
|
|
558
|
+
acquired_ebitda=acquired_ebitda,
|
|
559
|
+
)
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
return cls(
|
|
563
|
+
periods=tuple(rows),
|
|
564
|
+
opening_revenue=money(opening_revenue),
|
|
565
|
+
opening_net_working_capital=opening_nwc,
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
# -- Aggregates ------------------------------------------------------
|
|
569
|
+
|
|
570
|
+
@property
|
|
571
|
+
def grid_periods(self) -> tuple[Period, ...]:
|
|
572
|
+
return tuple(p.period for p in self.periods)
|
|
573
|
+
|
|
574
|
+
@property
|
|
575
|
+
def periods_per_year(self) -> int:
|
|
576
|
+
return self.periods[0].period.periods_per_year
|
|
577
|
+
|
|
578
|
+
def window(self, position: int, months: int = 12) -> TrailingWindow:
|
|
579
|
+
"""The trailing window ending with the period at ``position``."""
|
|
580
|
+
return trailing_window(self.grid_periods, position, months)
|
|
581
|
+
|
|
582
|
+
def trailing_ebitda(self, position: int, months: int = 12) -> Money:
|
|
583
|
+
"""Earnings over the twelve months ending with period ``position``.
|
|
584
|
+
|
|
585
|
+
The figure every multiple and every covenant is struck against. On an
|
|
586
|
+
annual grid it is the period's own EBITDA and this is an expensive way
|
|
587
|
+
of saying so; on a quarterly one it is the four columns that make up the
|
|
588
|
+
year, which is the only figure a lender would recognise.
|
|
589
|
+
|
|
590
|
+
An incomplete window sums what there is. Callers that must not report a
|
|
591
|
+
part-year as a year ask :meth:`window` whether it was complete — the
|
|
592
|
+
covenant layer does exactly that, and refuses to certify against one.
|
|
593
|
+
"""
|
|
594
|
+
rows = {p.index for p in self.window(position, months)}
|
|
595
|
+
return sum((r.ebitda for r in self.periods if r.period.index in rows), ZERO)
|
|
596
|
+
|
|
597
|
+
def trailing_revenue(self, position: int, months: int = 12) -> Money:
|
|
598
|
+
rows = {p.index for p in self.window(position, months)}
|
|
599
|
+
return sum((r.revenue for r in self.periods if r.period.index in rows), ZERO)
|
|
600
|
+
|
|
601
|
+
@property
|
|
602
|
+
def entry_ebitda(self) -> Money:
|
|
603
|
+
"""Earnings over the first twelve months of whole-period trading.
|
|
604
|
+
|
|
605
|
+
What the business is bought on. A stub is left out of it deliberately:
|
|
606
|
+
the entry multiple is applied to a year, and the six weeks between
|
|
607
|
+
signing and the first accounts are not one — folding them in would price
|
|
608
|
+
the deal on thirteen and a half months of earnings.
|
|
609
|
+
|
|
610
|
+
On an annual grid without a stub this is the first period, which is what
|
|
611
|
+
it has always been.
|
|
612
|
+
"""
|
|
613
|
+
whole = [r for r in self.periods if not r.period.is_stub]
|
|
614
|
+
if not whole:
|
|
615
|
+
return self.periods[0].ebitda
|
|
616
|
+
return sum((r.ebitda for r in whole[: self.periods_per_year]), ZERO)
|
|
617
|
+
|
|
618
|
+
@property
|
|
619
|
+
def exit_ebitda(self) -> Money:
|
|
620
|
+
"""Earnings over the twelve months to the exit, which price it.
|
|
621
|
+
|
|
622
|
+
A trailing figure rather than the final column, because a multiple is
|
|
623
|
+
applied to a year of earnings. On an annual grid the two are the same
|
|
624
|
+
number and nothing about an existing model moves; on a quarterly grid
|
|
625
|
+
the final column is three months, and pricing an exit on it would value
|
|
626
|
+
the business at a quarter of what it is worth.
|
|
627
|
+
"""
|
|
628
|
+
return self.trailing_ebitda(len(self.periods) - 1)
|
|
629
|
+
|
|
630
|
+
@property
|
|
631
|
+
def total_unlevered_free_cash_flow(self) -> Money:
|
|
632
|
+
return sum((p.unlevered_free_cash_flow for p in self.periods), ZERO)
|
|
633
|
+
|
|
634
|
+
@property
|
|
635
|
+
def total_cash_tax(self) -> Money:
|
|
636
|
+
return sum((p.tax.cash_tax for p in self.periods), ZERO)
|
|
637
|
+
|
|
638
|
+
@property
|
|
639
|
+
def total_capital_expenditure(self) -> Money:
|
|
640
|
+
return sum((p.capital_expenditure for p in self.periods), ZERO)
|
|
641
|
+
|
|
642
|
+
@property
|
|
643
|
+
def working_capital_absorbed(self) -> Money:
|
|
644
|
+
"""Cumulative cash taken up by working capital across the hold.
|
|
645
|
+
|
|
646
|
+
Positive means the business consumed cash funding its own growth.
|
|
647
|
+
"""
|
|
648
|
+
return sum((p.change_in_net_working_capital for p in self.periods), ZERO)
|
|
649
|
+
|
|
650
|
+
@property
|
|
651
|
+
def closing_carryforward(self) -> Money:
|
|
652
|
+
return self.periods[-1].tax.closing_carryforward
|
|
653
|
+
|
|
654
|
+
@property
|
|
655
|
+
def has_acquisitions(self) -> bool:
|
|
656
|
+
return any(p.acquired_revenue != 0 for p in self.periods)
|
|
657
|
+
|
|
658
|
+
@property
|
|
659
|
+
def exit_acquired_ebitda(self) -> Money:
|
|
660
|
+
"""The share of the earnings that price the exit which was bought.
|
|
661
|
+
|
|
662
|
+
The uncomfortable number in a buy-and-build, and the one worth putting
|
|
663
|
+
next to the exit multiple: earnings bought at eight turns and sold at
|
|
664
|
+
eleven are worth having, but they are not the same achievement as
|
|
665
|
+
earnings grown from the platform, and the bridge should not pretend
|
|
666
|
+
otherwise.
|
|
667
|
+
"""
|
|
668
|
+
last = len(self.periods) - 1
|
|
669
|
+
rows = {p.index for p in self.window(last)}
|
|
670
|
+
return sum(
|
|
671
|
+
(r.acquired_ebitda for r in self.periods if r.period.index in rows), ZERO
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
@property
|
|
675
|
+
def organic_exit_ebitda(self) -> Money:
|
|
676
|
+
"""The rest of the earnings that price the exit, over the same year."""
|
|
677
|
+
return self.exit_ebitda - self.exit_acquired_ebitda
|