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/fees.py ADDED
@@ -0,0 +1,484 @@
1
+ """Capitalised financing costs, and the balance they leave behind.
2
+
3
+ Arrangement fees and original issue discount are raised at close and spent at
4
+ close, but they are not a cost of the period they were paid in. They are a cost
5
+ of borrowing the money, and borrowing the money is what the next several years
6
+ consist of. So they are capitalised — carried as a deduction from the debt
7
+ rather than as an asset, which is what a lender's economics actually describe —
8
+ and released to the income statement across the life of the paper they bought.
9
+
10
+ Three things make that more than a division.
11
+
12
+ *The two amounts belong together.* A fee of 2% on face and a placement at 98
13
+ raise the same cash and leave the same hole; presenting the fee as an asset and
14
+ the discount as a contra-liability puts one number in two places and invites a
15
+ model to amortise one and forget the other. They are one carrying-amount
16
+ adjustment here.
17
+
18
+ *The effective rate is not the coupon.* A five-year loan at 6% placed at 98 with
19
+ 2% of fees does not cost 6%. It costs the rate at which the contractual cash
20
+ flows discount back to what was actually received, which is higher, and the gap
21
+ between that rate and the coupon is precisely the amortisation charge. Charging
22
+ the capitalised balance evenly instead front-loads too little in the early years,
23
+ when the balance is largest and the charge should be too.
24
+
25
+ *The profile is contractual, not actual.* The rate is solved against the
26
+ scheduled amortisation and the bullet at maturity. A cash sweep is discretionary
27
+ and cannot be part of the profile — a borrower who sweeps hard in year one has
28
+ not renegotiated the effective rate on the money it borrowed.
29
+
30
+ Nothing here is cash. The charge does not move a balance in the debt schedule
31
+ and does not shift a return by a basis point. It is carried because the balance
32
+ it leaves is what a refinancing writes off, and a write-off that is derived can
33
+ be checked where one that is typed in cannot.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import dataclasses
39
+ from collections.abc import Iterator, Sequence
40
+ from dataclasses import dataclass
41
+ from decimal import Decimal
42
+ from enum import Enum
43
+
44
+ from .debt import CapitalStructure, Tranche
45
+ from .money import ZERO, Money, Numeric, is_close, money, safe_div
46
+ from .returns import brent_root
47
+
48
+ __all__ = [
49
+ "FeeMethod",
50
+ "FeePeriod",
51
+ "FeeSchedule",
52
+ "TrancheFees",
53
+ "effective_rate",
54
+ ]
55
+
56
+ #: The widest per-period rate the effective-rate solve will look over. A period
57
+ #: rate of 500% is far past any paper that would be placed; the bracket exists
58
+ #: to fail loudly on a profile with no solution rather than to accommodate one.
59
+ MAXIMUM_PERIOD_RATE = 5.0
60
+
61
+ #: How close the carrying amount must come to zero at maturity for the solved
62
+ #: rate to be accepted, per unit of face.
63
+ SOLVE_TOLERANCE = "1E-9"
64
+
65
+
66
+ class FeeMethod(Enum):
67
+ """How a capitalised balance is released over the life of the paper.
68
+
69
+ ``EFFECTIVE_INTEREST`` is the general rule for term debt: the charge is the
70
+ difference between interest at the effective rate on the carrying amount and
71
+ interest at the coupon on the balance outstanding. The charge tracks the
72
+ carrying amount rather than moving in one direction — on a bullet it rises
73
+ across the life as the discount unwinds towards face, and on an amortising
74
+ loan it falls, because principal comes off faster than the discount closes.
75
+ A straight line is neither, which is the point.
76
+
77
+ ``STRAIGHT_LINE`` spreads the balance evenly. It is the right answer for a
78
+ revolving facility, which has no principal profile to solve a rate against —
79
+ the fee buys a commitment for a term, and the commitment is consumed evenly
80
+ whether or not the facility is drawn — and it is permitted for term debt
81
+ where the difference from the effective method is immaterial.
82
+ """
83
+
84
+ EFFECTIVE_INTEREST = "effective_interest"
85
+ STRAIGHT_LINE = "straight_line"
86
+
87
+ def __str__(self) -> str:
88
+ return self.value.replace("_", " ")
89
+
90
+
91
+ def contractual_profile(
92
+ tranche: Tranche, periods: int, *, basis: Money | None = None
93
+ ) -> tuple[list[Money], list[Money]]:
94
+ """The balances and principal repayments the credit agreement contemplates.
95
+
96
+ Returns the opening balance for each period and the principal repaid in it.
97
+ Scheduled amortisation runs until the facility matures, and whatever is left
98
+ is repaid at maturity — which is the last period of the projection where the
99
+ tranche has no stated maturity, because a model cannot amortise paper past
100
+ the end of its own grid.
101
+
102
+ The sweep is deliberately absent. It is discretionary, and a profile that
103
+ included it would solve a different effective rate for every operating case
104
+ the same loan was run against.
105
+ """
106
+ if periods <= 0:
107
+ raise ValueError("a profile needs at least one period")
108
+ face = tranche.face if basis is None else basis
109
+ last = periods if tranche.maturity is None else min(tranche.maturity - 1, periods)
110
+ opening: list[Money] = []
111
+ principal: list[Money] = []
112
+ balance = face
113
+ for index in range(periods):
114
+ opening.append(balance)
115
+ if index >= last:
116
+ principal.append(ZERO)
117
+ continue
118
+ due = min(tranche.scheduled_amortisation(index, face), balance)
119
+ if index == last - 1:
120
+ due = balance
121
+ principal.append(due)
122
+ balance -= due
123
+ return opening, principal
124
+
125
+
126
+ def effective_rate(
127
+ proceeds: Money,
128
+ opening: Sequence[Money],
129
+ principal: Sequence[Money],
130
+ coupon: Money,
131
+ ) -> Money:
132
+ """The per-period rate at which the contractual flows discount to ``proceeds``.
133
+
134
+ Solved rather than derived, because there is no closed form once principal
135
+ repays on a schedule. The function is monotone in the rate over any interval
136
+ where the flows are all outward — every flow is discounted harder as the
137
+ rate rises — so a bracket and a root find are enough, and the bracket runs
138
+ from zero to a rate no instrument would ever carry.
139
+
140
+ ``coupon`` is the contractual rate per period, so a 6% annual coupon on a
141
+ quarterly grid is 0.015 here. The caller does that conversion because only
142
+ the caller knows the day count the interest was actually accrued on.
143
+ """
144
+ if not opening:
145
+ raise ValueError("an effective rate needs at least one period")
146
+ if len(opening) != len(principal):
147
+ raise ValueError("one principal repayment per period")
148
+ if proceeds <= 0:
149
+ raise ValueError("paper that raised nothing has no effective rate")
150
+
151
+ flows = [float(o * coupon + p) for o, p in zip(opening, principal, strict=True)]
152
+ target = float(proceeds)
153
+
154
+ def carrying_at_maturity(rate: float) -> float:
155
+ balance = target
156
+ for flow in flows:
157
+ balance = balance * (1.0 + rate) - flow
158
+ return balance
159
+
160
+ # At a rate of zero the flows total more than the proceeds whenever any
161
+ # interest is charged at all, so the balance ends negative; at a very high
162
+ # rate it ends positive. That sign change is the bracket.
163
+ low = carrying_at_maturity(0.0)
164
+ if abs(low) < float(Decimal(SOLVE_TOLERANCE)) * target:
165
+ return ZERO
166
+ high = carrying_at_maturity(MAXIMUM_PERIOD_RATE)
167
+ if low * high > 0:
168
+ raise ValueError(
169
+ "no effective rate reconciles these flows to the proceeds; the paper "
170
+ "as described repays less than it raised"
171
+ )
172
+ return money(str(brent_root(carrying_at_maturity, 0.0, MAXIMUM_PERIOD_RATE)))
173
+
174
+
175
+ @dataclass(frozen=True, slots=True)
176
+ class FeePeriod:
177
+ """One period of one tranche's capitalised balance."""
178
+
179
+ index: int
180
+ opening: Money
181
+ charge: Money
182
+ closing: Money
183
+
184
+ def reconciles(self, tolerance: Numeric = "1E-9") -> bool:
185
+ return is_close(self.closing, self.opening - self.charge, tolerance=tolerance)
186
+
187
+
188
+ @dataclass(frozen=True, slots=True)
189
+ class TrancheFees:
190
+ """What one tranche capitalised, and how it is released."""
191
+
192
+ name: str
193
+ capitalised: Money
194
+ method: FeeMethod
195
+ effective_rate: Money
196
+ coupon: Money
197
+ periods: tuple[FeePeriod, ...]
198
+
199
+ def __len__(self) -> int:
200
+ return len(self.periods)
201
+
202
+ def __iter__(self) -> Iterator[FeePeriod]:
203
+ return iter(self.periods)
204
+
205
+ def unamortised_at(self, index: int) -> Money:
206
+ """The balance left at the end of period ``index``, zero-based.
207
+
208
+ Reading past the end returns zero rather than raising: paper that has
209
+ matured has nothing left to write off, and a caller asking about a
210
+ period beyond the grid is asking a question with an obvious answer.
211
+ """
212
+ if index < 0:
213
+ raise IndexError("period index must not be negative")
214
+ if not self.periods:
215
+ return ZERO
216
+ if index >= len(self.periods):
217
+ return self.periods[-1].closing
218
+ return self.periods[index].closing
219
+
220
+ @property
221
+ def total_charged(self) -> Money:
222
+ return sum((p.charge for p in self.periods), ZERO)
223
+
224
+ @property
225
+ def rate_uplift(self) -> Money:
226
+ """How much the capitalised cost adds to the cost of the money.
227
+
228
+ The number worth quoting next to a coupon. A loan at 6% placed at 98
229
+ with 2% of fees is not a 6% loan, and the difference is what the
230
+ borrower is actually paying for having borrowed.
231
+ """
232
+ return self.effective_rate - self.coupon
233
+
234
+
235
+ @dataclass(frozen=True, slots=True)
236
+ class FeeSchedule:
237
+ """Capitalised financing costs across the whole structure."""
238
+
239
+ tranches: tuple[TrancheFees, ...]
240
+
241
+ def __len__(self) -> int:
242
+ return len(self.tranches)
243
+
244
+ def __iter__(self) -> Iterator[TrancheFees]:
245
+ return iter(self.tranches)
246
+
247
+ def tranche(self, name: str) -> TrancheFees:
248
+ for row in self.tranches:
249
+ if row.name == name:
250
+ return row
251
+ raise KeyError(f"no capitalised balance for a tranche named {name!r}")
252
+
253
+ def unamortised_at(self, name: str, index: int) -> Money:
254
+ """What is left on ``name`` at the end of period ``index``.
255
+
256
+ Zero for a tranche that capitalised nothing, rather than a lookup
257
+ failure: a structure can carry paper placed at par with no arrangement
258
+ fee, and asking what it has left to write off is a fair question with a
259
+ zero answer.
260
+ """
261
+ for row in self.tranches:
262
+ if row.name == name:
263
+ return row.unamortised_at(index)
264
+ return ZERO
265
+
266
+ @property
267
+ def total_capitalised(self) -> Money:
268
+ return sum((t.capitalised for t in self.tranches), ZERO)
269
+
270
+ def charge_in(self, index: int) -> Money:
271
+ """The non-cash charge across the structure in period ``index``."""
272
+ return sum(
273
+ (
274
+ t.periods[index].charge
275
+ for t in self.tranches
276
+ if index < len(t.periods)
277
+ ),
278
+ ZERO,
279
+ )
280
+
281
+ @property
282
+ def total_charged(self) -> Money:
283
+ return sum((t.total_charged for t in self.tranches), ZERO)
284
+
285
+ @property
286
+ def unreleased(self) -> Money:
287
+ """What is still capitalised at the end of the projection.
288
+
289
+ Non-zero whenever paper outlives the model, which is the normal case: a
290
+ seven-year loan on a five-year hold has two years of balance left, and
291
+ it is written off at the exit by whoever repays the loan.
292
+ """
293
+ return sum((t.unamortised_at(len(t) - 1) for t in self.tranches if len(t)), ZERO)
294
+
295
+ @classmethod
296
+ def build(
297
+ cls,
298
+ structure: CapitalStructure,
299
+ capitalised: dict[str, Money],
300
+ periods: int,
301
+ *,
302
+ periods_per_year: int = 1,
303
+ base_rate: Money | None = None,
304
+ has_stub: bool = False,
305
+ method: FeeMethod = FeeMethod.EFFECTIVE_INTEREST,
306
+ ) -> FeeSchedule:
307
+ """Amortise each tranche's capitalised balance across ``periods``.
308
+
309
+ ``capitalised`` is the arrangement fee plus the original issue discount,
310
+ by tranche name. A tranche absent from it, or present with nothing,
311
+ carries no balance and no charge.
312
+
313
+ ``base_rate`` prices the floating tranches. It is a single rate rather
314
+ than a curve on purpose: the effective rate is a property of the paper
315
+ as it was placed, and re-solving it every period as the forward curve
316
+ moves would restate a historic cost with information that arrived later.
317
+
318
+ A revolving facility is always released straight-line whatever
319
+ ``method`` says. There is no principal profile to solve a rate against —
320
+ the fee buys a commitment for a term, and the term is what gets
321
+ consumed.
322
+
323
+ ``has_stub`` says the first of ``periods`` is a short one at close. The
324
+ release runs over whole periods, so a grid with a stub carries one more
325
+ column than the paper has periods of life, and the last column shows
326
+ whatever is left rather than a period of release that is not there. The
327
+ stub itself releases nothing: capitalising a cost and beginning to
328
+ release it in the same instant is a distinction without a period.
329
+ """
330
+ if periods <= 0:
331
+ raise ValueError("a fee schedule needs at least one period")
332
+ if periods_per_year <= 0:
333
+ raise ValueError("a year holds at least one period")
334
+ for name in capitalised:
335
+ if not any(t.name == name for t in structure):
336
+ raise KeyError(
337
+ f"there is no tranche named {name!r} to capitalise anything against"
338
+ )
339
+
340
+ rows: list[TrancheFees] = []
341
+ for tranche in structure:
342
+ amount = capitalised.get(tranche.name, ZERO)
343
+ if amount < 0:
344
+ raise ValueError(
345
+ f"{tranche.name}: a negative capitalised cost is a premium, and "
346
+ f"paper placed above par is not modelled here"
347
+ )
348
+ released = _amortise(
349
+ tranche,
350
+ amount,
351
+ periods - 1 if has_stub else periods,
352
+ periods_per_year=periods_per_year,
353
+ base_rate=base_rate,
354
+ method=method,
355
+ )
356
+ rows.append(_with_stub(released) if has_stub else released)
357
+ return cls(tranches=tuple(rows))
358
+
359
+
360
+ def _with_stub(released: TrancheFees) -> TrancheFees:
361
+ """Put a column in front that releases nothing.
362
+
363
+ The stub carries the balance it was capitalised at. Nothing is written down
364
+ between signing the fee letter and drawing the money, and a first column
365
+ that released a sliver would say otherwise.
366
+ """
367
+ opening = released.periods[0].opening if released.periods else ZERO
368
+ stub = FeePeriod(index=0, opening=opening, charge=ZERO, closing=opening)
369
+ shifted = tuple(
370
+ FeePeriod(
371
+ index=p.index + 1, opening=p.opening, charge=p.charge, closing=p.closing
372
+ )
373
+ for p in released.periods
374
+ )
375
+ return dataclasses.replace(released, periods=(stub, *shifted))
376
+
377
+
378
+ def _straight_line(amount: Money, life: int, periods: int) -> list[FeePeriod]:
379
+ """Release ``amount`` evenly over ``life`` periods, with the last squaring it.
380
+
381
+ The last period takes the rounding rather than spreading it, so the balance
382
+ reaches exactly zero instead of a residual that a reader has to ignore.
383
+ """
384
+ rows: list[FeePeriod] = []
385
+ balance = amount
386
+ per_period = safe_div(amount, money(life), default=ZERO)
387
+ for index in range(periods):
388
+ if index >= life or balance <= 0:
389
+ rows.append(FeePeriod(index=index, opening=balance, charge=ZERO, closing=balance))
390
+ continue
391
+ charge = balance if index == life - 1 else min(per_period, balance)
392
+ rows.append(
393
+ FeePeriod(index=index, opening=balance, charge=charge, closing=balance - charge)
394
+ )
395
+ balance -= charge
396
+ return rows
397
+
398
+
399
+ def _amortise(
400
+ tranche: Tranche,
401
+ amount: Money,
402
+ periods: int,
403
+ *,
404
+ periods_per_year: int,
405
+ base_rate: Money | None,
406
+ method: FeeMethod,
407
+ ) -> TrancheFees:
408
+ """Build one tranche's release schedule."""
409
+ coupon_annual = tranche.rate_at(base_rate if base_rate is not None else ZERO)
410
+ coupon = coupon_annual / money(periods_per_year)
411
+ # The life is the paper's, not the projection's. A seven-year loan on a
412
+ # five-year hold releases a seventh of the balance a year and still has two
413
+ # years of it capitalised when the model ends — which is exactly the balance
414
+ # somebody writes off at the exit, and a release compressed into the grid
415
+ # would report it as already gone.
416
+ life = max(periods if tranche.maturity is None else tranche.maturity - 1, 1)
417
+
418
+ if amount == 0:
419
+ return TrancheFees(
420
+ name=tranche.name,
421
+ capitalised=ZERO,
422
+ method=method,
423
+ effective_rate=coupon,
424
+ coupon=coupon,
425
+ periods=tuple(
426
+ FeePeriod(index=i, opening=ZERO, charge=ZERO, closing=ZERO)
427
+ for i in range(periods)
428
+ ),
429
+ )
430
+
431
+ # A revolver has no principal profile, and a tranche placed with nothing
432
+ # drawn has no proceeds to solve a rate against. Both fall back to the
433
+ # straight line, which is what a fee on a commitment describes anyway.
434
+ if (
435
+ method is FeeMethod.STRAIGHT_LINE
436
+ or tranche.is_revolving
437
+ or tranche.face <= 0
438
+ or tranche.face <= amount
439
+ ):
440
+ even = _straight_line(amount, life, periods)
441
+ return TrancheFees(
442
+ name=tranche.name,
443
+ capitalised=amount,
444
+ method=FeeMethod.STRAIGHT_LINE,
445
+ effective_rate=coupon,
446
+ coupon=coupon,
447
+ periods=tuple(even),
448
+ )
449
+
450
+ opening, principal = contractual_profile(tranche, life)
451
+ proceeds = tranche.face - amount
452
+ rate = effective_rate(proceeds, opening, principal, coupon)
453
+
454
+ rows: list[FeePeriod] = []
455
+ carrying = proceeds
456
+ balance = amount
457
+ for index in range(periods):
458
+ if index >= life or balance <= 0:
459
+ rows.append(FeePeriod(index=index, opening=balance, charge=ZERO, closing=balance))
460
+ continue
461
+ effective_interest = carrying * rate
462
+ contractual_interest = opening[index] * coupon
463
+ charge = effective_interest - contractual_interest
464
+ # The last period squares the balance. The solve is to a tolerance, not
465
+ # to the last digit, and a residual left on a balance that the paper
466
+ # has finished with would be reported as something still capitalised.
467
+ if index == life - 1:
468
+ charge = balance
469
+ charge = min(max(charge, ZERO), balance)
470
+ rows.append(
471
+ FeePeriod(index=index, opening=balance, charge=charge, closing=balance - charge)
472
+ )
473
+ balance -= charge
474
+ carrying += charge
475
+ carrying -= principal[index]
476
+
477
+ return TrancheFees(
478
+ name=tranche.name,
479
+ capitalised=amount,
480
+ method=FeeMethod.EFFECTIVE_INTEREST,
481
+ effective_rate=rate,
482
+ coupon=coupon,
483
+ periods=tuple(rows),
484
+ )