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