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/report.py
ADDED
|
@@ -0,0 +1,1188 @@
|
|
|
1
|
+
"""The memo.
|
|
2
|
+
|
|
3
|
+
Every other subcommand answers one question. This one assembles the answers into
|
|
4
|
+
the document somebody actually takes into a room, in the order the argument is
|
|
5
|
+
made rather than the order the code was written: what is being bought and for
|
|
6
|
+
what, how it is funded, what the case says, what the schedule does to the debt,
|
|
7
|
+
where the covenants sit, what the sponsor makes, and where the value came from.
|
|
8
|
+
|
|
9
|
+
*The section that is not reformatting.* A memo that restates the base case tells
|
|
10
|
+
a reader the one thing they already believe. The last section says where the
|
|
11
|
+
case stops working — the exit multiple at which the sponsor gets its money back
|
|
12
|
+
and no more, the margin shift at which the tightest covenant trips, the leverage
|
|
13
|
+
at which the structure stops clearing its tests. Those are break-evens, solved by
|
|
14
|
+
re-running the engine along the dimension until the metric crosses, and they are
|
|
15
|
+
the numbers a committee spends its time on.
|
|
16
|
+
|
|
17
|
+
*Two renderings, one document.* The content is assembled once into sections,
|
|
18
|
+
lines and tables, then rendered as aligned text for a terminal or as markdown for
|
|
19
|
+
anything that has to be pasted somewhere. Assembling twice is how the two
|
|
20
|
+
versions come to disagree about a figure, which is the sort of thing that is
|
|
21
|
+
noticed at exactly the wrong moment.
|
|
22
|
+
|
|
23
|
+
*What is not here.* No recommendation. The engine can say what the case is worth
|
|
24
|
+
and where it breaks; whether that is a deal worth doing is a judgement, and a
|
|
25
|
+
model that renders one has overstepped.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import dataclasses
|
|
31
|
+
from collections.abc import Iterator, Sequence
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from datetime import date
|
|
34
|
+
|
|
35
|
+
from .covenants import CovenantReport
|
|
36
|
+
from .incentive import PoolOutcome
|
|
37
|
+
from .money import ONE, ZERO, Money, money, quantize, safe_div
|
|
38
|
+
from .outcome import Outcome
|
|
39
|
+
from .returns import cagr
|
|
40
|
+
from .sensitivity import (
|
|
41
|
+
Breakeven,
|
|
42
|
+
Case,
|
|
43
|
+
Dimension,
|
|
44
|
+
Metric,
|
|
45
|
+
SensitivityError,
|
|
46
|
+
Unit,
|
|
47
|
+
format_value,
|
|
48
|
+
solve,
|
|
49
|
+
)
|
|
50
|
+
from .spec import Deal
|
|
51
|
+
|
|
52
|
+
__all__ = ["Line", "Report", "Section", "Table", "prepare"]
|
|
53
|
+
|
|
54
|
+
#: Where each break-even is looked for. Wide enough to hold the answer for any
|
|
55
|
+
#: deal that is recognisably a buyout, and bounded, because a search that
|
|
56
|
+
#: wanders far enough finds prices at which the deal is not a transaction.
|
|
57
|
+
EXIT_MULTIPLE_BRACKET = (money(1), money(30))
|
|
58
|
+
LEVERAGE_BRACKET = (money("0.5"), money(15))
|
|
59
|
+
MARGIN_SHIFT_BRACKET = (money("-0.08"), money("0.08"))
|
|
60
|
+
|
|
61
|
+
_NONE = "n/a"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class Line:
|
|
66
|
+
"""One labelled figure, with an optional gloss."""
|
|
67
|
+
|
|
68
|
+
label: str
|
|
69
|
+
value: str
|
|
70
|
+
note: str = ""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True, slots=True)
|
|
74
|
+
class Table:
|
|
75
|
+
"""A grid of already-rendered strings.
|
|
76
|
+
|
|
77
|
+
Formatting happens when the section is assembled rather than when it is
|
|
78
|
+
printed, so the text and the markdown cannot round a figure differently.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
headings: tuple[str, ...]
|
|
82
|
+
rows: tuple[tuple[str, ...], ...]
|
|
83
|
+
align: tuple[str, ...] = ()
|
|
84
|
+
|
|
85
|
+
def __post_init__(self) -> None:
|
|
86
|
+
width = len(self.headings)
|
|
87
|
+
for row in self.rows:
|
|
88
|
+
if len(row) != width:
|
|
89
|
+
raise ValueError(
|
|
90
|
+
f"a row has {len(row)} cells and the table has {width} columns"
|
|
91
|
+
)
|
|
92
|
+
if self.align and len(self.align) != width:
|
|
93
|
+
raise ValueError("the alignment must name every column or none of them")
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def alignment(self) -> tuple[str, ...]:
|
|
97
|
+
"""Left for the first column, right for the rest, unless told otherwise."""
|
|
98
|
+
if self.align:
|
|
99
|
+
return self.align
|
|
100
|
+
return ("l",) + ("r",) * (len(self.headings) - 1)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True, slots=True)
|
|
104
|
+
class Section:
|
|
105
|
+
"""One part of the memo."""
|
|
106
|
+
|
|
107
|
+
title: str
|
|
108
|
+
summary: str = ""
|
|
109
|
+
lines: tuple[Line, ...] = ()
|
|
110
|
+
table: Table | None = None
|
|
111
|
+
notes: tuple[str, ...] = ()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True, slots=True)
|
|
115
|
+
class Report:
|
|
116
|
+
"""The whole memo, assembled and not yet rendered."""
|
|
117
|
+
|
|
118
|
+
name: str
|
|
119
|
+
close: date | None
|
|
120
|
+
sections: tuple[Section, ...] = field(default_factory=tuple)
|
|
121
|
+
|
|
122
|
+
def __len__(self) -> int:
|
|
123
|
+
return len(self.sections)
|
|
124
|
+
|
|
125
|
+
def __iter__(self) -> Iterator[Section]:
|
|
126
|
+
return iter(self.sections)
|
|
127
|
+
|
|
128
|
+
def section(self, title: str) -> Section:
|
|
129
|
+
for part in self.sections:
|
|
130
|
+
if part.title == title:
|
|
131
|
+
return part
|
|
132
|
+
raise KeyError(f"no section titled {title!r}")
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def title(self) -> str:
|
|
136
|
+
if self.close is None:
|
|
137
|
+
return f"{self.name} — investment committee"
|
|
138
|
+
return f"{self.name} — investment committee (close {self.close.isoformat()})"
|
|
139
|
+
|
|
140
|
+
def as_text(self) -> str:
|
|
141
|
+
return _render_text(self)
|
|
142
|
+
|
|
143
|
+
def as_markdown(self) -> str:
|
|
144
|
+
return _render_markdown(self)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# -- Assembly ------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def prepare(deal: Deal, *, breakevens: bool = True) -> Report:
|
|
151
|
+
"""Run every layer once and lay the answers out as a memo.
|
|
152
|
+
|
|
153
|
+
``breakevens`` is separable because it is the expensive part: each crossing
|
|
154
|
+
costs a bounded number of full engine runs, and a caller that only wants
|
|
155
|
+
the base case should not pay for the boundary of it.
|
|
156
|
+
"""
|
|
157
|
+
case = Case.run(deal)
|
|
158
|
+
sections = [
|
|
159
|
+
_transaction(deal, case),
|
|
160
|
+
_operating(deal, case),
|
|
161
|
+
_schedule(deal, case),
|
|
162
|
+
]
|
|
163
|
+
if case.covenants is not None:
|
|
164
|
+
sections.append(_covenants(case.covenants))
|
|
165
|
+
sections.append(_exit(case.outcome))
|
|
166
|
+
if deal.has_acquisitions:
|
|
167
|
+
sections.append(_acquisitions(deal, case))
|
|
168
|
+
if case.schedule.refinancings:
|
|
169
|
+
sections.append(_refinancing(deal, case))
|
|
170
|
+
if case.outcome.was_recapitalised:
|
|
171
|
+
sections.append(_recapitalisation(deal, case))
|
|
172
|
+
if case.outcome.incentive is not None:
|
|
173
|
+
sections.append(_incentive(case.outcome.incentive))
|
|
174
|
+
sections.append(_bridge(case.outcome))
|
|
175
|
+
if breakevens:
|
|
176
|
+
sections.append(_boundary(deal, case))
|
|
177
|
+
return Report(name=deal.name, close=deal.close_date, sections=tuple(sections))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _amount(value: Money) -> str:
|
|
181
|
+
return f"{quantize(value, 1):,}"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _turns(value: Money) -> str:
|
|
185
|
+
return format_value(Unit.TURNS, value)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _percent(value: Money) -> str:
|
|
189
|
+
return format_value(Unit.RATE, value)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _rate(value: float | None) -> str:
|
|
193
|
+
return _NONE if value is None else format_value(Unit.RATE, money(repr(value)))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _multiple(value: Money | None) -> str:
|
|
197
|
+
return _NONE if value is None else format_value(Unit.MULTIPLE, value)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _transaction(deal: Deal, case: Case) -> Section:
|
|
201
|
+
transaction = deal.transaction
|
|
202
|
+
valuation = transaction.valuation
|
|
203
|
+
opening_cash = case.schedule.opening_cash
|
|
204
|
+
net_debt = transaction.total_debt - opening_cash
|
|
205
|
+
leverage = safe_div(net_debt, valuation.ltm_ebitda, default=ZERO)
|
|
206
|
+
gross = safe_div(transaction.total_debt, valuation.ltm_ebitda, default=ZERO)
|
|
207
|
+
|
|
208
|
+
return Section(
|
|
209
|
+
title="The transaction",
|
|
210
|
+
summary=(
|
|
211
|
+
f"{_amount(valuation.ltm_ebitda)} of LTM EBITDA at "
|
|
212
|
+
f"{_turns(valuation.entry_multiple)}, funded at {_turns(gross)} of "
|
|
213
|
+
f"gross debt and {_turns(leverage)} net of the cash left on the "
|
|
214
|
+
f"balance sheet."
|
|
215
|
+
),
|
|
216
|
+
lines=(
|
|
217
|
+
Line("LTM EBITDA", _amount(valuation.ltm_ebitda)),
|
|
218
|
+
Line("Entry multiple", _turns(valuation.entry_multiple)),
|
|
219
|
+
Line("Enterprise value", _amount(valuation.enterprise_value)),
|
|
220
|
+
Line(
|
|
221
|
+
"Equity purchase price",
|
|
222
|
+
_amount(valuation.equity_purchase_price),
|
|
223
|
+
"after repaying the target's own debt",
|
|
224
|
+
),
|
|
225
|
+
Line("Debt raised", _amount(transaction.total_debt), "at face"),
|
|
226
|
+
Line("Rollover equity", _amount(transaction.rollover_equity)),
|
|
227
|
+
Line(
|
|
228
|
+
"Sponsor equity",
|
|
229
|
+
_amount(transaction.sponsor_equity),
|
|
230
|
+
"the plug that balances the funding table",
|
|
231
|
+
),
|
|
232
|
+
Line(
|
|
233
|
+
"Fees and issue discount",
|
|
234
|
+
_amount(transaction.total_fees + transaction.original_issue_discount),
|
|
235
|
+
),
|
|
236
|
+
Line("Cash at close", _amount(opening_cash)),
|
|
237
|
+
Line("Net leverage at close", _turns(leverage)),
|
|
238
|
+
),
|
|
239
|
+
table=Table(
|
|
240
|
+
headings=("Tranche", "Face", "Proceeds", "Turns"),
|
|
241
|
+
rows=tuple(
|
|
242
|
+
(
|
|
243
|
+
tranche.name,
|
|
244
|
+
_amount(tranche.face),
|
|
245
|
+
_amount(tranche.proceeds),
|
|
246
|
+
_turns(
|
|
247
|
+
safe_div(tranche.face, valuation.ltm_ebitda, default=ZERO)
|
|
248
|
+
),
|
|
249
|
+
)
|
|
250
|
+
for tranche in transaction.debt
|
|
251
|
+
),
|
|
252
|
+
),
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _operating(deal: Deal, case: Case) -> Section:
|
|
257
|
+
model = case.model
|
|
258
|
+
first, last = model[0], model[-1]
|
|
259
|
+
years = _years(deal, len(model))
|
|
260
|
+
# Compounded from the position at close rather than from the first
|
|
261
|
+
# projected period. First-to-last across five periods spans four years of
|
|
262
|
+
# growth, and reporting it over five understates the case by about a fifth
|
|
263
|
+
# of itself - the same off-by-one that flatters a deal in a pitch.
|
|
264
|
+
opening = deal.opening_revenue
|
|
265
|
+
growth = (
|
|
266
|
+
_growth(opening, last.revenue, years) if opening is not None else _NONE
|
|
267
|
+
)
|
|
268
|
+
ebitda_growth = _growth(
|
|
269
|
+
deal.transaction.valuation.ltm_ebitda, last.ebitda, years
|
|
270
|
+
)
|
|
271
|
+
margin_move = last.ebitda_margin - first.ebitda_margin
|
|
272
|
+
|
|
273
|
+
return Section(
|
|
274
|
+
title="The operating case",
|
|
275
|
+
summary=(
|
|
276
|
+
f"Revenue compounds at {growth} from close and EBITDA at "
|
|
277
|
+
f"{ebitda_growth}, with margin moving {_points(margin_move)} across "
|
|
278
|
+
f"the projected periods to end at {_percent(last.ebitda_margin)}."
|
|
279
|
+
),
|
|
280
|
+
table=Table(
|
|
281
|
+
headings=(
|
|
282
|
+
"Period",
|
|
283
|
+
"Revenue",
|
|
284
|
+
"EBITDA",
|
|
285
|
+
"Margin",
|
|
286
|
+
"Capex",
|
|
287
|
+
"Cash tax",
|
|
288
|
+
"Unlevered FCF",
|
|
289
|
+
),
|
|
290
|
+
rows=tuple(
|
|
291
|
+
(
|
|
292
|
+
period.period.end.isoformat(),
|
|
293
|
+
_amount(period.revenue),
|
|
294
|
+
_amount(period.ebitda),
|
|
295
|
+
_percent(period.ebitda_margin),
|
|
296
|
+
_amount(period.capital_expenditure),
|
|
297
|
+
_amount(period.tax.cash_tax),
|
|
298
|
+
_amount(period.unlevered_free_cash_flow),
|
|
299
|
+
)
|
|
300
|
+
for period in model
|
|
301
|
+
),
|
|
302
|
+
),
|
|
303
|
+
lines=(
|
|
304
|
+
Line(
|
|
305
|
+
"Cumulative unlevered free cash flow",
|
|
306
|
+
_amount(model.total_unlevered_free_cash_flow),
|
|
307
|
+
),
|
|
308
|
+
Line(
|
|
309
|
+
"Cash conversion, final period",
|
|
310
|
+
_percent(last.cash_conversion),
|
|
311
|
+
"unlevered free cash flow over EBITDA",
|
|
312
|
+
),
|
|
313
|
+
),
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _schedule(deal: Deal, case: Case) -> Section:
|
|
318
|
+
schedule = case.schedule
|
|
319
|
+
first, last = schedule[0], schedule[-1]
|
|
320
|
+
# Two different figures, and the difference is the point. Repayments are
|
|
321
|
+
# what left the business; the net movement is that less the interest that
|
|
322
|
+
# accrued in kind and grew the balance while nobody paid it.
|
|
323
|
+
repaid = sum(
|
|
324
|
+
(p.mandatory_repayment + p.sweep_repayment for p in schedule), ZERO
|
|
325
|
+
)
|
|
326
|
+
drawn = sum((p.revolver_draw for p in schedule), ZERO)
|
|
327
|
+
net_movement = first.opening_debt - last.closing_debt
|
|
328
|
+
# Leverage at close is quoted on LTM EBITDA, which is what the deal was
|
|
329
|
+
# priced against, and at exit on exit EBITDA. Anything else has the two
|
|
330
|
+
# ends of the same sentence measured against different denominators.
|
|
331
|
+
entry_leverage = safe_div(
|
|
332
|
+
first.opening_debt - schedule.opening_cash,
|
|
333
|
+
deal.transaction.valuation.ltm_ebitda,
|
|
334
|
+
default=ZERO,
|
|
335
|
+
)
|
|
336
|
+
exit_leverage = safe_div(last.net_debt, case.model.exit_ebitda, default=ZERO)
|
|
337
|
+
|
|
338
|
+
notes = []
|
|
339
|
+
drawing = [p for p in schedule if p.revolver_draw > 0]
|
|
340
|
+
if drawing:
|
|
341
|
+
last_draw = drawing[-1].index
|
|
342
|
+
notes.append(
|
|
343
|
+
f"The revolver is drawn through period {last_draw}: until then the "
|
|
344
|
+
f"business does not cover its own interest and amortisation out of "
|
|
345
|
+
f"cash flow, and the facility is carrying the difference."
|
|
346
|
+
)
|
|
347
|
+
if any(not p.is_funded for p in schedule):
|
|
348
|
+
first_gap = next(p for p in schedule if not p.is_funded)
|
|
349
|
+
notes.append(
|
|
350
|
+
f"Period {first_gap.index} does not fund itself: the case is short "
|
|
351
|
+
f"{_amount(first_gap.funding_shortfall)} after the revolver."
|
|
352
|
+
)
|
|
353
|
+
elif any(not p.meets_minimum_cash for p in schedule):
|
|
354
|
+
first_low = next(p for p in schedule if not p.meets_minimum_cash)
|
|
355
|
+
notes.append(
|
|
356
|
+
f"Period {first_low.index} closes below the minimum cash balance by "
|
|
357
|
+
f"{_amount(first_low.cash_below_minimum)}, which is a conversation "
|
|
358
|
+
f"rather than an unpaid bill."
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
return Section(
|
|
362
|
+
title="The debt schedule",
|
|
363
|
+
summary=(
|
|
364
|
+
f"{_amount(repaid)} repaid across the hold against "
|
|
365
|
+
f"{_amount(schedule.total_pik_interest)} accrued in kind and "
|
|
366
|
+
f"{_amount(drawn)} drawn on the revolver, a net reduction of "
|
|
367
|
+
f"{_amount(net_movement)}. Net leverage goes from "
|
|
368
|
+
f"{_turns(entry_leverage)} on LTM EBITDA to {_turns(exit_leverage)} "
|
|
369
|
+
f"on exit EBITDA."
|
|
370
|
+
),
|
|
371
|
+
table=Table(
|
|
372
|
+
headings=(
|
|
373
|
+
"Period",
|
|
374
|
+
"Opening",
|
|
375
|
+
"Interest",
|
|
376
|
+
"Mandatory",
|
|
377
|
+
"Sweep",
|
|
378
|
+
"Closing",
|
|
379
|
+
"Net leverage",
|
|
380
|
+
),
|
|
381
|
+
rows=tuple(
|
|
382
|
+
(
|
|
383
|
+
period.period.end.isoformat(),
|
|
384
|
+
_amount(period.opening_debt),
|
|
385
|
+
_amount(period.cash_interest + period.pik_interest),
|
|
386
|
+
_amount(period.mandatory_repayment),
|
|
387
|
+
_amount(period.sweep_repayment),
|
|
388
|
+
_amount(period.closing_debt),
|
|
389
|
+
# A stub is a whole balance against a fraction of a year's
|
|
390
|
+
# earnings, which is not a leverage reading.
|
|
391
|
+
"-"
|
|
392
|
+
if period.period.is_stub
|
|
393
|
+
else _turns(
|
|
394
|
+
safe_div(period.net_debt, case.model[i].ebitda, default=ZERO)
|
|
395
|
+
),
|
|
396
|
+
)
|
|
397
|
+
for i, period in enumerate(schedule)
|
|
398
|
+
),
|
|
399
|
+
),
|
|
400
|
+
lines=(
|
|
401
|
+
Line("Cash interest paid", _amount(schedule.total_cash_interest)),
|
|
402
|
+
Line(
|
|
403
|
+
"Interest accrued in kind",
|
|
404
|
+
_amount(schedule.total_pik_interest),
|
|
405
|
+
"owed at exit rather than paid during the hold",
|
|
406
|
+
),
|
|
407
|
+
Line("Repayments", _amount(repaid), "mandatory and swept"),
|
|
408
|
+
Line("Drawn on the revolver", _amount(drawn)),
|
|
409
|
+
Line(
|
|
410
|
+
"Net reduction in debt",
|
|
411
|
+
_amount(net_movement),
|
|
412
|
+
"repayments less what accrued in kind and was drawn",
|
|
413
|
+
),
|
|
414
|
+
Line("Cash at exit", _amount(last.closing_cash)),
|
|
415
|
+
),
|
|
416
|
+
notes=tuple(notes),
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _covenants(report: CovenantReport) -> Section:
|
|
421
|
+
tightest = report.tightest
|
|
422
|
+
breach = report.first_breach
|
|
423
|
+
|
|
424
|
+
if breach is not None:
|
|
425
|
+
summary = (
|
|
426
|
+
f"{breach.covenant} breaches in period {breach.index}, at "
|
|
427
|
+
f"{_turns(breach.actual)} against a threshold of "
|
|
428
|
+
f"{_turns(breach.threshold)}."
|
|
429
|
+
if breach.actual is not None
|
|
430
|
+
else f"{breach.covenant} breaches in period {breach.index}."
|
|
431
|
+
)
|
|
432
|
+
elif tightest is not None and tightest.ebitda_cushion is not None:
|
|
433
|
+
summary = (
|
|
434
|
+
f"Every test is met. The structure is tightest against "
|
|
435
|
+
f"{tightest.covenant} in period {tightest.index}, where EBITDA "
|
|
436
|
+
f"could fall {_percent(tightest.ebitda_cushion)} before it trips."
|
|
437
|
+
)
|
|
438
|
+
else:
|
|
439
|
+
summary = "Every test is met, and none of them is measurable on this case."
|
|
440
|
+
|
|
441
|
+
rows = []
|
|
442
|
+
for covenant in report.covenants:
|
|
443
|
+
observations = [o for o in report.for_covenant(covenant.name) if o.tested]
|
|
444
|
+
if not observations:
|
|
445
|
+
rows.append((covenant.name, str(covenant.measure), _NONE, _NONE, "not tested"))
|
|
446
|
+
continue
|
|
447
|
+
worst = min(
|
|
448
|
+
observations,
|
|
449
|
+
key=lambda o: o.ebitda_cushion if o.ebitda_cushion is not None else ZERO,
|
|
450
|
+
)
|
|
451
|
+
rows.append(
|
|
452
|
+
(
|
|
453
|
+
covenant.name,
|
|
454
|
+
str(covenant.measure),
|
|
455
|
+
_turns(worst.threshold),
|
|
456
|
+
_NONE if worst.actual is None else _turns(worst.actual),
|
|
457
|
+
(
|
|
458
|
+
f"breaches in period {worst.index}"
|
|
459
|
+
if worst.breached
|
|
460
|
+
else f"{_percent(worst.ebitda_cushion)} of cushion in period {worst.index}"
|
|
461
|
+
if worst.ebitda_cushion is not None
|
|
462
|
+
else f"tightest in period {worst.index}"
|
|
463
|
+
),
|
|
464
|
+
)
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
return Section(
|
|
468
|
+
title="Covenants",
|
|
469
|
+
summary=summary,
|
|
470
|
+
table=Table(
|
|
471
|
+
headings=("Covenant", "Measure", "Threshold", "Actual", "At its tightest"),
|
|
472
|
+
rows=tuple(rows),
|
|
473
|
+
align=("l", "l", "r", "r", "l"),
|
|
474
|
+
),
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _exit(outcome: Outcome) -> Section:
|
|
479
|
+
valuation = outcome.valuation
|
|
480
|
+
rows = []
|
|
481
|
+
for row in outcome:
|
|
482
|
+
rows.append(
|
|
483
|
+
(
|
|
484
|
+
row.name,
|
|
485
|
+
str(row.security.kind),
|
|
486
|
+
_amount(row.invested),
|
|
487
|
+
_amount(row.proceeds),
|
|
488
|
+
_multiple(row.moic),
|
|
489
|
+
_rate(row.irr) if row.irr is not None else (row.irr_note or _NONE),
|
|
490
|
+
)
|
|
491
|
+
)
|
|
492
|
+
rows.append(
|
|
493
|
+
(
|
|
494
|
+
"Total",
|
|
495
|
+
"",
|
|
496
|
+
_amount(outcome.invested),
|
|
497
|
+
_amount(outcome.proceeds),
|
|
498
|
+
_multiple(outcome.moic),
|
|
499
|
+
_rate(outcome.irr),
|
|
500
|
+
)
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
notes = []
|
|
504
|
+
for row in outcome:
|
|
505
|
+
if row.shortfall > 0:
|
|
506
|
+
notes.append(
|
|
507
|
+
f"{row.name} is left {_amount(row.shortfall)} short of its "
|
|
508
|
+
f"preferred claim."
|
|
509
|
+
)
|
|
510
|
+
if valuation.is_wiped_out:
|
|
511
|
+
notes.append(
|
|
512
|
+
"The business is worth less than it owes, so the equity is wiped "
|
|
513
|
+
"out and the lenders take what there is."
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
return Section(
|
|
517
|
+
title="The exit",
|
|
518
|
+
summary=(
|
|
519
|
+
f"Sold at {_turns(valuation.multiple)} on "
|
|
520
|
+
f"{_amount(valuation.ebitda)} of EBITDA, leaving "
|
|
521
|
+
f"{_amount(valuation.equity_value)} of equity value after "
|
|
522
|
+
f"{_amount(valuation.net_debt)} of net debt and the cost of sale. "
|
|
523
|
+
f"The equity returns {_multiple(outcome.moic)} and "
|
|
524
|
+
f"{_rate(outcome.irr)} over "
|
|
525
|
+
f"{quantize(outcome.holding_period_years, 2)} years."
|
|
526
|
+
),
|
|
527
|
+
lines=(
|
|
528
|
+
Line("Exit EBITDA", _amount(valuation.ebitda)),
|
|
529
|
+
Line("Exit multiple", _turns(valuation.multiple)),
|
|
530
|
+
Line("Enterprise value", _amount(valuation.enterprise_value)),
|
|
531
|
+
Line("Net debt", _amount(valuation.net_debt)),
|
|
532
|
+
Line("Cost of sale", _amount(valuation.fees)),
|
|
533
|
+
Line("Equity value", _amount(valuation.equity_value)),
|
|
534
|
+
Line("Net leverage at exit", _turns(valuation.exit_leverage)),
|
|
535
|
+
),
|
|
536
|
+
table=Table(
|
|
537
|
+
headings=("Security", "Kind", "Invested", "Proceeds", "MoIC", "IRR"),
|
|
538
|
+
rows=tuple(rows),
|
|
539
|
+
align=("l", "l", "r", "r", "r", "r"),
|
|
540
|
+
),
|
|
541
|
+
notes=tuple(notes),
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _acquisitions(deal: Deal, case: Case) -> Section:
|
|
546
|
+
"""What was bought, what it blended the entry down to, and whether it paid.
|
|
547
|
+
|
|
548
|
+
The counterfactual is the platform on its own — same structure, same
|
|
549
|
+
operating case, no purchases — and it is the only way to answer the question
|
|
550
|
+
the strategy is judged on. A buy-and-build reported against itself always
|
|
551
|
+
looks good: earnings went up. Reported against the platform it was built on,
|
|
552
|
+
it has to show that the earnings arrived for less than they were worth, that
|
|
553
|
+
the debt raised to buy them was serviced, and that what is left over after
|
|
554
|
+
both is larger than doing nothing.
|
|
555
|
+
|
|
556
|
+
Not every roll-up clears that. One that buys three turns below its own
|
|
557
|
+
multiple and spends two of them on fees, interest and integration has
|
|
558
|
+
created a bigger business and a worse return, and this is the section where
|
|
559
|
+
that becomes visible.
|
|
560
|
+
"""
|
|
561
|
+
blended = deal.blended_entry
|
|
562
|
+
schedule = case.schedule
|
|
563
|
+
outcome = case.outcome
|
|
564
|
+
|
|
565
|
+
platform = Case.run(dataclasses.replace(deal, acquisitions=()))
|
|
566
|
+
moved_moic = _delta_multiple(outcome.moic, platform.outcome.moic)
|
|
567
|
+
moved_irr = _delta_rate(outcome.irr, platform.outcome.irr)
|
|
568
|
+
|
|
569
|
+
rows = tuple(
|
|
570
|
+
(
|
|
571
|
+
add_on.label,
|
|
572
|
+
f"P{add_on.period}",
|
|
573
|
+
_amount(add_on.ebitda),
|
|
574
|
+
_multiple(add_on.multiple),
|
|
575
|
+
_amount(add_on.enterprise_value),
|
|
576
|
+
_amount(add_on.face),
|
|
577
|
+
_amount(add_on.from_cash),
|
|
578
|
+
)
|
|
579
|
+
for add_on in deal.acquisitions
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
lines = [
|
|
583
|
+
Line("Businesses acquired", str(len(deal.acquisitions))),
|
|
584
|
+
Line("EBITDA acquired", _amount(blended.acquired_ebitda), "run-rate at purchase"),
|
|
585
|
+
Line("Enterprise value paid", _amount(blended.acquired_enterprise_value)),
|
|
586
|
+
Line(
|
|
587
|
+
"Capital deployed",
|
|
588
|
+
_amount(blended.capital_deployed),
|
|
589
|
+
"platform and add-ons, fees included",
|
|
590
|
+
),
|
|
591
|
+
Line("Funded with new debt", _amount(schedule.total_acquisition_debt)),
|
|
592
|
+
Line("Funded from cash", _amount(schedule.total_acquisition_from_cash)),
|
|
593
|
+
Line("Platform multiple", _multiple(blended.platform_multiple)),
|
|
594
|
+
Line("Blended multiple", _multiple(blended.blended_multiple)),
|
|
595
|
+
Line(
|
|
596
|
+
"After synergies",
|
|
597
|
+
_multiple(blended.synergised_multiple),
|
|
598
|
+
"earnings not yet earned",
|
|
599
|
+
),
|
|
600
|
+
Line("On capital deployed", _multiple(blended.all_in_multiple)),
|
|
601
|
+
Line("Money multiple, as run", _multiple(outcome.moic)),
|
|
602
|
+
Line("Money multiple, platform alone", _multiple(platform.outcome.moic)),
|
|
603
|
+
Line("Rate of return, as run", _rate(outcome.irr)),
|
|
604
|
+
Line("Rate of return, platform alone", _rate(platform.outcome.irr)),
|
|
605
|
+
]
|
|
606
|
+
|
|
607
|
+
acquired = case.model.exit_acquired_ebitda
|
|
608
|
+
notes = [
|
|
609
|
+
f"{_amount(acquired)} of the {_amount(case.model.exit_ebitda)} of EBITDA "
|
|
610
|
+
f"the exit is priced on was bought rather than built, which is "
|
|
611
|
+
f"{_percent(safe_div(acquired, case.model.exit_ebitda, default=ZERO))} of "
|
|
612
|
+
f"it. An exit multiple argued from the platform's own growth has to "
|
|
613
|
+
f"carry that share too.",
|
|
614
|
+
"Each purchase closes at a period end, so the debt raised for it is "
|
|
615
|
+
"outstanding for a full period before any of the earnings it bought are "
|
|
616
|
+
"recorded. Leverage measured at that boundary is therefore at its worst "
|
|
617
|
+
"reading of the hold, and the covenant tests see it.",
|
|
618
|
+
]
|
|
619
|
+
|
|
620
|
+
return Section(
|
|
621
|
+
title="Bought during the hold",
|
|
622
|
+
summary=(
|
|
623
|
+
f"{len(deal.acquisitions)} acquisitions added "
|
|
624
|
+
f"{_amount(blended.acquired_ebitda)} of run-rate EBITDA at "
|
|
625
|
+
f"{_multiple(blended.blended_multiple)} blended against a platform "
|
|
626
|
+
f"bought at {_multiple(blended.platform_multiple)}, "
|
|
627
|
+
f"{_turns(blended.arbitrage)} of arbitrage. Against the platform run "
|
|
628
|
+
f"on its own the money multiple moves {moved_moic} and the rate of "
|
|
629
|
+
f"return moves {moved_irr}."
|
|
630
|
+
),
|
|
631
|
+
lines=tuple(lines),
|
|
632
|
+
table=Table(
|
|
633
|
+
headings=(
|
|
634
|
+
"Business",
|
|
635
|
+
"Closes",
|
|
636
|
+
"EBITDA",
|
|
637
|
+
"Multiple",
|
|
638
|
+
"Price",
|
|
639
|
+
"New debt",
|
|
640
|
+
"From cash",
|
|
641
|
+
),
|
|
642
|
+
rows=rows,
|
|
643
|
+
align=("l", "l", "r", "r", "r", "r", "r"),
|
|
644
|
+
),
|
|
645
|
+
notes=tuple(notes),
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _refinancing(deal: Deal, case: Case) -> Section:
|
|
650
|
+
"""What the takeout cost, what it saves, and whether the second covers the first.
|
|
651
|
+
|
|
652
|
+
The comparison is the whole section. A repricing is always attractive stated
|
|
653
|
+
as a spread and often unattractive stated as a cash cost against the hold
|
|
654
|
+
that remains, because the premium and the fees are paid at once and the
|
|
655
|
+
saving arrives a period at a time. Two years from an exit there is frequently
|
|
656
|
+
not enough left of the hold to earn back what the exercise costs, and a model
|
|
657
|
+
that reports the new coupon without that arithmetic has made the case for
|
|
658
|
+
something it has not tested.
|
|
659
|
+
|
|
660
|
+
The write-off sits apart from the cash cost deliberately. It is a charge
|
|
661
|
+
against reported earnings and nothing else — no balance in this model moves
|
|
662
|
+
and the return does not shift — but it is real in a real set of accounts, and
|
|
663
|
+
the reader should meet it here rather than from somebody else.
|
|
664
|
+
"""
|
|
665
|
+
schedule = case.schedule
|
|
666
|
+
events = schedule.refinancings
|
|
667
|
+
|
|
668
|
+
rows = tuple(
|
|
669
|
+
(
|
|
670
|
+
e.label,
|
|
671
|
+
f"P{e.event.period}",
|
|
672
|
+
_amount(e.repaid),
|
|
673
|
+
_percent(e.old_rate),
|
|
674
|
+
_percent(e.new_rate),
|
|
675
|
+
_amount(e.call_premium),
|
|
676
|
+
_amount(e.annual_saving),
|
|
677
|
+
)
|
|
678
|
+
for e in events
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
cash_cost = sum((e.cash_cost for e in events), ZERO)
|
|
682
|
+
saving = sum((e.saving_over_the_remainder for e in events), ZERO)
|
|
683
|
+
lines = [
|
|
684
|
+
Line("Face retired early", _amount(schedule.total_refinanced)),
|
|
685
|
+
Line("Face of the new paper", _amount(sum((e.face for e in events), ZERO))),
|
|
686
|
+
Line("Call premiums paid", _amount(schedule.total_call_premiums)),
|
|
687
|
+
Line(
|
|
688
|
+
"Cost of the exercise",
|
|
689
|
+
_amount(cash_cost),
|
|
690
|
+
"premium, fees and discount",
|
|
691
|
+
),
|
|
692
|
+
Line(
|
|
693
|
+
"Interest saved over the remainder",
|
|
694
|
+
_amount(saving),
|
|
695
|
+
"undiscounted, before amortisation",
|
|
696
|
+
),
|
|
697
|
+
Line(
|
|
698
|
+
"Fees written off",
|
|
699
|
+
_amount(schedule.total_fees_written_off),
|
|
700
|
+
(
|
|
701
|
+
"non-cash; derived from the capitalised balance"
|
|
702
|
+
if all(e.write_off_was_derived for e in events)
|
|
703
|
+
else "non-cash; no balance here moves"
|
|
704
|
+
),
|
|
705
|
+
),
|
|
706
|
+
]
|
|
707
|
+
|
|
708
|
+
notes = []
|
|
709
|
+
for e in events:
|
|
710
|
+
verdict = (
|
|
711
|
+
"more than covers what it cost"
|
|
712
|
+
if e.pays_back
|
|
713
|
+
else "does not cover what it cost"
|
|
714
|
+
)
|
|
715
|
+
notes.append(
|
|
716
|
+
f"{e.label} took {_amount(e.repaid)} out at {_percent(e.old_rate)} and "
|
|
717
|
+
f"replaced it at {_percent(e.new_rate)}, saving {_amount(e.annual_saving)} "
|
|
718
|
+
f"a period with {e.periods_remaining} of them left. Over that remainder "
|
|
719
|
+
f"the saving {verdict}: {_amount(e.saving_over_the_remainder)} against "
|
|
720
|
+
f"{_amount(e.cash_cost)}."
|
|
721
|
+
)
|
|
722
|
+
notes.append(
|
|
723
|
+
"The saving is struck on the balance retired and ignores the amortisation "
|
|
724
|
+
"and the sweep that will reduce it, both of which cut the figure. It is an "
|
|
725
|
+
"upper bound on the benefit, which is the direction a cost comparison "
|
|
726
|
+
"should err in."
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
verdict = "earns back" if saving > cash_cost else "does not earn back"
|
|
730
|
+
return Section(
|
|
731
|
+
title="Refinanced during the hold",
|
|
732
|
+
summary=(
|
|
733
|
+
f"{_amount(schedule.total_refinanced)} of paper was retired early at a "
|
|
734
|
+
f"cash cost of {_amount(cash_cost)}. The lower coupon {verdict} that "
|
|
735
|
+
f"over the hold that remains: {_amount(saving)} of interest saved. A "
|
|
736
|
+
f"further {_amount(schedule.total_fees_written_off)} of capitalised fees "
|
|
737
|
+
f"was written off, which is a charge against earnings and not against "
|
|
738
|
+
f"cash."
|
|
739
|
+
),
|
|
740
|
+
lines=tuple(lines),
|
|
741
|
+
table=Table(
|
|
742
|
+
headings=("Facility", "Retired", "Balance", "Old", "New", "Premium", "Saved a period"),
|
|
743
|
+
rows=rows,
|
|
744
|
+
align=("l", "l", "r", "r", "r", "r", "r"),
|
|
745
|
+
),
|
|
746
|
+
notes=tuple(notes),
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _recapitalisation(deal: Deal, case: Case) -> Section:
|
|
751
|
+
"""What was paid out mid-hold, what it cost, and which measure noticed.
|
|
752
|
+
|
|
753
|
+
The counterfactual costs a second run of the whole engine and is worth it.
|
|
754
|
+
A recapitalisation reported on its own says the sponsor received some money
|
|
755
|
+
early; reported against the same deal without it, it says what that was
|
|
756
|
+
worth — and the two measures disagree in the direction that explains why
|
|
757
|
+
anybody does this.
|
|
758
|
+
"""
|
|
759
|
+
outcome = case.outcome
|
|
760
|
+
schedule = case.schedule
|
|
761
|
+
|
|
762
|
+
flat = Case.run(dataclasses.replace(deal, recapitalisations=()))
|
|
763
|
+
moved_moic = _delta_multiple(outcome.moic, flat.outcome.moic)
|
|
764
|
+
moved_irr = _delta_rate(outcome.irr, flat.outcome.irr)
|
|
765
|
+
|
|
766
|
+
rows = tuple(
|
|
767
|
+
(
|
|
768
|
+
d.when.isoformat(),
|
|
769
|
+
d.label,
|
|
770
|
+
_amount(d.amount),
|
|
771
|
+
f"{quantize(d.years, 2)}",
|
|
772
|
+
_amount(d.to_preferred),
|
|
773
|
+
_amount(d.to_common),
|
|
774
|
+
)
|
|
775
|
+
for d in outcome.distributions
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
lines = [
|
|
779
|
+
Line("Distributed during the hold", _amount(outcome.interim)),
|
|
780
|
+
Line("Incremental face raised", _amount(schedule.total_recapitalised)),
|
|
781
|
+
Line(
|
|
782
|
+
"Cost of raising it",
|
|
783
|
+
_amount(sum((e.cost_of_raising for e in schedule.recapitalisations), ZERO)),
|
|
784
|
+
"fees and issue discount",
|
|
785
|
+
),
|
|
786
|
+
Line("Money multiple, as run", _multiple(outcome.moic)),
|
|
787
|
+
Line("Money multiple, held flat", _multiple(flat.outcome.moic)),
|
|
788
|
+
Line("Rate of return, as run", _rate(outcome.irr)),
|
|
789
|
+
Line("Rate of return, held flat", _rate(flat.outcome.irr)),
|
|
790
|
+
]
|
|
791
|
+
|
|
792
|
+
notes = []
|
|
793
|
+
for event in schedule.recapitalisations:
|
|
794
|
+
before, after = event.leverage_before, event.leverage_after
|
|
795
|
+
if event.turns_added is not None and before is not None and after is not None:
|
|
796
|
+
notes.append(
|
|
797
|
+
f"{event.label} put {_turns(event.turns_added)} of leverage back "
|
|
798
|
+
f"on, taking net debt from {_turns(before)} to {_turns(after)} of "
|
|
799
|
+
f"EBITDA."
|
|
800
|
+
)
|
|
801
|
+
notes.append(
|
|
802
|
+
"The plan settles against the exit alone. An interim distribution goes "
|
|
803
|
+
"to the securities rather than being shared with the pool, though what "
|
|
804
|
+
"the holders it watches have already received does count towards a "
|
|
805
|
+
"ratchet hurdle."
|
|
806
|
+
)
|
|
807
|
+
|
|
808
|
+
return Section(
|
|
809
|
+
title="Paid during the hold",
|
|
810
|
+
summary=(
|
|
811
|
+
f"{_amount(outcome.interim)} reached the equity before the exit, "
|
|
812
|
+
f"funded by {_amount(schedule.total_recapitalised)} of new debt. The "
|
|
813
|
+
f"money multiple moves {moved_moic} and the rate of return moves "
|
|
814
|
+
f"{moved_irr}: the same money, banked earlier, less what the debt "
|
|
815
|
+
f"cost to carry."
|
|
816
|
+
),
|
|
817
|
+
lines=tuple(lines),
|
|
818
|
+
table=Table(
|
|
819
|
+
headings=("Date", "Payment", "Amount", "Years", "Preferred", "Common"),
|
|
820
|
+
rows=rows,
|
|
821
|
+
align=("l", "l", "r", "r", "r", "r"),
|
|
822
|
+
),
|
|
823
|
+
notes=tuple(notes),
|
|
824
|
+
)
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def _delta_multiple(actual: Money | None, flat: Money | None) -> str:
|
|
828
|
+
if actual is None or flat is None:
|
|
829
|
+
return _NONE
|
|
830
|
+
change = actual - flat
|
|
831
|
+
return f"{'+' if change >= 0 else ''}{quantize(change, 2)}x"
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def _delta_rate(actual: float | None, flat: float | None) -> str:
|
|
835
|
+
if actual is None or flat is None:
|
|
836
|
+
return _NONE
|
|
837
|
+
change = actual - flat
|
|
838
|
+
return f"{'+' if change >= 0 else ''}{change * 100:.1f}pp"
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def _incentive(settled: PoolOutcome) -> Section:
|
|
842
|
+
"""The management plan: what it is worth, and what it cost the common."""
|
|
843
|
+
pool = settled.pool
|
|
844
|
+
notes: list[str] = []
|
|
845
|
+
|
|
846
|
+
if not settled.exercised:
|
|
847
|
+
notes.append(
|
|
848
|
+
"The pool is out of the money at this exit. The options lapse, so "
|
|
849
|
+
"management are paid nothing and the common are not diluted — which "
|
|
850
|
+
"is the point of striking a plan rather than granting shares."
|
|
851
|
+
)
|
|
852
|
+
if settled.forfeited_share > 0:
|
|
853
|
+
notes.append(
|
|
854
|
+
f"{_percent(settled.forfeited_share)} of the pool had not vested by "
|
|
855
|
+
f"the exit and is forfeited rather than accelerated."
|
|
856
|
+
)
|
|
857
|
+
if pool.ratchet is not None:
|
|
858
|
+
watched = (
|
|
859
|
+
", ".join(pool.ratchet.measured_on)
|
|
860
|
+
if pool.ratchet.measured_on
|
|
861
|
+
else "the equity as a whole"
|
|
862
|
+
)
|
|
863
|
+
notes.append(
|
|
864
|
+
f"The ratchet is measured on {watched}, and pays a marginal share "
|
|
865
|
+
f"above each hurdle rather than a higher share of everything. That "
|
|
866
|
+
f"is what keeps the sponsor's proceeds rising with the sale price at "
|
|
867
|
+
f"every point, including the one where a hurdle is crossed."
|
|
868
|
+
)
|
|
869
|
+
|
|
870
|
+
lines = [
|
|
871
|
+
Line("Pool, fully diluted", _percent(pool.share)),
|
|
872
|
+
Line("Vested at exit", _percent(settled.vested)),
|
|
873
|
+
Line("Residual before the plan", _amount(settled.residual)),
|
|
874
|
+
Line("Strike paid in", _amount(settled.strike_paid)),
|
|
875
|
+
Line("Pot divided", _amount(settled.pot)),
|
|
876
|
+
Line("Share of the pot taken", _percent(settled.effective_share)),
|
|
877
|
+
Line("Paid to management", _amount(settled.paid)),
|
|
878
|
+
]
|
|
879
|
+
|
|
880
|
+
if pool.ratchet is not None:
|
|
881
|
+
rows = tuple(
|
|
882
|
+
(
|
|
883
|
+
_turns(band.hurdle) if band.hurdle > 0 else "from the first pound",
|
|
884
|
+
_percent(band.share),
|
|
885
|
+
)
|
|
886
|
+
for band in pool.ratchet
|
|
887
|
+
)
|
|
888
|
+
table: Table | None = Table(
|
|
889
|
+
headings=("Above", "Marginal share"),
|
|
890
|
+
rows=rows,
|
|
891
|
+
align=("l", "r"),
|
|
892
|
+
)
|
|
893
|
+
else:
|
|
894
|
+
table = None
|
|
895
|
+
|
|
896
|
+
summary = (
|
|
897
|
+
f"Management take {_amount(settled.paid)} out of "
|
|
898
|
+
f"{_amount(settled.residual)} reaching the common, an effective "
|
|
899
|
+
f"{_percent(settled.effective_share)} of the pot they share in. What "
|
|
900
|
+
f"they are paid is what the common give up, to the penny."
|
|
901
|
+
)
|
|
902
|
+
if not settled.exercised:
|
|
903
|
+
summary = (
|
|
904
|
+
f"The plan pays nothing at this exit: a "
|
|
905
|
+
f"{_percent(pool.share)} pool struck at "
|
|
906
|
+
f"{_amount(pool.strike)} is not worth exercising against "
|
|
907
|
+
f"{_amount(settled.residual)} of residual."
|
|
908
|
+
)
|
|
909
|
+
|
|
910
|
+
return Section(
|
|
911
|
+
title=pool.name,
|
|
912
|
+
summary=summary,
|
|
913
|
+
lines=tuple(lines),
|
|
914
|
+
table=table,
|
|
915
|
+
notes=tuple(notes),
|
|
916
|
+
)
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def _bridge(outcome: Outcome) -> Section:
|
|
920
|
+
attribution = outcome.attribution
|
|
921
|
+
components = (
|
|
922
|
+
("EBITDA growth", attribution.ebitda_growth),
|
|
923
|
+
("Multiple change", attribution.multiple_change),
|
|
924
|
+
("Debt paydown", attribution.debt_paydown),
|
|
925
|
+
("Entry and exit costs", attribution.costs),
|
|
926
|
+
)
|
|
927
|
+
largest = max(components, key=lambda pair: abs(pair[1]))
|
|
928
|
+
|
|
929
|
+
notes = []
|
|
930
|
+
if not attribution.reconciles():
|
|
931
|
+
notes.append(
|
|
932
|
+
"The components do not sum to the change in equity value, which "
|
|
933
|
+
"means the bridge is not describing this deal."
|
|
934
|
+
)
|
|
935
|
+
if attribution.floored > 0:
|
|
936
|
+
notes.append(
|
|
937
|
+
f"{_amount(attribution.floored)} of the loss falls on the lenders "
|
|
938
|
+
f"rather than the shareholders, who owe nothing beyond their capital."
|
|
939
|
+
)
|
|
940
|
+
|
|
941
|
+
return Section(
|
|
942
|
+
title="Where the value came from",
|
|
943
|
+
summary=(
|
|
944
|
+
f"{largest[0]} accounts for {_percent(abs(attribution.share(largest[1])))} "
|
|
945
|
+
f"of the gross movement in equity value."
|
|
946
|
+
),
|
|
947
|
+
table=Table(
|
|
948
|
+
headings=("Source", "Value", "Share of gross"),
|
|
949
|
+
rows=(
|
|
950
|
+
*(
|
|
951
|
+
(label, _amount(amount), _percent(abs(attribution.share(amount))))
|
|
952
|
+
for label, amount in components
|
|
953
|
+
),
|
|
954
|
+
("Value created", _amount(attribution.total), ""),
|
|
955
|
+
),
|
|
956
|
+
),
|
|
957
|
+
notes=tuple(notes),
|
|
958
|
+
)
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def _boundary(deal: Deal, case: Case) -> Section:
|
|
962
|
+
"""Where the case stops working, one dimension at a time."""
|
|
963
|
+
questions: list[tuple[str, Dimension, Metric, Money, tuple[Money, Money]]] = [
|
|
964
|
+
(
|
|
965
|
+
"Exit multiple returning capital and no more",
|
|
966
|
+
Dimension.EXIT_MULTIPLE,
|
|
967
|
+
Metric.MOIC,
|
|
968
|
+
ONE,
|
|
969
|
+
EXIT_MULTIPLE_BRACKET,
|
|
970
|
+
),
|
|
971
|
+
(
|
|
972
|
+
"Exit multiple returning twice capital",
|
|
973
|
+
Dimension.EXIT_MULTIPLE,
|
|
974
|
+
Metric.MOIC,
|
|
975
|
+
money(2),
|
|
976
|
+
EXIT_MULTIPLE_BRACKET,
|
|
977
|
+
),
|
|
978
|
+
]
|
|
979
|
+
if case.covenants is not None:
|
|
980
|
+
questions.extend(
|
|
981
|
+
[
|
|
982
|
+
(
|
|
983
|
+
"Margin shift tripping the first covenant",
|
|
984
|
+
Dimension.EBITDA_MARGIN,
|
|
985
|
+
Metric.CUSHION,
|
|
986
|
+
ZERO,
|
|
987
|
+
MARGIN_SHIFT_BRACKET,
|
|
988
|
+
),
|
|
989
|
+
(
|
|
990
|
+
"Opening leverage tripping the first covenant",
|
|
991
|
+
Dimension.LEVERAGE,
|
|
992
|
+
Metric.CUSHION,
|
|
993
|
+
ZERO,
|
|
994
|
+
LEVERAGE_BRACKET,
|
|
995
|
+
),
|
|
996
|
+
]
|
|
997
|
+
)
|
|
998
|
+
|
|
999
|
+
found: list[Breakeven] = []
|
|
1000
|
+
rows = []
|
|
1001
|
+
# Reasons go underneath rather than into a column. A sentence explaining
|
|
1002
|
+
# why a crossing does not exist is longer than every other cell in the
|
|
1003
|
+
# table put together, and putting it inline stretches the page to fit it.
|
|
1004
|
+
notes = [
|
|
1005
|
+
"Each figure is the crossing found by re-running the whole model along "
|
|
1006
|
+
"that assumption, holding everything else at the case above."
|
|
1007
|
+
]
|
|
1008
|
+
for label, dimension, metric, target, (low, high) in questions:
|
|
1009
|
+
try:
|
|
1010
|
+
crossing = solve(
|
|
1011
|
+
deal, dimension, metric, target=target, low=low, high=high
|
|
1012
|
+
)
|
|
1013
|
+
except SensitivityError as exc:
|
|
1014
|
+
rows.append((label, _NONE))
|
|
1015
|
+
notes.append(f"{label}: {exc}.")
|
|
1016
|
+
continue
|
|
1017
|
+
found.append(crossing)
|
|
1018
|
+
rows.append((label, crossing.format()))
|
|
1019
|
+
if crossing.note:
|
|
1020
|
+
notes.append(f"{label}: {crossing.note}.")
|
|
1021
|
+
|
|
1022
|
+
return Section(
|
|
1023
|
+
title="Where the case stops working",
|
|
1024
|
+
summary=_boundary_summary(deal, case, found),
|
|
1025
|
+
table=Table(
|
|
1026
|
+
headings=("Question", "Answer"),
|
|
1027
|
+
rows=tuple(rows),
|
|
1028
|
+
align=("l", "r"),
|
|
1029
|
+
),
|
|
1030
|
+
notes=tuple(notes),
|
|
1031
|
+
)
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
def _years(deal: Deal, periods: int) -> Money:
|
|
1035
|
+
"""The hold in years, from the grid's own frequency."""
|
|
1036
|
+
per_year = deal.grid.frequency.periods_per_year if deal.grid is not None else 1
|
|
1037
|
+
return money(periods) / money(per_year)
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
def _boundary_summary(deal: Deal, case: Case, found: Sequence[Breakeven]) -> str:
|
|
1041
|
+
"""One sentence on how much room the entry price has, if it has any."""
|
|
1042
|
+
for crossing in found:
|
|
1043
|
+
if (
|
|
1044
|
+
crossing.dimension is Dimension.EXIT_MULTIPLE
|
|
1045
|
+
and crossing.metric is Metric.MOIC
|
|
1046
|
+
and crossing.target == ONE
|
|
1047
|
+
and crossing.value is not None
|
|
1048
|
+
):
|
|
1049
|
+
entry = deal.transaction.valuation.entry_multiple
|
|
1050
|
+
room = entry - crossing.value
|
|
1051
|
+
if room >= 0:
|
|
1052
|
+
return (
|
|
1053
|
+
f"The sponsor gets its capital back at "
|
|
1054
|
+
f"{_turns(crossing.value)}, which is {_turns(room)} below "
|
|
1055
|
+
f"the {_turns(entry)} paid going in."
|
|
1056
|
+
)
|
|
1057
|
+
return (
|
|
1058
|
+
f"The sponsor only gets its capital back at "
|
|
1059
|
+
f"{_turns(crossing.value)}, which is {_turns(-room)} above the "
|
|
1060
|
+
f"{_turns(entry)} paid going in: this case needs a buyer to pay "
|
|
1061
|
+
f"more than the sponsor did."
|
|
1062
|
+
)
|
|
1063
|
+
return "No break-even was found inside the brackets searched."
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
def _growth(begin: Money, end: Money, years: Money) -> str:
|
|
1067
|
+
try:
|
|
1068
|
+
return format_value(Unit.RATE, money(repr(cagr(begin, end, years))))
|
|
1069
|
+
except (ValueError, ArithmeticError):
|
|
1070
|
+
return _NONE
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def _points(value: Money) -> str:
|
|
1074
|
+
return format_value(Unit.POINTS, value)
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
# -- Rendering -----------------------------------------------------------
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
def _widths(table: Table) -> list[int]:
|
|
1081
|
+
widths = [len(h) for h in table.headings]
|
|
1082
|
+
for row in table.rows:
|
|
1083
|
+
for i, cell in enumerate(row):
|
|
1084
|
+
widths[i] = max(widths[i], len(cell))
|
|
1085
|
+
return widths
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
def _render_text(report: Report) -> str:
|
|
1089
|
+
out: list[str] = [report.title, "=" * len(report.title)]
|
|
1090
|
+
for section in report:
|
|
1091
|
+
out.append("")
|
|
1092
|
+
out.append(f" {section.title}")
|
|
1093
|
+
out.append(f" {'-' * len(section.title)}")
|
|
1094
|
+
if section.summary:
|
|
1095
|
+
out.append("")
|
|
1096
|
+
out.extend(f" {line}" for line in _wrap(section.summary, 74))
|
|
1097
|
+
if section.lines:
|
|
1098
|
+
out.append("")
|
|
1099
|
+
label_width = max(len(line.label) for line in section.lines)
|
|
1100
|
+
value_width = max(len(line.value) for line in section.lines)
|
|
1101
|
+
for line in section.lines:
|
|
1102
|
+
note = f" {line.note}" if line.note else ""
|
|
1103
|
+
out.append(
|
|
1104
|
+
f" {line.label:<{label_width}} "
|
|
1105
|
+
f"{line.value:>{value_width}}{note}".rstrip()
|
|
1106
|
+
)
|
|
1107
|
+
if section.table is not None and section.table.rows:
|
|
1108
|
+
out.append("")
|
|
1109
|
+
out.extend(_render_text_table(section.table))
|
|
1110
|
+
for note in section.notes:
|
|
1111
|
+
out.append("")
|
|
1112
|
+
out.extend(f" {line}" for line in _wrap(note, 72))
|
|
1113
|
+
return "\n".join(out) + "\n"
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
def _render_text_table(table: Table) -> list[str]:
|
|
1117
|
+
widths = _widths(table)
|
|
1118
|
+
alignment = table.alignment
|
|
1119
|
+
out = [
|
|
1120
|
+
" "
|
|
1121
|
+
+ " ".join(
|
|
1122
|
+
heading.ljust(widths[i]) if alignment[i] == "l" else heading.rjust(widths[i])
|
|
1123
|
+
for i, heading in enumerate(table.headings)
|
|
1124
|
+
).rstrip()
|
|
1125
|
+
]
|
|
1126
|
+
out.append(" " + " ".join("-" * w for w in widths))
|
|
1127
|
+
for row in table.rows:
|
|
1128
|
+
out.append(
|
|
1129
|
+
" "
|
|
1130
|
+
+ " ".join(
|
|
1131
|
+
cell.ljust(widths[i]) if alignment[i] == "l" else cell.rjust(widths[i])
|
|
1132
|
+
for i, cell in enumerate(row)
|
|
1133
|
+
).rstrip()
|
|
1134
|
+
)
|
|
1135
|
+
return out
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
def _render_markdown(report: Report) -> str:
|
|
1139
|
+
out: list[str] = [f"# {report.title}"]
|
|
1140
|
+
for section in report:
|
|
1141
|
+
out.append("")
|
|
1142
|
+
out.append(f"## {section.title}")
|
|
1143
|
+
if section.summary:
|
|
1144
|
+
out.append("")
|
|
1145
|
+
out.append(section.summary)
|
|
1146
|
+
if section.lines:
|
|
1147
|
+
out.append("")
|
|
1148
|
+
out.append("| | |")
|
|
1149
|
+
out.append("|---|---:|")
|
|
1150
|
+
for line in section.lines:
|
|
1151
|
+
label = f"{line.label} <sub>{line.note}</sub>" if line.note else line.label
|
|
1152
|
+
out.append(f"| {label} | {line.value} |")
|
|
1153
|
+
if section.table is not None and section.table.rows:
|
|
1154
|
+
out.append("")
|
|
1155
|
+
out.extend(_render_markdown_table(section.table))
|
|
1156
|
+
for note in section.notes:
|
|
1157
|
+
out.append("")
|
|
1158
|
+
out.append(f"> {note}")
|
|
1159
|
+
return "\n".join(out) + "\n"
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
def _render_markdown_table(table: Table) -> list[str]:
|
|
1163
|
+
alignment = table.alignment
|
|
1164
|
+
rule = "|" + "|".join(
|
|
1165
|
+
"---" if a == "l" else "---:" for a in alignment
|
|
1166
|
+
) + "|"
|
|
1167
|
+
out = ["| " + " | ".join(table.headings) + " |", rule]
|
|
1168
|
+
for row in table.rows:
|
|
1169
|
+
out.append("| " + " | ".join(row) + " |")
|
|
1170
|
+
return out
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
def _wrap(text: str, width: int) -> list[str]:
|
|
1174
|
+
"""Wrap on spaces, without pulling in a dependency to do it.
|
|
1175
|
+
|
|
1176
|
+
Long words are left long rather than broken. A number split across two
|
|
1177
|
+
lines is worse than a line that runs a little over.
|
|
1178
|
+
"""
|
|
1179
|
+
words = text.split()
|
|
1180
|
+
if not words:
|
|
1181
|
+
return []
|
|
1182
|
+
lines = [words[0]]
|
|
1183
|
+
for word in words[1:]:
|
|
1184
|
+
if len(lines[-1]) + 1 + len(word) <= width:
|
|
1185
|
+
lines[-1] += " " + word
|
|
1186
|
+
else:
|
|
1187
|
+
lines.append(word)
|
|
1188
|
+
return lines
|