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/events.py ADDED
@@ -0,0 +1,914 @@
1
+ """Things that happen between buying a business and selling it.
2
+
3
+ Every layer below this one models a deal done once, held flat and sold once. That
4
+ shape makes the equity a two-point cash-flow stream — money out at close, money
5
+ back at exit — which is why none of the return machinery's harder cases have ever
6
+ had to fire for the sponsor's own paper.
7
+
8
+ A dividend recapitalisation breaks the shape in the way that matters most. New
9
+ debt is raised part-way through the hold and the proceeds go straight out to the
10
+ shareholders. Nothing about the business changes: the same earnings, the same
11
+ multiple, the same buyer at the end. The sponsor simply has some of its money
12
+ back three years early, and money three years early is worth more than the same
13
+ money at exit.
14
+
15
+ That is the whole trade, and it is worth being precise about which measure sees
16
+ it. The money multiple barely moves — the sponsor is paid the same total, give or
17
+ take the interest the new debt costs. The rate of return moves a lot, because it
18
+ is the only measure that knows when anything happened. A model that reports one
19
+ without the other reports either a deal that did nothing or a deal that created
20
+ value out of nowhere, and neither is what happened.
21
+
22
+ The cost is real and it is carried by the schedule rather than stated here: the
23
+ new debt accrues from the following period, is swept and amortised like anything
24
+ else, and shows up in the leverage the covenants test. A recapitalisation that
25
+ looks free is one whose interest has not been modelled.
26
+
27
+ An add-on acquisition breaks the shape in the other direction. A
28
+ recapitalisation changes who holds the claims on a fixed business; an add-on
29
+ changes the business. Earnings arrive that were not underwritten, paid for at a
30
+ multiple that is usually below the platform's, and the whole argument for
31
+ buy-and-build is that difference: buy at seven, own inside something the market
32
+ prices at eleven, and the arbitrage is booked the moment the transaction closes
33
+ rather than earned over the hold.
34
+
35
+ Which is why the number the strategy lives or dies by is the blended entry
36
+ multiple — every pound of capital deployed into the business, over every turn of
37
+ EBITDA that capital bought. A platform bought at 9.75x that adds three tuck-ins
38
+ at 6.5x has an entry multiple somewhere in between, and it is that number, not
39
+ the headline one, that the exit multiple has to be compared against. Reporting
40
+ the platform multiple alone flatters the deal by exactly the arbitrage.
41
+
42
+ Two things keep the arithmetic honest here. Synergies are held out of the
43
+ unsynergised multiple, because a multiple struck on earnings that do not exist
44
+ yet is a forecast wearing the clothes of a fact; both are reported. And fees are
45
+ held out of the multiple but not out of the capital deployed, because a multiple
46
+ is quoted on enterprise value while the cheque is written for rather more than
47
+ that.
48
+
49
+ A refinancing changes neither the business nor who holds the equity. It changes
50
+ the price of money already borrowed, for whatever part of the hold remains, and
51
+ it is one of the few things a sponsor still controls after signing. The trade is
52
+ a cash cost now — a call premium, and the fees on the new paper — against a
53
+ lower coupon later, and whether it clears is arithmetic anybody can check.
54
+
55
+ The write-off of unamortised financing fees is the part most often reported
56
+ wrongly. It is not cash. The fees on the original paper were capitalised at
57
+ close and written down over its life; taking that paper out early charges
58
+ whatever is left in one go. No balance in this model moves and no return
59
+ changes. It is reported anyway, and reported separately from the cash cost,
60
+ because it is a real charge in a real set of accounts and because netting it
61
+ into a cash figure is precisely the error that separation prevents.
62
+ """
63
+
64
+ from __future__ import annotations
65
+
66
+ from collections.abc import Iterator, Sequence
67
+ from dataclasses import dataclass
68
+
69
+ from .drivers import Driver
70
+ from .money import ONE, ZERO, Money, Numeric, money, safe_div
71
+ from .operating import AcquiredStream
72
+
73
+ __all__ = [
74
+ "AddOn",
75
+ "AddOnError",
76
+ "AddOnOutcome",
77
+ "BlendedEntry",
78
+ "Draw",
79
+ "Recapitalisation",
80
+ "RecapitalisationError",
81
+ "RecapitalisationOutcome",
82
+ "Refinancing",
83
+ "RefinancingError",
84
+ "RefinancingOutcome",
85
+ ]
86
+
87
+
88
+ class RecapitalisationError(ValueError):
89
+ """The recapitalisation as described cannot be funded or cannot be applied."""
90
+
91
+
92
+ @dataclass(frozen=True, slots=True)
93
+ class Draw:
94
+ """Incremental face taken down on one tranche.
95
+
96
+ Priced on its own terms rather than the tranche's original ones, because it
97
+ is a separate raise: an add-on to a term loan two years into a hold is
98
+ marketed at whatever the market is paying then, and the discount it clears at
99
+ is not the discount the original clearing price implied.
100
+
101
+ The margin it carries, though, *is* the tranche's. Modelling a blended rate
102
+ across an original and an incremental tranche needs the two to be separate
103
+ facilities in the file, which is the honest way to describe them when the
104
+ pricing genuinely differs.
105
+ """
106
+
107
+ tranche: str
108
+ amount: Money
109
+ issue_price: Money = ONE
110
+ financing_fee_rate: Money = ZERO
111
+
112
+ @classmethod
113
+ def of(
114
+ cls,
115
+ tranche: str,
116
+ amount: Numeric,
117
+ *,
118
+ issue_price: Numeric = 1,
119
+ financing_fee_rate: Numeric = 0,
120
+ ) -> Draw:
121
+ return cls(
122
+ tranche=tranche,
123
+ amount=money(amount),
124
+ issue_price=money(issue_price),
125
+ financing_fee_rate=money(financing_fee_rate),
126
+ )
127
+
128
+ def __post_init__(self) -> None:
129
+ if not self.tranche.strip():
130
+ raise RecapitalisationError("a draw has to name the tranche it is taken on")
131
+ if self.amount <= 0:
132
+ raise RecapitalisationError(
133
+ f"{self.tranche}: a draw of {self.amount} raises nothing"
134
+ )
135
+ if not (0 < self.issue_price <= 2):
136
+ raise RecapitalisationError(
137
+ f"{self.tranche}: an issue price of {self.issue_price} is not a price"
138
+ )
139
+ if not (0 <= self.financing_fee_rate < 1):
140
+ raise RecapitalisationError(
141
+ f"{self.tranche}: a financing fee of {self.financing_fee_rate} would "
142
+ f"cost more than the draw raises"
143
+ )
144
+
145
+ @property
146
+ def gross_proceeds(self) -> Money:
147
+ """What the paper sells for, before the cost of selling it."""
148
+ return self.amount * self.issue_price
149
+
150
+ @property
151
+ def fees(self) -> Money:
152
+ """Charged on face, which is how a financing fee is quoted."""
153
+ return self.amount * self.financing_fee_rate
154
+
155
+ @property
156
+ def net_proceeds(self) -> Money:
157
+ return self.gross_proceeds - self.fees
158
+
159
+ @property
160
+ def discount(self) -> Money:
161
+ """Original issue discount: face given up to clear the paper."""
162
+ return self.amount - self.gross_proceeds
163
+
164
+
165
+ @dataclass(frozen=True, slots=True)
166
+ class Recapitalisation:
167
+ """New debt raised at a period boundary, paid straight out to the equity.
168
+
169
+ ``period`` is the one at whose *end* the raise happens, numbered from one.
170
+ Placing it at a boundary rather than at an arbitrary date is a real
171
+ simplification and worth naming: a recapitalisation that closes in March is
172
+ modelled here as closing at the period end, so it carries a period's less
173
+ interest than it would in life. The alternative is a mid-period stub for
174
+ every event, which buys precision the rest of the annual model cannot use.
175
+
176
+ ``from_cash`` is balance-sheet cash paid out alongside the new proceeds. It
177
+ is separate because the two behave differently: proceeds never touch the
178
+ balance sheet, while cash taken off it has to still leave the structure with
179
+ the minimum it is required to hold.
180
+ """
181
+
182
+ period: int
183
+ draws: tuple[Draw, ...] = ()
184
+ from_cash: Money = ZERO
185
+ label: str = "Dividend recapitalisation"
186
+
187
+ @classmethod
188
+ def of(
189
+ cls,
190
+ period: int,
191
+ draws: Sequence[Draw] = (),
192
+ *,
193
+ from_cash: Numeric = 0,
194
+ label: str = "Dividend recapitalisation",
195
+ ) -> Recapitalisation:
196
+ return cls(
197
+ period=int(period),
198
+ draws=tuple(draws),
199
+ from_cash=money(from_cash),
200
+ label=label,
201
+ )
202
+
203
+ def __post_init__(self) -> None:
204
+ if self.period < 1:
205
+ raise RecapitalisationError(
206
+ f"periods are numbered from one, so period {self.period} is not one "
207
+ f"of them"
208
+ )
209
+ if not self.label.strip():
210
+ raise RecapitalisationError("a recapitalisation needs a label")
211
+ if self.from_cash < 0:
212
+ raise RecapitalisationError(
213
+ "a recapitalisation pays cash out; a negative amount is a contribution"
214
+ )
215
+ names = [d.tranche for d in self.draws]
216
+ if len(names) != len(set(names)):
217
+ raise RecapitalisationError(
218
+ "each tranche can be drawn once per recapitalisation; combine the "
219
+ "amounts rather than listing the tranche twice"
220
+ )
221
+ if not self.draws and self.from_cash == 0:
222
+ raise RecapitalisationError(
223
+ "this recapitalisation raises nothing and pays nothing out"
224
+ )
225
+
226
+ def __len__(self) -> int:
227
+ return len(self.draws)
228
+
229
+ def __iter__(self) -> Iterator[Draw]:
230
+ return iter(self.draws)
231
+
232
+ @property
233
+ def face(self) -> Money:
234
+ """Total incremental face, which is what the leverage will carry."""
235
+ return sum((d.amount for d in self.draws), ZERO)
236
+
237
+ @property
238
+ def fees(self) -> Money:
239
+ return sum((d.fees for d in self.draws), ZERO)
240
+
241
+ @property
242
+ def discount(self) -> Money:
243
+ return sum((d.discount for d in self.draws), ZERO)
244
+
245
+ @property
246
+ def net_proceeds(self) -> Money:
247
+ return sum((d.net_proceeds for d in self.draws), ZERO)
248
+
249
+ @property
250
+ def distribution(self) -> Money:
251
+ """What reaches the shareholders: the net raise plus the cash taken off.
252
+
253
+ The cost of raising is borne here rather than left on the balance sheet.
254
+ A recapitalisation that raised 300 of face at 99 with 2% of fees pays out
255
+ 291, and the 9 of difference is the reason a recapitalisation is not free
256
+ even before a penny of interest accrues on it.
257
+ """
258
+ return self.net_proceeds + self.from_cash
259
+
260
+ def draw_on(self, tranche: str) -> Money:
261
+ for draw in self.draws:
262
+ if draw.tranche == tranche:
263
+ return draw.amount
264
+ return ZERO
265
+
266
+
267
+ @dataclass(frozen=True, slots=True)
268
+ class RecapitalisationOutcome:
269
+ """A recapitalisation as it actually landed in the schedule."""
270
+
271
+ event: Recapitalisation
272
+ index: int
273
+ cash_before: Money
274
+ cash_after: Money
275
+ leverage_before: Money | None = None
276
+ leverage_after: Money | None = None
277
+
278
+ @property
279
+ def label(self) -> str:
280
+ return self.event.label
281
+
282
+ @property
283
+ def distribution(self) -> Money:
284
+ return self.event.distribution
285
+
286
+ @property
287
+ def face(self) -> Money:
288
+ return self.event.face
289
+
290
+ @property
291
+ def cost_of_raising(self) -> Money:
292
+ """Fees and issue discount: the part of the face that never reaches anyone."""
293
+ return self.event.fees + self.event.discount
294
+
295
+ @property
296
+ def turns_added(self) -> Money | None:
297
+ """How much leverage the raise put back on, in turns of EBITDA."""
298
+ if self.leverage_before is None or self.leverage_after is None:
299
+ return None
300
+ return self.leverage_after - self.leverage_before
301
+
302
+
303
+ class AddOnError(ValueError):
304
+ """The acquisition as described cannot be funded or cannot be applied."""
305
+
306
+
307
+ @dataclass(frozen=True, slots=True)
308
+ class AddOn:
309
+ """A business bought during the hold, at a period boundary.
310
+
311
+ Priced cash-free and debt-free, which is how a mid-market bolt-on is
312
+ actually bought and which removes an entire class of ambiguity from the
313
+ funding table: the enterprise value is what leaves the structure, because
314
+ whatever debt the target carried is repaid out of the same cheque.
315
+
316
+ ``period`` is the one at whose end the acquisition closes, numbered from
317
+ one, matching :class:`Recapitalisation`. It has to fall strictly inside the
318
+ projection. An acquisition closing at the end of the final period would pay
319
+ cash for earnings that no period ever records, and would then be sold at a
320
+ multiple applied to a figure it never reached — a deal that destroys its own
321
+ purchase price and reports nothing.
322
+
323
+ Funding is a plug against the balance sheet rather than a table that has to
324
+ be made to balance by hand. The draws raise what they raise; whatever is
325
+ left of the uses comes off the cash the business is holding, and if that
326
+ would breach the minimum the structure is required to keep, the acquisition
327
+ is refused rather than silently shrunk. Draws that raise more than the uses
328
+ leave the surplus on the balance sheet, which is what over-funding a
329
+ facility does.
330
+ """
331
+
332
+ period: int
333
+ ebitda: Money
334
+ multiple: Money
335
+ revenue: Money | None = None
336
+ synergies: Money = ZERO
337
+ synergy_phase_in: int = 1
338
+ growth: Driver | None = None
339
+ fee_rate: Money = ZERO
340
+ integration_cost: Money = ZERO
341
+ draws: tuple[Draw, ...] = ()
342
+ label: str = "Add-on acquisition"
343
+
344
+ @classmethod
345
+ def of(
346
+ cls,
347
+ period: int,
348
+ *,
349
+ ebitda: Numeric,
350
+ multiple: Numeric,
351
+ revenue: Numeric | None = None,
352
+ synergies: Numeric = 0,
353
+ synergy_phase_in: int = 1,
354
+ growth: Driver | None = None,
355
+ fee_rate: Numeric = 0,
356
+ integration_cost: Numeric = 0,
357
+ draws: Sequence[Draw] = (),
358
+ label: str = "Add-on acquisition",
359
+ ) -> AddOn:
360
+ return cls(
361
+ period=int(period),
362
+ ebitda=money(ebitda),
363
+ multiple=money(multiple),
364
+ revenue=None if revenue is None else money(revenue),
365
+ synergies=money(synergies),
366
+ synergy_phase_in=int(synergy_phase_in),
367
+ growth=growth,
368
+ fee_rate=money(fee_rate),
369
+ integration_cost=money(integration_cost),
370
+ draws=tuple(draws),
371
+ label=label,
372
+ )
373
+
374
+ def __post_init__(self) -> None:
375
+ if self.period < 1:
376
+ raise AddOnError(
377
+ f"periods are numbered from one, so period {self.period} is not one "
378
+ f"of them"
379
+ )
380
+ if not self.label.strip():
381
+ raise AddOnError("an acquisition needs a label")
382
+ if self.ebitda <= 0:
383
+ raise AddOnError(
384
+ f"{self.label}: an acquisition of a business earning {self.ebitda} is "
385
+ f"a purchase of losses; model it as a cost, not a multiple"
386
+ )
387
+ if self.multiple <= 0:
388
+ raise AddOnError(
389
+ f"{self.label}: a multiple of {self.multiple} is not a price"
390
+ )
391
+ if self.revenue is not None and self.revenue < self.ebitda:
392
+ raise AddOnError(
393
+ f"{self.label}: revenue of {self.revenue} against EBITDA of "
394
+ f"{self.ebitda} is a margin above one"
395
+ )
396
+ if self.synergies < 0:
397
+ raise AddOnError(
398
+ f"{self.label}: a negative synergy is a dis-synergy, which belongs in "
399
+ f"the earnings the business is bought on"
400
+ )
401
+ if self.synergy_phase_in < 1:
402
+ raise AddOnError(
403
+ f"{self.label}: synergies phase in over at least one period"
404
+ )
405
+ if not (0 <= self.fee_rate < 1):
406
+ raise AddOnError(
407
+ f"{self.label}: transaction fees of {self.fee_rate} of enterprise "
408
+ f"value are not a fee rate"
409
+ )
410
+ if self.integration_cost < 0:
411
+ raise AddOnError(
412
+ f"{self.label}: a negative integration cost is a receipt, not a cost"
413
+ )
414
+ names = [d.tranche for d in self.draws]
415
+ if len(names) != len(set(names)):
416
+ raise AddOnError(
417
+ "each tranche can be drawn once per acquisition; combine the amounts "
418
+ "rather than listing the tranche twice"
419
+ )
420
+
421
+ def __len__(self) -> int:
422
+ return len(self.draws)
423
+
424
+ def __iter__(self) -> Iterator[Draw]:
425
+ return iter(self.draws)
426
+
427
+ @property
428
+ def enterprise_value(self) -> Money:
429
+ """What the business is bought for, before the cost of buying it."""
430
+ return self.ebitda * self.multiple
431
+
432
+ @property
433
+ def fees(self) -> Money:
434
+ """Advisory and financing-adjacent fees, quoted on enterprise value."""
435
+ return self.enterprise_value * self.fee_rate
436
+
437
+ @property
438
+ def uses(self) -> Money:
439
+ """Everything the acquisition has to fund.
440
+
441
+ Integration cost sits here rather than in the operating case. It is a
442
+ one-off, it is known at signing, and charging it to EBITDA would put it
443
+ through the exit multiple — valuing a cost that will never recur at nine
444
+ times itself.
445
+ """
446
+ return self.enterprise_value + self.fees + self.integration_cost
447
+
448
+ @property
449
+ def face(self) -> Money:
450
+ """Incremental face taken down to fund it, which the leverage carries."""
451
+ return sum((d.amount for d in self.draws), ZERO)
452
+
453
+ @property
454
+ def financing_fees(self) -> Money:
455
+ return sum((d.fees for d in self.draws), ZERO)
456
+
457
+ @property
458
+ def discount(self) -> Money:
459
+ return sum((d.discount for d in self.draws), ZERO)
460
+
461
+ @property
462
+ def debt_proceeds(self) -> Money:
463
+ """What the draws deliver, after the discount and the fees on them."""
464
+ return sum((d.net_proceeds for d in self.draws), ZERO)
465
+
466
+ @property
467
+ def from_cash(self) -> Money:
468
+ """The plug: uses not met by the new debt come off the balance sheet.
469
+
470
+ Negative when the draws over-fund, in which case the surplus is left as
471
+ cash rather than being handed back — a facility drawn for more than the
472
+ purchase price leaves the money in the account.
473
+ """
474
+ return self.uses - self.debt_proceeds
475
+
476
+ @property
477
+ def total_cost(self) -> Money:
478
+ """Capital deployed: the price, plus everything paid to pay the price."""
479
+ return self.uses + self.financing_fees + self.discount
480
+
481
+ @property
482
+ def implied_margin(self) -> Money | None:
483
+ """The margin the business is bought on, where revenue was stated."""
484
+ if self.revenue is None:
485
+ return None
486
+ return safe_div(self.ebitda, self.revenue, default=ZERO)
487
+
488
+ @property
489
+ def synergised_ebitda(self) -> Money:
490
+ return self.ebitda + self.synergies
491
+
492
+ @property
493
+ def synergised_multiple(self) -> Money:
494
+ """What the business costs per turn of earnings once synergies land.
495
+
496
+ The number a buyer defends the price with, and the number to be most
497
+ careful about: it is a multiple of an outcome rather than of a fact.
498
+ """
499
+ return safe_div(self.enterprise_value, self.synergised_ebitda, default=ZERO)
500
+
501
+ def draw_on(self, tranche: str) -> Money:
502
+ for draw in self.draws:
503
+ if draw.tranche == tranche:
504
+ return draw.amount
505
+ return ZERO
506
+
507
+ def stream(self, platform_margin: Money) -> AcquiredStream:
508
+ """The earnings this acquisition puts into the operating case.
509
+
510
+ ``platform_margin`` is used only when the file did not state the
511
+ acquired revenue. Carrying the business at the platform's margin is the
512
+ assumption that changes the model least — the earnings are what was
513
+ bought and the revenue behind them is a detail — but it is an assumption,
514
+ and stating revenue in the file replaces it with a fact.
515
+ """
516
+ if self.revenue is not None:
517
+ revenue, margin = self.revenue, self.ebitda / self.revenue
518
+ else:
519
+ if platform_margin <= 0:
520
+ raise AddOnError(
521
+ f"{self.label}: no revenue was stated and the platform earns "
522
+ f"nothing on its own revenue in period {self.period}, so there is "
523
+ f"no margin to carry the acquisition at"
524
+ )
525
+ revenue, margin = self.ebitda / platform_margin, platform_margin
526
+ return AcquiredStream(
527
+ period=self.period,
528
+ revenue=revenue,
529
+ margin=margin,
530
+ synergies=self.synergies,
531
+ synergy_phase_in=self.synergy_phase_in,
532
+ growth=self.growth,
533
+ label=self.label,
534
+ )
535
+
536
+
537
+ @dataclass(frozen=True, slots=True)
538
+ class AddOnOutcome:
539
+ """An acquisition as it actually landed in the schedule."""
540
+
541
+ event: AddOn
542
+ index: int
543
+ cash_before: Money
544
+ cash_after: Money
545
+ leverage_before: Money | None = None
546
+ leverage_after: Money | None = None
547
+
548
+ @property
549
+ def label(self) -> str:
550
+ return self.event.label
551
+
552
+ @property
553
+ def enterprise_value(self) -> Money:
554
+ return self.event.enterprise_value
555
+
556
+ @property
557
+ def face(self) -> Money:
558
+ return self.event.face
559
+
560
+ @property
561
+ def from_cash(self) -> Money:
562
+ return self.event.from_cash
563
+
564
+ @property
565
+ def debt_funded_share(self) -> Money:
566
+ """How much of the price the new debt carried, as a share of the uses."""
567
+ return safe_div(self.event.debt_proceeds, self.event.uses, default=ZERO)
568
+
569
+ @property
570
+ def turns_added(self) -> Money | None:
571
+ """The leverage effect: new debt against newly acquired earnings.
572
+
573
+ An acquisition funded entirely with debt at a multiple below the
574
+ structure's own leverage is deleveraging, which is the mechanism behind
575
+ a buy-and-build that stays inside its covenants while doubling in size.
576
+ """
577
+ if self.leverage_before is None or self.leverage_after is None:
578
+ return None
579
+ return self.leverage_after - self.leverage_before
580
+
581
+
582
+ @dataclass(frozen=True, slots=True)
583
+ class BlendedEntry:
584
+ """The platform and everything bought after it, priced as one entry.
585
+
586
+ Deliberately built from the events rather than from the schedule, so the
587
+ blended multiple can be quoted from a deal file before anything has been
588
+ run. Nothing here depends on how the acquisitions were funded — the multiple
589
+ is a statement about price, and price does not change because the money came
590
+ from a revolver.
591
+ """
592
+
593
+ platform_enterprise_value: Money
594
+ platform_ebitda: Money
595
+ add_ons: tuple[AddOn, ...] = ()
596
+
597
+ def __post_init__(self) -> None:
598
+ if self.platform_ebitda <= 0:
599
+ raise AddOnError(
600
+ "a blended multiple needs the earnings the platform was priced on"
601
+ )
602
+
603
+ def __len__(self) -> int:
604
+ return len(self.add_ons)
605
+
606
+ def __iter__(self) -> Iterator[AddOn]:
607
+ return iter(self.add_ons)
608
+
609
+ @property
610
+ def acquired_enterprise_value(self) -> Money:
611
+ return sum((a.enterprise_value for a in self.add_ons), ZERO)
612
+
613
+ @property
614
+ def acquired_ebitda(self) -> Money:
615
+ """Run-rate earnings bought, before synergies."""
616
+ return sum((a.ebitda for a in self.add_ons), ZERO)
617
+
618
+ @property
619
+ def synergies(self) -> Money:
620
+ return sum((a.synergies for a in self.add_ons), ZERO)
621
+
622
+ @property
623
+ def enterprise_value(self) -> Money:
624
+ return self.platform_enterprise_value + self.acquired_enterprise_value
625
+
626
+ @property
627
+ def ebitda(self) -> Money:
628
+ return self.platform_ebitda + self.acquired_ebitda
629
+
630
+ @property
631
+ def capital_deployed(self) -> Money:
632
+ """Every pound put into the business, fees and discount included.
633
+
634
+ Larger than the enterprise value, and the gap is the cost of doing five
635
+ transactions instead of one. A buy-and-build that adds a turn of
636
+ arbitrage per deal and spends most of it on advisers has a strategy on
637
+ paper only, and this is the line that shows it.
638
+ """
639
+ return self.platform_enterprise_value + sum(
640
+ (a.total_cost for a in self.add_ons), ZERO
641
+ )
642
+
643
+ @property
644
+ def platform_multiple(self) -> Money:
645
+ return safe_div(
646
+ self.platform_enterprise_value, self.platform_ebitda, default=ZERO
647
+ )
648
+
649
+ @property
650
+ def blended_multiple(self) -> Money:
651
+ """Enterprise value over earnings, platform and add-ons together."""
652
+ return safe_div(self.enterprise_value, self.ebitda, default=ZERO)
653
+
654
+ @property
655
+ def synergised_multiple(self) -> Money:
656
+ """The same, crediting the synergies the add-ons were bought for."""
657
+ return safe_div(self.enterprise_value, self.ebitda + self.synergies, default=ZERO)
658
+
659
+ @property
660
+ def all_in_multiple(self) -> Money:
661
+ """Capital deployed over earnings bought: what it really cost per turn."""
662
+ return safe_div(self.capital_deployed, self.ebitda, default=ZERO)
663
+
664
+ @property
665
+ def arbitrage(self) -> Money:
666
+ """Turns taken off the entry multiple by buying below it.
667
+
668
+ Positive when the add-ons were bought cheaper than the platform, which
669
+ is the only reason to do them at this scale. Reported in turns because
670
+ that is the unit the exit multiple is argued in.
671
+ """
672
+ return self.platform_multiple - self.blended_multiple
673
+
674
+ @property
675
+ def acquired_share(self) -> Money:
676
+ """Share of the combined entry earnings that was bought rather than built."""
677
+ return safe_div(self.acquired_ebitda, self.ebitda, default=ZERO)
678
+
679
+
680
+ class RefinancingError(ValueError):
681
+ """The takeout as described cannot be funded or cannot be applied."""
682
+
683
+
684
+ @dataclass(frozen=True, slots=True)
685
+ class Refinancing:
686
+ """An existing facility repaid in full and replaced with new paper.
687
+
688
+ The event a sponsor reaches for when the market improves. Nothing about the
689
+ business changes and nothing reaches the shareholders; what changes is the
690
+ coupon on the money already borrowed, for the periods that remain.
691
+
692
+ ``into`` may draw on the tranche being taken out, which is a repricing of
693
+ the same facility, or on a different one, which is a genuine refinancing
694
+ into new paper. Both are the same arithmetic: the old balance goes to zero
695
+ and the new draws land, in that order, so a same-tranche takeout nets to the
696
+ difference rather than doubling the balance for an instant.
697
+
698
+ Two costs make a refinancing something other than free, and they behave
699
+ differently enough that reporting one without the other is misleading.
700
+
701
+ The *call premium* is cash. Non-call protection is what the original lenders
702
+ were paid for taking the risk that this would happen, and it is charged on
703
+ the balance repaid at the moment it is repaid.
704
+
705
+ The *unamortised financing fees* are not cash. The fees on the original
706
+ paper were capitalised at close and written down over its life; taking the
707
+ paper out early writes off whatever is left in a single charge. Nothing
708
+ moves in the bank account, no balance in this model changes, and the return
709
+ does not shift by a basis point — but it is a real charge in a real set of
710
+ accounts, and a model that never mentions it leaves its reader to discover
711
+ the number from somebody else.
712
+ """
713
+
714
+ period: int
715
+ tranche: str
716
+ into: tuple[Draw, ...] = ()
717
+ call_premium_rate: Money = ZERO
718
+ unamortised_fees: Money | None = None
719
+ label: str = "Refinancing"
720
+
721
+ @classmethod
722
+ def of(
723
+ cls,
724
+ period: int,
725
+ tranche: str,
726
+ into: Sequence[Draw] = (),
727
+ *,
728
+ call_premium_rate: Numeric = 0,
729
+ unamortised_fees: Numeric | None = None,
730
+ label: str = "Refinancing",
731
+ ) -> Refinancing:
732
+ return cls(
733
+ period=int(period),
734
+ tranche=tranche,
735
+ into=tuple(into),
736
+ call_premium_rate=money(call_premium_rate),
737
+ unamortised_fees=(
738
+ None if unamortised_fees is None else money(unamortised_fees)
739
+ ),
740
+ label=label,
741
+ )
742
+
743
+ def __post_init__(self) -> None:
744
+ if self.period < 1:
745
+ raise RefinancingError(
746
+ f"periods are numbered from one, so period {self.period} is not one "
747
+ f"of them"
748
+ )
749
+ if not self.tranche.strip():
750
+ raise RefinancingError("a refinancing has to name the facility it takes out")
751
+ if not self.label.strip():
752
+ raise RefinancingError("a refinancing needs a label")
753
+ if not (0 <= self.call_premium_rate < 1):
754
+ raise RefinancingError(
755
+ f"{self.label}: a call premium of {self.call_premium_rate} of the "
756
+ f"balance is not a premium"
757
+ )
758
+ if self.unamortised_fees is not None and self.unamortised_fees < 0:
759
+ raise RefinancingError(
760
+ f"{self.label}: a negative write-off is a credit, not a cost"
761
+ )
762
+ names = [d.tranche for d in self.into]
763
+ if len(names) != len(set(names)):
764
+ raise RefinancingError(
765
+ "each tranche can be drawn once per refinancing; combine the amounts "
766
+ "rather than listing the tranche twice"
767
+ )
768
+
769
+ def __len__(self) -> int:
770
+ return len(self.into)
771
+
772
+ def __iter__(self) -> Iterator[Draw]:
773
+ return iter(self.into)
774
+
775
+ @property
776
+ def states_its_write_off(self) -> bool:
777
+ """Whether the file put a figure on the unamortised balance.
778
+
779
+ A deal file may state it, and an older one always did. Where it does
780
+ not, the balance is derived from the capitalised costs the paper was
781
+ placed with and how long it had run — which is the reading a reader can
782
+ check, because it comes from the funding table rather than from an
783
+ assertion.
784
+ """
785
+ return self.unamortised_fees is not None
786
+
787
+ def write_off(self, derived: Money = ZERO) -> Money:
788
+ """The unamortised balance charged off, stated or derived."""
789
+ return derived if self.unamortised_fees is None else self.unamortised_fees
790
+
791
+ @property
792
+ def face(self) -> Money:
793
+ """Face of the new paper, which is what the leverage will carry."""
794
+ return sum((d.amount for d in self.into), ZERO)
795
+
796
+ @property
797
+ def financing_fees(self) -> Money:
798
+ return sum((d.fees for d in self.into), ZERO)
799
+
800
+ @property
801
+ def discount(self) -> Money:
802
+ return sum((d.discount for d in self.into), ZERO)
803
+
804
+ @property
805
+ def proceeds(self) -> Money:
806
+ """What the new paper delivers, after the discount and the fees on it."""
807
+ return sum((d.net_proceeds for d in self.into), ZERO)
808
+
809
+ def premium_on(self, balance: Money) -> Money:
810
+ return balance * self.call_premium_rate
811
+
812
+ def uses(self, balance: Money) -> Money:
813
+ """The balance being retired, plus what it costs to retire it early."""
814
+ return balance + self.premium_on(balance)
815
+
816
+ def from_cash(self, balance: Money) -> Money:
817
+ """The plug, on the same terms as every other event in this module.
818
+
819
+ Negative when the new paper raises more than the old balance and its
820
+ premium, which is an upsizing rather than a refinancing and leaves the
821
+ surplus on the balance sheet.
822
+ """
823
+ return self.uses(balance) - self.proceeds
824
+
825
+ def draw_on(self, tranche: str) -> Money:
826
+ for draw in self.into:
827
+ if draw.tranche == tranche:
828
+ return draw.amount
829
+ return ZERO
830
+
831
+
832
+ @dataclass(frozen=True, slots=True)
833
+ class RefinancingOutcome:
834
+ """A refinancing as it actually landed in the schedule."""
835
+
836
+ event: Refinancing
837
+ index: int
838
+ repaid: Money
839
+ cash_before: Money
840
+ cash_after: Money
841
+ old_rate: Money = ZERO
842
+ new_rate: Money = ZERO
843
+ periods_remaining: int = 0
844
+ #: The unamortised balance actually charged off, whether the file stated it
845
+ #: or the capitalised-cost schedule derived it.
846
+ written_off: Money = ZERO
847
+
848
+ @property
849
+ def label(self) -> str:
850
+ return self.event.label
851
+
852
+ @property
853
+ def write_off_was_derived(self) -> bool:
854
+ """Whether the charge came from the schedule rather than from the file."""
855
+ return not self.event.states_its_write_off
856
+
857
+ @property
858
+ def call_premium(self) -> Money:
859
+ return self.event.premium_on(self.repaid)
860
+
861
+ @property
862
+ def face(self) -> Money:
863
+ return self.event.face
864
+
865
+ @property
866
+ def from_cash(self) -> Money:
867
+ return self.event.from_cash(self.repaid)
868
+
869
+ @property
870
+ def fees_written_off(self) -> Money:
871
+ return self.written_off
872
+
873
+ @property
874
+ def cash_cost(self) -> Money:
875
+ """What the exercise takes out of the business.
876
+
877
+ The premium, the fees on the new paper and the discount it clears at.
878
+ The write-off is deliberately not here: it is a charge against reported
879
+ earnings and nothing else, and adding it to a cash cost would be the
880
+ error this separation exists to prevent.
881
+ """
882
+ return self.call_premium + self.event.financing_fees + self.event.discount
883
+
884
+ @property
885
+ def spread_saved(self) -> Money:
886
+ """Coupon given up, in rate terms. Negative if the new paper is dearer."""
887
+ return self.old_rate - self.new_rate
888
+
889
+ @property
890
+ def annual_saving(self) -> Money:
891
+ """Interest saved in a full period, on the balance that was refinanced.
892
+
893
+ Struck on the old balance rather than the new face, so an upsizing does
894
+ not report the cost of the extra money it borrowed as a saving on the
895
+ money it already owed.
896
+ """
897
+ return self.repaid * self.spread_saved
898
+
899
+ @property
900
+ def saving_over_the_remainder(self) -> Money:
901
+ """That saving across the periods left in the projection.
902
+
903
+ An undiscounted sum, and read as an order of magnitude rather than a
904
+ valuation: it ignores the amortisation and the sweep that will reduce
905
+ the balance it is struck on, both of which cut it. What it answers is
906
+ whether the exercise was worth its cash cost at all, which is a
907
+ comparison the premium and this figure can settle between them.
908
+ """
909
+ return self.annual_saving * money(self.periods_remaining)
910
+
911
+ @property
912
+ def pays_back(self) -> bool:
913
+ """Whether the interest saved covers what the exercise cost in cash."""
914
+ return self.saving_over_the_remainder > self.cash_cost