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