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.
@@ -0,0 +1,430 @@
1
+ """The transaction: what is bought, and what pays for it.
2
+
3
+ A sources and uses table is the first page of every deal model. Uses are
4
+ everything cash goes towards at close; sources are everything cash comes from.
5
+ The two sides are equal by construction, and the sponsor's equity cheque is
6
+ whatever number makes them equal.
7
+
8
+ The equality is exact here, not approximate. A table that balances to within a
9
+ few thousand on a billion-dollar deal has not nearly balanced — it has a missing
10
+ line item, and making that impossible to overlook is most of what the table is
11
+ for.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+ from enum import Enum
18
+
19
+ from .money import ONE, ZERO, Money, Numeric, money, safe_div
20
+
21
+ __all__ = [
22
+ "DebtFunding",
23
+ "EntryValuation",
24
+ "LineItem",
25
+ "SourcesAndUses",
26
+ "Transaction",
27
+ "UnbalancedTransactionError",
28
+ ]
29
+
30
+
31
+ class UnbalancedTransactionError(ValueError):
32
+ """Sources and uses do not agree."""
33
+
34
+
35
+ class LineKind(Enum):
36
+ SOURCE = "source"
37
+ USE = "use"
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class LineItem:
42
+ """One row of the funding table.
43
+
44
+ ``capitalised`` says what a payment leaves behind. Most closing payments buy
45
+ nothing — a change-of-control payment to management settles a contract and
46
+ is gone — and those are charged against equity on day one. A few buy
47
+ something the balance sheet keeps, such as a licence acquired alongside the
48
+ business. The funding table does not care either way, but the opening
49
+ balance sheet cannot be built without knowing.
50
+ """
51
+
52
+ label: str
53
+ amount: Money
54
+ note: str = ""
55
+ capitalised: bool = False
56
+
57
+ def __post_init__(self) -> None:
58
+ if not self.label.strip():
59
+ raise ValueError("a line item needs a label")
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class EntryValuation:
64
+ """What the business is worth going in, and what the equity in it costs.
65
+
66
+ The multiple is applied to last-twelve-months EBITDA to give enterprise
67
+ value. Getting from there to the cheque written to selling shareholders
68
+ means undoing the target's own capital structure: repay what it owes, keep
69
+ what it holds.
70
+ """
71
+
72
+ ltm_ebitda: Money
73
+ entry_multiple: Money
74
+ existing_debt: Money = ZERO
75
+ existing_cash: Money = ZERO
76
+
77
+ @classmethod
78
+ def of(
79
+ cls,
80
+ ltm_ebitda: Numeric,
81
+ entry_multiple: Numeric,
82
+ *,
83
+ existing_debt: Numeric = 0,
84
+ existing_cash: Numeric = 0,
85
+ ) -> EntryValuation:
86
+ return cls(
87
+ ltm_ebitda=money(ltm_ebitda),
88
+ entry_multiple=money(entry_multiple),
89
+ existing_debt=money(existing_debt),
90
+ existing_cash=money(existing_cash),
91
+ )
92
+
93
+ def __post_init__(self) -> None:
94
+ if self.ltm_ebitda <= 0:
95
+ raise ValueError("a buyout needs positive EBITDA to price off")
96
+ if self.entry_multiple <= 0:
97
+ raise ValueError("the entry multiple must be positive")
98
+ if self.existing_debt < 0:
99
+ raise ValueError("existing debt must not be negative")
100
+ if self.existing_cash < 0:
101
+ raise ValueError("existing cash must not be negative")
102
+
103
+ @property
104
+ def enterprise_value(self) -> Money:
105
+ return self.ltm_ebitda * self.entry_multiple
106
+
107
+ @property
108
+ def net_debt(self) -> Money:
109
+ return self.existing_debt - self.existing_cash
110
+
111
+ @property
112
+ def equity_purchase_price(self) -> Money:
113
+ """What selling shareholders receive.
114
+
115
+ Enterprise value less what the business owes, plus what it holds. This
116
+ can be negative for a business bought at a low multiple with more debt
117
+ than the enterprise is worth — a distressed situation, and one the model
118
+ should be able to express rather than reject.
119
+ """
120
+ return self.enterprise_value - self.net_debt
121
+
122
+
123
+ @dataclass(frozen=True, slots=True)
124
+ class DebtFunding:
125
+ """A tranche as it appears in the funding table.
126
+
127
+ ``issue_price`` is expressed per unit of face: 1 is par, 0.99 is ninety-nine
128
+ cents on the dollar. A tranche placed below par raises less than it carries,
129
+ and the shortfall — original issue discount — is a use of funds. Modelling
130
+ the tranche at proceeds instead of face is the common mistake: it
131
+ understates leverage and understates every interest payment that follows,
132
+ because interest accrues on what is owed rather than on what was received.
133
+ """
134
+
135
+ name: str
136
+ face: Money
137
+ issue_price: Money = ONE
138
+ financing_fee_rate: Money = ZERO
139
+
140
+ @classmethod
141
+ def of(
142
+ cls,
143
+ name: str,
144
+ face: Numeric,
145
+ *,
146
+ issue_price: Numeric = 1,
147
+ financing_fee_rate: Numeric = 0,
148
+ ) -> DebtFunding:
149
+ return cls(
150
+ name=name,
151
+ face=money(face),
152
+ issue_price=money(issue_price),
153
+ financing_fee_rate=money(financing_fee_rate),
154
+ )
155
+
156
+ def __post_init__(self) -> None:
157
+ if not self.name.strip():
158
+ raise ValueError("a tranche needs a name")
159
+ if self.face < 0:
160
+ raise ValueError(f"{self.name}: face amount must not be negative")
161
+ if not (0 < self.issue_price <= 2):
162
+ raise ValueError(
163
+ f"{self.name}: issue price is per unit of face, so 0.99 rather than 99"
164
+ )
165
+ if self.financing_fee_rate < 0:
166
+ raise ValueError(f"{self.name}: financing fee rate must not be negative")
167
+
168
+ @property
169
+ def proceeds(self) -> Money:
170
+ """Cash actually raised."""
171
+ return self.face * self.issue_price
172
+
173
+ @property
174
+ def original_issue_discount(self) -> Money:
175
+ """Face less proceeds. Negative for paper placed above par."""
176
+ return self.face - self.proceeds
177
+
178
+ @property
179
+ def financing_fee(self) -> Money:
180
+ """Arrangement and underwriting fees, charged on face."""
181
+ return self.face * self.financing_fee_rate
182
+
183
+
184
+ @dataclass(frozen=True, slots=True)
185
+ class SourcesAndUses:
186
+ """A funding table that is equal on both sides by construction."""
187
+
188
+ sources: tuple[LineItem, ...]
189
+ uses: tuple[LineItem, ...]
190
+
191
+ def __post_init__(self) -> None:
192
+ if self.total_sources != self.total_uses:
193
+ raise UnbalancedTransactionError(
194
+ f"sources ({self.total_sources}) do not equal uses ({self.total_uses}); "
195
+ f"the difference is {self.total_sources - self.total_uses}"
196
+ )
197
+
198
+ @property
199
+ def total_sources(self) -> Money:
200
+ return sum((item.amount for item in self.sources), ZERO)
201
+
202
+ @property
203
+ def total_uses(self) -> Money:
204
+ return sum((item.amount for item in self.uses), ZERO)
205
+
206
+ def source(self, label: str) -> Money:
207
+ """Amount on the sources side under ``label``, zero if absent."""
208
+ return sum((i.amount for i in self.sources if i.label == label), ZERO)
209
+
210
+ def use(self, label: str) -> Money:
211
+ """Amount on the uses side under ``label``, zero if absent."""
212
+ return sum((i.amount for i in self.uses if i.label == label), ZERO)
213
+
214
+
215
+ #: Labels used on the funding table, named once so lookups cannot drift.
216
+ SPONSOR_EQUITY = "Sponsor equity"
217
+ ROLLOVER_EQUITY = "Rollover equity"
218
+ CASH_FROM_BALANCE_SHEET = "Cash from balance sheet"
219
+ EQUITY_PURCHASE_PRICE = "Purchase of equity"
220
+ REFINANCE_EXISTING_DEBT = "Repay existing debt"
221
+ TRANSACTION_FEES = "Transaction fees"
222
+ FINANCING_FEES = "Financing fees"
223
+ ORIGINAL_ISSUE_DISCOUNT = "Original issue discount"
224
+ CASH_TO_BALANCE_SHEET = "Cash to balance sheet"
225
+
226
+
227
+ @dataclass(frozen=True, slots=True)
228
+ class Transaction:
229
+ """A complete purchase: what is bought, how it is funded, what it costs.
230
+
231
+ The sponsor's cheque is not an input. It is derived as the residual that
232
+ makes the table balance, which is how it works on a real deal: leverage is
233
+ negotiated with lenders, the price is negotiated with the seller, and equity
234
+ fills whatever gap is left.
235
+ """
236
+
237
+ valuation: EntryValuation
238
+ debt: tuple[DebtFunding, ...] = ()
239
+ rollover_equity: Money = ZERO
240
+ cash_from_balance_sheet: Money = ZERO
241
+ cash_to_balance_sheet: Money = ZERO
242
+ transaction_fee_rate: Money = ZERO
243
+ other_uses: tuple[LineItem, ...] = field(default_factory=tuple)
244
+
245
+ @classmethod
246
+ def of(
247
+ cls,
248
+ valuation: EntryValuation,
249
+ *,
250
+ debt: tuple[DebtFunding, ...] = (),
251
+ rollover_equity: Numeric = 0,
252
+ cash_from_balance_sheet: Numeric = 0,
253
+ cash_to_balance_sheet: Numeric = 0,
254
+ transaction_fee_rate: Numeric = 0,
255
+ other_uses: tuple[LineItem, ...] = (),
256
+ ) -> Transaction:
257
+ return cls(
258
+ valuation=valuation,
259
+ debt=debt,
260
+ rollover_equity=money(rollover_equity),
261
+ cash_from_balance_sheet=money(cash_from_balance_sheet),
262
+ cash_to_balance_sheet=money(cash_to_balance_sheet),
263
+ transaction_fee_rate=money(transaction_fee_rate),
264
+ other_uses=other_uses,
265
+ )
266
+
267
+ def __post_init__(self) -> None:
268
+ if self.rollover_equity < 0:
269
+ raise ValueError("rollover equity must not be negative")
270
+ if self.cash_from_balance_sheet < 0:
271
+ raise ValueError("cash taken from the balance sheet must not be negative")
272
+ if self.cash_to_balance_sheet < 0:
273
+ raise ValueError("cash funded to the balance sheet must not be negative")
274
+ if self.transaction_fee_rate < 0:
275
+ raise ValueError("the transaction fee rate must not be negative")
276
+ if self.cash_from_balance_sheet > self.valuation.existing_cash:
277
+ raise ValueError(
278
+ f"cannot take {self.cash_from_balance_sheet} from the balance sheet; "
279
+ f"the target holds {self.valuation.existing_cash}"
280
+ )
281
+ names = [t.name for t in self.debt]
282
+ if len(names) != len(set(names)):
283
+ raise ValueError("tranche names must be distinct")
284
+
285
+ # -- Derived amounts -------------------------------------------------
286
+
287
+ @property
288
+ def total_debt(self) -> Money:
289
+ """Face value of debt raised. This is what accrues interest."""
290
+ return sum((t.face for t in self.debt), ZERO)
291
+
292
+ @property
293
+ def debt_proceeds(self) -> Money:
294
+ """Cash raised from the debt, after issue discount."""
295
+ return sum((t.proceeds for t in self.debt), ZERO)
296
+
297
+ @property
298
+ def original_issue_discount(self) -> Money:
299
+ return sum((t.original_issue_discount for t in self.debt), ZERO)
300
+
301
+ @property
302
+ def financing_fees(self) -> Money:
303
+ """Capitalised at close and amortised over the life of the paper."""
304
+ return sum((t.financing_fee for t in self.debt), ZERO)
305
+
306
+ @property
307
+ def transaction_fees(self) -> Money:
308
+ """Advisory, legal and diligence costs, charged on enterprise value."""
309
+ return self.valuation.enterprise_value * self.transaction_fee_rate
310
+
311
+ @property
312
+ def total_fees(self) -> Money:
313
+ return self.transaction_fees + self.financing_fees
314
+
315
+ @property
316
+ def sponsor_equity(self) -> Money:
317
+ """The plug.
318
+
319
+ Everything the deal needs, less everything other than the sponsor that
320
+ pays for it. A negative result means the debt raised more than the deal
321
+ required and the excess goes out to the sponsor at close.
322
+ """
323
+ return self._gross_uses - self._non_sponsor_sources
324
+
325
+ @property
326
+ def is_overfunded(self) -> bool:
327
+ """Whether the structure returns cash to the sponsor at close.
328
+
329
+ Legal and not unheard of, but it means the deal is being financed
330
+ entirely by its own borrowing capacity, so it is worth naming.
331
+ """
332
+ return self.sponsor_equity < 0
333
+
334
+ @property
335
+ def _gross_uses(self) -> Money:
336
+ return (
337
+ self.valuation.equity_purchase_price
338
+ + self.valuation.existing_debt
339
+ + self.transaction_fees
340
+ + self.financing_fees
341
+ + self.original_issue_discount
342
+ + self.cash_to_balance_sheet
343
+ + sum((item.amount for item in self.other_uses), ZERO)
344
+ )
345
+
346
+ @property
347
+ def _non_sponsor_sources(self) -> Money:
348
+ return self.total_debt + self.rollover_equity + self.cash_from_balance_sheet
349
+
350
+ # -- The table -------------------------------------------------------
351
+
352
+ def sources_and_uses(self) -> SourcesAndUses:
353
+ """Build the funding table.
354
+
355
+ Debt appears on the sources side at face rather than at proceeds, with
356
+ the discount carried across to uses. Both presentations balance, but
357
+ this one keeps face value visible, and face value is what leverage and
358
+ interest are computed on.
359
+ """
360
+ sources: list[LineItem] = [
361
+ LineItem(t.name, t.face, note=_issue_note(t)) for t in self.debt if t.face
362
+ ]
363
+ if self.rollover_equity:
364
+ sources.append(
365
+ LineItem(ROLLOVER_EQUITY, self.rollover_equity, note="no cash moves")
366
+ )
367
+ if self.cash_from_balance_sheet:
368
+ sources.append(LineItem(CASH_FROM_BALANCE_SHEET, self.cash_from_balance_sheet))
369
+ sources.append(
370
+ LineItem(
371
+ SPONSOR_EQUITY,
372
+ self.sponsor_equity,
373
+ note="distribution to the sponsor at close" if self.is_overfunded else "the plug",
374
+ )
375
+ )
376
+
377
+ uses: list[LineItem] = [
378
+ LineItem(EQUITY_PURCHASE_PRICE, self.valuation.equity_purchase_price)
379
+ ]
380
+ if self.valuation.existing_debt:
381
+ uses.append(LineItem(REFINANCE_EXISTING_DEBT, self.valuation.existing_debt))
382
+ if self.transaction_fees:
383
+ uses.append(LineItem(TRANSACTION_FEES, self.transaction_fees, note="expensed"))
384
+ if self.financing_fees:
385
+ uses.append(LineItem(FINANCING_FEES, self.financing_fees, note="capitalised"))
386
+ if self.original_issue_discount:
387
+ uses.append(LineItem(ORIGINAL_ISSUE_DISCOUNT, self.original_issue_discount))
388
+ if self.cash_to_balance_sheet:
389
+ uses.append(LineItem(CASH_TO_BALANCE_SHEET, self.cash_to_balance_sheet))
390
+ uses.extend(self.other_uses)
391
+
392
+ return SourcesAndUses(sources=tuple(sources), uses=tuple(uses))
393
+
394
+ # -- Entry metrics ---------------------------------------------------
395
+
396
+ @property
397
+ def entry_leverage(self) -> Money:
398
+ """Total debt as a multiple of LTM EBITDA."""
399
+ return self.total_debt / self.valuation.ltm_ebitda
400
+
401
+ @property
402
+ def total_capitalisation(self) -> Money:
403
+ """Debt plus every form of equity that funds the deal."""
404
+ return self.total_debt + self.sponsor_equity + self.rollover_equity
405
+
406
+ @property
407
+ def equity_contribution_rate(self) -> Money:
408
+ """Equity as a share of total capitalisation.
409
+
410
+ The number a lender looks at first. Sponsors are expected to have real
411
+ money at risk, and a thin contribution is what a credit committee
412
+ objects to before it objects to anything else.
413
+ """
414
+ return safe_div(
415
+ self.sponsor_equity + self.rollover_equity,
416
+ self.total_capitalisation,
417
+ default=ZERO,
418
+ )
419
+
420
+ @property
421
+ def sponsor_ownership(self) -> Money:
422
+ """The sponsor's share of the equity, with rollover holders taking the rest."""
423
+ total_equity = self.sponsor_equity + self.rollover_equity
424
+ return safe_div(self.sponsor_equity, total_equity, default=ZERO)
425
+
426
+
427
+ def _issue_note(tranche: DebtFunding) -> str:
428
+ if tranche.issue_price == ONE:
429
+ return ""
430
+ return f"issued at {tranche.issue_price * 100:.2f}"