capstack-lbo 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- capstack/__init__.py +243 -0
- capstack/balance_sheet.py +295 -0
- capstack/cli.py +1995 -0
- capstack/covenants.py +594 -0
- capstack/daycount.py +121 -0
- capstack/debt.py +2003 -0
- capstack/drivers.py +167 -0
- capstack/events.py +914 -0
- capstack/fees.py +484 -0
- capstack/incentive.py +517 -0
- capstack/money.py +204 -0
- capstack/operating.py +677 -0
- capstack/outcome.py +958 -0
- capstack/periods.py +431 -0
- capstack/py.typed +0 -0
- capstack/report.py +1188 -0
- capstack/returns.py +426 -0
- capstack/sensitivity.py +940 -0
- capstack/spec.py +1686 -0
- capstack/tax.py +834 -0
- capstack/transaction.py +430 -0
- capstack_lbo-0.1.0.dist-info/METADATA +1260 -0
- capstack_lbo-0.1.0.dist-info/RECORD +27 -0
- capstack_lbo-0.1.0.dist-info/WHEEL +5 -0
- capstack_lbo-0.1.0.dist-info/entry_points.txt +3 -0
- capstack_lbo-0.1.0.dist-info/licenses/LICENSE +21 -0
- capstack_lbo-0.1.0.dist-info/top_level.txt +1 -0
capstack/debt.py
ADDED
|
@@ -0,0 +1,2003 @@
|
|
|
1
|
+
"""The capital structure, and the schedule that pays it down.
|
|
2
|
+
|
|
3
|
+
This is the layer that makes a leveraged buyout leveraged. The operating case
|
|
4
|
+
produces cash without knowing who has a claim on it; the transaction produces
|
|
5
|
+
tranches at face without knowing whether they will ever be repaid. Here the two
|
|
6
|
+
meet: cash arrives, interest is charged, scheduled amortisation is paid, and
|
|
7
|
+
whatever is left is swept against the debt in order of seniority.
|
|
8
|
+
|
|
9
|
+
Four things distinguish a schedule that works from one that merely runs.
|
|
10
|
+
|
|
11
|
+
*Interest is split.* A tranche that pays 6% in cash and accrues 5% costs the
|
|
12
|
+
business 6% this period and 11% by the exit. Mezzanine and seller paper are
|
|
13
|
+
priced that way precisely because they cost nothing while the sponsor is trying
|
|
14
|
+
to survive the first two years, and modelling the whole coupon as cash makes the
|
|
15
|
+
early years look tighter than they are and the exit look better than it is.
|
|
16
|
+
|
|
17
|
+
*The revolver runs in both directions.* It is drawn when a period is short and
|
|
18
|
+
repaid first when a period is long, which is why peak drawn balance rather than
|
|
19
|
+
closing balance is the number a credit committee asks for.
|
|
20
|
+
|
|
21
|
+
*The sweep respects seniority.* Surplus cash pays the most senior claim first,
|
|
22
|
+
and pro rata inside a class. Getting the order wrong repays the cheap paper with
|
|
23
|
+
cash that contractually belongs to the expensive paper, which flatters interest
|
|
24
|
+
cost for the whole remaining hold.
|
|
25
|
+
|
|
26
|
+
*Interest and balance are circular.* Accrue on the average of the opening and
|
|
27
|
+
closing balance — as most credit agreements effectively do, and as any model
|
|
28
|
+
with a mid-period repayment must — and the closing balance depends on what was
|
|
29
|
+
repaid, which depends on the cash left after interest, which depends on the
|
|
30
|
+
balance the interest was accrued on. There is no closed form once repayments are
|
|
31
|
+
capped at balances and sweeps are capped at available cash. The engine iterates
|
|
32
|
+
to a fixed point and reports the residual it converged to, rather than breaking
|
|
33
|
+
the loop by accruing on the opening balance and calling the difference small.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import dataclasses
|
|
39
|
+
from collections.abc import Iterator, Sequence
|
|
40
|
+
from dataclasses import dataclass, field, replace
|
|
41
|
+
from enum import Enum
|
|
42
|
+
|
|
43
|
+
from .daycount import DayCount
|
|
44
|
+
from .drivers import Driver
|
|
45
|
+
from .events import (
|
|
46
|
+
AddOn,
|
|
47
|
+
AddOnError,
|
|
48
|
+
AddOnOutcome,
|
|
49
|
+
Recapitalisation,
|
|
50
|
+
RecapitalisationError,
|
|
51
|
+
RecapitalisationOutcome,
|
|
52
|
+
Refinancing,
|
|
53
|
+
RefinancingError,
|
|
54
|
+
RefinancingOutcome,
|
|
55
|
+
)
|
|
56
|
+
from .money import (
|
|
57
|
+
ONE,
|
|
58
|
+
ZERO,
|
|
59
|
+
Money,
|
|
60
|
+
Numeric,
|
|
61
|
+
allocate_pro_rata,
|
|
62
|
+
is_close,
|
|
63
|
+
money,
|
|
64
|
+
safe_div,
|
|
65
|
+
)
|
|
66
|
+
from .operating import OperatingModel
|
|
67
|
+
from .periods import Period, trailing_window
|
|
68
|
+
|
|
69
|
+
__all__ = [
|
|
70
|
+
"AmortisationBasis",
|
|
71
|
+
"CapitalStructure",
|
|
72
|
+
"CircularityError",
|
|
73
|
+
"DebtPeriod",
|
|
74
|
+
"DebtSchedule",
|
|
75
|
+
"InterestBasis",
|
|
76
|
+
"SweepGrid",
|
|
77
|
+
"SweepStep",
|
|
78
|
+
"Tranche",
|
|
79
|
+
"TrancheKind",
|
|
80
|
+
"TranchePeriod",
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class TrancheKind(Enum):
|
|
85
|
+
"""The layers a leveraged structure is built from, most senior first.
|
|
86
|
+
|
|
87
|
+
The kind is not merely a label. It carries the conventions that go with the
|
|
88
|
+
instrument — where it sits in the waterfall, and whether surplus cash is
|
|
89
|
+
contractually applied to it — so a deal file can describe a term loan
|
|
90
|
+
without restating what a term loan is.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
REVOLVER = "revolver"
|
|
94
|
+
TERM_LOAN = "term_loan"
|
|
95
|
+
NOTES = "notes"
|
|
96
|
+
MEZZANINE = "mezzanine"
|
|
97
|
+
SELLER_NOTE = "seller_note"
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def default_seniority(self) -> int:
|
|
101
|
+
"""Where this layer sits in the repayment waterfall, 0 being first.
|
|
102
|
+
|
|
103
|
+
The revolver is repaid ahead of the term loans it ranks alongside, not
|
|
104
|
+
because it is more senior but because it can be redrawn: leaving cash on
|
|
105
|
+
the balance sheet while the facility is drawn pays a margin for nothing.
|
|
106
|
+
"""
|
|
107
|
+
return {
|
|
108
|
+
TrancheKind.REVOLVER: 0,
|
|
109
|
+
TrancheKind.TERM_LOAN: 1,
|
|
110
|
+
TrancheKind.NOTES: 2,
|
|
111
|
+
TrancheKind.MEZZANINE: 3,
|
|
112
|
+
TrancheKind.SELLER_NOTE: 4,
|
|
113
|
+
}[self]
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def default_swept(self) -> bool:
|
|
117
|
+
"""Whether surplus cash is contractually applied to this layer.
|
|
118
|
+
|
|
119
|
+
Bank debt is swept. Notes are not: they are issued with call protection,
|
|
120
|
+
so surplus cash cannot be forced into them at par and the sponsor would
|
|
121
|
+
not want it to be. Mezzanine and seller paper sit outside the sweep for
|
|
122
|
+
the same reason and are repaid at exit.
|
|
123
|
+
"""
|
|
124
|
+
return self in (TrancheKind.REVOLVER, TrancheKind.TERM_LOAN)
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def default_floating(self) -> bool:
|
|
128
|
+
"""Whether the coupon is quoted as a margin over a base rate.
|
|
129
|
+
|
|
130
|
+
Bank debt floats; bonds and seller paper are fixed. Mezzanine is
|
|
131
|
+
conventionally fixed even where it sits alongside floating bank debt.
|
|
132
|
+
"""
|
|
133
|
+
return self in (TrancheKind.REVOLVER, TrancheKind.TERM_LOAN)
|
|
134
|
+
|
|
135
|
+
def __str__(self) -> str:
|
|
136
|
+
return self.value.replace("_", " ")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class InterestBasis(Enum):
|
|
140
|
+
"""What balance a period's interest is accrued on.
|
|
141
|
+
|
|
142
|
+
``OPENING`` charges the balance at the start of the period and is the
|
|
143
|
+
simplification most spreadsheet models make, because it breaks the
|
|
144
|
+
circularity. It overstates interest whenever the balance falls during the
|
|
145
|
+
period, which in a deleveraging structure is every period.
|
|
146
|
+
|
|
147
|
+
``AVERAGE`` charges the mean of the opening and closing balances, which is
|
|
148
|
+
the right answer for repayments spread through the period and is what makes
|
|
149
|
+
the schedule circular.
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
OPENING = "opening"
|
|
153
|
+
AVERAGE = "average"
|
|
154
|
+
|
|
155
|
+
def __str__(self) -> str:
|
|
156
|
+
return self.value
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True, slots=True)
|
|
160
|
+
class Tranche:
|
|
161
|
+
"""One layer of the capital structure.
|
|
162
|
+
|
|
163
|
+
``cash_rate`` and ``pik_rate`` are annual. For a floating tranche they are
|
|
164
|
+
margins over the structure's base rate; for a fixed one they are the coupon
|
|
165
|
+
itself. ``amortisation`` is a fraction of face per period, which is how a
|
|
166
|
+
credit agreement writes it — 1% a year on a term loan means 1% of what was
|
|
167
|
+
borrowed, not 1% of what is left. The face it is struck against is the face
|
|
168
|
+
drawn at close plus anything drawn on the name since; the schedule carries
|
|
169
|
+
that forward in an :class:`AmortisationBasis`.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
name: str
|
|
173
|
+
kind: TrancheKind
|
|
174
|
+
face: Money
|
|
175
|
+
cash_rate: Money = ZERO
|
|
176
|
+
pik_rate: Money = ZERO
|
|
177
|
+
floating: bool = False
|
|
178
|
+
floor: Money = ZERO
|
|
179
|
+
amortisation: Driver | None = None
|
|
180
|
+
seniority: int = 0
|
|
181
|
+
swept: bool = False
|
|
182
|
+
commitment: Money = ZERO
|
|
183
|
+
undrawn_fee: Money = ZERO
|
|
184
|
+
availability: int | None = None
|
|
185
|
+
maturity: int | None = None
|
|
186
|
+
|
|
187
|
+
@classmethod
|
|
188
|
+
def of(
|
|
189
|
+
cls,
|
|
190
|
+
name: str,
|
|
191
|
+
kind: TrancheKind,
|
|
192
|
+
face: Numeric,
|
|
193
|
+
*,
|
|
194
|
+
cash_rate: Numeric = 0,
|
|
195
|
+
pik_rate: Numeric = 0,
|
|
196
|
+
floating: bool | None = None,
|
|
197
|
+
floor: Numeric = 0,
|
|
198
|
+
amortisation: Driver | None = None,
|
|
199
|
+
seniority: int | None = None,
|
|
200
|
+
swept: bool | None = None,
|
|
201
|
+
commitment: Numeric | None = None,
|
|
202
|
+
undrawn_fee: Numeric = 0,
|
|
203
|
+
availability: int | None = None,
|
|
204
|
+
maturity: int | None = None,
|
|
205
|
+
) -> Tranche:
|
|
206
|
+
"""Build a tranche, taking the conventions of its kind where not told otherwise."""
|
|
207
|
+
return cls(
|
|
208
|
+
name=name,
|
|
209
|
+
kind=kind,
|
|
210
|
+
face=money(face),
|
|
211
|
+
cash_rate=money(cash_rate),
|
|
212
|
+
pik_rate=money(pik_rate),
|
|
213
|
+
floating=kind.default_floating if floating is None else floating,
|
|
214
|
+
floor=money(floor),
|
|
215
|
+
amortisation=amortisation,
|
|
216
|
+
seniority=kind.default_seniority if seniority is None else seniority,
|
|
217
|
+
swept=kind.default_swept if swept is None else swept,
|
|
218
|
+
commitment=(
|
|
219
|
+
money(face) if commitment is None and kind is TrancheKind.REVOLVER
|
|
220
|
+
else money(commitment or 0)
|
|
221
|
+
),
|
|
222
|
+
undrawn_fee=money(undrawn_fee),
|
|
223
|
+
availability=availability,
|
|
224
|
+
maturity=maturity,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
def __post_init__(self) -> None:
|
|
228
|
+
if not self.name.strip():
|
|
229
|
+
raise ValueError("a tranche needs a name")
|
|
230
|
+
if self.face < 0:
|
|
231
|
+
raise ValueError(f"{self.name}: drawn face must not be negative")
|
|
232
|
+
if self.cash_rate < 0:
|
|
233
|
+
raise ValueError(f"{self.name}: the cash rate must not be negative")
|
|
234
|
+
if self.pik_rate < 0:
|
|
235
|
+
raise ValueError(f"{self.name}: the PIK rate must not be negative")
|
|
236
|
+
if self.floor < 0:
|
|
237
|
+
raise ValueError(f"{self.name}: the base-rate floor must not be negative")
|
|
238
|
+
if self.undrawn_fee < 0:
|
|
239
|
+
raise ValueError(f"{self.name}: the commitment fee must not be negative")
|
|
240
|
+
if self.undrawn_fee and not self.commitment:
|
|
241
|
+
raise ValueError(
|
|
242
|
+
f"{self.name}: a fee on undrawn capacity needs a commitment to be "
|
|
243
|
+
f"undrawn against"
|
|
244
|
+
)
|
|
245
|
+
if self.seniority < 0:
|
|
246
|
+
raise ValueError(f"{self.name}: seniority must not be negative")
|
|
247
|
+
if self.commitment < 0:
|
|
248
|
+
raise ValueError(f"{self.name}: the commitment must not be negative")
|
|
249
|
+
if self.commitment and self.face > self.commitment:
|
|
250
|
+
raise ValueError(
|
|
251
|
+
f"{self.name}: drawn at close ({self.face}) exceeds the commitment "
|
|
252
|
+
f"({self.commitment})"
|
|
253
|
+
)
|
|
254
|
+
if self.commitment and not self.is_revolving and self.availability is None:
|
|
255
|
+
raise ValueError(
|
|
256
|
+
f"{self.name}: a term commitment states how long it can be drawn. "
|
|
257
|
+
f"A revolver is available until it matures, but a delayed-draw or "
|
|
258
|
+
f"acquisition facility has an availability period, and without one "
|
|
259
|
+
f"there is nothing to stop the ticking fee running to maturity"
|
|
260
|
+
)
|
|
261
|
+
if self.availability is not None and self.availability < 1:
|
|
262
|
+
raise ValueError(
|
|
263
|
+
f"{self.name}: availability is a period index, so 1 or later"
|
|
264
|
+
)
|
|
265
|
+
if (
|
|
266
|
+
self.availability is not None
|
|
267
|
+
and self.maturity is not None
|
|
268
|
+
and self.availability > self.maturity
|
|
269
|
+
):
|
|
270
|
+
raise ValueError(
|
|
271
|
+
f"{self.name}: available to draw in period {self.availability}, which "
|
|
272
|
+
f"is after it matures in period {self.maturity}"
|
|
273
|
+
)
|
|
274
|
+
if self.maturity is not None and self.maturity < 1:
|
|
275
|
+
raise ValueError(f"{self.name}: maturity is a period index, so 1 or later")
|
|
276
|
+
if self.amortisation is not None and any(v < 0 for v in self.amortisation):
|
|
277
|
+
raise ValueError(f"{self.name}: amortisation must not be negative")
|
|
278
|
+
|
|
279
|
+
@property
|
|
280
|
+
def is_revolving(self) -> bool:
|
|
281
|
+
return self.kind is TrancheKind.REVOLVER
|
|
282
|
+
|
|
283
|
+
@property
|
|
284
|
+
def accretes(self) -> bool:
|
|
285
|
+
"""Whether any of the coupon is paid in kind rather than in cash."""
|
|
286
|
+
return self.pik_rate > 0
|
|
287
|
+
|
|
288
|
+
def rate_at(self, base: Money) -> Money:
|
|
289
|
+
"""The all-in cash coupon, given the base rate for the period.
|
|
290
|
+
|
|
291
|
+
A floating tranche prices off the base rate subject to its floor. The
|
|
292
|
+
floor is not decoration: it was written into every loan document drafted
|
|
293
|
+
while base rates were near zero, and it is the reason a fall in rates
|
|
294
|
+
below the floor does not reach the borrower at all.
|
|
295
|
+
"""
|
|
296
|
+
if not self.floating:
|
|
297
|
+
return self.cash_rate
|
|
298
|
+
return max(base, self.floor) + self.cash_rate
|
|
299
|
+
|
|
300
|
+
def scheduled_amortisation(self, index: int, basis: Money | None = None) -> Money:
|
|
301
|
+
"""Contractual repayment for period ``index``, as an amount.
|
|
302
|
+
|
|
303
|
+
Struck against face rather than against the balance outstanding, so a
|
|
304
|
+
sweep that runs ahead of the schedule does not reduce what is
|
|
305
|
+
contractually due next period. Credit agreements differ on whether it
|
|
306
|
+
should — many allow prepayments to be applied against future instalments
|
|
307
|
+
in order — and the conservative reading is the one modelled here.
|
|
308
|
+
|
|
309
|
+
``basis`` is the face the instalment is measured on. It defaults to the
|
|
310
|
+
face drawn at close, which is the whole story for paper placed at close
|
|
311
|
+
and only part of it for anything drawn later; see
|
|
312
|
+
:class:`AmortisationBasis` for how the schedule carries it forward.
|
|
313
|
+
"""
|
|
314
|
+
if self.amortisation is None:
|
|
315
|
+
return ZERO
|
|
316
|
+
face = self.face if basis is None else basis
|
|
317
|
+
return face * self.amortisation.at(index)
|
|
318
|
+
|
|
319
|
+
def has_matured(self, index: int) -> bool:
|
|
320
|
+
"""Whether period ``index`` is at or past this tranche's maturity.
|
|
321
|
+
|
|
322
|
+
``index`` is zero-based across the schedule, matching how every other
|
|
323
|
+
series is read, so period one is index zero and a tranche maturing in
|
|
324
|
+
period three has matured from index two onward.
|
|
325
|
+
"""
|
|
326
|
+
return self.matured_at(index + 1)
|
|
327
|
+
|
|
328
|
+
def matured_at(self, period: int) -> bool:
|
|
329
|
+
"""Whether the period *numbered* ``period`` is at or past maturity.
|
|
330
|
+
|
|
331
|
+
Stated in period numbers rather than in positions because that is how a
|
|
332
|
+
credit agreement states it, and because a grid opening on a stub has a
|
|
333
|
+
period zero — paper maturing in period two matures at the end of the
|
|
334
|
+
second whole period whether or not six weeks of trading sit in front of
|
|
335
|
+
the first.
|
|
336
|
+
"""
|
|
337
|
+
return self.maturity is not None and period >= self.maturity
|
|
338
|
+
|
|
339
|
+
@property
|
|
340
|
+
def has_commitment(self) -> bool:
|
|
341
|
+
"""Whether anything can be drawn on this name after close."""
|
|
342
|
+
return self.commitment > 0
|
|
343
|
+
|
|
344
|
+
def available_at(self, period: int) -> bool:
|
|
345
|
+
"""Whether the facility can still be drawn in the period numbered ``period``.
|
|
346
|
+
|
|
347
|
+
A revolver is available until it matures. A term commitment — a
|
|
348
|
+
delayed-draw facility, a committed acquisition line — is available for a
|
|
349
|
+
stated period and then lapses, drawn or not, which is the difference
|
|
350
|
+
between the two instruments and the reason the availability period is
|
|
351
|
+
required on one and optional on the other.
|
|
352
|
+
"""
|
|
353
|
+
if self.matured_at(period):
|
|
354
|
+
return False
|
|
355
|
+
return self.availability is None or period <= self.availability
|
|
356
|
+
|
|
357
|
+
def undrawn_at(self, drawn: Money, index: int = 0, *, period: int | None = None) -> Money:
|
|
358
|
+
"""Commitment not yet available to the borrower, given what is ``drawn``.
|
|
359
|
+
|
|
360
|
+
What ``drawn`` means differs by instrument, and the difference is the
|
|
361
|
+
whole distinction between the two. On a revolver it is the balance
|
|
362
|
+
outstanding: repay it and the capacity comes back, which is what
|
|
363
|
+
revolving means. On a term commitment it is the cumulative face taken
|
|
364
|
+
down since close, because repaying a delayed-draw term loan does not
|
|
365
|
+
entitle anyone to draw it again. The caller passes the right one; a
|
|
366
|
+
model that passed the balance for both would let a borrower draw an
|
|
367
|
+
acquisition facility, repay it out of cash flow, and draw it again.
|
|
368
|
+
|
|
369
|
+
A matured facility has no commitment left, and neither has one whose
|
|
370
|
+
availability period has run — which is what stops a ticking fee being
|
|
371
|
+
charged for the rest of a hold on capacity nobody can take down.
|
|
372
|
+
"""
|
|
373
|
+
at = index + 1 if period is None else period
|
|
374
|
+
if not self.has_commitment or not self.available_at(at):
|
|
375
|
+
return ZERO
|
|
376
|
+
return self.commitment - drawn
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
class AmortisationBasis:
|
|
380
|
+
"""The face each tranche's instalment is struck against, carried forward.
|
|
381
|
+
|
|
382
|
+
A credit agreement writes amortisation as a fraction of face — 1% a year on
|
|
383
|
+
a term loan means 1% of what was borrowed, not 1% of what is left. Reading
|
|
384
|
+
"what was borrowed" as the face drawn at close is right for paper placed at
|
|
385
|
+
close and wrong for everything else, in both directions.
|
|
386
|
+
|
|
387
|
+
It understates. A delayed-draw facility described with nothing drawn at
|
|
388
|
+
close has a basis of zero, so it repays nothing however much is taken down
|
|
389
|
+
on it later; an incremental facility drawn on an existing name grows the
|
|
390
|
+
balance without growing the instalment, so a term loan that doubles in size
|
|
391
|
+
still repays 1% of the original.
|
|
392
|
+
|
|
393
|
+
And it overstates. A facility retired at a refinancing keeps a basis it no
|
|
394
|
+
longer has any face behind, so the schedule carries a mandatory repayment on
|
|
395
|
+
paper that has been taken out.
|
|
396
|
+
|
|
397
|
+
So the basis is a ledger rather than a property of the tranche. It opens at
|
|
398
|
+
the face drawn at close, rises by incremental face taken down at a period
|
|
399
|
+
boundary, and goes to zero when the facility is retired. Draws are applied
|
|
400
|
+
*after* the period they land in has been solved, which is what makes face
|
|
401
|
+
drawn at the end of period two first amortise in period three — the
|
|
402
|
+
instalment for a period is struck on the basis that period opened on.
|
|
403
|
+
"""
|
|
404
|
+
|
|
405
|
+
__slots__ = ("_basis", "_retired")
|
|
406
|
+
|
|
407
|
+
def __init__(self, structure: CapitalStructure) -> None:
|
|
408
|
+
self._basis: dict[str, Money] = {t.name: t.face for t in structure}
|
|
409
|
+
self._retired: set[str] = set()
|
|
410
|
+
|
|
411
|
+
def at(self, name: str) -> Money:
|
|
412
|
+
"""The face period instalments are currently measured against."""
|
|
413
|
+
return self._basis[name]
|
|
414
|
+
|
|
415
|
+
def snapshot(self) -> dict[str, Money]:
|
|
416
|
+
return dict(self._basis)
|
|
417
|
+
|
|
418
|
+
def draw(self, name: str, amount: Money) -> None:
|
|
419
|
+
"""Add face taken down on an existing name.
|
|
420
|
+
|
|
421
|
+
Incremental face amortises on the same terms as the paper it joins,
|
|
422
|
+
which is what an incremental facility documented under an existing
|
|
423
|
+
credit agreement actually does — it is fungible with the original by
|
|
424
|
+
construction, so it cannot repay on a different schedule.
|
|
425
|
+
"""
|
|
426
|
+
if amount <= 0:
|
|
427
|
+
return
|
|
428
|
+
self._basis[name] += amount
|
|
429
|
+
|
|
430
|
+
def retire(self, name: str) -> None:
|
|
431
|
+
"""Drop the basis to zero because the facility has been taken out.
|
|
432
|
+
|
|
433
|
+
Zero rather than reduced by the balance repaid: a refinancing retires
|
|
434
|
+
the facility, not a slice of it, and what remains of the original face
|
|
435
|
+
is a claim the replacement paper now carries on its own terms.
|
|
436
|
+
|
|
437
|
+
The name is remembered as well as zeroed. A basis of zero is otherwise
|
|
438
|
+
indistinguishable from a committed facility nobody has drawn yet, and
|
|
439
|
+
the two want opposite treatment: the undrawn one still ticks, and the
|
|
440
|
+
retired one has no commitment left to tick on.
|
|
441
|
+
"""
|
|
442
|
+
self._basis[name] = ZERO
|
|
443
|
+
self._retired.add(name)
|
|
444
|
+
|
|
445
|
+
@property
|
|
446
|
+
def retired(self) -> frozenset[str]:
|
|
447
|
+
"""The facilities taken out, which can no longer be drawn."""
|
|
448
|
+
return frozenset(self._retired)
|
|
449
|
+
|
|
450
|
+
def apply(self, row: DebtPeriod) -> None:
|
|
451
|
+
"""Carry the basis across a solved period's events, in the order they ran."""
|
|
452
|
+
for tranche in row.tranches:
|
|
453
|
+
if tranche.refinancing_repayment > 0:
|
|
454
|
+
self.retire(tranche.name)
|
|
455
|
+
self.draw(tranche.name, tranche.incremental_draw)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
@dataclass(frozen=True, slots=True)
|
|
459
|
+
class SweepStep:
|
|
460
|
+
"""One rung of a sweep grid: a leverage level and the rate that applies at it."""
|
|
461
|
+
|
|
462
|
+
leverage: Money
|
|
463
|
+
rate: Money
|
|
464
|
+
|
|
465
|
+
@classmethod
|
|
466
|
+
def of(cls, leverage: Numeric, rate: Numeric) -> SweepStep:
|
|
467
|
+
return cls(leverage=money(leverage), rate=money(rate))
|
|
468
|
+
|
|
469
|
+
def __post_init__(self) -> None:
|
|
470
|
+
if self.leverage < 0:
|
|
471
|
+
raise ValueError("a sweep step is set at a leverage level, so not negative")
|
|
472
|
+
if not (0 <= self.rate <= 1):
|
|
473
|
+
raise ValueError("a sweep rate is a share, so between 0 and 1")
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
@dataclass(frozen=True, slots=True)
|
|
477
|
+
class SweepGrid:
|
|
478
|
+
"""A sweep percentage that steps down as leverage falls.
|
|
479
|
+
|
|
480
|
+
Almost no credit agreement sweeps a flat percentage for the life of the
|
|
481
|
+
loan. The usual shape is fifty per cent of excess cash flow, stepping to
|
|
482
|
+
twenty-five and then to nothing as leverage comes through agreed levels,
|
|
483
|
+
which is the lenders paying for deleveraging with the sponsor's own cash. A
|
|
484
|
+
model that sweeps a flat rate repays too fast in the good years and reports
|
|
485
|
+
an exit leverage the documents would never have produced.
|
|
486
|
+
|
|
487
|
+
``steps`` are read as "at this leverage or above, sweep this much", and are
|
|
488
|
+
sorted highest first regardless of the order given. Below every step the
|
|
489
|
+
grid returns ``floor``, which is zero in the ordinary case.
|
|
490
|
+
|
|
491
|
+
*Which leverage the step is read against* is the decision that matters. The
|
|
492
|
+
obvious answer — the leverage of the period being swept — makes the sweep
|
|
493
|
+
depend on the closing balance, which depends on the sweep, and drops a step
|
|
494
|
+
function into the middle of the interest fixed point, where it can sit and
|
|
495
|
+
oscillate between two rungs forever. The step is resolved against the
|
|
496
|
+
leverage at the most recent test date instead: the certificate delivered for
|
|
497
|
+
the period just ended. That is not a modelling convenience but how the
|
|
498
|
+
payment is actually made — an excess cash flow sweep is paid in arrears, at
|
|
499
|
+
a rate set by a certificate that was signed before the cash was counted.
|
|
500
|
+
"""
|
|
501
|
+
|
|
502
|
+
steps: tuple[SweepStep, ...]
|
|
503
|
+
floor: Money = ZERO
|
|
504
|
+
net: bool = True
|
|
505
|
+
|
|
506
|
+
@classmethod
|
|
507
|
+
def of(
|
|
508
|
+
cls,
|
|
509
|
+
steps: Sequence[tuple[Numeric, Numeric]] | Sequence[SweepStep],
|
|
510
|
+
*,
|
|
511
|
+
floor: Numeric = 0,
|
|
512
|
+
net: bool = True,
|
|
513
|
+
) -> SweepGrid:
|
|
514
|
+
rungs = tuple(
|
|
515
|
+
s if isinstance(s, SweepStep) else SweepStep.of(s[0], s[1]) for s in steps
|
|
516
|
+
)
|
|
517
|
+
return cls(steps=rungs, floor=money(floor), net=net)
|
|
518
|
+
|
|
519
|
+
def __post_init__(self) -> None:
|
|
520
|
+
if not self.steps:
|
|
521
|
+
raise ValueError("a sweep grid needs at least one step")
|
|
522
|
+
if not (0 <= self.floor <= 1):
|
|
523
|
+
raise ValueError("the floor sweep rate is a share, so between 0 and 1")
|
|
524
|
+
levels = [s.leverage for s in self.steps]
|
|
525
|
+
if len(set(levels)) != len(levels):
|
|
526
|
+
raise ValueError("two steps set at the same leverage contradict each other")
|
|
527
|
+
object.__setattr__(
|
|
528
|
+
self, "steps", tuple(sorted(self.steps, key=lambda s: s.leverage, reverse=True))
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
@property
|
|
532
|
+
def top_rate(self) -> Money:
|
|
533
|
+
"""The rate that applies at the highest rung, and to unknown leverage."""
|
|
534
|
+
return self.steps[0].rate
|
|
535
|
+
|
|
536
|
+
def rate_at(self, leverage: Money | None) -> Money:
|
|
537
|
+
"""The sweep rate for a certified leverage level.
|
|
538
|
+
|
|
539
|
+
``None`` means the ratio could not be measured — no earnings to divide
|
|
540
|
+
by. That is the state in which lenders would least like the sweep
|
|
541
|
+
relaxed, so it takes the top rate rather than the floor.
|
|
542
|
+
"""
|
|
543
|
+
if leverage is None:
|
|
544
|
+
return self.top_rate
|
|
545
|
+
for step in self.steps:
|
|
546
|
+
if leverage >= step.leverage:
|
|
547
|
+
return step.rate
|
|
548
|
+
return self.floor
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
@dataclass(frozen=True, slots=True)
|
|
552
|
+
class CapitalStructure:
|
|
553
|
+
"""The whole stack, plus the rules that govern how cash moves through it.
|
|
554
|
+
|
|
555
|
+
``sweep_rate`` is the share of the period's excess cash flow applied to debt
|
|
556
|
+
rather than left on the balance sheet. ``minimum_cash`` is the balance the business is left
|
|
557
|
+
with before any sweep, which is an operating requirement rather than a
|
|
558
|
+
financing one: a business cannot run its payroll out of a revolver it has to
|
|
559
|
+
ask permission to draw.
|
|
560
|
+
|
|
561
|
+
``damping`` is where the fixed-point iteration starts, not where it stays:
|
|
562
|
+
it is halved whenever a step fails to reduce the residual. Full steps are
|
|
563
|
+
right for the smooth part of the problem and wrong at a clamp boundary, and
|
|
564
|
+
the solver cannot know in advance which one it is standing on.
|
|
565
|
+
"""
|
|
566
|
+
|
|
567
|
+
tranches: tuple[Tranche, ...]
|
|
568
|
+
minimum_cash: Money = ZERO
|
|
569
|
+
sweep_rate: Money = ONE
|
|
570
|
+
sweep_grid: SweepGrid | None = None
|
|
571
|
+
base_rate: Driver | None = None
|
|
572
|
+
day_count: DayCount = DayCount.ACT_360
|
|
573
|
+
interest_basis: InterestBasis = InterestBasis.AVERAGE
|
|
574
|
+
damping: Money = ONE
|
|
575
|
+
tolerance: Money = field(default_factory=lambda: money("0.000000001"))
|
|
576
|
+
max_iterations: int = 100
|
|
577
|
+
|
|
578
|
+
@classmethod
|
|
579
|
+
def of(
|
|
580
|
+
cls,
|
|
581
|
+
tranches: Sequence[Tranche],
|
|
582
|
+
*,
|
|
583
|
+
minimum_cash: Numeric = 0,
|
|
584
|
+
sweep_rate: Numeric = 1,
|
|
585
|
+
sweep_grid: SweepGrid | None = None,
|
|
586
|
+
base_rate: Driver | None = None,
|
|
587
|
+
day_count: DayCount = DayCount.ACT_360,
|
|
588
|
+
interest_basis: InterestBasis = InterestBasis.AVERAGE,
|
|
589
|
+
damping: Numeric = 1,
|
|
590
|
+
tolerance: Numeric = "0.000000001",
|
|
591
|
+
max_iterations: int = 100,
|
|
592
|
+
) -> CapitalStructure:
|
|
593
|
+
return cls(
|
|
594
|
+
tranches=tuple(tranches),
|
|
595
|
+
minimum_cash=money(minimum_cash),
|
|
596
|
+
sweep_rate=money(sweep_rate),
|
|
597
|
+
sweep_grid=sweep_grid,
|
|
598
|
+
base_rate=base_rate,
|
|
599
|
+
day_count=day_count,
|
|
600
|
+
interest_basis=interest_basis,
|
|
601
|
+
damping=money(damping),
|
|
602
|
+
tolerance=money(tolerance),
|
|
603
|
+
max_iterations=max_iterations,
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
def __post_init__(self) -> None:
|
|
607
|
+
if not self.tranches:
|
|
608
|
+
raise ValueError("a capital structure needs at least one tranche")
|
|
609
|
+
names = [t.name for t in self.tranches]
|
|
610
|
+
if len(names) != len(set(names)):
|
|
611
|
+
raise ValueError("tranche names must be distinct")
|
|
612
|
+
if self.minimum_cash < 0:
|
|
613
|
+
raise ValueError("the minimum cash balance must not be negative")
|
|
614
|
+
if not (0 <= self.sweep_rate <= 1):
|
|
615
|
+
raise ValueError("the sweep rate is a share, so between 0 and 1")
|
|
616
|
+
if self.sweep_grid is not None and self.sweep_rate != ONE:
|
|
617
|
+
# Refused rather than resolved by precedence. A structure carrying
|
|
618
|
+
# both a grid and a flat rate has two answers to the same question,
|
|
619
|
+
# and quietly preferring one would hide a contradiction in the
|
|
620
|
+
# description of the deal.
|
|
621
|
+
raise ValueError(
|
|
622
|
+
"a sweep grid sets the rate for every period, so a flat sweep "
|
|
623
|
+
"rate alongside it says two different things"
|
|
624
|
+
)
|
|
625
|
+
if not (0 < self.damping <= 1):
|
|
626
|
+
raise ValueError("damping must be greater than 0 and at most 1")
|
|
627
|
+
if self.tolerance <= 0:
|
|
628
|
+
raise ValueError("the convergence tolerance must be positive")
|
|
629
|
+
if self.max_iterations < 1:
|
|
630
|
+
raise ValueError("at least one iteration is required")
|
|
631
|
+
if self.base_rate is None and any(t.floating for t in self.tranches):
|
|
632
|
+
floating = ", ".join(t.name for t in self.tranches if t.floating)
|
|
633
|
+
raise ValueError(
|
|
634
|
+
f"a base rate is required: {floating} price off one"
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
def __len__(self) -> int:
|
|
638
|
+
return len(self.tranches)
|
|
639
|
+
|
|
640
|
+
def __iter__(self) -> Iterator[Tranche]:
|
|
641
|
+
return iter(self.tranches)
|
|
642
|
+
|
|
643
|
+
def __getitem__(self, index: int) -> Tranche:
|
|
644
|
+
return self.tranches[index]
|
|
645
|
+
|
|
646
|
+
def tranche(self, name: str) -> Tranche:
|
|
647
|
+
for t in self.tranches:
|
|
648
|
+
if t.name == name:
|
|
649
|
+
return t
|
|
650
|
+
raise KeyError(f"no tranche named {name!r}")
|
|
651
|
+
|
|
652
|
+
def base_at(self, index: int) -> Money:
|
|
653
|
+
return ZERO if self.base_rate is None else self.base_rate.at(index)
|
|
654
|
+
|
|
655
|
+
@property
|
|
656
|
+
def total_face(self) -> Money:
|
|
657
|
+
"""Drawn at close. This is what leverage is measured on."""
|
|
658
|
+
return sum((t.face for t in self.tranches), ZERO)
|
|
659
|
+
|
|
660
|
+
@property
|
|
661
|
+
def total_commitment(self) -> Money:
|
|
662
|
+
"""Revolver capacity, drawn or not."""
|
|
663
|
+
return sum((t.commitment for t in self.tranches), ZERO)
|
|
664
|
+
|
|
665
|
+
@property
|
|
666
|
+
def sweep_order(self) -> tuple[tuple[int, tuple[Tranche, ...]], ...]:
|
|
667
|
+
"""Swept tranches grouped by seniority, most senior class first.
|
|
668
|
+
|
|
669
|
+
Grouping rather than ordering, because tranches at the same rank share a
|
|
670
|
+
repayment pro rata rather than queueing behind one another.
|
|
671
|
+
"""
|
|
672
|
+
ranks: dict[int, list[Tranche]] = {}
|
|
673
|
+
for t in self.tranches:
|
|
674
|
+
if t.swept:
|
|
675
|
+
ranks.setdefault(t.seniority, []).append(t)
|
|
676
|
+
return tuple((rank, tuple(ranks[rank])) for rank in sorted(ranks))
|
|
677
|
+
|
|
678
|
+
def blended_cash_rate(self, index: int = 0) -> Money:
|
|
679
|
+
"""Weighted average cash coupon across the drawn stack.
|
|
680
|
+
|
|
681
|
+
The single number a structure is summarised by, and the one that decides
|
|
682
|
+
whether the business can carry it.
|
|
683
|
+
"""
|
|
684
|
+
drawn = self.total_face
|
|
685
|
+
if drawn == 0:
|
|
686
|
+
return ZERO
|
|
687
|
+
base = self.base_at(index)
|
|
688
|
+
weighted = sum((t.face * t.rate_at(base) for t in self.tranches), ZERO)
|
|
689
|
+
return safe_div(weighted, drawn, default=ZERO)
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
#: The shortest step the solver will take before giving up. Below this the
|
|
693
|
+
#: iteration is not converging on anything, it is inching towards a boundary it
|
|
694
|
+
#: will never cross, and saying so is more useful than another sixty steps.
|
|
695
|
+
MINIMUM_DAMPING = money("0.015625")
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
#: How close a roll-forward has to come to closing before it counts as closed.
|
|
699
|
+
#:
|
|
700
|
+
#: The funding table checks its identity with exact equality, and rightly: its
|
|
701
|
+
#: inputs are short decimals and its arithmetic is a single sum, so anything
|
|
702
|
+
#: other than zero is a missing line item. A debt schedule is a different shape.
|
|
703
|
+
#: Its year fractions do not terminate, so five periods of compounding push
|
|
704
|
+
#: balances out to the full working precision, and re-adding those balances in a
|
|
705
|
+
#: different order disagrees with itself in the thirty-fourth significant digit.
|
|
706
|
+
#: A tolerance a thousand billion times tighter than a cent separates that from
|
|
707
|
+
#: an error a reader would care about.
|
|
708
|
+
RECONCILIATION_TOLERANCE = "1E-12"
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
class CircularityError(RuntimeError):
|
|
712
|
+
"""The interest/balance fixed point did not converge.
|
|
713
|
+
|
|
714
|
+
Raised rather than returning the last iterate, because an unconverged
|
|
715
|
+
schedule is not an approximate answer — it is a set of balances that do not
|
|
716
|
+
reconcile with the interest charged against them.
|
|
717
|
+
"""
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
@dataclass(frozen=True, slots=True)
|
|
721
|
+
class TranchePeriod:
|
|
722
|
+
"""What happened to one tranche in one period."""
|
|
723
|
+
|
|
724
|
+
name: str
|
|
725
|
+
kind: TrancheKind
|
|
726
|
+
rate: Money
|
|
727
|
+
opening: Money
|
|
728
|
+
draw: Money
|
|
729
|
+
cash_interest: Money
|
|
730
|
+
pik_interest: Money
|
|
731
|
+
undrawn_fee: Money
|
|
732
|
+
mandatory_repayment: Money
|
|
733
|
+
sweep_repayment: Money
|
|
734
|
+
closing: Money
|
|
735
|
+
recapitalisation: Money = ZERO
|
|
736
|
+
acquisition: Money = ZERO
|
|
737
|
+
refinancing_draw: Money = ZERO
|
|
738
|
+
refinancing_repayment: Money = ZERO
|
|
739
|
+
amortisation_basis: Money = ZERO
|
|
740
|
+
|
|
741
|
+
@property
|
|
742
|
+
def incremental_draw(self) -> Money:
|
|
743
|
+
"""Face taken down at the period boundary, whatever it funded.
|
|
744
|
+
|
|
745
|
+
Split by purpose rather than carried as one number, because a reader of
|
|
746
|
+
the schedule wants to know whether a tranche grew to pay the
|
|
747
|
+
shareholders, to buy something, or to replace paper priced in a worse
|
|
748
|
+
market."""
|
|
749
|
+
return self.recapitalisation + self.acquisition + self.refinancing_draw
|
|
750
|
+
|
|
751
|
+
@property
|
|
752
|
+
def total_repayment(self) -> Money:
|
|
753
|
+
return self.mandatory_repayment + self.sweep_repayment
|
|
754
|
+
|
|
755
|
+
@property
|
|
756
|
+
def total_interest(self) -> Money:
|
|
757
|
+
"""Economic cost for the period, whether or not it was paid in cash."""
|
|
758
|
+
return self.cash_interest + self.pik_interest
|
|
759
|
+
|
|
760
|
+
def reconciles(self, tolerance: Numeric = RECONCILIATION_TOLERANCE) -> bool:
|
|
761
|
+
"""Whether the roll-forward closes.
|
|
762
|
+
|
|
763
|
+
Checked in tests rather than asserted here: the arithmetic that builds
|
|
764
|
+
the row is the same arithmetic this would re-run, so an assertion would
|
|
765
|
+
only prove the engine agrees with itself. It is exposed because a caller
|
|
766
|
+
assembling rows by hand deserves the check.
|
|
767
|
+
"""
|
|
768
|
+
expected = (
|
|
769
|
+
self.opening
|
|
770
|
+
+ self.draw
|
|
771
|
+
+ self.pik_interest
|
|
772
|
+
- self.mandatory_repayment
|
|
773
|
+
- self.sweep_repayment
|
|
774
|
+
+ self.incremental_draw
|
|
775
|
+
- self.refinancing_repayment
|
|
776
|
+
)
|
|
777
|
+
return is_close(self.closing, expected, tolerance=tolerance)
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
@dataclass(frozen=True, slots=True)
|
|
781
|
+
class DebtPeriod:
|
|
782
|
+
"""One column of the debt schedule."""
|
|
783
|
+
|
|
784
|
+
period: Period
|
|
785
|
+
base_rate: Money
|
|
786
|
+
tranches: tuple[TranchePeriod, ...]
|
|
787
|
+
opening_cash: Money
|
|
788
|
+
unlevered_free_cash_flow: Money
|
|
789
|
+
revolver_draw: Money
|
|
790
|
+
funding_shortfall: Money
|
|
791
|
+
cash_below_minimum: Money
|
|
792
|
+
closing_cash: Money
|
|
793
|
+
iterations: int
|
|
794
|
+
residual: Money
|
|
795
|
+
damping_used: Money = ONE
|
|
796
|
+
sweep_rate: Money = ONE
|
|
797
|
+
certified_leverage: Money | None = None
|
|
798
|
+
distribution: Money = ZERO
|
|
799
|
+
distribution_from_cash: Money = ZERO
|
|
800
|
+
acquisition_spend: Money = ZERO
|
|
801
|
+
acquisition_from_cash: Money = ZERO
|
|
802
|
+
call_premium: Money = ZERO
|
|
803
|
+
fees_written_off: Money = ZERO
|
|
804
|
+
refinancing_from_cash: Money = ZERO
|
|
805
|
+
|
|
806
|
+
@property
|
|
807
|
+
def index(self) -> int:
|
|
808
|
+
return int(self.period.index)
|
|
809
|
+
|
|
810
|
+
def tranche(self, name: str) -> TranchePeriod:
|
|
811
|
+
for row in self.tranches:
|
|
812
|
+
if row.name == name:
|
|
813
|
+
return row
|
|
814
|
+
raise KeyError(f"no tranche named {name!r}")
|
|
815
|
+
|
|
816
|
+
@property
|
|
817
|
+
def opening_debt(self) -> Money:
|
|
818
|
+
return sum((t.opening for t in self.tranches), ZERO)
|
|
819
|
+
|
|
820
|
+
@property
|
|
821
|
+
def closing_debt(self) -> Money:
|
|
822
|
+
return sum((t.closing for t in self.tranches), ZERO)
|
|
823
|
+
|
|
824
|
+
@property
|
|
825
|
+
def cash_interest(self) -> Money:
|
|
826
|
+
return sum((t.cash_interest for t in self.tranches), ZERO)
|
|
827
|
+
|
|
828
|
+
@property
|
|
829
|
+
def pik_interest(self) -> Money:
|
|
830
|
+
return sum((t.pik_interest for t in self.tranches), ZERO)
|
|
831
|
+
|
|
832
|
+
@property
|
|
833
|
+
def undrawn_fees(self) -> Money:
|
|
834
|
+
return sum((t.undrawn_fee for t in self.tranches), ZERO)
|
|
835
|
+
|
|
836
|
+
@property
|
|
837
|
+
def mandatory_repayment(self) -> Money:
|
|
838
|
+
return sum((t.mandatory_repayment for t in self.tranches), ZERO)
|
|
839
|
+
|
|
840
|
+
@property
|
|
841
|
+
def sweep_repayment(self) -> Money:
|
|
842
|
+
return sum((t.sweep_repayment for t in self.tranches), ZERO)
|
|
843
|
+
|
|
844
|
+
@property
|
|
845
|
+
def total_repayment(self) -> Money:
|
|
846
|
+
return self.mandatory_repayment + self.sweep_repayment
|
|
847
|
+
|
|
848
|
+
@property
|
|
849
|
+
def recapitalisation(self) -> Money:
|
|
850
|
+
"""Incremental face taken down at the end of this period."""
|
|
851
|
+
return sum((t.recapitalisation for t in self.tranches), ZERO)
|
|
852
|
+
|
|
853
|
+
@property
|
|
854
|
+
def acquisition_debt(self) -> Money:
|
|
855
|
+
"""Incremental face taken down to fund an acquisition."""
|
|
856
|
+
return sum((t.acquisition for t in self.tranches), ZERO)
|
|
857
|
+
|
|
858
|
+
@property
|
|
859
|
+
def refinanced(self) -> Money:
|
|
860
|
+
"""Face retired early at the period boundary."""
|
|
861
|
+
return sum((t.refinancing_repayment for t in self.tranches), ZERO)
|
|
862
|
+
|
|
863
|
+
@property
|
|
864
|
+
def refinancing_draw(self) -> Money:
|
|
865
|
+
"""Face of the paper that replaced it."""
|
|
866
|
+
return sum((t.refinancing_draw for t in self.tranches), ZERO)
|
|
867
|
+
|
|
868
|
+
@property
|
|
869
|
+
def incremental_draw(self) -> Money:
|
|
870
|
+
return self.recapitalisation + self.acquisition_debt + self.refinancing_draw
|
|
871
|
+
|
|
872
|
+
@property
|
|
873
|
+
def cash_cost_of_debt(self) -> Money:
|
|
874
|
+
"""Everything the capital structure took in cash this period."""
|
|
875
|
+
return self.cash_interest + self.undrawn_fees
|
|
876
|
+
|
|
877
|
+
@property
|
|
878
|
+
def levered_free_cash_flow(self) -> Money:
|
|
879
|
+
"""Cash left after servicing the debt, before repaying any of it."""
|
|
880
|
+
return self.unlevered_free_cash_flow - self.cash_cost_of_debt
|
|
881
|
+
|
|
882
|
+
@property
|
|
883
|
+
def is_funded(self) -> bool:
|
|
884
|
+
"""Whether the period paid for itself out of cash, flow and the revolver.
|
|
885
|
+
|
|
886
|
+
A solvency test, not a policy one. A business that ends the period on
|
|
887
|
+
less cash than its own minimum but still on more than nothing has funded
|
|
888
|
+
itself; see :attr:`meets_minimum_cash` for the policy.
|
|
889
|
+
"""
|
|
890
|
+
return self.funding_shortfall == 0
|
|
891
|
+
|
|
892
|
+
@property
|
|
893
|
+
def meets_minimum_cash(self) -> bool:
|
|
894
|
+
"""Whether the period closed on at least the minimum cash balance."""
|
|
895
|
+
return self.cash_below_minimum == 0
|
|
896
|
+
|
|
897
|
+
@property
|
|
898
|
+
def net_debt(self) -> Money:
|
|
899
|
+
return self.closing_debt - self.closing_cash
|
|
900
|
+
|
|
901
|
+
def reconciles(self, tolerance: Numeric = RECONCILIATION_TOLERANCE) -> bool:
|
|
902
|
+
"""Whether cash in equals cash out for the period."""
|
|
903
|
+
expected = (
|
|
904
|
+
self.opening_cash
|
|
905
|
+
+ self.unlevered_free_cash_flow
|
|
906
|
+
- self.cash_cost_of_debt
|
|
907
|
+
- self.total_repayment
|
|
908
|
+
+ self.revolver_draw
|
|
909
|
+
+ self.funding_shortfall
|
|
910
|
+
- self.distribution_from_cash
|
|
911
|
+
- self.acquisition_from_cash
|
|
912
|
+
- self.refinancing_from_cash
|
|
913
|
+
)
|
|
914
|
+
return is_close(self.closing_cash, expected, tolerance=tolerance)
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def _by_period(
|
|
918
|
+
events: Sequence[Recapitalisation], structure: CapitalStructure, span: int
|
|
919
|
+
) -> dict[int, Recapitalisation]:
|
|
920
|
+
"""Index the events by the period they land in, checking each one is possible.
|
|
921
|
+
|
|
922
|
+
Everything checkable without running the schedule is checked here, so a
|
|
923
|
+
structure that cannot support the event fails before twenty periods of
|
|
924
|
+
arithmetic rather than in the middle of them.
|
|
925
|
+
"""
|
|
926
|
+
known = {t.name for t in structure.tranches}
|
|
927
|
+
scheduled: dict[int, Recapitalisation] = {}
|
|
928
|
+
for event in events:
|
|
929
|
+
if event.period > span:
|
|
930
|
+
raise RecapitalisationError(
|
|
931
|
+
f"{event.label}: period {event.period} is beyond the {span} "
|
|
932
|
+
f"periods the schedule covers"
|
|
933
|
+
)
|
|
934
|
+
if event.period in scheduled:
|
|
935
|
+
raise RecapitalisationError(
|
|
936
|
+
f"two recapitalisations land at the end of period {event.period}; "
|
|
937
|
+
f"combine them into one"
|
|
938
|
+
)
|
|
939
|
+
for draw in event.draws:
|
|
940
|
+
if draw.tranche not in known:
|
|
941
|
+
raise RecapitalisationError(
|
|
942
|
+
f"{event.label}: there is no tranche named {draw.tranche!r} to "
|
|
943
|
+
f"draw on; the structure holds "
|
|
944
|
+
f"{', '.join(repr(n) for n in sorted(known))}"
|
|
945
|
+
)
|
|
946
|
+
tranche = structure.tranche(draw.tranche)
|
|
947
|
+
if tranche.matured_at(event.period):
|
|
948
|
+
raise RecapitalisationError(
|
|
949
|
+
f"{event.label}: {draw.tranche} has matured by period "
|
|
950
|
+
f"{event.period}, so there is nothing left to draw on it"
|
|
951
|
+
)
|
|
952
|
+
scheduled[event.period] = event
|
|
953
|
+
return scheduled
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def _refinancings_by_period(
|
|
957
|
+
events: Sequence[Refinancing],
|
|
958
|
+
structure: CapitalStructure,
|
|
959
|
+
span: int,
|
|
960
|
+
taken: frozenset[int],
|
|
961
|
+
) -> dict[int, Refinancing]:
|
|
962
|
+
"""Index the takeouts by period, checking what can be checked in advance.
|
|
963
|
+
|
|
964
|
+
Whether there is anything left outstanding to retire cannot be checked here
|
|
965
|
+
— it depends on how fast the sweep has run — so that one waits for the
|
|
966
|
+
schedule. Everything else fails before twenty periods of arithmetic rather
|
|
967
|
+
than in the middle of them.
|
|
968
|
+
"""
|
|
969
|
+
known = {t.name for t in structure.tranches}
|
|
970
|
+
scheduled: dict[int, Refinancing] = {}
|
|
971
|
+
for event in events:
|
|
972
|
+
if event.period > span:
|
|
973
|
+
raise RefinancingError(
|
|
974
|
+
f"{event.label}: period {event.period} is beyond the {span} periods "
|
|
975
|
+
f"the schedule covers"
|
|
976
|
+
)
|
|
977
|
+
if event.period in scheduled:
|
|
978
|
+
raise RefinancingError(
|
|
979
|
+
f"two refinancings land at the end of period {event.period}; "
|
|
980
|
+
f"combine them into one"
|
|
981
|
+
)
|
|
982
|
+
if event.period in taken:
|
|
983
|
+
raise RefinancingError(
|
|
984
|
+
f"{event.label}: another event already lands at the end of period "
|
|
985
|
+
f"{event.period}; two things happening at the same instant cannot "
|
|
986
|
+
f"say which came first, and the balance they each act on differs "
|
|
987
|
+
f"by which"
|
|
988
|
+
)
|
|
989
|
+
for name in (event.tranche, *(d.tranche for d in event.into)):
|
|
990
|
+
if name not in known:
|
|
991
|
+
raise RefinancingError(
|
|
992
|
+
f"{event.label}: there is no tranche named {name!r}; the "
|
|
993
|
+
f"structure holds {', '.join(repr(n) for n in sorted(known))}"
|
|
994
|
+
)
|
|
995
|
+
if structure.tranche(event.tranche).is_revolving:
|
|
996
|
+
raise RefinancingError(
|
|
997
|
+
f"{event.label}: {event.tranche} is a revolver, which the schedule "
|
|
998
|
+
f"already repays and redraws every period; refinancing it says "
|
|
999
|
+
f"nothing the sweep has not said"
|
|
1000
|
+
)
|
|
1001
|
+
scheduled[event.period] = event
|
|
1002
|
+
return scheduled
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
def _refinance(
|
|
1006
|
+
structure: CapitalStructure,
|
|
1007
|
+
row: DebtPeriod,
|
|
1008
|
+
event: Refinancing,
|
|
1009
|
+
*,
|
|
1010
|
+
index: int,
|
|
1011
|
+
span: int,
|
|
1012
|
+
unamortised_fees: Money = ZERO,
|
|
1013
|
+
) -> tuple[DebtPeriod, RefinancingOutcome]:
|
|
1014
|
+
"""Retire a facility at the end of a solved period and draw its replacement.
|
|
1015
|
+
|
|
1016
|
+
The order is repay then draw, which matters when the new paper is the same
|
|
1017
|
+
facility at a new price: a repricing nets to the difference rather than
|
|
1018
|
+
carrying both balances for an instant, and a same-tranche takeout that drew
|
|
1019
|
+
first would report a balance the credit agreement never showed.
|
|
1020
|
+
|
|
1021
|
+
The balance retired is the one left after the period's own amortisation and
|
|
1022
|
+
sweep have run, because that is the balance the notice would be served on.
|
|
1023
|
+
"""
|
|
1024
|
+
balance = row.tranche(event.tranche).closing
|
|
1025
|
+
if balance <= 0:
|
|
1026
|
+
raise RefinancingError(
|
|
1027
|
+
f"{event.label}: {event.tranche} has nothing outstanding at the end of "
|
|
1028
|
+
f"period {event.period}, so there is nothing to refinance"
|
|
1029
|
+
)
|
|
1030
|
+
|
|
1031
|
+
from_cash = event.from_cash(balance)
|
|
1032
|
+
available = row.closing_cash - structure.minimum_cash
|
|
1033
|
+
if from_cash > available:
|
|
1034
|
+
raise RefinancingError(
|
|
1035
|
+
f"{event.label}: retiring {balance} at a premium needs {from_cash} off a "
|
|
1036
|
+
f"balance sheet holding {row.closing_cash} against a minimum of "
|
|
1037
|
+
f"{structure.minimum_cash}, which leaves only {available}; raise more on "
|
|
1038
|
+
f"the new paper or move the takeout later"
|
|
1039
|
+
)
|
|
1040
|
+
|
|
1041
|
+
base = structure.base_at(index)
|
|
1042
|
+
old = structure.tranche(event.tranche)
|
|
1043
|
+
old_rate = old.rate_at(base) + old.pik_rate
|
|
1044
|
+
new_rate = ZERO
|
|
1045
|
+
if event.face > 0:
|
|
1046
|
+
weighted = sum(
|
|
1047
|
+
(
|
|
1048
|
+
d.amount
|
|
1049
|
+
* (
|
|
1050
|
+
structure.tranche(d.tranche).rate_at(base)
|
|
1051
|
+
+ structure.tranche(d.tranche).pik_rate
|
|
1052
|
+
)
|
|
1053
|
+
for d in event.into
|
|
1054
|
+
),
|
|
1055
|
+
ZERO,
|
|
1056
|
+
)
|
|
1057
|
+
new_rate = safe_div(weighted, event.face, default=ZERO)
|
|
1058
|
+
|
|
1059
|
+
tranches = tuple(
|
|
1060
|
+
replace(
|
|
1061
|
+
t,
|
|
1062
|
+
refinancing_repayment=balance if t.name == event.tranche else ZERO,
|
|
1063
|
+
refinancing_draw=event.draw_on(t.name),
|
|
1064
|
+
closing=(
|
|
1065
|
+
t.closing
|
|
1066
|
+
- (balance if t.name == event.tranche else ZERO)
|
|
1067
|
+
+ event.draw_on(t.name)
|
|
1068
|
+
),
|
|
1069
|
+
)
|
|
1070
|
+
for t in row.tranches
|
|
1071
|
+
)
|
|
1072
|
+
cash_after = row.closing_cash - from_cash
|
|
1073
|
+
written_off = event.write_off(unamortised_fees)
|
|
1074
|
+
updated = replace(
|
|
1075
|
+
row,
|
|
1076
|
+
tranches=tranches,
|
|
1077
|
+
closing_cash=cash_after,
|
|
1078
|
+
call_premium=event.premium_on(balance),
|
|
1079
|
+
fees_written_off=written_off,
|
|
1080
|
+
refinancing_from_cash=from_cash,
|
|
1081
|
+
)
|
|
1082
|
+
return updated, RefinancingOutcome(
|
|
1083
|
+
event=event,
|
|
1084
|
+
index=index,
|
|
1085
|
+
repaid=balance,
|
|
1086
|
+
cash_before=row.closing_cash,
|
|
1087
|
+
cash_after=cash_after,
|
|
1088
|
+
old_rate=old_rate,
|
|
1089
|
+
new_rate=new_rate,
|
|
1090
|
+
periods_remaining=span - event.period,
|
|
1091
|
+
written_off=written_off,
|
|
1092
|
+
)
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
def _acquisitions_by_period(
|
|
1096
|
+
events: Sequence[AddOn],
|
|
1097
|
+
structure: CapitalStructure,
|
|
1098
|
+
span: int,
|
|
1099
|
+
taken: dict[int, Recapitalisation],
|
|
1100
|
+
) -> dict[int, AddOn]:
|
|
1101
|
+
"""Index the acquisitions by period, checking each one before anything runs.
|
|
1102
|
+
|
|
1103
|
+
An acquisition in the final period is refused rather than modelled. It would
|
|
1104
|
+
pay cash for earnings no period ever records, and the exit — priced on the
|
|
1105
|
+
final period's EBITDA — would then value nothing at all: a purchase price
|
|
1106
|
+
out, no earnings in, and a bridge that blames the shortfall on the operating
|
|
1107
|
+
case. Bringing the acquisition forward or extending the projection is what
|
|
1108
|
+
the file actually means.
|
|
1109
|
+
"""
|
|
1110
|
+
known = {t.name for t in structure.tranches}
|
|
1111
|
+
scheduled: dict[int, AddOn] = {}
|
|
1112
|
+
for event in events:
|
|
1113
|
+
if event.period >= span:
|
|
1114
|
+
raise AddOnError(
|
|
1115
|
+
f"{event.label}: closes at the end of period {event.period} of "
|
|
1116
|
+
f"{span}, so nothing it buys is ever earned; move it earlier or "
|
|
1117
|
+
f"lengthen the projection"
|
|
1118
|
+
)
|
|
1119
|
+
if event.period in scheduled:
|
|
1120
|
+
raise AddOnError(
|
|
1121
|
+
f"two acquisitions close at the end of period {event.period}; "
|
|
1122
|
+
f"combine them into one"
|
|
1123
|
+
)
|
|
1124
|
+
if event.period in taken:
|
|
1125
|
+
raise AddOnError(
|
|
1126
|
+
f"{event.label}: a recapitalisation already lands at the end of "
|
|
1127
|
+
f"period {event.period}; a model that pays the shareholders and "
|
|
1128
|
+
f"buys a business at the same instant cannot say which happened "
|
|
1129
|
+
f"first, and the leverage differs by which"
|
|
1130
|
+
)
|
|
1131
|
+
for draw in event.draws:
|
|
1132
|
+
if draw.tranche not in known:
|
|
1133
|
+
raise AddOnError(
|
|
1134
|
+
f"{event.label}: there is no tranche named {draw.tranche!r} to "
|
|
1135
|
+
f"draw on; the structure holds "
|
|
1136
|
+
f"{', '.join(repr(n) for n in sorted(known))}"
|
|
1137
|
+
)
|
|
1138
|
+
if structure.tranche(draw.tranche).matured_at(event.period):
|
|
1139
|
+
raise AddOnError(
|
|
1140
|
+
f"{event.label}: {draw.tranche} has matured by period "
|
|
1141
|
+
f"{event.period}, so there is nothing left to draw on it"
|
|
1142
|
+
)
|
|
1143
|
+
scheduled[event.period] = event
|
|
1144
|
+
|
|
1145
|
+
# Capacity is consumed in date order and never comes back, so the events are
|
|
1146
|
+
# walked in that order rather than checked one at a time. Two add-ons that
|
|
1147
|
+
# each fit inside a facility can still overdraw it between them, and that is
|
|
1148
|
+
# exactly the mistake a committed acquisition line exists to prevent.
|
|
1149
|
+
#
|
|
1150
|
+
# A tranche with no commitment is an uncommitted accordion: there is no
|
|
1151
|
+
# capacity to check against, and drawing on one remains a statement about
|
|
1152
|
+
# what a lender would agree to rather than something the file can verify.
|
|
1153
|
+
remaining = {
|
|
1154
|
+
t.name: t.commitment - t.face for t in structure if t.has_commitment
|
|
1155
|
+
}
|
|
1156
|
+
for period in sorted(scheduled):
|
|
1157
|
+
event = scheduled[period]
|
|
1158
|
+
for draw in event.draws:
|
|
1159
|
+
facility = structure.tranche(draw.tranche)
|
|
1160
|
+
if not facility.has_commitment:
|
|
1161
|
+
continue
|
|
1162
|
+
if not facility.available_at(period):
|
|
1163
|
+
raise AddOnError(
|
|
1164
|
+
f"{event.label}: {draw.tranche} was available to draw until "
|
|
1165
|
+
f"period {facility.availability}, and this closes at the end "
|
|
1166
|
+
f"of period {period}"
|
|
1167
|
+
)
|
|
1168
|
+
left = remaining[draw.tranche]
|
|
1169
|
+
if draw.amount > left:
|
|
1170
|
+
raise AddOnError(
|
|
1171
|
+
f"{event.label}: draws {draw.amount} on {draw.tranche}, which "
|
|
1172
|
+
f"is committed at {facility.commitment} and has {left} of it "
|
|
1173
|
+
f"left; a facility is a limit rather than a description of "
|
|
1174
|
+
f"where the money comes from"
|
|
1175
|
+
)
|
|
1176
|
+
remaining[draw.tranche] = left - draw.amount
|
|
1177
|
+
return scheduled
|
|
1178
|
+
|
|
1179
|
+
|
|
1180
|
+
def _acquire(
|
|
1181
|
+
structure: CapitalStructure,
|
|
1182
|
+
row: DebtPeriod,
|
|
1183
|
+
event: AddOn,
|
|
1184
|
+
*,
|
|
1185
|
+
index: int,
|
|
1186
|
+
earnings: Money | None,
|
|
1187
|
+
) -> tuple[DebtPeriod, AddOnOutcome]:
|
|
1188
|
+
"""Apply an acquisition at the end of a solved period.
|
|
1189
|
+
|
|
1190
|
+
The same placement as a recapitalisation, and for the same reason: the new
|
|
1191
|
+
balance is what the following period opens on, so interest, the sweep and
|
|
1192
|
+
the covenant tests all pick the acquisition up without knowing it happened.
|
|
1193
|
+
|
|
1194
|
+
What differs is the direction of the cash and what it buys. A
|
|
1195
|
+
recapitalisation takes cash out of the business and leaves the earnings
|
|
1196
|
+
alone. An acquisition takes cash out and puts earnings in — but the earnings
|
|
1197
|
+
arrive through the operating case, one layer up, which is why nothing here
|
|
1198
|
+
touches EBITDA. The leverage recorded on either side of the event is
|
|
1199
|
+
therefore measured against the *same* earnings, and reads worse than the
|
|
1200
|
+
deal is: the debt lands a period before the profit it bought.
|
|
1201
|
+
"""
|
|
1202
|
+
from_cash = event.from_cash
|
|
1203
|
+
available = row.closing_cash - structure.minimum_cash
|
|
1204
|
+
if from_cash > available:
|
|
1205
|
+
raise AddOnError(
|
|
1206
|
+
f"{event.label}: needs {from_cash} off a balance sheet holding "
|
|
1207
|
+
f"{row.closing_cash} against a minimum of {structure.minimum_cash}, "
|
|
1208
|
+
f"which leaves only {available}; fund more of it with debt or move it "
|
|
1209
|
+
f"to a period the business can pay for it in"
|
|
1210
|
+
)
|
|
1211
|
+
|
|
1212
|
+
before = _leverage(row.closing_debt, row.closing_cash, earnings)
|
|
1213
|
+
tranches = tuple(
|
|
1214
|
+
replace(
|
|
1215
|
+
t,
|
|
1216
|
+
acquisition=event.draw_on(t.name),
|
|
1217
|
+
closing=t.closing + event.draw_on(t.name),
|
|
1218
|
+
)
|
|
1219
|
+
for t in row.tranches
|
|
1220
|
+
)
|
|
1221
|
+
cash_after = row.closing_cash - from_cash
|
|
1222
|
+
updated = replace(
|
|
1223
|
+
row,
|
|
1224
|
+
tranches=tranches,
|
|
1225
|
+
closing_cash=cash_after,
|
|
1226
|
+
acquisition_spend=event.uses,
|
|
1227
|
+
acquisition_from_cash=from_cash,
|
|
1228
|
+
)
|
|
1229
|
+
return updated, AddOnOutcome(
|
|
1230
|
+
event=event,
|
|
1231
|
+
index=index,
|
|
1232
|
+
cash_before=row.closing_cash,
|
|
1233
|
+
cash_after=cash_after,
|
|
1234
|
+
leverage_before=before,
|
|
1235
|
+
leverage_after=_leverage(updated.closing_debt, cash_after, earnings),
|
|
1236
|
+
)
|
|
1237
|
+
|
|
1238
|
+
|
|
1239
|
+
def _recapitalise(
|
|
1240
|
+
structure: CapitalStructure,
|
|
1241
|
+
row: DebtPeriod,
|
|
1242
|
+
event: Recapitalisation,
|
|
1243
|
+
*,
|
|
1244
|
+
index: int,
|
|
1245
|
+
earnings: Money | None,
|
|
1246
|
+
) -> tuple[DebtPeriod, RecapitalisationOutcome]:
|
|
1247
|
+
"""Apply a recapitalisation at the end of a solved period.
|
|
1248
|
+
|
|
1249
|
+
Placed after the period rather than inside it, which is what makes the event
|
|
1250
|
+
cost a period's less interest than a mid-period raise would. The new balance
|
|
1251
|
+
is what the following period opens on, so everything downstream — interest,
|
|
1252
|
+
the sweep, the covenant tests — picks it up without being told about the
|
|
1253
|
+
event at all.
|
|
1254
|
+
|
|
1255
|
+
Cash taken off the balance sheet is refused rather than clamped when it would
|
|
1256
|
+
leave the structure below the minimum it is required to hold. A model that
|
|
1257
|
+
quietly paid a smaller dividend than the file described would answer a
|
|
1258
|
+
question nobody asked.
|
|
1259
|
+
"""
|
|
1260
|
+
available = row.closing_cash - structure.minimum_cash
|
|
1261
|
+
if event.from_cash > available:
|
|
1262
|
+
raise RecapitalisationError(
|
|
1263
|
+
f"{event.label}: takes {event.from_cash} off a balance sheet holding "
|
|
1264
|
+
f"{row.closing_cash} against a minimum of {structure.minimum_cash}, "
|
|
1265
|
+
f"which leaves only {available} to pay out"
|
|
1266
|
+
)
|
|
1267
|
+
|
|
1268
|
+
before = _leverage(row.closing_debt, row.closing_cash, earnings)
|
|
1269
|
+
tranches = tuple(
|
|
1270
|
+
dataclasses.replace(
|
|
1271
|
+
t,
|
|
1272
|
+
recapitalisation=event.draw_on(t.name),
|
|
1273
|
+
closing=t.closing + event.draw_on(t.name),
|
|
1274
|
+
)
|
|
1275
|
+
for t in row.tranches
|
|
1276
|
+
)
|
|
1277
|
+
cash_after = row.closing_cash - event.from_cash
|
|
1278
|
+
updated = dataclasses.replace(
|
|
1279
|
+
row,
|
|
1280
|
+
tranches=tranches,
|
|
1281
|
+
closing_cash=cash_after,
|
|
1282
|
+
distribution=event.distribution,
|
|
1283
|
+
distribution_from_cash=event.from_cash,
|
|
1284
|
+
)
|
|
1285
|
+
return updated, RecapitalisationOutcome(
|
|
1286
|
+
event=event,
|
|
1287
|
+
index=index,
|
|
1288
|
+
cash_before=row.closing_cash,
|
|
1289
|
+
cash_after=cash_after,
|
|
1290
|
+
leverage_before=before,
|
|
1291
|
+
leverage_after=_leverage(updated.closing_debt, cash_after, earnings),
|
|
1292
|
+
)
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
def _leverage(debt: Money, cash: Money, earnings: Money | None) -> Money | None:
|
|
1296
|
+
if earnings is None or earnings <= 0:
|
|
1297
|
+
return None
|
|
1298
|
+
return (debt - cash) / earnings
|
|
1299
|
+
|
|
1300
|
+
|
|
1301
|
+
@dataclass(frozen=True, slots=True)
|
|
1302
|
+
class DebtSchedule:
|
|
1303
|
+
"""The capital structure rolled forward across a projection."""
|
|
1304
|
+
|
|
1305
|
+
structure: CapitalStructure
|
|
1306
|
+
periods: tuple[DebtPeriod, ...]
|
|
1307
|
+
opening_cash: Money
|
|
1308
|
+
recapitalisations: tuple[RecapitalisationOutcome, ...] = ()
|
|
1309
|
+
acquisitions: tuple[AddOnOutcome, ...] = ()
|
|
1310
|
+
refinancings: tuple[RefinancingOutcome, ...] = ()
|
|
1311
|
+
|
|
1312
|
+
def __len__(self) -> int:
|
|
1313
|
+
return len(self.periods)
|
|
1314
|
+
|
|
1315
|
+
def __iter__(self) -> Iterator[DebtPeriod]:
|
|
1316
|
+
return iter(self.periods)
|
|
1317
|
+
|
|
1318
|
+
def __getitem__(self, index: int) -> DebtPeriod:
|
|
1319
|
+
return self.periods[index]
|
|
1320
|
+
|
|
1321
|
+
@classmethod
|
|
1322
|
+
def run(
|
|
1323
|
+
cls,
|
|
1324
|
+
structure: CapitalStructure,
|
|
1325
|
+
periods: Sequence[Period],
|
|
1326
|
+
unlevered_cash_flows: Sequence[Numeric],
|
|
1327
|
+
*,
|
|
1328
|
+
opening_cash: Numeric = 0,
|
|
1329
|
+
ebitda: Sequence[Numeric] | None = None,
|
|
1330
|
+
opening_ebitda: Numeric | None = None,
|
|
1331
|
+
recapitalisations: Sequence[Recapitalisation] = (),
|
|
1332
|
+
acquisitions: Sequence[AddOn] = (),
|
|
1333
|
+
refinancings: Sequence[Refinancing] = (),
|
|
1334
|
+
write_offs: dict[int, Money] | None = None,
|
|
1335
|
+
) -> DebtSchedule:
|
|
1336
|
+
"""Roll the structure forward, one period at a time.
|
|
1337
|
+
|
|
1338
|
+
Takes bare periods and cash flows rather than an operating model, so the
|
|
1339
|
+
schedule can be exercised against a hand-written cash-flow series. See
|
|
1340
|
+
:meth:`from_operating_model` for the usual entry point.
|
|
1341
|
+
|
|
1342
|
+
``ebitda`` is needed only by a sweep grid, which reads a leverage level
|
|
1343
|
+
to decide what share of the period's excess cash flow is swept.
|
|
1344
|
+
``opening_ebitda`` is the level the first period's step is set against —
|
|
1345
|
+
the LTM figure the deal was priced on. Without it the first projected
|
|
1346
|
+
period stands in, which is the pro forma reading of the same test and is
|
|
1347
|
+
the closest thing available when the schedule is run on its own.
|
|
1348
|
+
|
|
1349
|
+
``write_offs`` supplies the unamortised capitalised cost on the paper a
|
|
1350
|
+
refinancing takes out, keyed by the period the takeout lands in, for
|
|
1351
|
+
events that did not state one. The schedule does not derive it — the
|
|
1352
|
+
balance belongs to the funding table, one layer up, and a schedule that
|
|
1353
|
+
reached for it would have to know how the paper was placed as well as
|
|
1354
|
+
what it costs to carry. See :mod:`capstack.fees`.
|
|
1355
|
+
"""
|
|
1356
|
+
if len(periods) != len(unlevered_cash_flows):
|
|
1357
|
+
raise ValueError(
|
|
1358
|
+
f"{len(periods)} periods against {len(unlevered_cash_flows)} cash flows"
|
|
1359
|
+
)
|
|
1360
|
+
if not periods:
|
|
1361
|
+
raise ValueError("a schedule needs at least one period")
|
|
1362
|
+
grid = structure.sweep_grid
|
|
1363
|
+
if grid is not None and ebitda is None:
|
|
1364
|
+
raise ValueError(
|
|
1365
|
+
"a sweep grid steps with leverage, so the schedule needs the "
|
|
1366
|
+
"EBITDA each step is measured against"
|
|
1367
|
+
)
|
|
1368
|
+
if ebitda is not None and len(ebitda) != len(periods):
|
|
1369
|
+
raise ValueError(
|
|
1370
|
+
f"{len(periods)} periods against {len(ebitda)} EBITDA figures"
|
|
1371
|
+
)
|
|
1372
|
+
|
|
1373
|
+
# Events are described in period numbers, and a grid opening on a stub
|
|
1374
|
+
# has one fewer whole period than it has columns. Validating against the
|
|
1375
|
+
# column count would accept an event numbered past the last period there
|
|
1376
|
+
# is, and it would then simply never fire.
|
|
1377
|
+
span = max(p.index for p in periods)
|
|
1378
|
+
scheduled = _by_period(recapitalisations or (), structure, span)
|
|
1379
|
+
purchases = _acquisitions_by_period(
|
|
1380
|
+
acquisitions or (), structure, span, scheduled
|
|
1381
|
+
)
|
|
1382
|
+
write_offs = dict(write_offs or {})
|
|
1383
|
+
takeouts = _refinancings_by_period(
|
|
1384
|
+
refinancings or (),
|
|
1385
|
+
structure,
|
|
1386
|
+
span,
|
|
1387
|
+
frozenset(scheduled) | frozenset(purchases),
|
|
1388
|
+
)
|
|
1389
|
+
|
|
1390
|
+
balances = {t.name: t.face for t in structure.tranches}
|
|
1391
|
+
basis = AmortisationBasis(structure)
|
|
1392
|
+
cash = money(opening_cash)
|
|
1393
|
+
if cash < 0:
|
|
1394
|
+
raise ValueError("opening cash must not be negative")
|
|
1395
|
+
|
|
1396
|
+
rows: list[DebtPeriod] = []
|
|
1397
|
+
applied_events: list[RecapitalisationOutcome] = []
|
|
1398
|
+
applied_purchases: list[AddOnOutcome] = []
|
|
1399
|
+
applied_takeouts: list[RefinancingOutcome] = []
|
|
1400
|
+
for i, period in enumerate(periods):
|
|
1401
|
+
certified: Money | None = None
|
|
1402
|
+
rate = structure.sweep_rate
|
|
1403
|
+
if grid is not None and ebitda is not None:
|
|
1404
|
+
# The certificate for the period just ended: debt at the start
|
|
1405
|
+
# of this period against the earnings of the one before it. A
|
|
1406
|
+
# stub does not certify — six weeks of earnings is not the
|
|
1407
|
+
# twelve months a compliance certificate is struck on — so the
|
|
1408
|
+
# period after a stub falls back to the level the deal was
|
|
1409
|
+
# priced at, exactly as the first period does.
|
|
1410
|
+
# A date certifies only when a full year stands behind it, so
|
|
1411
|
+
# the first three quarters of a quarterly grid fall back to the
|
|
1412
|
+
# level the deal was priced at exactly as a stub does. Stepping
|
|
1413
|
+
# a sweep on a part-year would pin the structure at its top rate
|
|
1414
|
+
# for the whole of the first year on a leverage level that never
|
|
1415
|
+
# existed.
|
|
1416
|
+
previous = periods[i - 1] if i > 0 else None
|
|
1417
|
+
certifies = (
|
|
1418
|
+
previous is not None
|
|
1419
|
+
and not previous.is_stub
|
|
1420
|
+
and trailing_window(periods, i - 1).complete
|
|
1421
|
+
)
|
|
1422
|
+
earnings = (
|
|
1423
|
+
money(ebitda[i - 1])
|
|
1424
|
+
if certifies
|
|
1425
|
+
else money(opening_ebitda if opening_ebitda is not None else ebitda[0])
|
|
1426
|
+
)
|
|
1427
|
+
debt = sum(balances.values(), ZERO)
|
|
1428
|
+
if grid.net:
|
|
1429
|
+
debt -= cash
|
|
1430
|
+
certified = safe_div(debt, earnings) if earnings > 0 else None
|
|
1431
|
+
if certified is None and debt <= 0:
|
|
1432
|
+
certified = ZERO
|
|
1433
|
+
rate = grid.rate_at(certified)
|
|
1434
|
+
|
|
1435
|
+
row = _solve_period(
|
|
1436
|
+
structure,
|
|
1437
|
+
index=i,
|
|
1438
|
+
period=period,
|
|
1439
|
+
opening=balances,
|
|
1440
|
+
opening_cash=cash,
|
|
1441
|
+
unlevered_free_cash_flow=money(unlevered_cash_flows[i]),
|
|
1442
|
+
sweep_rate=rate,
|
|
1443
|
+
basis=basis.snapshot(),
|
|
1444
|
+
unavailable=basis.retired,
|
|
1445
|
+
amortisation_scale=_amortisation_scale(periods, i, structure.day_count),
|
|
1446
|
+
certified_leverage=certified,
|
|
1447
|
+
)
|
|
1448
|
+
event = scheduled.get(period.index)
|
|
1449
|
+
if event is not None:
|
|
1450
|
+
row, applied = _recapitalise(
|
|
1451
|
+
structure,
|
|
1452
|
+
row,
|
|
1453
|
+
event,
|
|
1454
|
+
index=i,
|
|
1455
|
+
earnings=money(ebitda[i]) if ebitda is not None else None,
|
|
1456
|
+
)
|
|
1457
|
+
applied_events.append(applied)
|
|
1458
|
+
|
|
1459
|
+
purchase = purchases.get(period.index)
|
|
1460
|
+
if purchase is not None:
|
|
1461
|
+
row, bought = _acquire(
|
|
1462
|
+
structure,
|
|
1463
|
+
row,
|
|
1464
|
+
purchase,
|
|
1465
|
+
index=i,
|
|
1466
|
+
earnings=money(ebitda[i]) if ebitda is not None else None,
|
|
1467
|
+
)
|
|
1468
|
+
applied_purchases.append(bought)
|
|
1469
|
+
|
|
1470
|
+
takeout = takeouts.get(period.index)
|
|
1471
|
+
if takeout is not None:
|
|
1472
|
+
row, retired = _refinance(
|
|
1473
|
+
structure,
|
|
1474
|
+
row,
|
|
1475
|
+
takeout,
|
|
1476
|
+
index=i,
|
|
1477
|
+
span=span,
|
|
1478
|
+
unamortised_fees=money(write_offs.get(takeout.period, ZERO)),
|
|
1479
|
+
)
|
|
1480
|
+
applied_takeouts.append(retired)
|
|
1481
|
+
|
|
1482
|
+
rows.append(row)
|
|
1483
|
+
basis.apply(row)
|
|
1484
|
+
balances = {t.name: t.closing for t in row.tranches}
|
|
1485
|
+
cash = row.closing_cash
|
|
1486
|
+
|
|
1487
|
+
return cls(
|
|
1488
|
+
structure=structure,
|
|
1489
|
+
periods=tuple(rows),
|
|
1490
|
+
opening_cash=money(opening_cash),
|
|
1491
|
+
recapitalisations=tuple(applied_events),
|
|
1492
|
+
acquisitions=tuple(applied_purchases),
|
|
1493
|
+
refinancings=tuple(applied_takeouts),
|
|
1494
|
+
)
|
|
1495
|
+
|
|
1496
|
+
@classmethod
|
|
1497
|
+
def from_operating_model(
|
|
1498
|
+
cls,
|
|
1499
|
+
structure: CapitalStructure,
|
|
1500
|
+
model: OperatingModel,
|
|
1501
|
+
*,
|
|
1502
|
+
opening_cash: Numeric = 0,
|
|
1503
|
+
opening_ebitda: Numeric | None = None,
|
|
1504
|
+
recapitalisations: Sequence[Recapitalisation] = (),
|
|
1505
|
+
acquisitions: Sequence[AddOn] = (),
|
|
1506
|
+
refinancings: Sequence[Refinancing] = (),
|
|
1507
|
+
write_offs: dict[int, Money] | None = None,
|
|
1508
|
+
) -> DebtSchedule:
|
|
1509
|
+
"""Run the structure against an operating case."""
|
|
1510
|
+
return cls.run(
|
|
1511
|
+
structure,
|
|
1512
|
+
[p.period for p in model],
|
|
1513
|
+
[p.unlevered_free_cash_flow for p in model],
|
|
1514
|
+
opening_cash=opening_cash,
|
|
1515
|
+
# The trailing year at each date rather than the period, because a
|
|
1516
|
+
# sweep grid steps on leverage and leverage is a year of earnings.
|
|
1517
|
+
ebitda=[model.trailing_ebitda(i) for i in range(len(model))],
|
|
1518
|
+
# The level the deal was priced at, which is what the dates with no
|
|
1519
|
+
# year behind them fall back to. It has to be a year as well: the
|
|
1520
|
+
# first entry of the series is a trailing figure over a window that
|
|
1521
|
+
# is not yet complete, and on a quarterly grid falling back to it
|
|
1522
|
+
# would certify the opening structure at four times its leverage.
|
|
1523
|
+
opening_ebitda=(
|
|
1524
|
+
opening_ebitda if opening_ebitda is not None else model.entry_ebitda
|
|
1525
|
+
),
|
|
1526
|
+
recapitalisations=recapitalisations,
|
|
1527
|
+
acquisitions=acquisitions,
|
|
1528
|
+
refinancings=refinancings,
|
|
1529
|
+
write_offs=write_offs,
|
|
1530
|
+
)
|
|
1531
|
+
|
|
1532
|
+
# -- Aggregates ------------------------------------------------------
|
|
1533
|
+
|
|
1534
|
+
@property
|
|
1535
|
+
def total_cash_interest(self) -> Money:
|
|
1536
|
+
return sum((p.cash_interest for p in self.periods), ZERO)
|
|
1537
|
+
|
|
1538
|
+
@property
|
|
1539
|
+
def total_pik_interest(self) -> Money:
|
|
1540
|
+
"""Interest that never left the account and is owed at exit instead."""
|
|
1541
|
+
return sum((p.pik_interest for p in self.periods), ZERO)
|
|
1542
|
+
|
|
1543
|
+
@property
|
|
1544
|
+
def total_undrawn_fees(self) -> Money:
|
|
1545
|
+
return sum((p.undrawn_fees for p in self.periods), ZERO)
|
|
1546
|
+
|
|
1547
|
+
@property
|
|
1548
|
+
def total_repaid(self) -> Money:
|
|
1549
|
+
return sum((p.total_repayment for p in self.periods), ZERO)
|
|
1550
|
+
|
|
1551
|
+
@property
|
|
1552
|
+
def total_drawn(self) -> Money:
|
|
1553
|
+
return sum((p.revolver_draw for p in self.periods), ZERO)
|
|
1554
|
+
|
|
1555
|
+
@property
|
|
1556
|
+
def total_recapitalised(self) -> Money:
|
|
1557
|
+
"""Incremental face raised across the hold, outside the revolver."""
|
|
1558
|
+
return sum((p.recapitalisation for p in self.periods), ZERO)
|
|
1559
|
+
|
|
1560
|
+
@property
|
|
1561
|
+
def total_acquisition_debt(self) -> Money:
|
|
1562
|
+
"""Incremental face raised to buy things, outside the revolver."""
|
|
1563
|
+
return sum((p.acquisition_debt for p in self.periods), ZERO)
|
|
1564
|
+
|
|
1565
|
+
@property
|
|
1566
|
+
def total_acquisition_spend(self) -> Money:
|
|
1567
|
+
"""Everything the acquisitions cost, however it was funded."""
|
|
1568
|
+
return sum((p.acquisition_spend for p in self.periods), ZERO)
|
|
1569
|
+
|
|
1570
|
+
@property
|
|
1571
|
+
def total_acquisition_from_cash(self) -> Money:
|
|
1572
|
+
"""The share of that spend the business paid for out of its own cash."""
|
|
1573
|
+
return sum((p.acquisition_from_cash for p in self.periods), ZERO)
|
|
1574
|
+
|
|
1575
|
+
@property
|
|
1576
|
+
def total_refinanced(self) -> Money:
|
|
1577
|
+
"""Face retired early across the hold."""
|
|
1578
|
+
return sum((p.refinanced for p in self.periods), ZERO)
|
|
1579
|
+
|
|
1580
|
+
@property
|
|
1581
|
+
def total_call_premiums(self) -> Money:
|
|
1582
|
+
return sum((p.call_premium for p in self.periods), ZERO)
|
|
1583
|
+
|
|
1584
|
+
@property
|
|
1585
|
+
def total_fees_written_off(self) -> Money:
|
|
1586
|
+
"""Non-cash, and reported apart from everything that is not."""
|
|
1587
|
+
return sum((p.fees_written_off for p in self.periods), ZERO)
|
|
1588
|
+
|
|
1589
|
+
@property
|
|
1590
|
+
def total_distributed(self) -> Money:
|
|
1591
|
+
"""Cash paid out to the equity during the hold."""
|
|
1592
|
+
return sum((p.distribution for p in self.periods), ZERO)
|
|
1593
|
+
|
|
1594
|
+
def distributions(self) -> tuple[tuple[Period, Money], ...]:
|
|
1595
|
+
"""Every interim distribution, with the date it was paid.
|
|
1596
|
+
|
|
1597
|
+
The dates are what make this useful rather than the amounts: an interim
|
|
1598
|
+
distribution is worth what it is worth because of when it arrives, and a
|
|
1599
|
+
return measured without the date is a return that cannot see the point
|
|
1600
|
+
of the exercise.
|
|
1601
|
+
"""
|
|
1602
|
+
return tuple(
|
|
1603
|
+
(p.period, p.distribution) for p in self.periods if p.distribution > 0
|
|
1604
|
+
)
|
|
1605
|
+
|
|
1606
|
+
@property
|
|
1607
|
+
def opening_debt(self) -> Money:
|
|
1608
|
+
return self.periods[0].opening_debt
|
|
1609
|
+
|
|
1610
|
+
@property
|
|
1611
|
+
def closing_debt(self) -> Money:
|
|
1612
|
+
return self.periods[-1].closing_debt
|
|
1613
|
+
|
|
1614
|
+
@property
|
|
1615
|
+
def closing_cash(self) -> Money:
|
|
1616
|
+
return self.periods[-1].closing_cash
|
|
1617
|
+
|
|
1618
|
+
@property
|
|
1619
|
+
def closing_net_debt(self) -> Money:
|
|
1620
|
+
return self.closing_debt - self.closing_cash
|
|
1621
|
+
|
|
1622
|
+
@property
|
|
1623
|
+
def debt_repaid(self) -> Money:
|
|
1624
|
+
"""Gross reduction in the face outstanding across the hold.
|
|
1625
|
+
|
|
1626
|
+
Negative if the structure ended larger than it started, which happens
|
|
1627
|
+
when accretion outruns repayment — the case a PIK instrument exists to
|
|
1628
|
+
create and the one that surprises people at exit.
|
|
1629
|
+
"""
|
|
1630
|
+
return self.opening_debt - self.closing_debt
|
|
1631
|
+
|
|
1632
|
+
@property
|
|
1633
|
+
def peak_revolver_drawn(self) -> Money:
|
|
1634
|
+
"""The largest revolver balance at any period end.
|
|
1635
|
+
|
|
1636
|
+
The number a credit committee asks for, because a facility that is fully
|
|
1637
|
+
drawn at the low point of the year has no capacity left for the
|
|
1638
|
+
emergency it exists to cover.
|
|
1639
|
+
"""
|
|
1640
|
+
drawn = [
|
|
1641
|
+
row.closing
|
|
1642
|
+
for p in self.periods
|
|
1643
|
+
for row in p.tranches
|
|
1644
|
+
if row.kind is TrancheKind.REVOLVER
|
|
1645
|
+
]
|
|
1646
|
+
return max(drawn, default=ZERO)
|
|
1647
|
+
|
|
1648
|
+
@property
|
|
1649
|
+
def is_funded(self) -> bool:
|
|
1650
|
+
"""Whether every period paid for itself."""
|
|
1651
|
+
return all(p.is_funded for p in self.periods)
|
|
1652
|
+
|
|
1653
|
+
@property
|
|
1654
|
+
def first_shortfall(self) -> DebtPeriod | None:
|
|
1655
|
+
"""The first period the structure could not fund, if any."""
|
|
1656
|
+
for p in self.periods:
|
|
1657
|
+
if not p.is_funded:
|
|
1658
|
+
return p
|
|
1659
|
+
return None
|
|
1660
|
+
|
|
1661
|
+
@property
|
|
1662
|
+
def total_shortfall(self) -> Money:
|
|
1663
|
+
"""New money the structure needed and could not raise, across the hold."""
|
|
1664
|
+
return sum((p.funding_shortfall for p in self.periods), ZERO)
|
|
1665
|
+
|
|
1666
|
+
@property
|
|
1667
|
+
def holds_minimum_cash(self) -> bool:
|
|
1668
|
+
"""Whether every period closed on at least the minimum cash balance."""
|
|
1669
|
+
return all(p.meets_minimum_cash for p in self.periods)
|
|
1670
|
+
|
|
1671
|
+
@property
|
|
1672
|
+
def max_iterations_used(self) -> int:
|
|
1673
|
+
return max((p.iterations for p in self.periods), default=0)
|
|
1674
|
+
|
|
1675
|
+
@property
|
|
1676
|
+
def max_residual(self) -> Money:
|
|
1677
|
+
return max((p.residual for p in self.periods), default=ZERO)
|
|
1678
|
+
|
|
1679
|
+
@property
|
|
1680
|
+
def shortest_step_taken(self) -> Money:
|
|
1681
|
+
"""The smallest damping any period needed.
|
|
1682
|
+
|
|
1683
|
+
Anything below one says a period met a clamp boundary and the solver had
|
|
1684
|
+
to shorten its stride to get past it.
|
|
1685
|
+
"""
|
|
1686
|
+
return min((p.damping_used for p in self.periods), default=ONE)
|
|
1687
|
+
|
|
1688
|
+
def balances_at(self, index: int) -> dict[str, Money]:
|
|
1689
|
+
"""Closing balance per tranche at period ``index``, zero-based."""
|
|
1690
|
+
return {row.name: row.closing for row in self.periods[index].tranches}
|
|
1691
|
+
|
|
1692
|
+
def leverage_at(self, index: int, ebitda: Numeric) -> Money:
|
|
1693
|
+
"""Closing debt as a multiple of the EBITDA supplied for that period."""
|
|
1694
|
+
return safe_div(self.periods[index].closing_debt, money(ebitda), default=ZERO)
|
|
1695
|
+
|
|
1696
|
+
def net_leverage_at(self, index: int, ebitda: Numeric) -> Money:
|
|
1697
|
+
return safe_div(self.periods[index].net_debt, money(ebitda), default=ZERO)
|
|
1698
|
+
|
|
1699
|
+
|
|
1700
|
+
def _amortisation_scale(
|
|
1701
|
+
periods: Sequence[Period], index: int, day_count: DayCount
|
|
1702
|
+
) -> Money:
|
|
1703
|
+
"""How much of a year's instalment a period of this length owes.
|
|
1704
|
+
|
|
1705
|
+
A credit agreement writes amortisation per year — 1% a year on a term loan —
|
|
1706
|
+
so a quarter owes a quarter of it. Charging the whole annual instalment in
|
|
1707
|
+
every period would repay 1% four times a year on a quarterly grid, and the
|
|
1708
|
+
schedule would retire the paper four times as fast as it is written to
|
|
1709
|
+
retire, on the side of the model where interest is already accrued on days.
|
|
1710
|
+
|
|
1711
|
+
A stub owes the fraction of a whole period it covers, and through it the
|
|
1712
|
+
same share of the year. Measuring the first part against the first whole
|
|
1713
|
+
period on the grid rather than against a nominal year is what lets a stub
|
|
1714
|
+
sit in front of a quarterly grid without a second special case.
|
|
1715
|
+
|
|
1716
|
+
An annual grid is unchanged: one whole period a year owes the whole of it.
|
|
1717
|
+
"""
|
|
1718
|
+
period = periods[index]
|
|
1719
|
+
share = ONE / Money(period.periods_per_year)
|
|
1720
|
+
if not period.is_stub:
|
|
1721
|
+
return share
|
|
1722
|
+
whole = next((p for p in periods if not p.is_stub), None)
|
|
1723
|
+
if whole is None:
|
|
1724
|
+
return share
|
|
1725
|
+
return share * safe_div(
|
|
1726
|
+
period.year_fraction(day_count), whole.year_fraction(day_count), default=ONE
|
|
1727
|
+
)
|
|
1728
|
+
|
|
1729
|
+
|
|
1730
|
+
def _one_pass(
|
|
1731
|
+
structure: CapitalStructure,
|
|
1732
|
+
*,
|
|
1733
|
+
index: int,
|
|
1734
|
+
period: Period,
|
|
1735
|
+
opening: dict[str, Money],
|
|
1736
|
+
opening_cash: Money,
|
|
1737
|
+
unlevered_free_cash_flow: Money,
|
|
1738
|
+
guess: dict[str, Money],
|
|
1739
|
+
sweep_rate: Money,
|
|
1740
|
+
basis: dict[str, Money] | None = None,
|
|
1741
|
+
unavailable: frozenset[str] = frozenset(),
|
|
1742
|
+
amortisation_scale: Money = ONE,
|
|
1743
|
+
certified_leverage: Money | None = None,
|
|
1744
|
+
) -> DebtPeriod:
|
|
1745
|
+
"""Run a period once, taking ``guess`` as the closing balances interest accrues on.
|
|
1746
|
+
|
|
1747
|
+
Under :attr:`InterestBasis.OPENING` the guess is ignored and the pass is the
|
|
1748
|
+
answer. Under ``AVERAGE`` it is one step of the iteration that finds the
|
|
1749
|
+
balances consistent with the interest they generate.
|
|
1750
|
+
|
|
1751
|
+
``amortisation_scale`` shortens the contractual instalment for a period
|
|
1752
|
+
shorter than a whole one. Interest needs no such help — it accrues on a day
|
|
1753
|
+
count and prorates itself — but an instalment written as 1% a year is not 1%
|
|
1754
|
+
in a forty-six-day stub, and nothing in the amortisation driver knows how
|
|
1755
|
+
long the period it is being read for actually is.
|
|
1756
|
+
"""
|
|
1757
|
+
year_fraction = period.year_fraction(structure.day_count)
|
|
1758
|
+
driver_index = period.driver_index
|
|
1759
|
+
base = structure.base_at(driver_index)
|
|
1760
|
+
half = money(2)
|
|
1761
|
+
|
|
1762
|
+
accrual_base: dict[str, Money] = {}
|
|
1763
|
+
cash_interest: dict[str, Money] = {}
|
|
1764
|
+
pik_interest: dict[str, Money] = {}
|
|
1765
|
+
undrawn_fee: dict[str, Money] = {}
|
|
1766
|
+
mandatory: dict[str, Money] = {}
|
|
1767
|
+
|
|
1768
|
+
for tranche in structure:
|
|
1769
|
+
start = opening[tranche.name]
|
|
1770
|
+
matured = tranche.matured_at(period.index)
|
|
1771
|
+
if structure.interest_basis is InterestBasis.AVERAGE and not matured:
|
|
1772
|
+
accrual = (start + guess[tranche.name]) / half
|
|
1773
|
+
else:
|
|
1774
|
+
# A maturing balance is repaid at the end of the period rather than
|
|
1775
|
+
# across it, so it owes a full period of interest. Averaging it with
|
|
1776
|
+
# a closing balance of zero halves the interest in the period that
|
|
1777
|
+
# carries the single largest repayment in the model.
|
|
1778
|
+
accrual = start
|
|
1779
|
+
# A balance driven negative by a bad guess would generate negative
|
|
1780
|
+
# interest, which is not a thing; the fixed point is approached from
|
|
1781
|
+
# above, so clamping here only affects intermediate iterates.
|
|
1782
|
+
accrual = max(accrual, ZERO)
|
|
1783
|
+
accrual_base[tranche.name] = accrual
|
|
1784
|
+
|
|
1785
|
+
cash_interest[tranche.name] = tranche.rate_at(base) * year_fraction * accrual
|
|
1786
|
+
pik_interest[tranche.name] = tranche.pik_rate * year_fraction * accrual
|
|
1787
|
+
# A revolver's spare capacity is measured against the balance
|
|
1788
|
+
# outstanding, because repaying it restores the capacity. A term
|
|
1789
|
+
# commitment is measured against the face taken down since close, which
|
|
1790
|
+
# is what the amortisation basis has been carrying all along — repaying
|
|
1791
|
+
# a delayed-draw loan does not entitle anyone to draw it again, and
|
|
1792
|
+
# charging its ticking fee on a falling balance would bill the borrower
|
|
1793
|
+
# more for the facility the more of it they repaid.
|
|
1794
|
+
if tranche.is_revolving:
|
|
1795
|
+
taken = accrual
|
|
1796
|
+
elif basis is not None:
|
|
1797
|
+
taken = basis[tranche.name]
|
|
1798
|
+
else:
|
|
1799
|
+
taken = tranche.face
|
|
1800
|
+
if tranche.name in unavailable:
|
|
1801
|
+
undrawn = ZERO
|
|
1802
|
+
else:
|
|
1803
|
+
undrawn = max(tranche.undrawn_at(taken, period=period.index), ZERO)
|
|
1804
|
+
undrawn_fee[tranche.name] = tranche.undrawn_fee * year_fraction * undrawn
|
|
1805
|
+
|
|
1806
|
+
owed = start + pik_interest[tranche.name]
|
|
1807
|
+
if matured:
|
|
1808
|
+
due = owed
|
|
1809
|
+
else:
|
|
1810
|
+
face = tranche.face if basis is None else basis[tranche.name]
|
|
1811
|
+
instalment = tranche.scheduled_amortisation(driver_index, face)
|
|
1812
|
+
due = min(instalment * amortisation_scale, owed)
|
|
1813
|
+
mandatory[tranche.name] = max(due, ZERO)
|
|
1814
|
+
|
|
1815
|
+
cash_cost = sum(cash_interest.values(), ZERO) + sum(undrawn_fee.values(), ZERO)
|
|
1816
|
+
after_service = opening_cash + unlevered_free_cash_flow - cash_cost
|
|
1817
|
+
after_mandatory = after_service - sum(mandatory.values(), ZERO)
|
|
1818
|
+
|
|
1819
|
+
draw: dict[str, Money] = {t.name: ZERO for t in structure}
|
|
1820
|
+
need = structure.minimum_cash - after_mandatory
|
|
1821
|
+
if need > 0:
|
|
1822
|
+
revolvers = [t for t in structure if t.is_revolving]
|
|
1823
|
+
capacity = [
|
|
1824
|
+
ZERO
|
|
1825
|
+
if t.matured_at(period.index)
|
|
1826
|
+
else max(
|
|
1827
|
+
t.commitment - (opening[t.name] + pik_interest[t.name] - mandatory[t.name]),
|
|
1828
|
+
ZERO,
|
|
1829
|
+
)
|
|
1830
|
+
for t in revolvers
|
|
1831
|
+
]
|
|
1832
|
+
drawn = allocate_pro_rata(need, capacity)
|
|
1833
|
+
for tranche, amount in zip(revolvers, drawn, strict=True):
|
|
1834
|
+
draw[tranche.name] = amount
|
|
1835
|
+
after_mandatory += sum(drawn, ZERO)
|
|
1836
|
+
|
|
1837
|
+
# Two different failures, which the model keeps apart because a lender does.
|
|
1838
|
+
# Ending below the minimum cash balance breaches a policy; ending below zero
|
|
1839
|
+
# means the period's bills went unpaid. Only the second is a funding gap,
|
|
1840
|
+
# and it is plugged notionally so the periods after it remain readable —
|
|
1841
|
+
# a schedule that carries a negative cash balance forward compounds a
|
|
1842
|
+
# deficit it has already reported and understates every later sweep.
|
|
1843
|
+
shortfall = max(-after_mandatory, ZERO)
|
|
1844
|
+
after_mandatory += shortfall
|
|
1845
|
+
below_minimum = max(structure.minimum_cash - after_mandatory, ZERO)
|
|
1846
|
+
|
|
1847
|
+
sweep: dict[str, Money] = {t.name: ZERO for t in structure}
|
|
1848
|
+
# The sweep is a share of *this period's* excess cash flow, not of the whole
|
|
1849
|
+
# cash balance. Cash retained by a partial sweep in an earlier period is no
|
|
1850
|
+
# longer excess cash flow and a credit agreement does not reach it again;
|
|
1851
|
+
# sweeping the balance instead would take the retained half back at the next
|
|
1852
|
+
# test date, and the half after that, until a 50% sweep had quietly become
|
|
1853
|
+
# a 100% one. What is available to pay with is still capped by the cash on
|
|
1854
|
+
# hand above the minimum, because a sweep cannot spend money that is not there.
|
|
1855
|
+
excess = max(
|
|
1856
|
+
unlevered_free_cash_flow - cash_cost - sum(mandatory.values(), ZERO), ZERO
|
|
1857
|
+
)
|
|
1858
|
+
available = max(after_mandatory - structure.minimum_cash, ZERO)
|
|
1859
|
+
pot = min(excess * sweep_rate, available)
|
|
1860
|
+
for _, group in structure.sweep_order:
|
|
1861
|
+
if pot <= 0:
|
|
1862
|
+
break
|
|
1863
|
+
balances = [
|
|
1864
|
+
max(
|
|
1865
|
+
opening[t.name]
|
|
1866
|
+
+ pik_interest[t.name]
|
|
1867
|
+
+ draw[t.name]
|
|
1868
|
+
- mandatory[t.name],
|
|
1869
|
+
ZERO,
|
|
1870
|
+
)
|
|
1871
|
+
for t in group
|
|
1872
|
+
]
|
|
1873
|
+
for tranche, amount in zip(group, allocate_pro_rata(pot, balances), strict=True):
|
|
1874
|
+
sweep[tranche.name] = amount
|
|
1875
|
+
pot -= amount
|
|
1876
|
+
|
|
1877
|
+
rows = tuple(
|
|
1878
|
+
TranchePeriod(
|
|
1879
|
+
name=t.name,
|
|
1880
|
+
kind=t.kind,
|
|
1881
|
+
rate=t.rate_at(base),
|
|
1882
|
+
opening=opening[t.name],
|
|
1883
|
+
draw=draw[t.name],
|
|
1884
|
+
cash_interest=cash_interest[t.name],
|
|
1885
|
+
pik_interest=pik_interest[t.name],
|
|
1886
|
+
undrawn_fee=undrawn_fee[t.name],
|
|
1887
|
+
mandatory_repayment=mandatory[t.name],
|
|
1888
|
+
sweep_repayment=sweep[t.name],
|
|
1889
|
+
closing=(
|
|
1890
|
+
opening[t.name]
|
|
1891
|
+
+ draw[t.name]
|
|
1892
|
+
+ pik_interest[t.name]
|
|
1893
|
+
- mandatory[t.name]
|
|
1894
|
+
- sweep[t.name]
|
|
1895
|
+
),
|
|
1896
|
+
amortisation_basis=t.face if basis is None else basis[t.name],
|
|
1897
|
+
)
|
|
1898
|
+
for t in structure
|
|
1899
|
+
)
|
|
1900
|
+
|
|
1901
|
+
return DebtPeriod(
|
|
1902
|
+
period=period,
|
|
1903
|
+
base_rate=base,
|
|
1904
|
+
tranches=rows,
|
|
1905
|
+
opening_cash=opening_cash,
|
|
1906
|
+
unlevered_free_cash_flow=unlevered_free_cash_flow,
|
|
1907
|
+
revolver_draw=sum(draw.values(), ZERO),
|
|
1908
|
+
funding_shortfall=shortfall,
|
|
1909
|
+
cash_below_minimum=below_minimum,
|
|
1910
|
+
closing_cash=after_mandatory - sum(sweep.values(), ZERO),
|
|
1911
|
+
iterations=1,
|
|
1912
|
+
residual=ZERO,
|
|
1913
|
+
sweep_rate=sweep_rate,
|
|
1914
|
+
certified_leverage=certified_leverage,
|
|
1915
|
+
)
|
|
1916
|
+
|
|
1917
|
+
|
|
1918
|
+
def _solve_period(
|
|
1919
|
+
structure: CapitalStructure,
|
|
1920
|
+
*,
|
|
1921
|
+
index: int,
|
|
1922
|
+
period: Period,
|
|
1923
|
+
opening: dict[str, Money],
|
|
1924
|
+
opening_cash: Money,
|
|
1925
|
+
unlevered_free_cash_flow: Money,
|
|
1926
|
+
sweep_rate: Money,
|
|
1927
|
+
basis: dict[str, Money] | None = None,
|
|
1928
|
+
unavailable: frozenset[str] = frozenset(),
|
|
1929
|
+
amortisation_scale: Money = ONE,
|
|
1930
|
+
certified_leverage: Money | None = None,
|
|
1931
|
+
) -> DebtPeriod:
|
|
1932
|
+
"""Find the closing balances consistent with the interest they generate.
|
|
1933
|
+
|
|
1934
|
+
The map is a strong contraction at any realistic coupon: one turn of the
|
|
1935
|
+
loop moves the balance by the interest on half the change, so the residual
|
|
1936
|
+
falls by a factor of roughly ``rate x year_fraction / 2`` each time — a few
|
|
1937
|
+
hundred basis points — and a full step converges in about ten iterations.
|
|
1938
|
+
Across three thousand randomised structures the step was never shortened and
|
|
1939
|
+
the worst case took twelve.
|
|
1940
|
+
|
|
1941
|
+
The halving is insurance rather than the mechanism. It exists because the
|
|
1942
|
+
problem is not smooth — repayments are capped at balances, sweeps at
|
|
1943
|
+
available cash, and a revolver draw switches on at a threshold — and a full
|
|
1944
|
+
step can in principle alternate between two sides of one of those
|
|
1945
|
+
boundaries. It also does nothing for the one regime that genuinely fails:
|
|
1946
|
+
a PIK rate near the pole at ``pik_rate x year_fraction = 2``, where the fixed
|
|
1947
|
+
point either does not exist or is approached too slowly to reach. That is
|
|
1948
|
+
reported rather than approximated, which is the right answer for a structure
|
|
1949
|
+
accreting faster than any cash flow could service.
|
|
1950
|
+
"""
|
|
1951
|
+
if structure.interest_basis is InterestBasis.OPENING:
|
|
1952
|
+
return _one_pass(
|
|
1953
|
+
structure,
|
|
1954
|
+
index=index,
|
|
1955
|
+
period=period,
|
|
1956
|
+
opening=opening,
|
|
1957
|
+
opening_cash=opening_cash,
|
|
1958
|
+
unlevered_free_cash_flow=unlevered_free_cash_flow,
|
|
1959
|
+
guess=opening,
|
|
1960
|
+
sweep_rate=sweep_rate,
|
|
1961
|
+
basis=basis,
|
|
1962
|
+
unavailable=unavailable,
|
|
1963
|
+
amortisation_scale=amortisation_scale,
|
|
1964
|
+
certified_leverage=certified_leverage,
|
|
1965
|
+
)
|
|
1966
|
+
|
|
1967
|
+
guess = dict(opening)
|
|
1968
|
+
damping = structure.damping
|
|
1969
|
+
previous: Money | None = None
|
|
1970
|
+
for iteration in range(1, structure.max_iterations + 1):
|
|
1971
|
+
result = _one_pass(
|
|
1972
|
+
structure,
|
|
1973
|
+
index=index,
|
|
1974
|
+
period=period,
|
|
1975
|
+
opening=opening,
|
|
1976
|
+
opening_cash=opening_cash,
|
|
1977
|
+
unlevered_free_cash_flow=unlevered_free_cash_flow,
|
|
1978
|
+
guess=guess,
|
|
1979
|
+
sweep_rate=sweep_rate,
|
|
1980
|
+
basis=basis,
|
|
1981
|
+
unavailable=unavailable,
|
|
1982
|
+
amortisation_scale=amortisation_scale,
|
|
1983
|
+
certified_leverage=certified_leverage,
|
|
1984
|
+
)
|
|
1985
|
+
produced = {row.name: row.closing for row in result.tranches}
|
|
1986
|
+
residual = max((abs(produced[k] - guess[k]) for k in guess), default=ZERO)
|
|
1987
|
+
if residual <= structure.tolerance:
|
|
1988
|
+
return replace(
|
|
1989
|
+
result, iterations=iteration, residual=residual, damping_used=damping
|
|
1990
|
+
)
|
|
1991
|
+
if previous is not None and residual >= previous and damping > MINIMUM_DAMPING:
|
|
1992
|
+
# The step did not help. Either the iteration is oscillating between
|
|
1993
|
+
# two clamp regimes or it overshot; both are answered by a shorter
|
|
1994
|
+
# step, and halving is enough to break either.
|
|
1995
|
+
damping = damping / money(2)
|
|
1996
|
+
previous = residual
|
|
1997
|
+
guess = {k: guess[k] + damping * (produced[k] - guess[k]) for k in guess}
|
|
1998
|
+
|
|
1999
|
+
raise CircularityError(
|
|
2000
|
+
f"period {period.index}: interest and balances did not settle within "
|
|
2001
|
+
f"{structure.max_iterations} iterations; the structure may be accreting "
|
|
2002
|
+
f"faster than the cash flow can service it"
|
|
2003
|
+
)
|