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/covenants.py
ADDED
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
"""Maintenance covenants, and the headroom left under them.
|
|
2
|
+
|
|
3
|
+
The debt schedule answers whether a structure *can* be serviced. A credit
|
|
4
|
+
agreement asks a narrower and more consequential question: whether the business
|
|
5
|
+
is still permitted to carry it. Tripping a maintenance test does not stop the
|
|
6
|
+
interest being paid — it hands the lenders a right to accelerate, and that is
|
|
7
|
+
what turns a case that merely disappoints into one that is renegotiated.
|
|
8
|
+
|
|
9
|
+
Three decisions shape this layer.
|
|
10
|
+
|
|
11
|
+
*A threshold is a series.* Covenants step down: a leverage test set at 7.00x at
|
|
12
|
+
close will be 5.50x by the fourth year, tightening as the model promises
|
|
13
|
+
deleveraging. A single number cannot express that, and a model that tests every
|
|
14
|
+
year against the closing level reports headroom that does not exist in the early
|
|
15
|
+
years and a breach that does not exist in the later ones. Tests also start
|
|
16
|
+
late — commonly at the end of the fourth full quarter — so a holiday is part of
|
|
17
|
+
the description rather than something the reader is expected to remember.
|
|
18
|
+
|
|
19
|
+
*Headroom is measured in EBITDA, not in turns.* The distance between 5.20x and a
|
|
20
|
+
6.00x threshold is 0.80x, which is arithmetic rather than information. What gets
|
|
21
|
+
asked in a credit committee is how far EBITDA can fall before the test trips,
|
|
22
|
+
and that is a different number for every test on the same page: a leverage test
|
|
23
|
+
scales with EBITDA directly while a coverage test does not. Every observation
|
|
24
|
+
here carries the EBITDA at which it would breach and the shortfall from the
|
|
25
|
+
projection as a percentage, so the tightest test in a structure is visible
|
|
26
|
+
rather than inferred.
|
|
27
|
+
|
|
28
|
+
*An undefined ratio is not a pass.* A period with no EBITDA has no leverage
|
|
29
|
+
ratio, and reporting one is worse than reporting none. The distinction that
|
|
30
|
+
matters is whether the ratio is undefined because nothing is at risk — no debt
|
|
31
|
+
to be levered, no interest to be covered — or because the denominator collapsed,
|
|
32
|
+
which is the case a covenant exists to catch. The first passes, the second
|
|
33
|
+
breaches, and neither reports a number.
|
|
34
|
+
|
|
35
|
+
*Every ratio is measured over twelve months.* A covenant compares a stock
|
|
36
|
+
against a flow, and a flow only means something over a stated interval. That
|
|
37
|
+
interval is a year in every credit agreement, not a reporting period — so on a
|
|
38
|
+
quarterly grid the debt is the balance on the test date and the earnings are the
|
|
39
|
+
four quarters behind it. Dividing a whole debt balance by one quarter's earnings
|
|
40
|
+
reports a structure at 5.2x as 20.8x and breaches every test in the file from the
|
|
41
|
+
first certification. On an annual grid the trailing year *is* the period, and
|
|
42
|
+
nothing about an existing model changes.
|
|
43
|
+
|
|
44
|
+
A business that has not yet traded twelve months cannot certify. Nine months of
|
|
45
|
+
earnings is not a conservative year, it is a different measure, and a ratio built
|
|
46
|
+
on one reads a third too high. Those periods are reported untested with the
|
|
47
|
+
reason on the row — which is what already happened to a stub, for the same
|
|
48
|
+
reason.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
from __future__ import annotations
|
|
52
|
+
|
|
53
|
+
from collections.abc import Iterator, Sequence
|
|
54
|
+
from dataclasses import dataclass
|
|
55
|
+
from enum import Enum
|
|
56
|
+
|
|
57
|
+
from .debt import DebtPeriod, DebtSchedule
|
|
58
|
+
from .drivers import Driver
|
|
59
|
+
from .money import ZERO, Money, Numeric, money
|
|
60
|
+
from .operating import OperatingModel
|
|
61
|
+
from .periods import Period, TrailingWindow, trailing_window
|
|
62
|
+
|
|
63
|
+
__all__ = [
|
|
64
|
+
"Certification",
|
|
65
|
+
"Covenant",
|
|
66
|
+
"CovenantObservation",
|
|
67
|
+
"CovenantReport",
|
|
68
|
+
"Direction",
|
|
69
|
+
"Measure",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
#: The interval a maintenance covenant is measured over, in months.
|
|
73
|
+
TEST_INTERVAL_MONTHS = 12
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Direction(Enum):
|
|
77
|
+
"""Which side of the threshold a test has to stay on."""
|
|
78
|
+
|
|
79
|
+
MAXIMUM = "maximum"
|
|
80
|
+
MINIMUM = "minimum"
|
|
81
|
+
|
|
82
|
+
def __str__(self) -> str:
|
|
83
|
+
return self.value
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Measure(Enum):
|
|
87
|
+
"""What a maintenance test measures.
|
|
88
|
+
|
|
89
|
+
``LEVERAGE`` and ``NET_LEVERAGE`` differ only in whether cash on the balance
|
|
90
|
+
sheet is netted against the debt. The distinction is not cosmetic: a
|
|
91
|
+
business holding a year of interest in cash is a different credit from one
|
|
92
|
+
that is not, and which of the two a lender agreed to test on is one of the
|
|
93
|
+
more heavily negotiated lines in a term sheet.
|
|
94
|
+
|
|
95
|
+
``FIXED_CHARGE_COVERAGE`` is the test with the most definitions in
|
|
96
|
+
circulation. The one used here charges the business for the cash it cannot
|
|
97
|
+
avoid spending — capital expenditure and tax — and asks whether what is left
|
|
98
|
+
covers cash interest and contractual amortisation. Agreements differ on
|
|
99
|
+
whether maintenance capex or all capex belongs in the numerator; the
|
|
100
|
+
conservative reading is modelled, and a deal that negotiated the other one
|
|
101
|
+
can express it by describing capex net of the growth component.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
LEVERAGE = "leverage"
|
|
105
|
+
NET_LEVERAGE = "net_leverage"
|
|
106
|
+
INTEREST_COVERAGE = "interest_coverage"
|
|
107
|
+
FIXED_CHARGE_COVERAGE = "fixed_charge_coverage"
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def direction(self) -> Direction:
|
|
111
|
+
"""Leverage is capped; coverage has a floor."""
|
|
112
|
+
if self in (Measure.LEVERAGE, Measure.NET_LEVERAGE):
|
|
113
|
+
return Direction.MAXIMUM
|
|
114
|
+
return Direction.MINIMUM
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def is_leverage(self) -> bool:
|
|
118
|
+
return self in (Measure.LEVERAGE, Measure.NET_LEVERAGE)
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def label(self) -> str:
|
|
122
|
+
return self.value.replace("_", " ")
|
|
123
|
+
|
|
124
|
+
def __str__(self) -> str:
|
|
125
|
+
return self.value
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
#: What a ratio is called when there is nothing to measure and nothing at risk.
|
|
129
|
+
NOTHING_AT_RISK = "no exposure to measure"
|
|
130
|
+
|
|
131
|
+
#: What it is called when the denominator collapsed instead.
|
|
132
|
+
DENOMINATOR_COLLAPSED = "the business earned nothing to measure against"
|
|
133
|
+
|
|
134
|
+
#: What a period is called when the business has not traded a year yet.
|
|
135
|
+
NO_YEAR_YET = "fewer than twelve months traded; nothing to certify against"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True, slots=True)
|
|
139
|
+
class Certification:
|
|
140
|
+
"""Everything one test date is measured on, assembled over twelve months.
|
|
141
|
+
|
|
142
|
+
The stocks — debt, cash — are the balances standing on the test date, which
|
|
143
|
+
is what a balance is. The flows are summed over the year behind it, which is
|
|
144
|
+
what a flow needs to be compared against one. Keeping them in a single
|
|
145
|
+
object is what stops the two being mixed up at the point of division, which
|
|
146
|
+
is the whole of the defect this class exists to prevent.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
window: TrailingWindow
|
|
150
|
+
debt: DebtPeriod
|
|
151
|
+
ebitda: Money
|
|
152
|
+
capital_expenditure: Money
|
|
153
|
+
cash_tax: Money
|
|
154
|
+
cash_cost_of_debt: Money
|
|
155
|
+
mandatory_repayment: Money
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def period(self) -> Period:
|
|
159
|
+
return self.debt.period
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def certifiable(self) -> bool:
|
|
163
|
+
"""Whether a full year stands behind this date."""
|
|
164
|
+
return self.window.complete and not self.period.is_stub
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def interval(self) -> str:
|
|
168
|
+
"""How the measurement would be described on a compliance certificate."""
|
|
169
|
+
if self.window.complete:
|
|
170
|
+
return f"twelve months to {self.window.closes.isoformat()}"
|
|
171
|
+
return f"{self.window.days} days to {self.window.closes.isoformat()}"
|
|
172
|
+
|
|
173
|
+
@classmethod
|
|
174
|
+
def assemble(
|
|
175
|
+
cls,
|
|
176
|
+
position: int,
|
|
177
|
+
schedule: DebtSchedule,
|
|
178
|
+
model: OperatingModel,
|
|
179
|
+
*,
|
|
180
|
+
months: int = TEST_INTERVAL_MONTHS,
|
|
181
|
+
) -> Certification:
|
|
182
|
+
"""Gather the twelve months ending with period ``position``."""
|
|
183
|
+
periods = [row.period for row in schedule]
|
|
184
|
+
window = trailing_window(periods, position, months)
|
|
185
|
+
inside = {p.index for p in window}
|
|
186
|
+
debt_rows = [row for row in schedule if row.period.index in inside]
|
|
187
|
+
case_rows = [row for row in model if row.period.index in inside]
|
|
188
|
+
return cls(
|
|
189
|
+
window=window,
|
|
190
|
+
debt=schedule[position],
|
|
191
|
+
ebitda=sum((r.ebitda for r in case_rows), ZERO),
|
|
192
|
+
capital_expenditure=sum((r.capital_expenditure for r in case_rows), ZERO),
|
|
193
|
+
cash_tax=sum((r.tax.cash_tax for r in case_rows), ZERO),
|
|
194
|
+
cash_cost_of_debt=sum((r.cash_cost_of_debt for r in debt_rows), ZERO),
|
|
195
|
+
mandatory_repayment=sum((r.mandatory_repayment for r in debt_rows), ZERO),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@dataclass(frozen=True, slots=True)
|
|
200
|
+
class Covenant:
|
|
201
|
+
"""One maintenance test, as a credit agreement would describe it.
|
|
202
|
+
|
|
203
|
+
``threshold`` is a series so a test can tighten over the life of the loan.
|
|
204
|
+
``first_test_period`` is 1-based and matches the period numbering used
|
|
205
|
+
everywhere else, so a covenant first tested at the end of the second year of
|
|
206
|
+
an annual model has a first test period of 2.
|
|
207
|
+
|
|
208
|
+
``tranches`` narrows a leverage test to part of the stack, which is how a
|
|
209
|
+
first-lien net leverage covenant is written: the same business, the same
|
|
210
|
+
EBITDA, a smaller numerator. Naming no tranches measures the whole stack.
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
name: str
|
|
214
|
+
measure: Measure
|
|
215
|
+
threshold: Driver
|
|
216
|
+
first_test_period: int = 1
|
|
217
|
+
tranches: tuple[str, ...] = ()
|
|
218
|
+
|
|
219
|
+
@classmethod
|
|
220
|
+
def of(
|
|
221
|
+
cls,
|
|
222
|
+
name: str,
|
|
223
|
+
measure: Measure,
|
|
224
|
+
threshold: Driver | Numeric,
|
|
225
|
+
*,
|
|
226
|
+
first_test_period: int = 1,
|
|
227
|
+
tranches: Sequence[str] = (),
|
|
228
|
+
) -> Covenant:
|
|
229
|
+
"""Build a covenant, accepting a bare number as a flat threshold."""
|
|
230
|
+
series = (
|
|
231
|
+
threshold
|
|
232
|
+
if isinstance(threshold, Driver)
|
|
233
|
+
else Driver.of([money(threshold)])
|
|
234
|
+
)
|
|
235
|
+
return cls(
|
|
236
|
+
name=name,
|
|
237
|
+
measure=measure,
|
|
238
|
+
threshold=series,
|
|
239
|
+
first_test_period=first_test_period,
|
|
240
|
+
tranches=tuple(tranches),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
def __post_init__(self) -> None:
|
|
244
|
+
if not self.name.strip():
|
|
245
|
+
raise ValueError("a covenant needs a name")
|
|
246
|
+
if self.first_test_period < 1:
|
|
247
|
+
raise ValueError(
|
|
248
|
+
f"{self.name}: the first test period is 1-based, so 1 or later"
|
|
249
|
+
)
|
|
250
|
+
if any(v <= 0 for v in self.threshold):
|
|
251
|
+
raise ValueError(f"{self.name}: a threshold must be positive")
|
|
252
|
+
if self.tranches and not self.measure.is_leverage:
|
|
253
|
+
raise ValueError(
|
|
254
|
+
f"{self.name}: naming tranches narrows the debt a leverage test "
|
|
255
|
+
f"measures, and {self.measure.label} does not measure debt"
|
|
256
|
+
)
|
|
257
|
+
if len(set(self.tranches)) != len(self.tranches):
|
|
258
|
+
raise ValueError(f"{self.name}: tranche names must be distinct")
|
|
259
|
+
|
|
260
|
+
def threshold_at(self, index: int) -> Money:
|
|
261
|
+
"""The level in force for period ``index``, zero-based."""
|
|
262
|
+
return self.threshold.at(index)
|
|
263
|
+
|
|
264
|
+
def tests(self, index: int) -> bool:
|
|
265
|
+
"""Whether the covenant is live in period ``index``, zero-based."""
|
|
266
|
+
return index + 1 >= self.first_test_period
|
|
267
|
+
|
|
268
|
+
def satisfied_by(self, actual: Money, threshold: Money) -> bool:
|
|
269
|
+
if self.measure.direction is Direction.MAXIMUM:
|
|
270
|
+
return actual <= threshold
|
|
271
|
+
return actual >= threshold
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@dataclass(frozen=True, slots=True)
|
|
275
|
+
class CovenantObservation:
|
|
276
|
+
"""One covenant, tested in one period.
|
|
277
|
+
|
|
278
|
+
``actual`` is ``None`` when the ratio is undefined, and ``note`` says which
|
|
279
|
+
of the two undefined cases it was. A reader should never have to work out
|
|
280
|
+
whether a blank cell was a pass or a breach; ``passes`` is always populated.
|
|
281
|
+
"""
|
|
282
|
+
|
|
283
|
+
covenant: str
|
|
284
|
+
measure: Measure
|
|
285
|
+
period: Period
|
|
286
|
+
tested: bool
|
|
287
|
+
threshold: Money
|
|
288
|
+
actual: Money | None
|
|
289
|
+
passes: bool
|
|
290
|
+
ebitda: Money
|
|
291
|
+
ebitda_at_breach: Money | None
|
|
292
|
+
note: str = ""
|
|
293
|
+
window: TrailingWindow | None = None
|
|
294
|
+
|
|
295
|
+
@property
|
|
296
|
+
def index(self) -> int:
|
|
297
|
+
return int(self.period.index)
|
|
298
|
+
|
|
299
|
+
@property
|
|
300
|
+
def interval(self) -> str:
|
|
301
|
+
"""The interval the flows were measured over, as a certificate says it.
|
|
302
|
+
|
|
303
|
+
Worth carrying on the row rather than leaving to the reader: on an
|
|
304
|
+
annual grid it restates the column heading, and on a quarterly one it is
|
|
305
|
+
the difference between a figure a lender would recognise and one four
|
|
306
|
+
times too large.
|
|
307
|
+
"""
|
|
308
|
+
if self.window is None:
|
|
309
|
+
return f"period to {self.period.end.isoformat()}"
|
|
310
|
+
if self.window.complete:
|
|
311
|
+
return f"twelve months to {self.window.closes.isoformat()}"
|
|
312
|
+
# Said in days rather than rounded up to months, because the whole
|
|
313
|
+
# reason the row is untested is that the interval is not the one a
|
|
314
|
+
# certificate names, and describing it as a year would bury that.
|
|
315
|
+
return f"{self.window.days} days to {self.window.closes.isoformat()}"
|
|
316
|
+
|
|
317
|
+
@property
|
|
318
|
+
def breached(self) -> bool:
|
|
319
|
+
return self.tested and not self.passes
|
|
320
|
+
|
|
321
|
+
@property
|
|
322
|
+
def headroom(self) -> Money | None:
|
|
323
|
+
"""Distance to the threshold in turns, positive when the test passes.
|
|
324
|
+
|
|
325
|
+
Signed so that the reading is the same for both directions: a leverage
|
|
326
|
+
test two turns below its cap and a coverage test two turns above its
|
|
327
|
+
floor both report two turns of headroom.
|
|
328
|
+
"""
|
|
329
|
+
if self.actual is None:
|
|
330
|
+
return None
|
|
331
|
+
if self.measure.direction is Direction.MAXIMUM:
|
|
332
|
+
return self.threshold - self.actual
|
|
333
|
+
return self.actual - self.threshold
|
|
334
|
+
|
|
335
|
+
@property
|
|
336
|
+
def ebitda_cushion(self) -> Money | None:
|
|
337
|
+
"""How far EBITDA could fall before the test trips, as a share of itself.
|
|
338
|
+
|
|
339
|
+
Negative when the test is already breached, in which case it reads as
|
|
340
|
+
the increase in EBITDA that would cure it. The debt and the charges are
|
|
341
|
+
held at their projected levels while EBITDA is flexed, which is the
|
|
342
|
+
standard simplification and the reason this is a cushion rather than a
|
|
343
|
+
forecast: a business earning less would in reality also sweep less and
|
|
344
|
+
carry more debt, so the true cushion is a little thinner than the one
|
|
345
|
+
reported here.
|
|
346
|
+
"""
|
|
347
|
+
if self.ebitda_at_breach is None or self.ebitda <= 0:
|
|
348
|
+
return None
|
|
349
|
+
return (self.ebitda - self.ebitda_at_breach) / self.ebitda
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _measured_debt(
|
|
353
|
+
covenant: Covenant, row: DebtPeriod
|
|
354
|
+
) -> Money:
|
|
355
|
+
"""Closing debt in scope for a leverage test."""
|
|
356
|
+
if not covenant.tranches:
|
|
357
|
+
return row.closing_debt
|
|
358
|
+
wanted = set(covenant.tranches)
|
|
359
|
+
return sum((t.closing for t in row.tranches if t.name in wanted), ZERO)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _observe(
|
|
363
|
+
covenant: Covenant,
|
|
364
|
+
index: int,
|
|
365
|
+
certification: Certification,
|
|
366
|
+
) -> CovenantObservation:
|
|
367
|
+
"""Test one covenant on one test date, over the year behind it."""
|
|
368
|
+
threshold = covenant.threshold_at(index)
|
|
369
|
+
debt = certification.debt
|
|
370
|
+
ebitda = certification.ebitda
|
|
371
|
+
|
|
372
|
+
def untested(note: str) -> CovenantObservation:
|
|
373
|
+
return CovenantObservation(
|
|
374
|
+
covenant=covenant.name,
|
|
375
|
+
measure=covenant.measure,
|
|
376
|
+
period=debt.period,
|
|
377
|
+
tested=False,
|
|
378
|
+
threshold=threshold,
|
|
379
|
+
actual=None,
|
|
380
|
+
passes=True,
|
|
381
|
+
ebitda=ebitda,
|
|
382
|
+
ebitda_at_breach=None,
|
|
383
|
+
note=note,
|
|
384
|
+
window=certification.window,
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
# A stub is not a test date. A maintenance covenant is certified on a
|
|
388
|
+
# reporting date against the last twelve months' earnings, and a business
|
|
389
|
+
# six weeks into a hold has no twelve months to certify — measuring debt
|
|
390
|
+
# against a part-period figure would report leverage several turns worse
|
|
391
|
+
# than the deal carries and breach a covenant nobody has breached.
|
|
392
|
+
if debt.period.is_stub:
|
|
393
|
+
return untested("stub period; no twelve months to certify against")
|
|
394
|
+
|
|
395
|
+
# The same objection, and it outlasts the stub. On a quarterly grid the
|
|
396
|
+
# first three certification dates have three, six and nine months behind
|
|
397
|
+
# them, and none of those is a year.
|
|
398
|
+
if not certification.certifiable:
|
|
399
|
+
return untested(NO_YEAR_YET)
|
|
400
|
+
|
|
401
|
+
if not covenant.tests(index):
|
|
402
|
+
return untested("not yet tested")
|
|
403
|
+
|
|
404
|
+
if covenant.measure.is_leverage:
|
|
405
|
+
numerator = _measured_debt(covenant, debt)
|
|
406
|
+
if covenant.measure is Measure.NET_LEVERAGE:
|
|
407
|
+
numerator -= debt.closing_cash
|
|
408
|
+
# Net of a cash balance larger than the debt, the ratio is negative,
|
|
409
|
+
# which is a real and passing state rather than something to clamp.
|
|
410
|
+
if ebitda > 0:
|
|
411
|
+
actual = numerator / ebitda
|
|
412
|
+
at_breach = numerator / threshold if numerator > 0 else ZERO
|
|
413
|
+
return CovenantObservation(
|
|
414
|
+
covenant=covenant.name,
|
|
415
|
+
measure=covenant.measure,
|
|
416
|
+
period=debt.period,
|
|
417
|
+
tested=True,
|
|
418
|
+
threshold=threshold,
|
|
419
|
+
actual=actual,
|
|
420
|
+
passes=covenant.satisfied_by(actual, threshold),
|
|
421
|
+
ebitda=ebitda,
|
|
422
|
+
ebitda_at_breach=at_breach,
|
|
423
|
+
window=certification.window,
|
|
424
|
+
)
|
|
425
|
+
passes = numerator <= 0
|
|
426
|
+
return CovenantObservation(
|
|
427
|
+
covenant=covenant.name,
|
|
428
|
+
measure=covenant.measure,
|
|
429
|
+
period=debt.period,
|
|
430
|
+
tested=True,
|
|
431
|
+
threshold=threshold,
|
|
432
|
+
actual=None,
|
|
433
|
+
passes=passes,
|
|
434
|
+
ebitda=ebitda,
|
|
435
|
+
ebitda_at_breach=None,
|
|
436
|
+
note=NOTHING_AT_RISK if passes else DENOMINATOR_COLLAPSED,
|
|
437
|
+
window=certification.window,
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
# Both sides of a coverage test are flows, so both are taken over the same
|
|
441
|
+
# twelve months. Measuring a period of earnings against a period of charges
|
|
442
|
+
# would land near the right answer by accident on a regular grid and nowhere
|
|
443
|
+
# near it on a grid with a stub, or in a year holding a refinancing.
|
|
444
|
+
if covenant.measure is Measure.INTEREST_COVERAGE:
|
|
445
|
+
charges = certification.cash_cost_of_debt
|
|
446
|
+
earnings = ebitda
|
|
447
|
+
floor = ZERO
|
|
448
|
+
else:
|
|
449
|
+
charges = certification.cash_cost_of_debt + certification.mandatory_repayment
|
|
450
|
+
unavoidable = certification.capital_expenditure + certification.cash_tax
|
|
451
|
+
earnings = ebitda - unavoidable
|
|
452
|
+
floor = unavoidable
|
|
453
|
+
|
|
454
|
+
if charges <= 0:
|
|
455
|
+
# Nothing to cover. A business with no cash debt service cannot fail a
|
|
456
|
+
# coverage test, and reporting an infinite ratio would be worse than
|
|
457
|
+
# reporting none.
|
|
458
|
+
return CovenantObservation(
|
|
459
|
+
covenant=covenant.name,
|
|
460
|
+
measure=covenant.measure,
|
|
461
|
+
period=debt.period,
|
|
462
|
+
tested=True,
|
|
463
|
+
threshold=threshold,
|
|
464
|
+
actual=None,
|
|
465
|
+
passes=True,
|
|
466
|
+
ebitda=ebitda,
|
|
467
|
+
ebitda_at_breach=None,
|
|
468
|
+
note=NOTHING_AT_RISK,
|
|
469
|
+
window=certification.window,
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
actual = earnings / charges
|
|
473
|
+
return CovenantObservation(
|
|
474
|
+
covenant=covenant.name,
|
|
475
|
+
measure=covenant.measure,
|
|
476
|
+
period=debt.period,
|
|
477
|
+
tested=True,
|
|
478
|
+
threshold=threshold,
|
|
479
|
+
actual=actual,
|
|
480
|
+
passes=covenant.satisfied_by(actual, threshold),
|
|
481
|
+
ebitda=ebitda,
|
|
482
|
+
ebitda_at_breach=threshold * charges + floor,
|
|
483
|
+
window=certification.window,
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
@dataclass(frozen=True, slots=True)
|
|
488
|
+
class CovenantReport:
|
|
489
|
+
"""Every covenant tested across every period of a schedule."""
|
|
490
|
+
|
|
491
|
+
covenants: tuple[Covenant, ...]
|
|
492
|
+
observations: tuple[CovenantObservation, ...]
|
|
493
|
+
|
|
494
|
+
def __len__(self) -> int:
|
|
495
|
+
return len(self.observations)
|
|
496
|
+
|
|
497
|
+
def __iter__(self) -> Iterator[CovenantObservation]:
|
|
498
|
+
return iter(self.observations)
|
|
499
|
+
|
|
500
|
+
@classmethod
|
|
501
|
+
def test(
|
|
502
|
+
cls,
|
|
503
|
+
covenants: Sequence[Covenant],
|
|
504
|
+
schedule: DebtSchedule,
|
|
505
|
+
model: OperatingModel,
|
|
506
|
+
) -> CovenantReport:
|
|
507
|
+
"""Run ``covenants`` against a schedule and the case that produced it.
|
|
508
|
+
|
|
509
|
+
Both are required and neither is derivable from the other: the schedule
|
|
510
|
+
holds the debt and what it costs, the operating case holds the EBITDA
|
|
511
|
+
the tests are measured against and the capex and tax a fixed-charge test
|
|
512
|
+
deducts.
|
|
513
|
+
"""
|
|
514
|
+
if len(schedule) != len(model):
|
|
515
|
+
raise ValueError(
|
|
516
|
+
f"the schedule covers {len(schedule)} periods and the operating "
|
|
517
|
+
f"case {len(model)}"
|
|
518
|
+
)
|
|
519
|
+
known = {t.name for t in schedule.structure}
|
|
520
|
+
for covenant in covenants:
|
|
521
|
+
unknown = [n for n in covenant.tranches if n not in known]
|
|
522
|
+
if unknown:
|
|
523
|
+
raise ValueError(
|
|
524
|
+
f"{covenant.name}: no tranche named {unknown[0]!r} in the structure"
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
# Ordered by period and then by covenant, because the page is read down
|
|
528
|
+
# a column: every test for one period before the next period's.
|
|
529
|
+
# Assembled once per test date rather than once per covenant: gathering
|
|
530
|
+
# a year of flows is the expensive half, and every covenant certified on
|
|
531
|
+
# the same date is measured on the same year of them.
|
|
532
|
+
certifications = [
|
|
533
|
+
Certification.assemble(i, schedule, model) for i in range(len(schedule))
|
|
534
|
+
]
|
|
535
|
+
observations = tuple(
|
|
536
|
+
_observe(covenant, schedule[i].period.driver_index, certifications[i])
|
|
537
|
+
for i in range(len(schedule))
|
|
538
|
+
for covenant in covenants
|
|
539
|
+
)
|
|
540
|
+
return cls(covenants=tuple(covenants), observations=observations)
|
|
541
|
+
|
|
542
|
+
def for_covenant(self, name: str) -> tuple[CovenantObservation, ...]:
|
|
543
|
+
rows = tuple(o for o in self.observations if o.covenant == name)
|
|
544
|
+
if not rows:
|
|
545
|
+
raise KeyError(f"no covenant named {name!r}")
|
|
546
|
+
return rows
|
|
547
|
+
|
|
548
|
+
def at(self, index: int) -> tuple[CovenantObservation, ...]:
|
|
549
|
+
"""Every test in period ``index``, zero-based."""
|
|
550
|
+
periods = sorted({o.index for o in self.observations})
|
|
551
|
+
if not periods:
|
|
552
|
+
return ()
|
|
553
|
+
if not -len(periods) <= index < len(periods):
|
|
554
|
+
raise IndexError(f"period {index} is outside the report")
|
|
555
|
+
wanted = periods[index]
|
|
556
|
+
return tuple(o for o in self.observations if o.index == wanted)
|
|
557
|
+
|
|
558
|
+
@property
|
|
559
|
+
def breaches(self) -> tuple[CovenantObservation, ...]:
|
|
560
|
+
return tuple(o for o in self.observations if o.breached)
|
|
561
|
+
|
|
562
|
+
@property
|
|
563
|
+
def first_breach(self) -> CovenantObservation | None:
|
|
564
|
+
"""The earliest failing test, which is the one that ends the hold.
|
|
565
|
+
|
|
566
|
+
Everything after a breach is hypothetical — the lenders have a right to
|
|
567
|
+
accelerate from that date — so the first one is the only one that
|
|
568
|
+
strictly matters, and the rest are shown because a reader wants to know
|
|
569
|
+
whether the case recovers.
|
|
570
|
+
"""
|
|
571
|
+
return self.breaches[0] if self.breaches else None
|
|
572
|
+
|
|
573
|
+
@property
|
|
574
|
+
def passes(self) -> bool:
|
|
575
|
+
return not self.breaches
|
|
576
|
+
|
|
577
|
+
@property
|
|
578
|
+
def tightest(self) -> CovenantObservation | None:
|
|
579
|
+
"""The tested observation with the least EBITDA cushion.
|
|
580
|
+
|
|
581
|
+
The single number that describes how much room a structure has, and the
|
|
582
|
+
one a sponsor is asked for before signing.
|
|
583
|
+
"""
|
|
584
|
+
measurable = [
|
|
585
|
+
o for o in self.observations if o.tested and o.ebitda_cushion is not None
|
|
586
|
+
]
|
|
587
|
+
if not measurable:
|
|
588
|
+
return None
|
|
589
|
+
return min(measurable, key=lambda o: o.ebitda_cushion or ZERO)
|
|
590
|
+
|
|
591
|
+
@property
|
|
592
|
+
def minimum_cushion(self) -> Money | None:
|
|
593
|
+
tightest = self.tightest
|
|
594
|
+
return None if tightest is None else tightest.ebitda_cushion
|