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/outcome.py
ADDED
|
@@ -0,0 +1,958 @@
|
|
|
1
|
+
"""The exit: what the equity is worth, who gets it, and where it came from.
|
|
2
|
+
|
|
3
|
+
Every layer below this one describes the hold. This one answers the question the
|
|
4
|
+
deal was done to answer, and it answers it three times over, because a return
|
|
5
|
+
that is not decomposed is a number nobody can argue with or learn from.
|
|
6
|
+
|
|
7
|
+
*What the business sells for.* An EBITDA multiple applied to the final projected
|
|
8
|
+
period, less the net debt the schedule leaves behind, less the cost of selling.
|
|
9
|
+
The multiple defaults to the entry multiple, because assuming expansion is
|
|
10
|
+
assuming the answer: a case that only works at a higher exit multiple is a case
|
|
11
|
+
that only works if somebody else pays more than the sponsor did.
|
|
12
|
+
|
|
13
|
+
*Who receives it.* A sponsor cheque is rarely one instrument. Preferred with a
|
|
14
|
+
compounding coupon sitting ahead of common is the ordinary shape, and management
|
|
15
|
+
rollover sits in the common beside it. That structure is the point rather than a
|
|
16
|
+
detail: the preferred takes its accrued return off the top and the common takes
|
|
17
|
+
the leverage on whatever is left, so the two report very different multiples on
|
|
18
|
+
the same exit. Reporting one blended figure for the equity hides the only
|
|
19
|
+
distinction the structure was built to create.
|
|
20
|
+
|
|
21
|
+
*Where it came from.* Three sources: the business earned more, the market paid a
|
|
22
|
+
different multiple for it, and the debt was repaid out of cash flow. The bridge
|
|
23
|
+
has to tie to the actual change in equity value, so there is a fourth line for
|
|
24
|
+
what the first three do not explain — entry and exit costs — rather than a
|
|
25
|
+
rounding difference left on the page for the reader to notice.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from collections.abc import Iterator, Sequence
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from datetime import date
|
|
33
|
+
from enum import Enum
|
|
34
|
+
from typing import NamedTuple
|
|
35
|
+
|
|
36
|
+
from .daycount import DayCount, year_fraction
|
|
37
|
+
from .debt import DebtSchedule
|
|
38
|
+
from .incentive import IncentiveError, OptionPool, PoolOutcome, Ratchet, settle_pool
|
|
39
|
+
from .money import (
|
|
40
|
+
ONE,
|
|
41
|
+
ZERO,
|
|
42
|
+
Money,
|
|
43
|
+
Numeric,
|
|
44
|
+
allocate_pro_rata,
|
|
45
|
+
is_close,
|
|
46
|
+
money,
|
|
47
|
+
safe_div,
|
|
48
|
+
)
|
|
49
|
+
from .operating import OperatingModel
|
|
50
|
+
from .returns import CashFlow, CashFlowStream, IRRError
|
|
51
|
+
from .transaction import Transaction
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
"Attribution",
|
|
55
|
+
"Distribution",
|
|
56
|
+
"ExitValuation",
|
|
57
|
+
"Outcome",
|
|
58
|
+
"Security",
|
|
59
|
+
"SecurityKind",
|
|
60
|
+
"SecurityOutcome",
|
|
61
|
+
"default_securities",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class SecurityKind(Enum):
|
|
66
|
+
"""Where an instrument sits in the equity waterfall.
|
|
67
|
+
|
|
68
|
+
``PREFERRED`` is repaid its capital and its accrued return before anything
|
|
69
|
+
reaches the common. ``COMMON`` takes what is left, which is why it carries
|
|
70
|
+
the leverage in both directions.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
PREFERRED = "preferred"
|
|
74
|
+
COMMON = "common"
|
|
75
|
+
|
|
76
|
+
def __str__(self) -> str:
|
|
77
|
+
return self.value
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True, slots=True)
|
|
81
|
+
class Security:
|
|
82
|
+
"""One instrument in the equity, and the terms it is held on.
|
|
83
|
+
|
|
84
|
+
``ownership`` is the share of the residual — what is left after every
|
|
85
|
+
preferred claim has been met. A non-participating preferred holds none of
|
|
86
|
+
it; a participating one holds both its claim and a share of the residual,
|
|
87
|
+
which is the term a sponsor asks for and a management team resists.
|
|
88
|
+
|
|
89
|
+
``preferred_rate`` accrues on the capital invested. It compounds by default,
|
|
90
|
+
because a preferred return that does not compound is worth materially less
|
|
91
|
+
over a five-year hold and the documents almost always say it does.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
name: str
|
|
95
|
+
kind: SecurityKind
|
|
96
|
+
invested: Money
|
|
97
|
+
ownership: Money = ZERO
|
|
98
|
+
preferred_rate: Money = ZERO
|
|
99
|
+
compounding: bool = True
|
|
100
|
+
seniority: int = 0
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
def of(
|
|
104
|
+
cls,
|
|
105
|
+
name: str,
|
|
106
|
+
kind: SecurityKind,
|
|
107
|
+
invested: Numeric,
|
|
108
|
+
*,
|
|
109
|
+
ownership: Numeric = 0,
|
|
110
|
+
preferred_rate: Numeric = 0,
|
|
111
|
+
compounding: bool = True,
|
|
112
|
+
seniority: int = 0,
|
|
113
|
+
) -> Security:
|
|
114
|
+
return cls(
|
|
115
|
+
name=name,
|
|
116
|
+
kind=kind,
|
|
117
|
+
invested=money(invested),
|
|
118
|
+
ownership=money(ownership),
|
|
119
|
+
preferred_rate=money(preferred_rate),
|
|
120
|
+
compounding=compounding,
|
|
121
|
+
seniority=seniority,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def __post_init__(self) -> None:
|
|
125
|
+
if not self.name.strip():
|
|
126
|
+
raise ValueError("a security needs a name")
|
|
127
|
+
if self.invested < 0:
|
|
128
|
+
raise ValueError(f"{self.name}: capital invested must not be negative")
|
|
129
|
+
if not (0 <= self.ownership <= 1):
|
|
130
|
+
raise ValueError(f"{self.name}: ownership is a share, so between 0 and 1")
|
|
131
|
+
if self.preferred_rate < 0:
|
|
132
|
+
raise ValueError(f"{self.name}: the preferred return must not be negative")
|
|
133
|
+
if self.preferred_rate and self.kind is not SecurityKind.PREFERRED:
|
|
134
|
+
raise ValueError(
|
|
135
|
+
f"{self.name}: a preferred return accrues on preferred capital, and "
|
|
136
|
+
f"this is described as {self.kind}"
|
|
137
|
+
)
|
|
138
|
+
if self.seniority < 0:
|
|
139
|
+
raise ValueError(f"{self.name}: seniority must not be negative")
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def participates(self) -> bool:
|
|
143
|
+
"""Whether this instrument shares in the residual as well as its claim."""
|
|
144
|
+
return self.ownership > 0
|
|
145
|
+
|
|
146
|
+
def accrued_at(self, years: Money) -> Money:
|
|
147
|
+
"""The preferred return accumulated over ``years``, excluding capital.
|
|
148
|
+
|
|
149
|
+
Compounding uses the exact elapsed year fraction rather than a whole
|
|
150
|
+
number of periods, so a deal exited eleven days late is worth eleven
|
|
151
|
+
days more to the preferred, which is what the document says.
|
|
152
|
+
"""
|
|
153
|
+
return self.accrue(
|
|
154
|
+
capital=self.invested, accrued=ZERO, years=years
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def accrue(self, *, capital: Money, accrued: Money, years: Money) -> Money:
|
|
158
|
+
"""What accrues over ``years`` on a claim standing at ``capital`` plus ``accrued``.
|
|
159
|
+
|
|
160
|
+
Split out from :meth:`accrued_at` because a claim does not always start
|
|
161
|
+
from the original cheque. A preferred paid down part-way through a hold
|
|
162
|
+
accrues afterwards on what is left, and charging its coupon on money it
|
|
163
|
+
no longer holds would overstate its claim at exit and understate
|
|
164
|
+
everything ranking behind it.
|
|
165
|
+
|
|
166
|
+
Compounding accrues on the whole standing claim; a non-compounding
|
|
167
|
+
preferred accrues on capital alone, which is what makes the distinction
|
|
168
|
+
worth several points over a five-year hold.
|
|
169
|
+
"""
|
|
170
|
+
if self.kind is not SecurityKind.PREFERRED or self.preferred_rate == 0:
|
|
171
|
+
return ZERO
|
|
172
|
+
if years <= 0:
|
|
173
|
+
return ZERO
|
|
174
|
+
if not self.compounding:
|
|
175
|
+
return capital * self.preferred_rate * years
|
|
176
|
+
# Decimal has no fractional power, so the growth factor is computed in
|
|
177
|
+
# floating point and returned to exact arithmetic immediately. The
|
|
178
|
+
# boundary is deliberate and the same one the IRR solver crosses.
|
|
179
|
+
factor = money(repr(float(ONE + self.preferred_rate) ** float(years)))
|
|
180
|
+
return (capital + accrued) * (factor - ONE)
|
|
181
|
+
|
|
182
|
+
def claim_at(self, years: Money) -> Money:
|
|
183
|
+
"""Capital plus accrued return: what must be paid before the common."""
|
|
184
|
+
if self.kind is not SecurityKind.PREFERRED:
|
|
185
|
+
return ZERO
|
|
186
|
+
return self.invested + self.accrued_at(years)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def default_securities(transaction: Transaction) -> tuple[Security, ...]:
|
|
190
|
+
"""The plain structure: sponsor and rollover common, split by ownership.
|
|
191
|
+
|
|
192
|
+
What a deal file describes when it says nothing about the equity. Both
|
|
193
|
+
holders share the residual in proportion to what they put in, which is the
|
|
194
|
+
arrangement any other structure is a departure from.
|
|
195
|
+
"""
|
|
196
|
+
sponsor = max(transaction.sponsor_equity, ZERO)
|
|
197
|
+
rollover = transaction.rollover_equity
|
|
198
|
+
total = sponsor + rollover
|
|
199
|
+
if total <= 0:
|
|
200
|
+
raise ValueError(
|
|
201
|
+
"this deal has no equity in it, so there is nothing to hold or to exit"
|
|
202
|
+
)
|
|
203
|
+
securities = [
|
|
204
|
+
Security.of(
|
|
205
|
+
"Sponsor equity",
|
|
206
|
+
SecurityKind.COMMON,
|
|
207
|
+
sponsor,
|
|
208
|
+
ownership=safe_div(sponsor, total, default=ZERO),
|
|
209
|
+
)
|
|
210
|
+
]
|
|
211
|
+
if rollover > 0:
|
|
212
|
+
securities.append(
|
|
213
|
+
Security.of(
|
|
214
|
+
"Rollover equity",
|
|
215
|
+
SecurityKind.COMMON,
|
|
216
|
+
rollover,
|
|
217
|
+
ownership=safe_div(rollover, total, default=ZERO),
|
|
218
|
+
)
|
|
219
|
+
)
|
|
220
|
+
else:
|
|
221
|
+
# One holder takes the whole residual; the division above would round
|
|
222
|
+
# to one anyway, but stating it avoids a share of 0.9999... on a deal
|
|
223
|
+
# where the arithmetic does not terminate.
|
|
224
|
+
securities[0] = Security.of(
|
|
225
|
+
"Sponsor equity", SecurityKind.COMMON, sponsor, ownership=1
|
|
226
|
+
)
|
|
227
|
+
return tuple(securities)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@dataclass(frozen=True, slots=True)
|
|
231
|
+
class ExitValuation:
|
|
232
|
+
"""What the business is sold for, and what is left after the debt.
|
|
233
|
+
|
|
234
|
+
``fee_rate`` is charged on enterprise value and covers the cost of running
|
|
235
|
+
a sale. It is small and it is not zero, and leaving it out flatters every
|
|
236
|
+
return in the model by roughly the same amount.
|
|
237
|
+
"""
|
|
238
|
+
|
|
239
|
+
when: date
|
|
240
|
+
ebitda: Money
|
|
241
|
+
multiple: Money
|
|
242
|
+
debt: Money
|
|
243
|
+
cash: Money
|
|
244
|
+
fee_rate: Money = ZERO
|
|
245
|
+
|
|
246
|
+
@classmethod
|
|
247
|
+
def of(
|
|
248
|
+
cls,
|
|
249
|
+
when: date,
|
|
250
|
+
ebitda: Numeric,
|
|
251
|
+
multiple: Numeric,
|
|
252
|
+
*,
|
|
253
|
+
debt: Numeric = 0,
|
|
254
|
+
cash: Numeric = 0,
|
|
255
|
+
fee_rate: Numeric = 0,
|
|
256
|
+
) -> ExitValuation:
|
|
257
|
+
return cls(
|
|
258
|
+
when=when,
|
|
259
|
+
ebitda=money(ebitda),
|
|
260
|
+
multiple=money(multiple),
|
|
261
|
+
debt=money(debt),
|
|
262
|
+
cash=money(cash),
|
|
263
|
+
fee_rate=money(fee_rate),
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
def __post_init__(self) -> None:
|
|
267
|
+
if self.multiple <= 0:
|
|
268
|
+
raise ValueError("the exit multiple must be positive")
|
|
269
|
+
if self.debt < 0:
|
|
270
|
+
raise ValueError("debt outstanding at exit must not be negative")
|
|
271
|
+
if self.cash < 0:
|
|
272
|
+
raise ValueError("cash at exit must not be negative")
|
|
273
|
+
if self.fee_rate < 0:
|
|
274
|
+
raise ValueError("the exit fee rate must not be negative")
|
|
275
|
+
|
|
276
|
+
@property
|
|
277
|
+
def enterprise_value(self) -> Money:
|
|
278
|
+
"""Negative EBITDA gives a negative enterprise value, which is honest.
|
|
279
|
+
|
|
280
|
+
A business losing money is not worth a multiple of its losses in any
|
|
281
|
+
real sale, but clamping it to zero would report an exit the case does
|
|
282
|
+
not support. The equity value below is floored instead, at the point
|
|
283
|
+
where the number stops being a valuation and becomes a distribution.
|
|
284
|
+
"""
|
|
285
|
+
return self.ebitda * self.multiple
|
|
286
|
+
|
|
287
|
+
@property
|
|
288
|
+
def fees(self) -> Money:
|
|
289
|
+
return max(self.enterprise_value, ZERO) * self.fee_rate
|
|
290
|
+
|
|
291
|
+
@property
|
|
292
|
+
def net_debt(self) -> Money:
|
|
293
|
+
return self.debt - self.cash
|
|
294
|
+
|
|
295
|
+
@property
|
|
296
|
+
def gross_equity_value(self) -> Money:
|
|
297
|
+
"""Before flooring: what the arithmetic says, sign and all."""
|
|
298
|
+
return self.enterprise_value - self.net_debt - self.fees
|
|
299
|
+
|
|
300
|
+
@property
|
|
301
|
+
def equity_value(self) -> Money:
|
|
302
|
+
"""What there is to distribute.
|
|
303
|
+
|
|
304
|
+
Floored at zero. Equity in a leveraged company is an option: the holders
|
|
305
|
+
can walk away, and a business worth less than its debt hands the keys to
|
|
306
|
+
the lenders rather than sending its shareholders a bill.
|
|
307
|
+
"""
|
|
308
|
+
return max(self.gross_equity_value, ZERO)
|
|
309
|
+
|
|
310
|
+
@property
|
|
311
|
+
def is_wiped_out(self) -> bool:
|
|
312
|
+
return self.gross_equity_value <= 0
|
|
313
|
+
|
|
314
|
+
@property
|
|
315
|
+
def exit_leverage(self) -> Money:
|
|
316
|
+
return safe_div(self.net_debt, self.ebitda, default=ZERO)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
@dataclass(frozen=True, slots=True)
|
|
320
|
+
class SecurityOutcome:
|
|
321
|
+
"""What one instrument received, and what that was worth to it."""
|
|
322
|
+
|
|
323
|
+
security: Security
|
|
324
|
+
accrued: Money
|
|
325
|
+
preferred_paid: Money
|
|
326
|
+
residual_paid: Money
|
|
327
|
+
irr: float | None
|
|
328
|
+
irr_note: str = ""
|
|
329
|
+
interim: Money = ZERO
|
|
330
|
+
standing_capital: Money = ZERO
|
|
331
|
+
unpaid_capital: Money = ZERO
|
|
332
|
+
|
|
333
|
+
@property
|
|
334
|
+
def name(self) -> str:
|
|
335
|
+
return self.security.name
|
|
336
|
+
|
|
337
|
+
@property
|
|
338
|
+
def invested(self) -> Money:
|
|
339
|
+
return self.security.invested
|
|
340
|
+
|
|
341
|
+
@property
|
|
342
|
+
def proceeds(self) -> Money:
|
|
343
|
+
"""What arrived at the exit."""
|
|
344
|
+
return self.preferred_paid + self.residual_paid
|
|
345
|
+
|
|
346
|
+
@property
|
|
347
|
+
def received(self) -> Money:
|
|
348
|
+
"""Everything that came back, whenever it came back."""
|
|
349
|
+
return self.proceeds + self.interim
|
|
350
|
+
|
|
351
|
+
@property
|
|
352
|
+
def profit(self) -> Money:
|
|
353
|
+
return self.received - self.invested
|
|
354
|
+
|
|
355
|
+
@property
|
|
356
|
+
def moic(self) -> Money | None:
|
|
357
|
+
"""Everything received over capital. ``None`` where no capital was put in.
|
|
358
|
+
|
|
359
|
+
Counts interim distributions, because a money multiple is a question
|
|
360
|
+
about totals and not about timing. The rate of return beside it is the
|
|
361
|
+
measure that knows when anything happened, and the gap between the two
|
|
362
|
+
is precisely what a recapitalisation creates.
|
|
363
|
+
"""
|
|
364
|
+
if self.invested <= 0:
|
|
365
|
+
return None
|
|
366
|
+
return self.received / self.invested
|
|
367
|
+
|
|
368
|
+
@property
|
|
369
|
+
def claim(self) -> Money:
|
|
370
|
+
"""What was owed at the exit: capital still outstanding plus accrued return.
|
|
371
|
+
|
|
372
|
+
For a preferred paid down during the hold this is smaller than the
|
|
373
|
+
original cheque plus a whole hold's coupon, which is the point of
|
|
374
|
+
paying it down.
|
|
375
|
+
"""
|
|
376
|
+
if self.security.kind is not SecurityKind.PREFERRED:
|
|
377
|
+
return ZERO
|
|
378
|
+
return self.standing_capital + self.accrued
|
|
379
|
+
|
|
380
|
+
@property
|
|
381
|
+
def capital_repaid_early(self) -> Money:
|
|
382
|
+
"""Capital met by an interim distribution rather than at the exit."""
|
|
383
|
+
if self.security.kind is not SecurityKind.PREFERRED:
|
|
384
|
+
return ZERO
|
|
385
|
+
return self.invested - self.standing_capital
|
|
386
|
+
|
|
387
|
+
@property
|
|
388
|
+
def shortfall(self) -> Money:
|
|
389
|
+
"""Preferred claim left unpaid, which is a real entitlement that went unmet."""
|
|
390
|
+
return max(self.claim - self.preferred_paid, ZERO)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
@dataclass(frozen=True, slots=True)
|
|
394
|
+
class Attribution:
|
|
395
|
+
"""Where the change in equity value came from.
|
|
396
|
+
|
|
397
|
+
The convention is stated rather than assumed, because the cross term has to
|
|
398
|
+
go somewhere. Earnings growth is valued at the *entry* multiple, and the
|
|
399
|
+
multiple change is applied to *exit* EBITDA, which puts the interaction
|
|
400
|
+
between the two — the extra turns earned on the extra EBITDA — in the
|
|
401
|
+
multiple line. The alternative convention flatters growth in exactly the
|
|
402
|
+
deals where the multiple expanded, which is to say the ones where the
|
|
403
|
+
sponsor least wants to be asked about it.
|
|
404
|
+
"""
|
|
405
|
+
|
|
406
|
+
ebitda_growth: Money
|
|
407
|
+
multiple_change: Money
|
|
408
|
+
debt_paydown: Money
|
|
409
|
+
costs: Money
|
|
410
|
+
invested: Money
|
|
411
|
+
realised: Money
|
|
412
|
+
floored: Money = ZERO
|
|
413
|
+
|
|
414
|
+
@property
|
|
415
|
+
def total(self) -> Money:
|
|
416
|
+
return self.ebitda_growth + self.multiple_change + self.debt_paydown + self.costs
|
|
417
|
+
|
|
418
|
+
@property
|
|
419
|
+
def value_created(self) -> Money:
|
|
420
|
+
return self.realised - self.invested
|
|
421
|
+
|
|
422
|
+
@property
|
|
423
|
+
def distributed(self) -> Money:
|
|
424
|
+
"""What the equity actually receives, after the limited-liability floor.
|
|
425
|
+
|
|
426
|
+
Below zero the shareholders hand the keys to the lenders rather than
|
|
427
|
+
writing a cheque, so the bridge and the distribution part company. The
|
|
428
|
+
difference is reported rather than smuggled into the costs line.
|
|
429
|
+
"""
|
|
430
|
+
return self.realised + self.floored
|
|
431
|
+
|
|
432
|
+
def reconciles(self, tolerance: Numeric = "1E-12") -> bool:
|
|
433
|
+
"""Whether the four components explain the change in value exactly."""
|
|
434
|
+
return is_close(self.total, self.value_created, tolerance=tolerance)
|
|
435
|
+
|
|
436
|
+
def share(self, component: Money) -> Money:
|
|
437
|
+
"""One component as a share of the gross movement.
|
|
438
|
+
|
|
439
|
+
Measured against the sum of the absolute components rather than against
|
|
440
|
+
the net, so a bridge with an offsetting multiple contraction still
|
|
441
|
+
reports shares that mean something instead of exceeding one.
|
|
442
|
+
"""
|
|
443
|
+
gross = (
|
|
444
|
+
abs(self.ebitda_growth)
|
|
445
|
+
+ abs(self.multiple_change)
|
|
446
|
+
+ abs(self.debt_paydown)
|
|
447
|
+
+ abs(self.costs)
|
|
448
|
+
)
|
|
449
|
+
return safe_div(component, gross, default=ZERO)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
@dataclass(frozen=True, slots=True)
|
|
453
|
+
class Distribution:
|
|
454
|
+
"""One payment to the equity, and how the waterfall split it.
|
|
455
|
+
|
|
456
|
+
Interim distributions and the exit are the same object because they are the
|
|
457
|
+
same event: cash arrives, the preferred take their claim, the rest goes to
|
|
458
|
+
the common. What differs is that the exit is the last one and the incentive
|
|
459
|
+
plan settles against it.
|
|
460
|
+
"""
|
|
461
|
+
|
|
462
|
+
when: date
|
|
463
|
+
years: Money
|
|
464
|
+
amount: Money
|
|
465
|
+
label: str
|
|
466
|
+
preferred_paid: tuple[Money, ...]
|
|
467
|
+
residual_paid: tuple[Money, ...]
|
|
468
|
+
accrued: tuple[Money, ...]
|
|
469
|
+
capital: tuple[Money, ...]
|
|
470
|
+
incentive: PoolOutcome | None = None
|
|
471
|
+
is_exit: bool = False
|
|
472
|
+
|
|
473
|
+
@property
|
|
474
|
+
def to_preferred(self) -> Money:
|
|
475
|
+
return sum(self.preferred_paid, ZERO)
|
|
476
|
+
|
|
477
|
+
@property
|
|
478
|
+
def to_common(self) -> Money:
|
|
479
|
+
return sum(self.residual_paid, ZERO)
|
|
480
|
+
|
|
481
|
+
@property
|
|
482
|
+
def to_incentive(self) -> Money:
|
|
483
|
+
return ZERO if self.incentive is None else self.incentive.paid
|
|
484
|
+
|
|
485
|
+
@property
|
|
486
|
+
def paid(self) -> Money:
|
|
487
|
+
"""Everything the payment was split into."""
|
|
488
|
+
return self.to_preferred + self.to_common + self.to_incentive
|
|
489
|
+
|
|
490
|
+
def reconciles(self, tolerance: Numeric = "1E-12") -> bool:
|
|
491
|
+
"""Whether the split accounts for the whole payment."""
|
|
492
|
+
return is_close(self.paid, self.amount, tolerance=tolerance)
|
|
493
|
+
|
|
494
|
+
def to(self, index: int) -> Money:
|
|
495
|
+
return self.preferred_paid[index] + self.residual_paid[index]
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
class _Ledger:
|
|
499
|
+
"""The equity's standing claims, rolled forward through a series of payments.
|
|
500
|
+
|
|
501
|
+
Held as capital and accrued separately rather than as one claim, because a
|
|
502
|
+
payment to a preferred instrument is applied to the accrued return before it
|
|
503
|
+
touches capital. That ordering is what makes "how much capital is still
|
|
504
|
+
outstanding" a well-defined question after a partial repayment, and the
|
|
505
|
+
answer is what the next period's coupon is charged on.
|
|
506
|
+
"""
|
|
507
|
+
|
|
508
|
+
def __init__(self, securities: Sequence[Security]) -> None:
|
|
509
|
+
self.securities = tuple(securities)
|
|
510
|
+
self.capital = [
|
|
511
|
+
s.invested if s.kind is SecurityKind.PREFERRED else ZERO
|
|
512
|
+
for s in self.securities
|
|
513
|
+
]
|
|
514
|
+
self.accrued = [ZERO for _ in self.securities]
|
|
515
|
+
self.received = [ZERO for _ in self.securities]
|
|
516
|
+
self.as_of = ZERO
|
|
517
|
+
|
|
518
|
+
def _roll(self, years: Money) -> None:
|
|
519
|
+
"""Bring every claim up to ``years``."""
|
|
520
|
+
elapsed = years - self.as_of
|
|
521
|
+
if elapsed <= 0:
|
|
522
|
+
return
|
|
523
|
+
for i, security in enumerate(self.securities):
|
|
524
|
+
self.accrued[i] += security.accrue(
|
|
525
|
+
capital=self.capital[i], accrued=self.accrued[i], years=elapsed
|
|
526
|
+
)
|
|
527
|
+
self.as_of = years
|
|
528
|
+
|
|
529
|
+
def _settle_preferred(self, pot: Money) -> tuple[list[Money], Money]:
|
|
530
|
+
"""Pay the preferred claims out of ``pot``, most senior class first."""
|
|
531
|
+
paid = [ZERO for _ in self.securities]
|
|
532
|
+
remaining = pot
|
|
533
|
+
ranks = sorted(
|
|
534
|
+
{
|
|
535
|
+
s.seniority
|
|
536
|
+
for s in self.securities
|
|
537
|
+
if s.kind is SecurityKind.PREFERRED
|
|
538
|
+
}
|
|
539
|
+
)
|
|
540
|
+
for rank in ranks:
|
|
541
|
+
if remaining <= 0:
|
|
542
|
+
break
|
|
543
|
+
members = [
|
|
544
|
+
i
|
|
545
|
+
for i, s in enumerate(self.securities)
|
|
546
|
+
if s.kind is SecurityKind.PREFERRED and s.seniority == rank
|
|
547
|
+
]
|
|
548
|
+
claims = [self.capital[i] + self.accrued[i] for i in members]
|
|
549
|
+
for i, amount in zip(members, allocate_pro_rata(remaining, claims), strict=True):
|
|
550
|
+
paid[i] = amount
|
|
551
|
+
remaining -= amount
|
|
552
|
+
# Accrued return first, then capital. The remaining capital is
|
|
553
|
+
# what the coupon runs on from here.
|
|
554
|
+
to_accrued = min(amount, self.accrued[i])
|
|
555
|
+
self.accrued[i] -= to_accrued
|
|
556
|
+
self.capital[i] -= amount - to_accrued
|
|
557
|
+
return paid, max(remaining, ZERO)
|
|
558
|
+
|
|
559
|
+
def pay(
|
|
560
|
+
self,
|
|
561
|
+
amount: Money,
|
|
562
|
+
*,
|
|
563
|
+
when: date,
|
|
564
|
+
years: Money,
|
|
565
|
+
label: str,
|
|
566
|
+
pool: OptionPool | None = None,
|
|
567
|
+
is_exit: bool = False,
|
|
568
|
+
) -> Distribution:
|
|
569
|
+
"""Run one payment through the waterfall and record what it did."""
|
|
570
|
+
self._roll(years)
|
|
571
|
+
# Snapshotted before the payment: the claim as it stood when the money
|
|
572
|
+
# arrived is what the payment was measured against.
|
|
573
|
+
standing_accrued = tuple(self.accrued)
|
|
574
|
+
standing_capital = tuple(self.capital)
|
|
575
|
+
preferred_paid, residual = self._settle_preferred(amount)
|
|
576
|
+
|
|
577
|
+
settled: PoolOutcome | None = None
|
|
578
|
+
if pool is not None:
|
|
579
|
+
watched = (
|
|
580
|
+
_measured(self.securities, pool.ratchet)
|
|
581
|
+
if pool.ratchet is not None
|
|
582
|
+
else []
|
|
583
|
+
)
|
|
584
|
+
settled = settle_pool(
|
|
585
|
+
pool,
|
|
586
|
+
residual,
|
|
587
|
+
years=years,
|
|
588
|
+
measured_capital=sum(
|
|
589
|
+
(self.securities[i].invested for i in watched), ZERO
|
|
590
|
+
),
|
|
591
|
+
# Everything the watched instruments have taken so far, this
|
|
592
|
+
# payment's preferred claim included. A money multiple counts
|
|
593
|
+
# what a holder has received, not only what arrives last.
|
|
594
|
+
measured_prior=sum(
|
|
595
|
+
(self.received[i] + preferred_paid[i] for i in watched), ZERO
|
|
596
|
+
),
|
|
597
|
+
measured_ownership=sum(
|
|
598
|
+
(self.securities[i].ownership for i in watched), ZERO
|
|
599
|
+
),
|
|
600
|
+
)
|
|
601
|
+
residual = settled.to_common
|
|
602
|
+
|
|
603
|
+
shares = [s.ownership for s in self.securities]
|
|
604
|
+
residual_paid = allocate_pro_rata(residual, [residual * s for s in shares])
|
|
605
|
+
for i in range(len(self.securities)):
|
|
606
|
+
self.received[i] += preferred_paid[i] + residual_paid[i]
|
|
607
|
+
|
|
608
|
+
return Distribution(
|
|
609
|
+
when=when,
|
|
610
|
+
years=years,
|
|
611
|
+
amount=amount,
|
|
612
|
+
label=label,
|
|
613
|
+
preferred_paid=tuple(preferred_paid),
|
|
614
|
+
residual_paid=tuple(residual_paid),
|
|
615
|
+
accrued=standing_accrued,
|
|
616
|
+
capital=standing_capital,
|
|
617
|
+
incentive=settled,
|
|
618
|
+
is_exit=is_exit,
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _measured(securities: Sequence[Security], ratchet: Ratchet) -> list[int]:
|
|
623
|
+
"""The securities a ratchet's hurdles are read against.
|
|
624
|
+
|
|
625
|
+
Naming nothing means the equity as a whole, which is the right reading when
|
|
626
|
+
the sponsor is the only institutional holder and the wrong one the moment a
|
|
627
|
+
rollover sits beside it. Naming an instrument that is not in the stack is an
|
|
628
|
+
error rather than an empty selection: a plan measured on nothing would clear
|
|
629
|
+
no hurdle at any price, and would do it silently.
|
|
630
|
+
"""
|
|
631
|
+
if not ratchet.measured_on:
|
|
632
|
+
return list(range(len(securities)))
|
|
633
|
+
by_name = {s.name: i for i, s in enumerate(securities)}
|
|
634
|
+
missing = [name for name in ratchet.measured_on if name not in by_name]
|
|
635
|
+
if missing:
|
|
636
|
+
raise IncentiveError(
|
|
637
|
+
f"the ratchet is measured on {', '.join(repr(m) for m in missing)}, "
|
|
638
|
+
f"which {'is' if len(missing) == 1 else 'are'} not in the equity; the "
|
|
639
|
+
f"stack holds {', '.join(repr(s.name) for s in securities)}"
|
|
640
|
+
)
|
|
641
|
+
return [by_name[name] for name in ratchet.measured_on]
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
@dataclass(frozen=True, slots=True)
|
|
645
|
+
class Outcome:
|
|
646
|
+
"""The exit, the waterfall through it, and the attribution behind it."""
|
|
647
|
+
|
|
648
|
+
valuation: ExitValuation
|
|
649
|
+
entry_date: date
|
|
650
|
+
securities: tuple[SecurityOutcome, ...]
|
|
651
|
+
attribution: Attribution
|
|
652
|
+
convention: DayCount = DayCount.ACT_365F
|
|
653
|
+
incentive: PoolOutcome | None = None
|
|
654
|
+
payments: tuple[Distribution, ...] = ()
|
|
655
|
+
|
|
656
|
+
def __len__(self) -> int:
|
|
657
|
+
return len(self.securities)
|
|
658
|
+
|
|
659
|
+
def __iter__(self) -> Iterator[SecurityOutcome]:
|
|
660
|
+
return iter(self.securities)
|
|
661
|
+
|
|
662
|
+
def security(self, name: str) -> SecurityOutcome:
|
|
663
|
+
for row in self.securities:
|
|
664
|
+
if row.name == name:
|
|
665
|
+
return row
|
|
666
|
+
raise KeyError(f"no security named {name!r}")
|
|
667
|
+
|
|
668
|
+
@classmethod
|
|
669
|
+
def realise(
|
|
670
|
+
cls,
|
|
671
|
+
transaction: Transaction,
|
|
672
|
+
model: OperatingModel,
|
|
673
|
+
schedule: DebtSchedule,
|
|
674
|
+
*,
|
|
675
|
+
entry_date: date,
|
|
676
|
+
exit_multiple: Numeric | None = None,
|
|
677
|
+
exit_fee_rate: Numeric = 0,
|
|
678
|
+
securities: Sequence[Security] | None = None,
|
|
679
|
+
incentive: OptionPool | None = None,
|
|
680
|
+
convention: DayCount = DayCount.ACT_365F,
|
|
681
|
+
) -> Outcome:
|
|
682
|
+
"""Value the exit and run the equity through it.
|
|
683
|
+
|
|
684
|
+
``exit_multiple`` defaults to the entry multiple. A flat-multiple case is
|
|
685
|
+
the one a sponsor has to be able to defend, and it is the only one that
|
|
686
|
+
does not borrow from the buyer on the way out.
|
|
687
|
+
|
|
688
|
+
``incentive`` is the management plan. Where there is one, the returns
|
|
689
|
+
reported per security are net of it, which is the only version of them
|
|
690
|
+
worth quoting: a sponsor multiple that ignores the pool is a multiple on
|
|
691
|
+
money somebody else is going to receive.
|
|
692
|
+
"""
|
|
693
|
+
if len(schedule) != len(model):
|
|
694
|
+
raise ValueError(
|
|
695
|
+
f"the schedule covers {len(schedule)} periods and the operating "
|
|
696
|
+
f"case {len(model)}"
|
|
697
|
+
)
|
|
698
|
+
holders = tuple(
|
|
699
|
+
securities if securities is not None else default_securities(transaction)
|
|
700
|
+
)
|
|
701
|
+
_validate(holders)
|
|
702
|
+
|
|
703
|
+
last = schedule[-1]
|
|
704
|
+
valuation = ExitValuation(
|
|
705
|
+
when=last.period.end,
|
|
706
|
+
ebitda=model.exit_ebitda,
|
|
707
|
+
multiple=(
|
|
708
|
+
money(exit_multiple)
|
|
709
|
+
if exit_multiple is not None
|
|
710
|
+
else transaction.valuation.entry_multiple
|
|
711
|
+
),
|
|
712
|
+
debt=last.closing_debt,
|
|
713
|
+
cash=last.closing_cash,
|
|
714
|
+
fee_rate=money(exit_fee_rate),
|
|
715
|
+
)
|
|
716
|
+
if valuation.when <= entry_date:
|
|
717
|
+
raise ValueError(
|
|
718
|
+
f"the exit ({valuation.when}) is not after the close ({entry_date})"
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
ledger = _Ledger(holders)
|
|
722
|
+
interim: list[Distribution] = []
|
|
723
|
+
for period, amount in schedule.distributions():
|
|
724
|
+
if period.end >= valuation.when:
|
|
725
|
+
# A distribution at the exit date is part of the exit, not a
|
|
726
|
+
# payment before it; folding it in twice would pay it twice.
|
|
727
|
+
continue
|
|
728
|
+
interim.append(
|
|
729
|
+
ledger.pay(
|
|
730
|
+
amount,
|
|
731
|
+
when=period.end,
|
|
732
|
+
years=year_fraction(entry_date, period.end, convention),
|
|
733
|
+
label=f"Distribution, period {period.index}",
|
|
734
|
+
)
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
years = year_fraction(entry_date, valuation.when, convention)
|
|
738
|
+
final = ledger.pay(
|
|
739
|
+
valuation.equity_value,
|
|
740
|
+
when=valuation.when,
|
|
741
|
+
years=years,
|
|
742
|
+
label="Exit proceeds",
|
|
743
|
+
pool=incentive,
|
|
744
|
+
is_exit=True,
|
|
745
|
+
)
|
|
746
|
+
payments = (*tuple(interim), final)
|
|
747
|
+
|
|
748
|
+
rows = []
|
|
749
|
+
for i, holder in enumerate(holders):
|
|
750
|
+
rate = _rate(
|
|
751
|
+
holder.invested,
|
|
752
|
+
[(p.when, p.to(i)) for p in payments],
|
|
753
|
+
entry_date,
|
|
754
|
+
convention,
|
|
755
|
+
)
|
|
756
|
+
rows.append(
|
|
757
|
+
SecurityOutcome(
|
|
758
|
+
security=holder,
|
|
759
|
+
accrued=final.accrued[i],
|
|
760
|
+
preferred_paid=final.preferred_paid[i],
|
|
761
|
+
residual_paid=final.residual_paid[i],
|
|
762
|
+
interim=sum((p.to(i) for p in interim), ZERO),
|
|
763
|
+
standing_capital=final.capital[i],
|
|
764
|
+
unpaid_capital=ledger.capital[i],
|
|
765
|
+
irr=rate.value,
|
|
766
|
+
irr_note=rate.note,
|
|
767
|
+
)
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
return cls(
|
|
771
|
+
valuation=valuation,
|
|
772
|
+
entry_date=entry_date,
|
|
773
|
+
securities=tuple(rows),
|
|
774
|
+
attribution=_attribute(transaction, model, schedule, valuation, holders),
|
|
775
|
+
convention=convention,
|
|
776
|
+
incentive=final.incentive,
|
|
777
|
+
payments=payments,
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
# -- Deal-level figures ----------------------------------------------
|
|
781
|
+
|
|
782
|
+
@property
|
|
783
|
+
def invested(self) -> Money:
|
|
784
|
+
return sum((r.invested for r in self.securities), ZERO)
|
|
785
|
+
|
|
786
|
+
@property
|
|
787
|
+
def proceeds(self) -> Money:
|
|
788
|
+
"""What the securities took at the exit."""
|
|
789
|
+
return sum((r.proceeds for r in self.securities), ZERO)
|
|
790
|
+
|
|
791
|
+
@property
|
|
792
|
+
def interim(self) -> Money:
|
|
793
|
+
"""What the securities took before the exit."""
|
|
794
|
+
return sum((r.interim for r in self.securities), ZERO)
|
|
795
|
+
|
|
796
|
+
@property
|
|
797
|
+
def received(self) -> Money:
|
|
798
|
+
return self.proceeds + self.interim
|
|
799
|
+
|
|
800
|
+
@property
|
|
801
|
+
def distributions(self) -> tuple[Distribution, ...]:
|
|
802
|
+
"""The payments before the exit, in the order they were made."""
|
|
803
|
+
return tuple(p for p in self.payments if not p.is_exit)
|
|
804
|
+
|
|
805
|
+
@property
|
|
806
|
+
def was_recapitalised(self) -> bool:
|
|
807
|
+
return bool(self.distributions)
|
|
808
|
+
|
|
809
|
+
@property
|
|
810
|
+
def profit(self) -> Money:
|
|
811
|
+
return self.received - self.invested
|
|
812
|
+
|
|
813
|
+
@property
|
|
814
|
+
def moic(self) -> Money | None:
|
|
815
|
+
if self.invested <= 0:
|
|
816
|
+
return None
|
|
817
|
+
return self.received / self.invested
|
|
818
|
+
|
|
819
|
+
@property
|
|
820
|
+
def holding_period_years(self) -> Money:
|
|
821
|
+
return year_fraction(self.entry_date, self.valuation.when, self.convention)
|
|
822
|
+
|
|
823
|
+
@property
|
|
824
|
+
def irr(self) -> float | None:
|
|
825
|
+
"""The deal-level rate, across all equity taken together."""
|
|
826
|
+
receipts = [
|
|
827
|
+
(p.when, sum(p.preferred_paid, ZERO) + sum(p.residual_paid, ZERO))
|
|
828
|
+
for p in self.payments
|
|
829
|
+
]
|
|
830
|
+
return _rate(self.invested, receipts, self.entry_date, self.convention).value
|
|
831
|
+
|
|
832
|
+
@property
|
|
833
|
+
def incentive_paid(self) -> Money:
|
|
834
|
+
"""What the management plan took, net of the cost of exercising."""
|
|
835
|
+
return ZERO if self.incentive is None else self.incentive.paid
|
|
836
|
+
|
|
837
|
+
@property
|
|
838
|
+
def distributed(self) -> Money:
|
|
839
|
+
"""Everything the equity value was split into, management included.
|
|
840
|
+
|
|
841
|
+
The strike does not appear because it cancels: management pay it in and
|
|
842
|
+
it is divided along with everything else, so the pot the holders share
|
|
843
|
+
is larger by exactly what the plan contributed to it.
|
|
844
|
+
"""
|
|
845
|
+
return self.proceeds + self.incentive_paid
|
|
846
|
+
|
|
847
|
+
@property
|
|
848
|
+
def distributes_everything(self) -> bool:
|
|
849
|
+
"""Whether the waterfall paid out exactly what there was to pay out."""
|
|
850
|
+
return is_close(self.distributed, self.valuation.equity_value, tolerance="1E-12")
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def _validate(securities: Sequence[Security]) -> None:
|
|
854
|
+
if not securities:
|
|
855
|
+
raise ValueError("the equity needs at least one security in it")
|
|
856
|
+
names = [s.name for s in securities]
|
|
857
|
+
if len(names) != len(set(names)):
|
|
858
|
+
raise ValueError("security names must be distinct")
|
|
859
|
+
ownership = sum((s.ownership for s in securities), ZERO)
|
|
860
|
+
if not is_close(ownership, ONE, tolerance="1E-9"):
|
|
861
|
+
raise ValueError(
|
|
862
|
+
f"the residual must be fully owned: the shares given sum to {ownership}, "
|
|
863
|
+
f"not to 1"
|
|
864
|
+
)
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
class Rate(NamedTuple):
|
|
868
|
+
"""An annualised rate, or the reason there is not one.
|
|
869
|
+
|
|
870
|
+
The reason travels with the missing number. A caller looking at ``None``
|
|
871
|
+
should not have to reconstruct from the amounts whether the security was
|
|
872
|
+
wiped out or was never funded in the first place.
|
|
873
|
+
"""
|
|
874
|
+
|
|
875
|
+
value: float | None
|
|
876
|
+
note: str = ""
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def _rate(
|
|
880
|
+
invested: Money,
|
|
881
|
+
receipts: Sequence[tuple[date, Money]],
|
|
882
|
+
entry: date,
|
|
883
|
+
convention: DayCount,
|
|
884
|
+
) -> Rate:
|
|
885
|
+
"""The annualised rate on capital in and everything that came back.
|
|
886
|
+
|
|
887
|
+
A dated stream rather than a pair, because an interim distribution is worth
|
|
888
|
+
what it is worth only by virtue of arriving early. Every receipt after the
|
|
889
|
+
first is positive, so the stream changes sign exactly once and the root is
|
|
890
|
+
unique — but the solver is asked rather than assumed, because a structure
|
|
891
|
+
that ever calls capital mid-hold would break that and should say so instead
|
|
892
|
+
of reporting whichever root it happened to find.
|
|
893
|
+
"""
|
|
894
|
+
if invested <= 0:
|
|
895
|
+
return Rate(None, "no capital was invested")
|
|
896
|
+
received = sum((amount for _, amount in receipts), ZERO)
|
|
897
|
+
if received <= 0:
|
|
898
|
+
return Rate(None, "wiped out: there is no rate at which nothing back is a return")
|
|
899
|
+
flows = [CashFlow(when=entry, amount=-invested, label="invested")]
|
|
900
|
+
flows.extend(
|
|
901
|
+
CashFlow(when=when, amount=amount, label="received")
|
|
902
|
+
for when, amount in receipts
|
|
903
|
+
if amount != 0
|
|
904
|
+
)
|
|
905
|
+
try:
|
|
906
|
+
return Rate(CashFlowStream(flows=tuple(flows), convention=convention).xirr())
|
|
907
|
+
except IRRError as exc: # pragma: no cover - one sign change always solves
|
|
908
|
+
return Rate(None, str(exc))
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def _attribute(
|
|
912
|
+
transaction: Transaction,
|
|
913
|
+
model: OperatingModel,
|
|
914
|
+
schedule: DebtSchedule,
|
|
915
|
+
valuation: ExitValuation,
|
|
916
|
+
securities: Sequence[Security],
|
|
917
|
+
) -> Attribution:
|
|
918
|
+
"""Decompose the change in equity value into its three sources and a residual.
|
|
919
|
+
|
|
920
|
+
Entry EBITDA is the LTM figure the deal was priced on rather than the first
|
|
921
|
+
projected period, because that is the number the entry multiple was paid
|
|
922
|
+
against. Entry net debt is what the structure funded less the cash it left
|
|
923
|
+
on the balance sheet, so the paydown line measures the schedule's work and
|
|
924
|
+
not the target's opening position.
|
|
925
|
+
"""
|
|
926
|
+
entry_multiple = transaction.valuation.entry_multiple
|
|
927
|
+
entry_ebitda = transaction.valuation.ltm_ebitda
|
|
928
|
+
exit_ebitda = valuation.ebitda
|
|
929
|
+
|
|
930
|
+
entry_net_debt = transaction.total_debt - schedule.opening_cash
|
|
931
|
+
invested = sum((s.invested for s in securities), ZERO)
|
|
932
|
+
|
|
933
|
+
growth = (exit_ebitda - entry_ebitda) * entry_multiple
|
|
934
|
+
multiple = (valuation.multiple - entry_multiple) * exit_ebitda
|
|
935
|
+
paydown = entry_net_debt - valuation.net_debt
|
|
936
|
+
|
|
937
|
+
# Computed from the deal rather than taken as the plug, so that the four
|
|
938
|
+
# components summing to the change in value is a real check on the bridge
|
|
939
|
+
# instead of an identity that cannot fail. In the ordinary case this is
|
|
940
|
+
# exactly the fees and issue discount the equity funded at close, plus the
|
|
941
|
+
# cost of selling: the implied equity value at close is lower than the
|
|
942
|
+
# cheque written for it by precisely the amount of those costs.
|
|
943
|
+
entry_equity_value = entry_multiple * entry_ebitda - entry_net_debt
|
|
944
|
+
costs = (entry_equity_value - invested) - valuation.fees
|
|
945
|
+
|
|
946
|
+
return Attribution(
|
|
947
|
+
ebitda_growth=growth,
|
|
948
|
+
multiple_change=multiple,
|
|
949
|
+
debt_paydown=paydown,
|
|
950
|
+
costs=costs,
|
|
951
|
+
invested=invested,
|
|
952
|
+
# The bridge is drawn on what the arithmetic says the equity is worth.
|
|
953
|
+
# Flooring it at zero is a legal fact about limited liability rather
|
|
954
|
+
# than a source of value, so it is reported beside the bridge and not
|
|
955
|
+
# inside it.
|
|
956
|
+
realised=valuation.gross_equity_value,
|
|
957
|
+
floored=valuation.equity_value - valuation.gross_equity_value,
|
|
958
|
+
)
|