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