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