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