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