capstack-lbo 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- capstack/__init__.py +243 -0
- capstack/balance_sheet.py +295 -0
- capstack/cli.py +1995 -0
- capstack/covenants.py +594 -0
- capstack/daycount.py +121 -0
- capstack/debt.py +2003 -0
- capstack/drivers.py +167 -0
- capstack/events.py +914 -0
- capstack/fees.py +484 -0
- capstack/incentive.py +517 -0
- capstack/money.py +204 -0
- capstack/operating.py +677 -0
- capstack/outcome.py +958 -0
- capstack/periods.py +431 -0
- capstack/py.typed +0 -0
- capstack/report.py +1188 -0
- capstack/returns.py +426 -0
- capstack/sensitivity.py +940 -0
- capstack/spec.py +1686 -0
- capstack/tax.py +834 -0
- capstack/transaction.py +430 -0
- capstack_lbo-0.1.0.dist-info/METADATA +1260 -0
- capstack_lbo-0.1.0.dist-info/RECORD +27 -0
- capstack_lbo-0.1.0.dist-info/WHEEL +5 -0
- capstack_lbo-0.1.0.dist-info/entry_points.txt +3 -0
- capstack_lbo-0.1.0.dist-info/licenses/LICENSE +21 -0
- capstack_lbo-0.1.0.dist-info/top_level.txt +1 -0
capstack/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
|
+
)
|