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/spec.py
ADDED
|
@@ -0,0 +1,1686 @@
|
|
|
1
|
+
"""Reading a deal from a file.
|
|
2
|
+
|
|
3
|
+
A deal is described as JSON rather than as command-line flags. There are already
|
|
4
|
+
more inputs than a flag list can carry legibly, and the operating case and debt
|
|
5
|
+
schedule will add more, so the file is the interface that scales.
|
|
6
|
+
|
|
7
|
+
Numbers are parsed straight to ``Decimal``. Going through ``float`` first would
|
|
8
|
+
reintroduce exactly the binary drift the money layer exists to avoid: a
|
|
9
|
+
``0.995`` issue price read as a float and multiplied by a face amount of four
|
|
10
|
+
hundred million lands a few hundredths away from where it should.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from collections.abc import Iterator, Sequence
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from datetime import date
|
|
19
|
+
from decimal import Decimal, InvalidOperation
|
|
20
|
+
from enum import Enum
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from .balance_sheet import OpeningBalanceSheet, PurchaseAccounting, TargetBookBalanceSheet
|
|
25
|
+
from .covenants import Covenant, CovenantReport, Measure
|
|
26
|
+
from .daycount import DayCount
|
|
27
|
+
from .debt import (
|
|
28
|
+
CapitalStructure,
|
|
29
|
+
DebtSchedule,
|
|
30
|
+
InterestBasis,
|
|
31
|
+
SweepGrid,
|
|
32
|
+
Tranche,
|
|
33
|
+
TrancheKind,
|
|
34
|
+
)
|
|
35
|
+
from .drivers import Driver
|
|
36
|
+
from .events import (
|
|
37
|
+
AddOn,
|
|
38
|
+
AddOnError,
|
|
39
|
+
BlendedEntry,
|
|
40
|
+
Draw,
|
|
41
|
+
Recapitalisation,
|
|
42
|
+
RecapitalisationError,
|
|
43
|
+
Refinancing,
|
|
44
|
+
RefinancingError,
|
|
45
|
+
)
|
|
46
|
+
from .fees import FeeMethod, FeeSchedule
|
|
47
|
+
from .incentive import IncentiveError, OptionPool, Ratchet, Vesting
|
|
48
|
+
from .money import ONE, ZERO, Money, money, safe_div
|
|
49
|
+
from .operating import (
|
|
50
|
+
DEFAULT_NOL_USAGE_LIMIT,
|
|
51
|
+
AcquiredStream,
|
|
52
|
+
OperatingAssumptions,
|
|
53
|
+
OperatingModel,
|
|
54
|
+
)
|
|
55
|
+
from .outcome import Outcome, Security, SecurityKind, default_securities
|
|
56
|
+
from .periods import Frequency, PeriodGrid
|
|
57
|
+
from .tax import (
|
|
58
|
+
EarningsBase,
|
|
59
|
+
InterestLimit,
|
|
60
|
+
LeveredSolution,
|
|
61
|
+
TaxPolicy,
|
|
62
|
+
TaxPolicyError,
|
|
63
|
+
solve_levered,
|
|
64
|
+
)
|
|
65
|
+
from .transaction import DebtFunding, EntryValuation, LineItem, Transaction
|
|
66
|
+
|
|
67
|
+
__all__ = [
|
|
68
|
+
"Deal",
|
|
69
|
+
"DealSpecError",
|
|
70
|
+
"EquityPlan",
|
|
71
|
+
"Funding",
|
|
72
|
+
"IncentivePlan",
|
|
73
|
+
"SecurityPlan",
|
|
74
|
+
"load_deal",
|
|
75
|
+
"parse_deal",
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
#: The default exit fee rate, named so the dataclass field below can carry it.
|
|
79
|
+
ZERO_RATE = money(0)
|
|
80
|
+
|
|
81
|
+
_FREQUENCIES = {
|
|
82
|
+
"annual": Frequency.ANNUAL,
|
|
83
|
+
"semi-annual": Frequency.SEMI_ANNUAL,
|
|
84
|
+
"quarterly": Frequency.QUARTERLY,
|
|
85
|
+
"monthly": Frequency.MONTHLY,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Funding(Enum):
|
|
90
|
+
"""Where a security's capital comes from.
|
|
91
|
+
|
|
92
|
+
``SPONSOR`` and ``ROLLOVER`` point at cheques the funding table derives.
|
|
93
|
+
``STATED`` is an amount written directly into the file, which is the right
|
|
94
|
+
answer for a co-investor whose commitment is fixed and the wrong one for
|
|
95
|
+
anything that moves when the price does.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
SPONSOR = "sponsor"
|
|
99
|
+
ROLLOVER = "rollover"
|
|
100
|
+
STATED = "stated"
|
|
101
|
+
|
|
102
|
+
def __str__(self) -> str:
|
|
103
|
+
return self.value
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True, slots=True)
|
|
107
|
+
class SecurityPlan:
|
|
108
|
+
"""One instrument, described the way the file describes it.
|
|
109
|
+
|
|
110
|
+
This exists because a ``Security`` holds an amount, and an amount is the
|
|
111
|
+
wrong thing to remember about a sponsor cheque. The sponsor's contribution
|
|
112
|
+
is the plug that balances the funding table: move the entry multiple by a
|
|
113
|
+
quarter of a turn and the cheque moves with it. A plan resolved once at
|
|
114
|
+
parse time and then carried around as a number would report every multiple
|
|
115
|
+
in a sensitivity column against the base case's denominator, which is a
|
|
116
|
+
quiet, plausible and completely wrong answer.
|
|
117
|
+
|
|
118
|
+
So the *description* survives — 85% of whatever the sponsor writes — and the
|
|
119
|
+
amount is derived against whichever transaction is being valued.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
name: str
|
|
123
|
+
kind: SecurityKind
|
|
124
|
+
funding: Funding
|
|
125
|
+
share: Money = ONE
|
|
126
|
+
amount: Money = ZERO
|
|
127
|
+
ownership: Money = ZERO
|
|
128
|
+
preferred_rate: Money = ZERO
|
|
129
|
+
compounding: bool = True
|
|
130
|
+
seniority: int = 0
|
|
131
|
+
|
|
132
|
+
def __post_init__(self) -> None:
|
|
133
|
+
# Everything checkable without a transaction is checked here, so a
|
|
134
|
+
# malformed file still fails when it is read rather than at the moment
|
|
135
|
+
# somebody asks for a number.
|
|
136
|
+
if not self.name.strip():
|
|
137
|
+
raise ValueError("a security needs a name")
|
|
138
|
+
if not (0 <= self.share <= 1):
|
|
139
|
+
raise ValueError(f"{self.name}: a share, so between 0 and 1")
|
|
140
|
+
if self.amount < 0:
|
|
141
|
+
raise ValueError(f"{self.name}: capital invested must not be negative")
|
|
142
|
+
if not (0 <= self.ownership <= 1):
|
|
143
|
+
raise ValueError(f"{self.name}: ownership is a share, so between 0 and 1")
|
|
144
|
+
if self.preferred_rate < 0:
|
|
145
|
+
raise ValueError(f"{self.name}: the preferred return must not be negative")
|
|
146
|
+
if self.preferred_rate and self.kind is not SecurityKind.PREFERRED:
|
|
147
|
+
raise ValueError(
|
|
148
|
+
f"{self.name}: a preferred return accrues on preferred capital, and "
|
|
149
|
+
f"this is described as {self.kind}"
|
|
150
|
+
)
|
|
151
|
+
if self.seniority < 0:
|
|
152
|
+
raise ValueError(f"{self.name}: seniority must not be negative")
|
|
153
|
+
|
|
154
|
+
def capital(self, transaction: Transaction) -> Money:
|
|
155
|
+
"""What this instrument puts in, given a transaction.
|
|
156
|
+
|
|
157
|
+
The sponsor cheque is floored at zero. A deal funded entirely by debt
|
|
158
|
+
and rollover has a negative plug — the structure raises more than the
|
|
159
|
+
purchase needs — and that is cash coming off the table rather than
|
|
160
|
+
capital contributed at a negative multiple.
|
|
161
|
+
"""
|
|
162
|
+
if self.funding is Funding.STATED:
|
|
163
|
+
return self.amount
|
|
164
|
+
cheque = (
|
|
165
|
+
max(transaction.sponsor_equity, ZERO)
|
|
166
|
+
if self.funding is Funding.SPONSOR
|
|
167
|
+
else transaction.rollover_equity
|
|
168
|
+
)
|
|
169
|
+
return cheque * self.share
|
|
170
|
+
|
|
171
|
+
def resolve(self, transaction: Transaction) -> Security:
|
|
172
|
+
"""This plan as a security, priced against ``transaction``."""
|
|
173
|
+
return Security(
|
|
174
|
+
name=self.name,
|
|
175
|
+
kind=self.kind,
|
|
176
|
+
invested=self.capital(transaction),
|
|
177
|
+
ownership=self.ownership,
|
|
178
|
+
preferred_rate=self.preferred_rate,
|
|
179
|
+
compounding=self.compounding,
|
|
180
|
+
seniority=self.seniority,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass(frozen=True, slots=True)
|
|
185
|
+
class EquityPlan:
|
|
186
|
+
"""The equity as the file describes it, before any transaction is applied."""
|
|
187
|
+
|
|
188
|
+
plans: tuple[SecurityPlan, ...] = ()
|
|
189
|
+
|
|
190
|
+
def __post_init__(self) -> None:
|
|
191
|
+
names = [p.name for p in self.plans]
|
|
192
|
+
if len(names) != len(set(names)):
|
|
193
|
+
raise ValueError("security names must be distinct")
|
|
194
|
+
# Every cheque referenced has to be fully allocated. Capital left over
|
|
195
|
+
# is capital that shares in nothing, which is never what was meant and
|
|
196
|
+
# would quietly overstate every multiple in the report.
|
|
197
|
+
claimed: dict[Funding, Money] = {}
|
|
198
|
+
for plan in self.plans:
|
|
199
|
+
if plan.funding is not Funding.STATED:
|
|
200
|
+
claimed[plan.funding] = claimed.get(plan.funding, ZERO) + plan.share
|
|
201
|
+
for source, total in claimed.items():
|
|
202
|
+
if total != ONE:
|
|
203
|
+
raise ValueError(
|
|
204
|
+
f"the {source} cheque is {total} allocated; the shares of a "
|
|
205
|
+
f"cheque must come to exactly 1"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def __bool__(self) -> bool:
|
|
209
|
+
return bool(self.plans)
|
|
210
|
+
|
|
211
|
+
def __len__(self) -> int:
|
|
212
|
+
return len(self.plans)
|
|
213
|
+
|
|
214
|
+
def __iter__(self) -> Iterator[SecurityPlan]:
|
|
215
|
+
return iter(self.plans)
|
|
216
|
+
|
|
217
|
+
def resolve(self, transaction: Transaction) -> tuple[Security, ...]:
|
|
218
|
+
return tuple(plan.resolve(transaction) for plan in self.plans)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@dataclass(frozen=True, slots=True)
|
|
222
|
+
class IncentivePlan:
|
|
223
|
+
"""The management plan as the file describes it, before a price is applied.
|
|
224
|
+
|
|
225
|
+
A plan exists for the same reason ``SecurityPlan`` does: the useful way to
|
|
226
|
+
describe a strike is not as an amount. Options granted at close are normally
|
|
227
|
+
struck at what the equity was worth that morning, so that management earn on
|
|
228
|
+
value they create and not on value they were handed. That figure moves with
|
|
229
|
+
the entry multiple, and a strike frozen at parse time would price every
|
|
230
|
+
column of a sensitivity grid against the base case's entry valuation.
|
|
231
|
+
|
|
232
|
+
``strike_at_entry`` is therefore a multiple of the fully diluted equity value
|
|
233
|
+
at close rather than an amount. The derivation matters and is easy to get
|
|
234
|
+
wrong: the existing holders' cheque buys them the share of the company the
|
|
235
|
+
pool does *not* hold, so the fully diluted value at close is that cheque
|
|
236
|
+
grossed up by one less the pool's share, and the strike is the pool's share
|
|
237
|
+
of it. Struck at exactly that, a plan is precisely at the money on a deal
|
|
238
|
+
that creates no value — which is what "struck at cost" is supposed to mean,
|
|
239
|
+
and what the naive derivation of share times cheque quietly fails to deliver.
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
name: str
|
|
243
|
+
share: Money
|
|
244
|
+
strike: Money = ZERO
|
|
245
|
+
strike_at_entry: Money | None = None
|
|
246
|
+
vesting: Vesting | None = None
|
|
247
|
+
ratchet: Ratchet | None = None
|
|
248
|
+
|
|
249
|
+
def __post_init__(self) -> None:
|
|
250
|
+
if not self.name.strip():
|
|
251
|
+
raise IncentiveError("an incentive plan needs a name")
|
|
252
|
+
if not (0 <= self.share < 1):
|
|
253
|
+
raise IncentiveError(
|
|
254
|
+
f"{self.name}: the pool holds a share of the equity, so at least 0 "
|
|
255
|
+
f"and below 1; got {self.share}"
|
|
256
|
+
)
|
|
257
|
+
if self.strike < 0:
|
|
258
|
+
raise IncentiveError(f"{self.name}: the strike must not be negative")
|
|
259
|
+
if self.strike_at_entry is not None and self.strike_at_entry < 0:
|
|
260
|
+
raise IncentiveError(
|
|
261
|
+
f"{self.name}: a strike at a negative multiple of the entry value "
|
|
262
|
+
f"is not a strike"
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
def strike_on(self, invested: Money) -> Money:
|
|
266
|
+
"""The aggregate cost of exercising the whole pool.
|
|
267
|
+
|
|
268
|
+
``invested`` is the equity capital the pool dilutes: every cheque in the
|
|
269
|
+
stack, preferred included, since the pool's share is of the company and
|
|
270
|
+
not of one instrument in it.
|
|
271
|
+
"""
|
|
272
|
+
if self.strike_at_entry is None:
|
|
273
|
+
return self.strike
|
|
274
|
+
entry_equity_value = safe_div(invested, ONE - self.share, default=ZERO)
|
|
275
|
+
return self.share * entry_equity_value * self.strike_at_entry
|
|
276
|
+
|
|
277
|
+
def resolve(self, securities: Sequence[Security]) -> OptionPool:
|
|
278
|
+
"""This plan as a pool, priced against the equity that funded the deal."""
|
|
279
|
+
invested = sum((s.invested for s in securities), ZERO)
|
|
280
|
+
return OptionPool(
|
|
281
|
+
name=self.name,
|
|
282
|
+
share=self.share,
|
|
283
|
+
strike=self.strike_on(invested),
|
|
284
|
+
vesting=self.vesting,
|
|
285
|
+
ratchet=self.ratchet,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@dataclass(frozen=True, slots=True)
|
|
290
|
+
class Deal:
|
|
291
|
+
"""Everything a deal file describes.
|
|
292
|
+
|
|
293
|
+
The transaction is always present; the projection is optional, because a
|
|
294
|
+
funding table is a useful thing to look at on its own before an operating
|
|
295
|
+
case exists.
|
|
296
|
+
"""
|
|
297
|
+
|
|
298
|
+
name: str
|
|
299
|
+
transaction: Transaction
|
|
300
|
+
close_date: date | None = None
|
|
301
|
+
grid: PeriodGrid | None = None
|
|
302
|
+
operating: OperatingAssumptions | None = None
|
|
303
|
+
opening_revenue: Money | None = None
|
|
304
|
+
opening_net_working_capital: Money | None = None
|
|
305
|
+
#: The cap on the interest deduction, where the file describes one.
|
|
306
|
+
interest_limit: InterestLimit | None = None
|
|
307
|
+
book: TargetBookBalanceSheet | None = None
|
|
308
|
+
accounting: PurchaseAccounting | None = None
|
|
309
|
+
structure: CapitalStructure | None = None
|
|
310
|
+
opening_cash: Money | None = None
|
|
311
|
+
covenants: tuple[Covenant, ...] = ()
|
|
312
|
+
exit_multiple: Money | None = None
|
|
313
|
+
exit_fee_rate: Money = ZERO_RATE
|
|
314
|
+
equity: EquityPlan = EquityPlan()
|
|
315
|
+
incentive: IncentivePlan | None = None
|
|
316
|
+
recapitalisations: tuple[Recapitalisation, ...] = ()
|
|
317
|
+
acquisitions: tuple[AddOn, ...] = ()
|
|
318
|
+
refinancings: tuple[Refinancing, ...] = ()
|
|
319
|
+
|
|
320
|
+
@property
|
|
321
|
+
def has_acquisitions(self) -> bool:
|
|
322
|
+
return bool(self.acquisitions)
|
|
323
|
+
|
|
324
|
+
@property
|
|
325
|
+
def has_refinancings(self) -> bool:
|
|
326
|
+
return bool(self.refinancings)
|
|
327
|
+
|
|
328
|
+
@property
|
|
329
|
+
def blended_entry(self) -> BlendedEntry:
|
|
330
|
+
"""The platform and its add-ons priced as a single entry.
|
|
331
|
+
|
|
332
|
+
Available whether or not the file describes any acquisitions: a deal
|
|
333
|
+
with none blends to its own multiple, which keeps every caller from
|
|
334
|
+
having to ask first.
|
|
335
|
+
"""
|
|
336
|
+
valuation = self.transaction.valuation
|
|
337
|
+
return BlendedEntry(
|
|
338
|
+
platform_enterprise_value=valuation.enterprise_value,
|
|
339
|
+
platform_ebitda=valuation.ltm_ebitda,
|
|
340
|
+
add_ons=self.acquisitions,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
def acquired_streams(self) -> tuple[AcquiredStream, ...]:
|
|
344
|
+
"""The acquired earnings, each carried at a margin this deal implies.
|
|
345
|
+
|
|
346
|
+
An acquisition that did not state its revenue is carried at the
|
|
347
|
+
platform's margin in the period it closes — not at the margin in the
|
|
348
|
+
first projected period, and not at an average. A business bought in year
|
|
349
|
+
three is bought out of the market as it stands in year three, and by
|
|
350
|
+
then a case with a margin ramp in it is somewhere quite different from
|
|
351
|
+
where it started.
|
|
352
|
+
"""
|
|
353
|
+
if not self.acquisitions or self.operating is None:
|
|
354
|
+
return ()
|
|
355
|
+
margins = self.operating.ebitda_margin
|
|
356
|
+
try:
|
|
357
|
+
return tuple(
|
|
358
|
+
a.stream(margins.at(a.period - 1)) for a in self.acquisitions
|
|
359
|
+
)
|
|
360
|
+
except AddOnError as exc:
|
|
361
|
+
raise DealSpecError(f"acquisitions: {exc}") from exc
|
|
362
|
+
|
|
363
|
+
@property
|
|
364
|
+
def pool(self) -> OptionPool | None:
|
|
365
|
+
"""The management plan, struck against this deal's equity.
|
|
366
|
+
|
|
367
|
+
Derived for the same reason the securities are: a deal rebuilt at a
|
|
368
|
+
different entry multiple raises a different sponsor cheque, and a plan
|
|
369
|
+
struck at the equity value of that cheque has to move with it.
|
|
370
|
+
"""
|
|
371
|
+
if self.incentive is None:
|
|
372
|
+
return None
|
|
373
|
+
return self.incentive.resolve(self.securities or default_securities(self.transaction))
|
|
374
|
+
|
|
375
|
+
@property
|
|
376
|
+
def securities(self) -> tuple[Security, ...]:
|
|
377
|
+
"""The equity, priced against this deal's transaction.
|
|
378
|
+
|
|
379
|
+
Derived rather than stored, so a deal rebuilt on a different price
|
|
380
|
+
carries an equity stack that agrees with the funding table that price
|
|
381
|
+
produced.
|
|
382
|
+
"""
|
|
383
|
+
return self.equity.resolve(self.transaction)
|
|
384
|
+
|
|
385
|
+
@property
|
|
386
|
+
def has_projection(self) -> bool:
|
|
387
|
+
return self.grid is not None and self.operating is not None
|
|
388
|
+
|
|
389
|
+
@property
|
|
390
|
+
def has_balance_sheet(self) -> bool:
|
|
391
|
+
return self.book is not None
|
|
392
|
+
|
|
393
|
+
def recapitalise(self) -> OpeningBalanceSheet:
|
|
394
|
+
"""Build the balance sheet the target carries out of close.
|
|
395
|
+
|
|
396
|
+
Requires the target's own book position, which the funding table does
|
|
397
|
+
not need and so is not required to describe a deal.
|
|
398
|
+
"""
|
|
399
|
+
if self.book is None:
|
|
400
|
+
raise DealSpecError(
|
|
401
|
+
'this deal has no opening balance sheet; add a "target" block '
|
|
402
|
+
"describing the book position before close"
|
|
403
|
+
)
|
|
404
|
+
return OpeningBalanceSheet.recapitalise(self.transaction, self.book, self.accounting)
|
|
405
|
+
|
|
406
|
+
@property
|
|
407
|
+
def has_structure(self) -> bool:
|
|
408
|
+
return self.structure is not None
|
|
409
|
+
|
|
410
|
+
@property
|
|
411
|
+
def cash_at_close(self) -> Money:
|
|
412
|
+
"""Cash the business holds the morning after the deal.
|
|
413
|
+
|
|
414
|
+
What the target held, less what the deal took out of it, plus what the
|
|
415
|
+
structure funded back in. Taking this from the transaction rather than
|
|
416
|
+
asking for it again keeps the schedule from opening on a cash balance
|
|
417
|
+
the funding table never produced.
|
|
418
|
+
"""
|
|
419
|
+
valuation = self.transaction.valuation
|
|
420
|
+
return (
|
|
421
|
+
valuation.existing_cash
|
|
422
|
+
- self.transaction.cash_from_balance_sheet
|
|
423
|
+
+ self.transaction.cash_to_balance_sheet
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
def fee_schedule(self, method: FeeMethod = FeeMethod.EFFECTIVE_INTEREST) -> FeeSchedule:
|
|
427
|
+
"""Release the capitalised financing costs across the projection.
|
|
428
|
+
|
|
429
|
+
The amounts come from the funding table, which is the only place that
|
|
430
|
+
knows how each tranche was placed: the arrangement fee charged on face
|
|
431
|
+
and the discount it cleared at, carried together because they are one
|
|
432
|
+
adjustment to what the paper is worth on the balance sheet.
|
|
433
|
+
|
|
434
|
+
Floating tranches are priced off the base rate at close rather than off
|
|
435
|
+
the curve. The effective rate is a property of the paper as it was
|
|
436
|
+
placed, and re-solving it every period as the forward curve moves would
|
|
437
|
+
restate a historic cost with information that arrived later.
|
|
438
|
+
"""
|
|
439
|
+
if self.structure is None or self.grid is None:
|
|
440
|
+
raise DealSpecError(
|
|
441
|
+
"capitalised costs are released across a projection, so this needs "
|
|
442
|
+
'both a "structure" and a "projection" block'
|
|
443
|
+
)
|
|
444
|
+
priced = {t.name: t for t in self.transaction.debt}
|
|
445
|
+
capitalised = {
|
|
446
|
+
t.name: priced[t.name].financing_fee + priced[t.name].original_issue_discount
|
|
447
|
+
for t in self.structure
|
|
448
|
+
if t.name in priced
|
|
449
|
+
}
|
|
450
|
+
return FeeSchedule.build(
|
|
451
|
+
self.structure,
|
|
452
|
+
capitalised,
|
|
453
|
+
len(self.grid),
|
|
454
|
+
has_stub=self.grid.has_stub,
|
|
455
|
+
periods_per_year=self.grid.frequency.periods_per_year,
|
|
456
|
+
base_rate=self.structure.base_at(0),
|
|
457
|
+
method=method,
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
def write_offs(self) -> dict[int, Money]:
|
|
461
|
+
"""The unamortised balance each takeout charges off, by period.
|
|
462
|
+
|
|
463
|
+
Only for the events that did not state one. A file that put a figure in
|
|
464
|
+
keeps it, and the schedule is never handed a second opinion to choose
|
|
465
|
+
between.
|
|
466
|
+
"""
|
|
467
|
+
pending = [r for r in self.refinancings if not r.states_its_write_off]
|
|
468
|
+
if not pending:
|
|
469
|
+
return {}
|
|
470
|
+
fees = self.fee_schedule()
|
|
471
|
+
# The takeout lands at the end of its period, so the balance charged
|
|
472
|
+
# off is the one left after that period's own release has run. A stub
|
|
473
|
+
# sits in front of period one on a grid that has one, which moves every
|
|
474
|
+
# column along by one.
|
|
475
|
+
assert self.grid is not None # fee_schedule refuses a deal without one
|
|
476
|
+
offset = 0 if self.grid.has_stub else 1
|
|
477
|
+
return {
|
|
478
|
+
event.period: fees.unamortised_at(event.tranche, event.period - offset)
|
|
479
|
+
for event in pending
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
def schedule(self, model: OperatingModel | None = None) -> DebtSchedule:
|
|
483
|
+
"""Run the capital structure against the operating case.
|
|
484
|
+
|
|
485
|
+
Needs both: a structure with nothing to service is not a schedule, and
|
|
486
|
+
an operating case with no structure is the projection that already
|
|
487
|
+
exists one layer down.
|
|
488
|
+
|
|
489
|
+
``model`` is taken rather than run again when the caller already has it.
|
|
490
|
+
A schedule solves a fixed point and a caller running the same deal many
|
|
491
|
+
times over — a sensitivity grid, say — should pay for that once per
|
|
492
|
+
case rather than once per question asked of it.
|
|
493
|
+
"""
|
|
494
|
+
if self.structure is None:
|
|
495
|
+
raise DealSpecError(
|
|
496
|
+
'this deal has no capital structure; add a "structure" block and '
|
|
497
|
+
"price the tranches under \"debt\""
|
|
498
|
+
)
|
|
499
|
+
try:
|
|
500
|
+
return DebtSchedule.from_operating_model(
|
|
501
|
+
self.structure,
|
|
502
|
+
self.project() if model is None else model,
|
|
503
|
+
opening_cash=(
|
|
504
|
+
self.opening_cash
|
|
505
|
+
if self.opening_cash is not None
|
|
506
|
+
else self.cash_at_close
|
|
507
|
+
),
|
|
508
|
+
# The certificate that sets the first period's sweep step was
|
|
509
|
+
# signed on the LTM figure the deal was priced on, not on a
|
|
510
|
+
# projection.
|
|
511
|
+
opening_ebitda=self.transaction.valuation.ltm_ebitda,
|
|
512
|
+
recapitalisations=self.recapitalisations,
|
|
513
|
+
acquisitions=self.acquisitions,
|
|
514
|
+
refinancings=self.refinancings,
|
|
515
|
+
write_offs=self.write_offs(),
|
|
516
|
+
)
|
|
517
|
+
except RecapitalisationError as exc:
|
|
518
|
+
raise DealSpecError(f"recapitalisations: {exc}") from exc
|
|
519
|
+
except AddOnError as exc:
|
|
520
|
+
raise DealSpecError(f"acquisitions: {exc}") from exc
|
|
521
|
+
except RefinancingError as exc:
|
|
522
|
+
raise DealSpecError(f"refinancings: {exc}") from exc
|
|
523
|
+
|
|
524
|
+
@property
|
|
525
|
+
def tax_policy(self) -> TaxPolicy:
|
|
526
|
+
"""The tax regime this deal is run under.
|
|
527
|
+
|
|
528
|
+
Built from the operating case rather than from anything separate, so
|
|
529
|
+
the rate and the loss limit cannot drift from the projection the saving
|
|
530
|
+
is measured against.
|
|
531
|
+
"""
|
|
532
|
+
if self.operating is None:
|
|
533
|
+
raise DealSpecError(
|
|
534
|
+
'this deal has no operating case; add "projection" and '
|
|
535
|
+
'"operating" blocks to the deal file'
|
|
536
|
+
)
|
|
537
|
+
return TaxPolicy.from_assumptions(self.operating, interest_limit=self.interest_limit)
|
|
538
|
+
|
|
539
|
+
def levered(self, model: OperatingModel | None = None) -> LeveredSolution:
|
|
540
|
+
"""Run the structure with the interest deduction settled against it.
|
|
541
|
+
|
|
542
|
+
The schedule :meth:`schedule` produces services the structure out of
|
|
543
|
+
unlevered free cash flow, which is to say out of a cash flow taxed as
|
|
544
|
+
though there were no structure. This one closes that loop.
|
|
545
|
+
|
|
546
|
+
``model`` is taken rather than projected again for the same reason it
|
|
547
|
+
is there: a caller asking several questions of one case should pay for
|
|
548
|
+
it once, and a solve runs the schedule several times over.
|
|
549
|
+
"""
|
|
550
|
+
if self.structure is None:
|
|
551
|
+
raise DealSpecError(
|
|
552
|
+
'this deal has no capital structure; add a "structure" block and '
|
|
553
|
+
"price the tranches under \"debt\""
|
|
554
|
+
)
|
|
555
|
+
case = self.project() if model is None else model
|
|
556
|
+
assert self.operating is not None # tax_policy refuses a deal without one
|
|
557
|
+
periods = [p.period for p in case]
|
|
558
|
+
ebitda = [case.trailing_ebitda(i) for i in range(len(case))]
|
|
559
|
+
opening_cash = (
|
|
560
|
+
self.opening_cash if self.opening_cash is not None else self.cash_at_close
|
|
561
|
+
)
|
|
562
|
+
anchor = self.transaction.valuation.ltm_ebitda
|
|
563
|
+
write_offs = self.write_offs()
|
|
564
|
+
|
|
565
|
+
def run(cash_flows: Sequence[Money]) -> DebtSchedule:
|
|
566
|
+
return DebtSchedule.run(
|
|
567
|
+
self.structure, # type: ignore[arg-type]
|
|
568
|
+
periods,
|
|
569
|
+
cash_flows,
|
|
570
|
+
opening_cash=opening_cash,
|
|
571
|
+
ebitda=ebitda,
|
|
572
|
+
opening_ebitda=anchor,
|
|
573
|
+
recapitalisations=self.recapitalisations,
|
|
574
|
+
acquisitions=self.acquisitions,
|
|
575
|
+
refinancings=self.refinancings,
|
|
576
|
+
write_offs=write_offs,
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
try:
|
|
580
|
+
return solve_levered(
|
|
581
|
+
model=case,
|
|
582
|
+
run=run,
|
|
583
|
+
policy=self.tax_policy,
|
|
584
|
+
opening_carryforward=self.operating.opening_carryforward,
|
|
585
|
+
)
|
|
586
|
+
except (RecapitalisationError, AddOnError, RefinancingError) as exc:
|
|
587
|
+
raise DealSpecError(f"{type(exc).__name__}: {exc}") from exc
|
|
588
|
+
|
|
589
|
+
@property
|
|
590
|
+
def has_recapitalisations(self) -> bool:
|
|
591
|
+
return bool(self.recapitalisations)
|
|
592
|
+
|
|
593
|
+
@property
|
|
594
|
+
def has_covenants(self) -> bool:
|
|
595
|
+
return bool(self.covenants)
|
|
596
|
+
|
|
597
|
+
def test_covenants(
|
|
598
|
+
self,
|
|
599
|
+
model: OperatingModel | None = None,
|
|
600
|
+
schedule: DebtSchedule | None = None,
|
|
601
|
+
) -> CovenantReport:
|
|
602
|
+
"""Run the described covenants against the schedule and the case.
|
|
603
|
+
|
|
604
|
+
Raises if the file described none rather than reporting a structure with
|
|
605
|
+
no tests as a structure that passes all of them.
|
|
606
|
+
"""
|
|
607
|
+
if not self.covenants:
|
|
608
|
+
raise DealSpecError(
|
|
609
|
+
'this deal has no covenants; add a "covenants" block describing '
|
|
610
|
+
"the maintenance tests"
|
|
611
|
+
)
|
|
612
|
+
case = self.project() if model is None else model
|
|
613
|
+
run = self.schedule(case) if schedule is None else schedule
|
|
614
|
+
return CovenantReport.test(self.covenants, run, case)
|
|
615
|
+
|
|
616
|
+
def realise(
|
|
617
|
+
self,
|
|
618
|
+
model: OperatingModel | None = None,
|
|
619
|
+
schedule: DebtSchedule | None = None,
|
|
620
|
+
) -> Outcome:
|
|
621
|
+
"""Value the exit and run the equity through it.
|
|
622
|
+
|
|
623
|
+
Needs a close date as well as a schedule, because a rate of return is
|
|
624
|
+
measured over elapsed time and the model has to know when the clock
|
|
625
|
+
started.
|
|
626
|
+
"""
|
|
627
|
+
if self.close_date is None:
|
|
628
|
+
raise DealSpecError(
|
|
629
|
+
"an exit is measured from close, so a close date is required"
|
|
630
|
+
)
|
|
631
|
+
case = self.project() if model is None else model
|
|
632
|
+
run = self.schedule(case) if schedule is None else schedule
|
|
633
|
+
try:
|
|
634
|
+
return Outcome.realise(
|
|
635
|
+
self.transaction,
|
|
636
|
+
case,
|
|
637
|
+
run,
|
|
638
|
+
entry_date=self.close_date,
|
|
639
|
+
exit_multiple=self.exit_multiple,
|
|
640
|
+
exit_fee_rate=self.exit_fee_rate,
|
|
641
|
+
securities=self.securities or None,
|
|
642
|
+
incentive=self.pool,
|
|
643
|
+
)
|
|
644
|
+
except IncentiveError as exc:
|
|
645
|
+
raise DealSpecError(f"exit.incentive: {exc}") from exc
|
|
646
|
+
except ValueError as exc:
|
|
647
|
+
raise DealSpecError(f"exit: {exc}") from exc
|
|
648
|
+
|
|
649
|
+
def project(self) -> OperatingModel:
|
|
650
|
+
"""Run the operating case.
|
|
651
|
+
|
|
652
|
+
Raises if the file did not describe one, rather than inventing
|
|
653
|
+
assumptions on the caller's behalf.
|
|
654
|
+
"""
|
|
655
|
+
if self.grid is None or self.operating is None or self.opening_revenue is None:
|
|
656
|
+
raise DealSpecError(
|
|
657
|
+
"this deal has no operating case; add \"projection\" and "
|
|
658
|
+
"\"operating\" blocks to the deal file"
|
|
659
|
+
)
|
|
660
|
+
try:
|
|
661
|
+
return OperatingModel.project(
|
|
662
|
+
self.grid,
|
|
663
|
+
self.operating,
|
|
664
|
+
opening_revenue=self.opening_revenue,
|
|
665
|
+
opening_net_working_capital=self.opening_net_working_capital,
|
|
666
|
+
acquisitions=self.acquired_streams(),
|
|
667
|
+
)
|
|
668
|
+
except ValueError as exc:
|
|
669
|
+
if isinstance(exc, DealSpecError):
|
|
670
|
+
raise
|
|
671
|
+
raise DealSpecError(f"acquisitions: {exc}") from exc
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
class DealSpecError(ValueError):
|
|
675
|
+
"""The deal file is missing something, or holds something it should not."""
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _require(data: dict[str, Any], key: str, where: str) -> Any:
|
|
679
|
+
if key not in data:
|
|
680
|
+
raise DealSpecError(f"{where}: missing required field {key!r}")
|
|
681
|
+
return data[key]
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _amount(value: Any, where: str) -> Money:
|
|
685
|
+
try:
|
|
686
|
+
return money(value if isinstance(value, (Decimal, int, str)) else str(value))
|
|
687
|
+
except (ValueError, TypeError, InvalidOperation) as exc:
|
|
688
|
+
raise DealSpecError(f"{where}: not a number: {value!r}") from exc
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _optional_amount(data: dict[str, Any], key: str, where: str, default: str = "0") -> Money:
|
|
692
|
+
if key not in data or data[key] is None:
|
|
693
|
+
return money(default)
|
|
694
|
+
return _amount(data[key], f"{where}.{key}")
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _flag(data: dict[str, Any], key: str, where: str) -> bool | None:
|
|
698
|
+
value = data.get(key)
|
|
699
|
+
if value is None:
|
|
700
|
+
return None
|
|
701
|
+
if not isinstance(value, bool):
|
|
702
|
+
raise DealSpecError(f"{where}.{key}: expected true or false, got {value!r}")
|
|
703
|
+
return value
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _whole(data: dict[str, Any], key: str, where: str) -> int | None:
|
|
707
|
+
value = data.get(key)
|
|
708
|
+
if value is None:
|
|
709
|
+
return None
|
|
710
|
+
try:
|
|
711
|
+
return int(value)
|
|
712
|
+
except (TypeError, ValueError) as exc:
|
|
713
|
+
raise DealSpecError(f"{where}.{key}: not a whole number: {value!r}") from exc
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _when(
|
|
717
|
+
data: dict[str, Any], key: str, where: str, frequency: Frequency
|
|
718
|
+
) -> int | None:
|
|
719
|
+
"""A period number, stated either as a column or as a year.
|
|
720
|
+
|
|
721
|
+
A bare number is a period, which is what it has always been and the right
|
|
722
|
+
unit for something that genuinely happens in a particular quarter — an
|
|
723
|
+
add-on that closes in the third one. It does not survive a change of
|
|
724
|
+
frequency, and it is not supposed to: period three is period three.
|
|
725
|
+
|
|
726
|
+
A number suffixed with ``y`` is the end of that year, resolved against the
|
|
727
|
+
grid. ``"7y"`` is period 7 on an annual grid and period 28 on a quarterly
|
|
728
|
+
one, which are the same date. That is what a maturity means, and stating it
|
|
729
|
+
as a bare 7 is how a file switched to quarterly retires its term loan a year
|
|
730
|
+
and a half into a five-year hold with nothing raised.
|
|
731
|
+
"""
|
|
732
|
+
value = data.get(key)
|
|
733
|
+
if value is None:
|
|
734
|
+
return None
|
|
735
|
+
if isinstance(value, str):
|
|
736
|
+
text = value.strip().lower()
|
|
737
|
+
if text.endswith("y"):
|
|
738
|
+
try:
|
|
739
|
+
year = int(text[:-1])
|
|
740
|
+
except ValueError as exc:
|
|
741
|
+
raise DealSpecError(
|
|
742
|
+
f"{where}.{key}: not a number of years: {value!r}"
|
|
743
|
+
) from exc
|
|
744
|
+
try:
|
|
745
|
+
return frequency.period_ending_year(year)
|
|
746
|
+
except ValueError as exc:
|
|
747
|
+
raise DealSpecError(f"{where}.{key}: {exc}") from exc
|
|
748
|
+
return _whole(data, key, where)
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _tranche(data: Any, index: int) -> DebtFunding:
|
|
752
|
+
"""The funding view of a tranche: what it raises and what it costs to raise."""
|
|
753
|
+
where = f"debt[{index}]"
|
|
754
|
+
if not isinstance(data, dict):
|
|
755
|
+
raise DealSpecError(f"{where}: expected an object")
|
|
756
|
+
return DebtFunding(
|
|
757
|
+
name=str(_require(data, "name", where)),
|
|
758
|
+
face=_amount(_require(data, "face", where), f"{where}.face"),
|
|
759
|
+
issue_price=_optional_amount(data, "issue_price", where, default="1"),
|
|
760
|
+
financing_fee_rate=_optional_amount(data, "financing_fee_rate", where),
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _schedule_tranche(
|
|
765
|
+
data: Any, index: int, periods: int, frequency: Frequency
|
|
766
|
+
) -> Tranche:
|
|
767
|
+
"""The schedule view of the same tranche: what it costs to carry.
|
|
768
|
+
|
|
769
|
+
Deliberately the same object in the file. A structure described twice is a
|
|
770
|
+
structure that will eventually disagree with itself — the funding table
|
|
771
|
+
showing one face and the schedule accruing on another.
|
|
772
|
+
"""
|
|
773
|
+
where = f"debt[{index}]"
|
|
774
|
+
if not isinstance(data, dict):
|
|
775
|
+
raise DealSpecError(f"{where}: expected an object")
|
|
776
|
+
|
|
777
|
+
kind_name = str(data.get("kind", TrancheKind.TERM_LOAN.value)).lower()
|
|
778
|
+
try:
|
|
779
|
+
kind = TrancheKind(kind_name)
|
|
780
|
+
except ValueError as exc:
|
|
781
|
+
raise DealSpecError(
|
|
782
|
+
f"{where}.kind: unknown kind {kind_name!r}; expected one of "
|
|
783
|
+
f"{', '.join(k.value for k in TrancheKind)}"
|
|
784
|
+
) from exc
|
|
785
|
+
|
|
786
|
+
amortisation = (
|
|
787
|
+
_driver(data["amortisation"], periods, f"{where}.amortisation")
|
|
788
|
+
if data.get("amortisation") is not None
|
|
789
|
+
else None
|
|
790
|
+
)
|
|
791
|
+
commitment = (
|
|
792
|
+
_amount(data["commitment"], f"{where}.commitment")
|
|
793
|
+
if data.get("commitment") is not None
|
|
794
|
+
else None
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
try:
|
|
798
|
+
return Tranche.of(
|
|
799
|
+
str(_require(data, "name", where)),
|
|
800
|
+
kind,
|
|
801
|
+
_amount(_require(data, "face", where), f"{where}.face"),
|
|
802
|
+
cash_rate=_optional_amount(data, "cash_rate", where),
|
|
803
|
+
pik_rate=_optional_amount(data, "pik_rate", where),
|
|
804
|
+
floating=_flag(data, "floating", where),
|
|
805
|
+
floor=_optional_amount(data, "floor", where),
|
|
806
|
+
amortisation=amortisation,
|
|
807
|
+
seniority=_whole(data, "seniority", where),
|
|
808
|
+
swept=_flag(data, "swept", where),
|
|
809
|
+
commitment=commitment,
|
|
810
|
+
undrawn_fee=_optional_amount(data, "undrawn_fee", where),
|
|
811
|
+
availability=_when(data, "availability", where, frequency),
|
|
812
|
+
maturity=_when(data, "maturity", where, frequency),
|
|
813
|
+
)
|
|
814
|
+
except ValueError as exc:
|
|
815
|
+
raise DealSpecError(f"{where}: {exc}") from exc
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def _other_use(data: Any, index: int) -> LineItem:
|
|
819
|
+
where = f"other_uses[{index}]"
|
|
820
|
+
if not isinstance(data, dict):
|
|
821
|
+
raise DealSpecError(f"{where}: expected an object")
|
|
822
|
+
capitalised = data.get("capitalised", False)
|
|
823
|
+
if not isinstance(capitalised, bool):
|
|
824
|
+
raise DealSpecError(
|
|
825
|
+
f"{where}.capitalised: expected true or false, got {capitalised!r}"
|
|
826
|
+
)
|
|
827
|
+
return LineItem(
|
|
828
|
+
label=str(_require(data, "label", where)),
|
|
829
|
+
amount=_amount(_require(data, "amount", where), f"{where}.amount"),
|
|
830
|
+
note=str(data.get("note", "")),
|
|
831
|
+
capitalised=capitalised,
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def _driver(value: Any, periods: int, where: str) -> Driver:
|
|
836
|
+
"""Read one assumption series.
|
|
837
|
+
|
|
838
|
+
Four shapes are accepted, and they exist because operating cases are
|
|
839
|
+
written four ways:
|
|
840
|
+
|
|
841
|
+
* a bare number — flat across the hold;
|
|
842
|
+
* a list — a value per period, spelled out;
|
|
843
|
+
* ``{"constant": x}`` — the same, said explicitly;
|
|
844
|
+
* ``{"ramp": [start, end]}`` — a straight line between two values.
|
|
845
|
+
|
|
846
|
+
A series shorter than the projection holds its final value, so supplying
|
|
847
|
+
three years of assumptions against a five-year hold is a decision rather
|
|
848
|
+
than a crash.
|
|
849
|
+
"""
|
|
850
|
+
if isinstance(value, dict):
|
|
851
|
+
if "ramp" in value:
|
|
852
|
+
ends = value["ramp"]
|
|
853
|
+
if not isinstance(ends, list) or len(ends) != 2:
|
|
854
|
+
raise DealSpecError(
|
|
855
|
+
f"{where}.ramp: expected exactly two values, a start and an end"
|
|
856
|
+
)
|
|
857
|
+
return Driver.ramp(_amount(ends[0], where), _amount(ends[1], where), periods)
|
|
858
|
+
if "constant" in value:
|
|
859
|
+
return Driver.constant(_amount(value["constant"], where), periods)
|
|
860
|
+
if "values" in value:
|
|
861
|
+
return _driver(value["values"], periods, where)
|
|
862
|
+
raise DealSpecError(f"{where}: expected one of 'constant', 'ramp' or 'values'")
|
|
863
|
+
if isinstance(value, list):
|
|
864
|
+
if not value:
|
|
865
|
+
raise DealSpecError(f"{where}: an empty series says nothing")
|
|
866
|
+
return Driver.of([_amount(v, where) for v in value]).extended_to(periods)
|
|
867
|
+
return Driver.constant(_amount(value, where), periods)
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def _parse_target(data: Any) -> tuple[TargetBookBalanceSheet, PurchaseAccounting]:
|
|
871
|
+
"""Read the target's book position and how the price is allocated over it."""
|
|
872
|
+
where = "target"
|
|
873
|
+
if not isinstance(data, dict):
|
|
874
|
+
raise DealSpecError(f"{where}: expected an object")
|
|
875
|
+
book = TargetBookBalanceSheet(
|
|
876
|
+
total_assets=_amount(
|
|
877
|
+
_require(data, "total_assets", where), f"{where}.total_assets"
|
|
878
|
+
),
|
|
879
|
+
total_liabilities=_amount(
|
|
880
|
+
_require(data, "total_liabilities", where), f"{where}.total_liabilities"
|
|
881
|
+
),
|
|
882
|
+
goodwill=_optional_amount(data, "goodwill", where),
|
|
883
|
+
)
|
|
884
|
+
accounting = PurchaseAccounting(
|
|
885
|
+
step_up=_optional_amount(data, "step_up", where),
|
|
886
|
+
step_up_tax_rate=_optional_amount(data, "step_up_tax_rate", where),
|
|
887
|
+
)
|
|
888
|
+
return book, accounting
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
_DAY_COUNTS = {c.value.lower(): c for c in DayCount}
|
|
892
|
+
_INTEREST_BASES = {b.value: b for b in InterestBasis}
|
|
893
|
+
_MEASURES = {m.value: m for m in Measure}
|
|
894
|
+
_SECURITY_KINDS = {k.value: k for k in SecurityKind}
|
|
895
|
+
_FUNDING_SOURCES = {
|
|
896
|
+
Funding.SPONSOR.value: Funding.SPONSOR,
|
|
897
|
+
Funding.ROLLOVER.value: Funding.ROLLOVER,
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def _parse_security(data: Any, index: int) -> SecurityPlan:
|
|
902
|
+
"""Read one equity instrument, and what funds it.
|
|
903
|
+
|
|
904
|
+
Capital can be stated outright or, more usefully, as a share of a cheque the
|
|
905
|
+
funding table derives. The sponsor's contribution is a plug — it is whatever
|
|
906
|
+
balances the deal — so a file that restates it as a number will disagree
|
|
907
|
+
with the funding table the first time any other assumption moves.
|
|
908
|
+
"""
|
|
909
|
+
where = f"exit.equity[{index}]"
|
|
910
|
+
if not isinstance(data, dict):
|
|
911
|
+
raise DealSpecError(f"{where}: expected an object")
|
|
912
|
+
|
|
913
|
+
kind_name = str(data.get("kind", SecurityKind.COMMON.value)).lower()
|
|
914
|
+
if kind_name not in _SECURITY_KINDS:
|
|
915
|
+
raise DealSpecError(
|
|
916
|
+
f"{where}.kind: unknown kind {data.get('kind')!r}; expected one of "
|
|
917
|
+
f"{', '.join(sorted(_SECURITY_KINDS))}"
|
|
918
|
+
)
|
|
919
|
+
|
|
920
|
+
source = data.get("of")
|
|
921
|
+
if source is None and data.get("invested") is None:
|
|
922
|
+
raise DealSpecError(
|
|
923
|
+
f"{where}: say what funds this security — either 'invested' as an "
|
|
924
|
+
f"amount, or 'of' naming a cheque from the funding table"
|
|
925
|
+
)
|
|
926
|
+
if source is not None and data.get("invested") is not None:
|
|
927
|
+
raise DealSpecError(
|
|
928
|
+
f"{where}: 'invested' and 'of' are two answers to the same question"
|
|
929
|
+
)
|
|
930
|
+
|
|
931
|
+
if source is None:
|
|
932
|
+
funding = Funding.STATED
|
|
933
|
+
amount = _amount(data["invested"], f"{where}.invested")
|
|
934
|
+
share = money(1)
|
|
935
|
+
else:
|
|
936
|
+
name = str(source).lower()
|
|
937
|
+
if name not in _FUNDING_SOURCES:
|
|
938
|
+
raise DealSpecError(
|
|
939
|
+
f"{where}.of: unknown source {source!r}; expected sponsor or rollover"
|
|
940
|
+
)
|
|
941
|
+
funding = _FUNDING_SOURCES[name]
|
|
942
|
+
amount = money(0)
|
|
943
|
+
share = _optional_amount(data, "share", where, default="1")
|
|
944
|
+
if not (0 <= share <= 1):
|
|
945
|
+
raise DealSpecError(f"{where}.share: a share, so between 0 and 1")
|
|
946
|
+
|
|
947
|
+
compounding = _flag(data, "compounding", where)
|
|
948
|
+
try:
|
|
949
|
+
return SecurityPlan(
|
|
950
|
+
name=str(_require(data, "name", where)),
|
|
951
|
+
kind=_SECURITY_KINDS[kind_name],
|
|
952
|
+
funding=funding,
|
|
953
|
+
share=share,
|
|
954
|
+
amount=amount,
|
|
955
|
+
ownership=_optional_amount(data, "ownership", where),
|
|
956
|
+
preferred_rate=_optional_amount(data, "preferred_rate", where),
|
|
957
|
+
compounding=True if compounding is None else compounding,
|
|
958
|
+
seniority=_whole(data, "seniority", where) or 0,
|
|
959
|
+
)
|
|
960
|
+
except ValueError as exc:
|
|
961
|
+
raise DealSpecError(f"{where}: {exc}") from exc
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def _parse_draw(data: Any, index: int, where: str, *, key: str = "draws") -> Draw:
|
|
965
|
+
"""Read one incremental take-down, priced on its own terms."""
|
|
966
|
+
at = f"{where}.{key}[{index}]"
|
|
967
|
+
if not isinstance(data, dict):
|
|
968
|
+
raise DealSpecError(f"{at}: expected an object")
|
|
969
|
+
try:
|
|
970
|
+
return Draw(
|
|
971
|
+
tranche=str(_require(data, "tranche", at)),
|
|
972
|
+
amount=_amount(_require(data, "amount", at), f"{at}.amount"),
|
|
973
|
+
issue_price=_optional_amount(data, "issue_price", at, default="1"),
|
|
974
|
+
financing_fee_rate=_optional_amount(data, "financing_fee_rate", at),
|
|
975
|
+
)
|
|
976
|
+
except RecapitalisationError as exc:
|
|
977
|
+
raise DealSpecError(f"{at}: {exc}") from exc
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
def _parse_recapitalisation(
|
|
981
|
+
data: Any, index: int, frequency: Frequency
|
|
982
|
+
) -> Recapitalisation:
|
|
983
|
+
"""Read one mid-hold raise, and what it pays out."""
|
|
984
|
+
where = f"recapitalisations[{index}]"
|
|
985
|
+
if not isinstance(data, dict):
|
|
986
|
+
raise DealSpecError(f"{where}: expected an object")
|
|
987
|
+
|
|
988
|
+
draws_raw = data.get("draws", [])
|
|
989
|
+
if not isinstance(draws_raw, list):
|
|
990
|
+
raise DealSpecError(f"{where}.draws: expected a list of take-downs")
|
|
991
|
+
|
|
992
|
+
period_raw = _when(data, "period", where, frequency)
|
|
993
|
+
if period_raw is None:
|
|
994
|
+
raise DealSpecError(
|
|
995
|
+
f"{where}: say which period this lands at the end of, numbered from one"
|
|
996
|
+
)
|
|
997
|
+
|
|
998
|
+
try:
|
|
999
|
+
return Recapitalisation(
|
|
1000
|
+
period=period_raw,
|
|
1001
|
+
draws=tuple(
|
|
1002
|
+
_parse_draw(item, i, where) for i, item in enumerate(draws_raw)
|
|
1003
|
+
),
|
|
1004
|
+
from_cash=_optional_amount(data, "from_cash", where),
|
|
1005
|
+
label=str(data.get("label", "Dividend recapitalisation")),
|
|
1006
|
+
)
|
|
1007
|
+
except RecapitalisationError as exc:
|
|
1008
|
+
raise DealSpecError(f"{where}: {exc}") from exc
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def _parse_acquisition(
|
|
1012
|
+
data: Any, index: int, periods: int, frequency: Frequency
|
|
1013
|
+
) -> AddOn:
|
|
1014
|
+
"""Read one business bought during the hold.
|
|
1015
|
+
|
|
1016
|
+
``revenue`` is optional and its absence is meaningful rather than lazy: a
|
|
1017
|
+
file that states only the earnings and the multiple is saying the business
|
|
1018
|
+
is carried at the platform's own margin, which is the assumption that
|
|
1019
|
+
changes the model least. Stating revenue replaces that assumption with the
|
|
1020
|
+
margin the business actually earns.
|
|
1021
|
+
"""
|
|
1022
|
+
where = f"acquisitions[{index}]"
|
|
1023
|
+
if not isinstance(data, dict):
|
|
1024
|
+
raise DealSpecError(f"{where}: expected an object")
|
|
1025
|
+
|
|
1026
|
+
period = _whole(data, "period", where)
|
|
1027
|
+
if period is None:
|
|
1028
|
+
raise DealSpecError(
|
|
1029
|
+
f"{where}: say which period this closes at the end of, numbered from one"
|
|
1030
|
+
)
|
|
1031
|
+
|
|
1032
|
+
draws_raw = data.get("draws", [])
|
|
1033
|
+
if not isinstance(draws_raw, list):
|
|
1034
|
+
raise DealSpecError(f"{where}.draws: expected a list of take-downs")
|
|
1035
|
+
|
|
1036
|
+
growth_raw = data.get("growth")
|
|
1037
|
+
growth = (
|
|
1038
|
+
None if growth_raw is None else _driver(growth_raw, periods, f"{where}.growth")
|
|
1039
|
+
)
|
|
1040
|
+
phase_in = _whole(data, "synergy_phase_in", where)
|
|
1041
|
+
|
|
1042
|
+
try:
|
|
1043
|
+
return AddOn(
|
|
1044
|
+
period=period,
|
|
1045
|
+
ebitda=_amount(_require(data, "ebitda", where), f"{where}.ebitda"),
|
|
1046
|
+
multiple=_amount(_require(data, "multiple", where), f"{where}.multiple"),
|
|
1047
|
+
revenue=(
|
|
1048
|
+
None
|
|
1049
|
+
if data.get("revenue") is None
|
|
1050
|
+
else _amount(data["revenue"], f"{where}.revenue")
|
|
1051
|
+
),
|
|
1052
|
+
synergies=_optional_amount(data, "synergies", where),
|
|
1053
|
+
synergy_phase_in=1 if phase_in is None else phase_in,
|
|
1054
|
+
growth=growth,
|
|
1055
|
+
fee_rate=_optional_amount(data, "fee_rate", where),
|
|
1056
|
+
integration_cost=_optional_amount(data, "integration_cost", where),
|
|
1057
|
+
draws=tuple(_parse_draw(item, i, where) for i, item in enumerate(draws_raw)),
|
|
1058
|
+
label=str(data.get("label", f"Add-on {index + 1}")),
|
|
1059
|
+
)
|
|
1060
|
+
except (AddOnError, RecapitalisationError) as exc:
|
|
1061
|
+
raise DealSpecError(f"{where}: {exc}") from exc
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
def _parse_refinancing(
|
|
1065
|
+
data: Any, index: int, frequency: Frequency
|
|
1066
|
+
) -> Refinancing:
|
|
1067
|
+
"""Read one takeout: the facility retired, and the paper that replaces it.
|
|
1068
|
+
|
|
1069
|
+
``unamortised_fees`` may be stated or left out. Left out, it is derived
|
|
1070
|
+
from the capitalised-cost schedule the deal builds off its own funding
|
|
1071
|
+
table: what the paper was placed with, released over its life, read at the
|
|
1072
|
+
period the takeout lands in. A file that has a figure of its own — a
|
|
1073
|
+
balance carried over from an earlier model, or one an accountant has
|
|
1074
|
+
already agreed — states it and is believed.
|
|
1075
|
+
"""
|
|
1076
|
+
where = f"refinancings[{index}]"
|
|
1077
|
+
if not isinstance(data, dict):
|
|
1078
|
+
raise DealSpecError(f"{where}: expected an object")
|
|
1079
|
+
|
|
1080
|
+
period = _whole(data, "period", where)
|
|
1081
|
+
if period is None:
|
|
1082
|
+
raise DealSpecError(
|
|
1083
|
+
f"{where}: say which period this lands at the end of, numbered from one"
|
|
1084
|
+
)
|
|
1085
|
+
|
|
1086
|
+
into_raw = data.get("into", [])
|
|
1087
|
+
if not isinstance(into_raw, list):
|
|
1088
|
+
raise DealSpecError(f"{where}.into: expected a list of take-downs")
|
|
1089
|
+
|
|
1090
|
+
try:
|
|
1091
|
+
return Refinancing(
|
|
1092
|
+
period=period,
|
|
1093
|
+
tranche=str(_require(data, "tranche", where)),
|
|
1094
|
+
into=tuple(
|
|
1095
|
+
_parse_draw(item, i, where, key="into")
|
|
1096
|
+
for i, item in enumerate(into_raw)
|
|
1097
|
+
),
|
|
1098
|
+
call_premium_rate=_optional_amount(data, "call_premium_rate", where),
|
|
1099
|
+
unamortised_fees=(
|
|
1100
|
+
_amount(data["unamortised_fees"], f"{where}.unamortised_fees")
|
|
1101
|
+
if data.get("unamortised_fees") is not None
|
|
1102
|
+
else None
|
|
1103
|
+
),
|
|
1104
|
+
label=str(data.get("label", "Refinancing")),
|
|
1105
|
+
)
|
|
1106
|
+
except (RefinancingError, RecapitalisationError) as exc:
|
|
1107
|
+
raise DealSpecError(f"{where}: {exc}") from exc
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
def _parse_vesting(data: Any, where: str) -> Vesting:
|
|
1111
|
+
"""Read a vesting schedule.
|
|
1112
|
+
|
|
1113
|
+
A bare number is the common case — "four-year straight-line" — and needs no
|
|
1114
|
+
object around it to say so.
|
|
1115
|
+
"""
|
|
1116
|
+
if not isinstance(data, dict):
|
|
1117
|
+
return _vesting(_amount(data, where), ZERO, False, where)
|
|
1118
|
+
accelerates = _flag(data, "accelerates", where)
|
|
1119
|
+
return _vesting(
|
|
1120
|
+
_amount(_require(data, "years", where), f"{where}.years"),
|
|
1121
|
+
_optional_amount(data, "cliff_years", where),
|
|
1122
|
+
False if accelerates is None else accelerates,
|
|
1123
|
+
where,
|
|
1124
|
+
)
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
def _vesting(years: Money, cliff: Money, accelerates: bool, where: str) -> Vesting:
|
|
1128
|
+
try:
|
|
1129
|
+
return Vesting(years=years, cliff_years=cliff, accelerates=accelerates)
|
|
1130
|
+
except IncentiveError as exc:
|
|
1131
|
+
raise DealSpecError(f"{where}: {exc}") from exc
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
def _parse_ratchet(data: Any, where: str) -> Ratchet:
|
|
1135
|
+
"""Read a ratchet: the bands, and the paper the hurdles are read against.
|
|
1136
|
+
|
|
1137
|
+
Bands are pairs for the same reason sweep-grid steps are — "5% to a 2.0x,
|
|
1138
|
+
10% above it" is two numbers and a level, and naming each half of the pair
|
|
1139
|
+
makes the schedule harder to read rather than easier.
|
|
1140
|
+
"""
|
|
1141
|
+
if not isinstance(data, dict):
|
|
1142
|
+
raise DealSpecError(f"{where}: expected an object")
|
|
1143
|
+
bands_raw = _require(data, "bands", where)
|
|
1144
|
+
if not isinstance(bands_raw, list) or not bands_raw:
|
|
1145
|
+
raise DealSpecError(f"{where}.bands: expected a list of [hurdle, share] pairs")
|
|
1146
|
+
|
|
1147
|
+
bands: list[tuple[Money, Money]] = []
|
|
1148
|
+
for i, item in enumerate(bands_raw):
|
|
1149
|
+
if not isinstance(item, list) or len(item) != 2:
|
|
1150
|
+
raise DealSpecError(f"{where}.bands[{i}]: expected a pair of [hurdle, share]")
|
|
1151
|
+
bands.append(
|
|
1152
|
+
(
|
|
1153
|
+
_amount(item[0], f"{where}.bands[{i}][0]"),
|
|
1154
|
+
_amount(item[1], f"{where}.bands[{i}][1]"),
|
|
1155
|
+
)
|
|
1156
|
+
)
|
|
1157
|
+
|
|
1158
|
+
measured_raw = data.get("measured_on", [])
|
|
1159
|
+
if not isinstance(measured_raw, list):
|
|
1160
|
+
raise DealSpecError(f"{where}.measured_on: expected a list of security names")
|
|
1161
|
+
|
|
1162
|
+
try:
|
|
1163
|
+
return Ratchet.of(bands, measured_on=[str(name) for name in measured_raw])
|
|
1164
|
+
except IncentiveError as exc:
|
|
1165
|
+
raise DealSpecError(f"{where}: {exc}") from exc
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
def _parse_incentive(data: Any) -> IncentivePlan:
|
|
1169
|
+
"""Read the management plan.
|
|
1170
|
+
|
|
1171
|
+
The strike is described one of two ways and not both. An amount is right for
|
|
1172
|
+
a plan whose price was agreed in cash. A multiple of the entry equity value
|
|
1173
|
+
is right for everything else, and it is the only form that survives being
|
|
1174
|
+
asked what the deal looks like at a different entry multiple.
|
|
1175
|
+
"""
|
|
1176
|
+
where = "exit.incentive"
|
|
1177
|
+
if not isinstance(data, dict):
|
|
1178
|
+
raise DealSpecError(f"{where}: expected an object")
|
|
1179
|
+
|
|
1180
|
+
if data.get("strike") is not None and data.get("strike_at_entry") is not None:
|
|
1181
|
+
raise DealSpecError(
|
|
1182
|
+
f"{where}: 'strike' and 'strike_at_entry' are two answers to the same "
|
|
1183
|
+
f"question; give one"
|
|
1184
|
+
)
|
|
1185
|
+
strike_at_entry = (
|
|
1186
|
+
_amount(data["strike_at_entry"], f"{where}.strike_at_entry")
|
|
1187
|
+
if data.get("strike_at_entry") is not None
|
|
1188
|
+
else None
|
|
1189
|
+
)
|
|
1190
|
+
|
|
1191
|
+
vesting = (
|
|
1192
|
+
_parse_vesting(data["vesting"], f"{where}.vesting")
|
|
1193
|
+
if data.get("vesting") is not None
|
|
1194
|
+
else None
|
|
1195
|
+
)
|
|
1196
|
+
ratchet = (
|
|
1197
|
+
_parse_ratchet(data["ratchet"], f"{where}.ratchet")
|
|
1198
|
+
if data.get("ratchet") is not None
|
|
1199
|
+
else None
|
|
1200
|
+
)
|
|
1201
|
+
|
|
1202
|
+
try:
|
|
1203
|
+
return IncentivePlan(
|
|
1204
|
+
name=str(data.get("name", "Management incentive plan")),
|
|
1205
|
+
share=_amount(_require(data, "share", where), f"{where}.share"),
|
|
1206
|
+
strike=_optional_amount(data, "strike", where),
|
|
1207
|
+
strike_at_entry=strike_at_entry,
|
|
1208
|
+
vesting=vesting,
|
|
1209
|
+
ratchet=ratchet,
|
|
1210
|
+
)
|
|
1211
|
+
except IncentiveError as exc:
|
|
1212
|
+
raise DealSpecError(f"{where}: {exc}") from exc
|
|
1213
|
+
|
|
1214
|
+
|
|
1215
|
+
def _parse_exit(data: Any) -> tuple[Money | None, Money, EquityPlan, IncentivePlan | None]:
|
|
1216
|
+
"""Read the exit assumptions and the equity that shares in them."""
|
|
1217
|
+
where = "exit"
|
|
1218
|
+
if not isinstance(data, dict):
|
|
1219
|
+
raise DealSpecError(f"{where}: expected an object")
|
|
1220
|
+
|
|
1221
|
+
multiple = (
|
|
1222
|
+
_amount(data["multiple"], f"{where}.multiple")
|
|
1223
|
+
if data.get("multiple") is not None
|
|
1224
|
+
else None
|
|
1225
|
+
)
|
|
1226
|
+
fee_rate = _optional_amount(data, "fee_rate", where)
|
|
1227
|
+
incentive = (
|
|
1228
|
+
_parse_incentive(data["incentive"]) if data.get("incentive") is not None else None
|
|
1229
|
+
)
|
|
1230
|
+
|
|
1231
|
+
equity_raw = data.get("equity")
|
|
1232
|
+
if equity_raw is None:
|
|
1233
|
+
return multiple, fee_rate, EquityPlan(), incentive
|
|
1234
|
+
if not isinstance(equity_raw, list) or not equity_raw:
|
|
1235
|
+
raise DealSpecError(f"{where}.equity: expected a list of securities")
|
|
1236
|
+
|
|
1237
|
+
plans = tuple(_parse_security(item, i) for i, item in enumerate(equity_raw))
|
|
1238
|
+
try:
|
|
1239
|
+
return multiple, fee_rate, EquityPlan(plans=plans), incentive
|
|
1240
|
+
except ValueError as exc:
|
|
1241
|
+
raise DealSpecError(f"{where}.equity: {exc}") from exc
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
def _parse_sweep_grid(data: Any, where: str) -> SweepGrid:
|
|
1245
|
+
"""Read a sweep grid: the rungs, the rate below them, and what is measured.
|
|
1246
|
+
|
|
1247
|
+
Written as pairs rather than as objects because that is how a term sheet
|
|
1248
|
+
puts it — "50% stepping to 25% at 4.50x" is two numbers and a level, and
|
|
1249
|
+
surrounding each pair with field names makes the grid harder to read, not
|
|
1250
|
+
easier.
|
|
1251
|
+
"""
|
|
1252
|
+
if not isinstance(data, dict):
|
|
1253
|
+
raise DealSpecError(f"{where}: expected an object")
|
|
1254
|
+
steps_raw = _require(data, "steps", where)
|
|
1255
|
+
if not isinstance(steps_raw, list) or not steps_raw:
|
|
1256
|
+
raise DealSpecError(f"{where}.steps: expected a list of [leverage, rate] pairs")
|
|
1257
|
+
|
|
1258
|
+
steps: list[tuple[Money, Money]] = []
|
|
1259
|
+
for i, item in enumerate(steps_raw):
|
|
1260
|
+
if not isinstance(item, list) or len(item) != 2:
|
|
1261
|
+
raise DealSpecError(
|
|
1262
|
+
f"{where}.steps[{i}]: expected a pair of [leverage, rate]"
|
|
1263
|
+
)
|
|
1264
|
+
steps.append(
|
|
1265
|
+
(
|
|
1266
|
+
_amount(item[0], f"{where}.steps[{i}][0]"),
|
|
1267
|
+
_amount(item[1], f"{where}.steps[{i}][1]"),
|
|
1268
|
+
)
|
|
1269
|
+
)
|
|
1270
|
+
|
|
1271
|
+
net = _flag(data, "net", where)
|
|
1272
|
+
try:
|
|
1273
|
+
return SweepGrid.of(
|
|
1274
|
+
steps,
|
|
1275
|
+
floor=_optional_amount(data, "floor", where),
|
|
1276
|
+
net=True if net is None else net,
|
|
1277
|
+
)
|
|
1278
|
+
except ValueError as exc:
|
|
1279
|
+
raise DealSpecError(f"{where}: {exc}") from exc
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
def _parse_covenant(
|
|
1283
|
+
data: Any, index: int, periods: int, frequency: Frequency
|
|
1284
|
+
) -> Covenant:
|
|
1285
|
+
"""Read one maintenance test."""
|
|
1286
|
+
where = f"covenants[{index}]"
|
|
1287
|
+
if not isinstance(data, dict):
|
|
1288
|
+
raise DealSpecError(f"{where}: expected an object")
|
|
1289
|
+
|
|
1290
|
+
measure_name = str(_require(data, "measure", where)).lower()
|
|
1291
|
+
if measure_name not in _MEASURES:
|
|
1292
|
+
raise DealSpecError(
|
|
1293
|
+
f"{where}.measure: unknown measure {data.get('measure')!r}; expected "
|
|
1294
|
+
f"one of {', '.join(sorted(_MEASURES))}"
|
|
1295
|
+
)
|
|
1296
|
+
|
|
1297
|
+
tranches_raw = data.get("tranches", [])
|
|
1298
|
+
if not isinstance(tranches_raw, list):
|
|
1299
|
+
raise DealSpecError(f"{where}.tranches: expected a list of tranche names")
|
|
1300
|
+
|
|
1301
|
+
try:
|
|
1302
|
+
return Covenant.of(
|
|
1303
|
+
str(_require(data, "name", where)),
|
|
1304
|
+
_MEASURES[measure_name],
|
|
1305
|
+
_driver(_require(data, "threshold", where), periods, f"{where}.threshold"),
|
|
1306
|
+
first_test_period=_when(data, "first_test_period", where, frequency) or 1,
|
|
1307
|
+
tranches=[str(t) for t in tranches_raw],
|
|
1308
|
+
)
|
|
1309
|
+
except ValueError as exc:
|
|
1310
|
+
raise DealSpecError(f"{where}: {exc}") from exc
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
def _parse_structure(
|
|
1314
|
+
data: Any, tranches: Sequence[Tranche], periods: int
|
|
1315
|
+
) -> tuple[CapitalStructure, Money | None]:
|
|
1316
|
+
"""Read the rules that govern how cash moves through the stack."""
|
|
1317
|
+
where = "structure"
|
|
1318
|
+
if not isinstance(data, dict):
|
|
1319
|
+
raise DealSpecError(f"{where}: expected an object")
|
|
1320
|
+
|
|
1321
|
+
day_count_name = str(data.get("day_count", DayCount.ACT_360.value)).lower()
|
|
1322
|
+
if day_count_name not in _DAY_COUNTS:
|
|
1323
|
+
raise DealSpecError(
|
|
1324
|
+
f"{where}.day_count: unknown convention {data.get('day_count')!r}; expected one "
|
|
1325
|
+
f"of {', '.join(c.value for c in DayCount)}"
|
|
1326
|
+
)
|
|
1327
|
+
|
|
1328
|
+
basis_name = str(data.get("interest_basis", InterestBasis.AVERAGE.value)).lower()
|
|
1329
|
+
if basis_name not in _INTEREST_BASES:
|
|
1330
|
+
raise DealSpecError(
|
|
1331
|
+
f"{where}.interest_basis: expected one of "
|
|
1332
|
+
f"{', '.join(sorted(_INTEREST_BASES))}, got {data.get('interest_basis')!r}"
|
|
1333
|
+
)
|
|
1334
|
+
|
|
1335
|
+
base_rate = (
|
|
1336
|
+
_driver(data["base_rate"], periods, f"{where}.base_rate")
|
|
1337
|
+
if data.get("base_rate") is not None
|
|
1338
|
+
else None
|
|
1339
|
+
)
|
|
1340
|
+
opening_cash = (
|
|
1341
|
+
_amount(data["opening_cash"], f"{where}.opening_cash")
|
|
1342
|
+
if data.get("opening_cash") is not None
|
|
1343
|
+
else None
|
|
1344
|
+
)
|
|
1345
|
+
sweep_grid = (
|
|
1346
|
+
_parse_sweep_grid(data["sweep_grid"], f"{where}.sweep_grid")
|
|
1347
|
+
if data.get("sweep_grid") is not None
|
|
1348
|
+
else None
|
|
1349
|
+
)
|
|
1350
|
+
|
|
1351
|
+
try:
|
|
1352
|
+
structure = CapitalStructure.of(
|
|
1353
|
+
tranches,
|
|
1354
|
+
minimum_cash=_optional_amount(data, "minimum_cash", where),
|
|
1355
|
+
sweep_rate=_optional_amount(data, "sweep_rate", where, default="1"),
|
|
1356
|
+
sweep_grid=sweep_grid,
|
|
1357
|
+
base_rate=base_rate,
|
|
1358
|
+
day_count=_DAY_COUNTS[day_count_name],
|
|
1359
|
+
interest_basis=_INTEREST_BASES[basis_name],
|
|
1360
|
+
damping=_optional_amount(data, "damping", where, default="1"),
|
|
1361
|
+
tolerance=_optional_amount(data, "tolerance", where, default="0.000000001"),
|
|
1362
|
+
max_iterations=_whole(data, "max_iterations", where) or 100,
|
|
1363
|
+
)
|
|
1364
|
+
except ValueError as exc:
|
|
1365
|
+
raise DealSpecError(f"{where}: {exc}") from exc
|
|
1366
|
+
return structure, opening_cash
|
|
1367
|
+
|
|
1368
|
+
|
|
1369
|
+
def _parse_projection(data: dict[str, Any], close: date | None) -> PeriodGrid:
|
|
1370
|
+
where = "projection"
|
|
1371
|
+
if close is None:
|
|
1372
|
+
raise DealSpecError(
|
|
1373
|
+
"projection: a close date is required, because the grid starts at close"
|
|
1374
|
+
)
|
|
1375
|
+
years_raw = _require(data, "years", where)
|
|
1376
|
+
try:
|
|
1377
|
+
years = int(years_raw)
|
|
1378
|
+
except (TypeError, ValueError) as exc:
|
|
1379
|
+
raise DealSpecError(f"{where}.years: not a whole number: {years_raw!r}") from exc
|
|
1380
|
+
|
|
1381
|
+
frequency_name = str(data.get("frequency", "annual")).lower()
|
|
1382
|
+
if frequency_name not in _FREQUENCIES:
|
|
1383
|
+
raise DealSpecError(
|
|
1384
|
+
f"{where}.frequency: unknown frequency {frequency_name!r}; "
|
|
1385
|
+
f"expected one of {', '.join(sorted(_FREQUENCIES))}"
|
|
1386
|
+
)
|
|
1387
|
+
stub_raw = data.get("first_period_end")
|
|
1388
|
+
stub_to: date | None = None
|
|
1389
|
+
if stub_raw is not None:
|
|
1390
|
+
try:
|
|
1391
|
+
stub_to = date.fromisoformat(str(stub_raw))
|
|
1392
|
+
except ValueError as exc:
|
|
1393
|
+
raise DealSpecError(
|
|
1394
|
+
f"{where}.first_period_end: not a date: {stub_raw!r}"
|
|
1395
|
+
) from exc
|
|
1396
|
+
|
|
1397
|
+
try:
|
|
1398
|
+
return PeriodGrid.build(
|
|
1399
|
+
close,
|
|
1400
|
+
years=years,
|
|
1401
|
+
frequency=_FREQUENCIES[frequency_name],
|
|
1402
|
+
stub_to=stub_to,
|
|
1403
|
+
)
|
|
1404
|
+
except ValueError as exc:
|
|
1405
|
+
raise DealSpecError(f"{where}: {exc}") from exc
|
|
1406
|
+
|
|
1407
|
+
|
|
1408
|
+
def _parse_operating(
|
|
1409
|
+
data: dict[str, Any], periods: int
|
|
1410
|
+
) -> tuple[OperatingAssumptions, Money, Money | None, InterestLimit | None]:
|
|
1411
|
+
where = "operating"
|
|
1412
|
+
opening_revenue = _amount(
|
|
1413
|
+
_require(data, "opening_revenue", where), f"{where}.opening_revenue"
|
|
1414
|
+
)
|
|
1415
|
+
opening_nwc = (
|
|
1416
|
+
_amount(data["opening_net_working_capital"], f"{where}.opening_net_working_capital")
|
|
1417
|
+
if data.get("opening_net_working_capital") is not None
|
|
1418
|
+
else None
|
|
1419
|
+
)
|
|
1420
|
+
|
|
1421
|
+
assumptions = OperatingAssumptions(
|
|
1422
|
+
revenue_growth=_driver(
|
|
1423
|
+
_require(data, "revenue_growth", where), periods, f"{where}.revenue_growth"
|
|
1424
|
+
),
|
|
1425
|
+
ebitda_margin=_driver(
|
|
1426
|
+
_require(data, "ebitda_margin", where), periods, f"{where}.ebitda_margin"
|
|
1427
|
+
),
|
|
1428
|
+
da_rate=_driver(data.get("da_rate", 0), periods, f"{where}.da_rate"),
|
|
1429
|
+
capex_rate=_driver(data.get("capex_rate", 0), periods, f"{where}.capex_rate"),
|
|
1430
|
+
nwc_rate=_driver(data.get("nwc_rate", 0), periods, f"{where}.nwc_rate"),
|
|
1431
|
+
tax_rate=_optional_amount(data, "tax_rate", where),
|
|
1432
|
+
opening_carryforward=_optional_amount(data, "opening_carryforward", where),
|
|
1433
|
+
nol_usage_limit=(
|
|
1434
|
+
_amount(data["nol_usage_limit"], f"{where}.nol_usage_limit")
|
|
1435
|
+
if data.get("nol_usage_limit") is not None
|
|
1436
|
+
else DEFAULT_NOL_USAGE_LIMIT
|
|
1437
|
+
),
|
|
1438
|
+
)
|
|
1439
|
+
return assumptions, opening_revenue, opening_nwc, _parse_interest_limit(data)
|
|
1440
|
+
|
|
1441
|
+
|
|
1442
|
+
def _parse_interest_limit(data: dict[str, Any]) -> InterestLimit | None:
|
|
1443
|
+
"""The cap on the interest deduction, if the file describes one.
|
|
1444
|
+
|
|
1445
|
+
Absent means an uncapped deduction, which is both a real regime and the
|
|
1446
|
+
comparison that prices the cap: run the same file twice and difference it.
|
|
1447
|
+
"""
|
|
1448
|
+
raw = data.get("interest_limit")
|
|
1449
|
+
if raw is None:
|
|
1450
|
+
return None
|
|
1451
|
+
where = "operating.interest_limit"
|
|
1452
|
+
if not isinstance(raw, dict):
|
|
1453
|
+
raise DealSpecError(f"{where}: expected an object")
|
|
1454
|
+
base_raw = str(raw.get("base", EarningsBase.EBITDA.value)).lower()
|
|
1455
|
+
try:
|
|
1456
|
+
base = EarningsBase(base_raw)
|
|
1457
|
+
except ValueError:
|
|
1458
|
+
allowed = ", ".join(repr(b.value) for b in EarningsBase)
|
|
1459
|
+
raise DealSpecError(f"{where}.base: expected one of {allowed}, got {base_raw!r}") from None
|
|
1460
|
+
try:
|
|
1461
|
+
return InterestLimit(rate=_amount(_require(raw, "rate", where), f"{where}.rate"), base=base)
|
|
1462
|
+
except TaxPolicyError as exc:
|
|
1463
|
+
raise DealSpecError(f"{where}: {exc}") from exc
|
|
1464
|
+
|
|
1465
|
+
|
|
1466
|
+
def parse_deal(data: dict[str, Any]) -> Deal:
|
|
1467
|
+
"""Build a deal from a parsed document."""
|
|
1468
|
+
if not isinstance(data, dict):
|
|
1469
|
+
raise DealSpecError("the deal file must contain an object at the top level")
|
|
1470
|
+
|
|
1471
|
+
name = str(data.get("name", "Untitled"))
|
|
1472
|
+
|
|
1473
|
+
close: date | None = None
|
|
1474
|
+
if data.get("close_date"):
|
|
1475
|
+
try:
|
|
1476
|
+
close = date.fromisoformat(str(data["close_date"]))
|
|
1477
|
+
except ValueError as exc:
|
|
1478
|
+
raise DealSpecError(f"close_date: not a date: {data['close_date']!r}") from exc
|
|
1479
|
+
|
|
1480
|
+
entry = _require(data, "entry", "deal")
|
|
1481
|
+
if not isinstance(entry, dict):
|
|
1482
|
+
raise DealSpecError("entry: expected an object")
|
|
1483
|
+
|
|
1484
|
+
valuation = EntryValuation(
|
|
1485
|
+
ltm_ebitda=_amount(_require(entry, "ltm_ebitda", "entry"), "entry.ltm_ebitda"),
|
|
1486
|
+
entry_multiple=_amount(_require(entry, "multiple", "entry"), "entry.multiple"),
|
|
1487
|
+
existing_debt=_optional_amount(entry, "existing_debt", "entry"),
|
|
1488
|
+
existing_cash=_optional_amount(entry, "existing_cash", "entry"),
|
|
1489
|
+
)
|
|
1490
|
+
|
|
1491
|
+
debt_raw = data.get("debt", [])
|
|
1492
|
+
if not isinstance(debt_raw, list):
|
|
1493
|
+
raise DealSpecError("debt: expected a list of tranches")
|
|
1494
|
+
debt = tuple(_tranche(item, i) for i, item in enumerate(debt_raw))
|
|
1495
|
+
|
|
1496
|
+
other_raw = data.get("other_uses", [])
|
|
1497
|
+
if not isinstance(other_raw, list):
|
|
1498
|
+
raise DealSpecError("other_uses: expected a list")
|
|
1499
|
+
other_uses = tuple(_other_use(item, i) for i, item in enumerate(other_raw))
|
|
1500
|
+
|
|
1501
|
+
transaction = Transaction(
|
|
1502
|
+
valuation=valuation,
|
|
1503
|
+
debt=debt,
|
|
1504
|
+
rollover_equity=_optional_amount(data, "rollover_equity", "deal"),
|
|
1505
|
+
cash_from_balance_sheet=_optional_amount(data, "cash_from_balance_sheet", "deal"),
|
|
1506
|
+
cash_to_balance_sheet=_optional_amount(data, "cash_to_balance_sheet", "deal"),
|
|
1507
|
+
transaction_fee_rate=_optional_amount(data, "transaction_fee_rate", "deal"),
|
|
1508
|
+
other_uses=other_uses,
|
|
1509
|
+
)
|
|
1510
|
+
|
|
1511
|
+
grid: PeriodGrid | None = None
|
|
1512
|
+
assumptions: OperatingAssumptions | None = None
|
|
1513
|
+
opening_revenue: Money | None = None
|
|
1514
|
+
opening_nwc: Money | None = None
|
|
1515
|
+
interest_limit: InterestLimit | None = None
|
|
1516
|
+
|
|
1517
|
+
projection_raw = data.get("projection")
|
|
1518
|
+
operating_raw = data.get("operating")
|
|
1519
|
+
if (projection_raw is None) != (operating_raw is None):
|
|
1520
|
+
missing = "operating" if projection_raw is not None else "projection"
|
|
1521
|
+
raise DealSpecError(
|
|
1522
|
+
f"a projection needs both a 'projection' and an 'operating' block; "
|
|
1523
|
+
f"{missing!r} is missing"
|
|
1524
|
+
)
|
|
1525
|
+
if projection_raw is not None and operating_raw is not None:
|
|
1526
|
+
if not isinstance(projection_raw, dict):
|
|
1527
|
+
raise DealSpecError("projection: expected an object")
|
|
1528
|
+
if not isinstance(operating_raw, dict):
|
|
1529
|
+
raise DealSpecError("operating: expected an object")
|
|
1530
|
+
grid = _parse_projection(projection_raw, close)
|
|
1531
|
+
assumptions, opening_revenue, opening_nwc, interest_limit = _parse_operating(
|
|
1532
|
+
operating_raw, grid.years
|
|
1533
|
+
)
|
|
1534
|
+
|
|
1535
|
+
# How the grid divides its years, which is what turns a date stated in years
|
|
1536
|
+
# into a period number. Without a projection there is nothing to resolve
|
|
1537
|
+
# against, and annual is the reading a bare period number already had.
|
|
1538
|
+
frequency = grid.frequency if grid is not None else Frequency.ANNUAL
|
|
1539
|
+
|
|
1540
|
+
book: TargetBookBalanceSheet | None = None
|
|
1541
|
+
accounting: PurchaseAccounting | None = None
|
|
1542
|
+
if data.get("target") is not None:
|
|
1543
|
+
book, accounting = _parse_target(data["target"])
|
|
1544
|
+
|
|
1545
|
+
structure: CapitalStructure | None = None
|
|
1546
|
+
opening_cash: Money | None = None
|
|
1547
|
+
if data.get("structure") is not None:
|
|
1548
|
+
if not debt_raw:
|
|
1549
|
+
raise DealSpecError(
|
|
1550
|
+
"structure: there are no tranches to schedule; describe them under 'debt'"
|
|
1551
|
+
)
|
|
1552
|
+
# Amortisation and base-rate series are read by year, so the grid has
|
|
1553
|
+
# to be known first — and it is the count of years that matters rather
|
|
1554
|
+
# than the count of columns. Without a grid they collapse to a single
|
|
1555
|
+
# year, which is enough to validate the structure but not to run it.
|
|
1556
|
+
span = grid.years if grid is not None else 1
|
|
1557
|
+
tranches = tuple(
|
|
1558
|
+
_schedule_tranche(item, i, span, frequency)
|
|
1559
|
+
for i, item in enumerate(debt_raw)
|
|
1560
|
+
)
|
|
1561
|
+
structure, opening_cash = _parse_structure(data["structure"], tranches, span)
|
|
1562
|
+
|
|
1563
|
+
covenants: tuple[Covenant, ...] = ()
|
|
1564
|
+
covenants_raw = data.get("covenants")
|
|
1565
|
+
if covenants_raw is not None:
|
|
1566
|
+
if not isinstance(covenants_raw, list):
|
|
1567
|
+
raise DealSpecError("covenants: expected a list of tests")
|
|
1568
|
+
if structure is None:
|
|
1569
|
+
raise DealSpecError(
|
|
1570
|
+
"covenants: there is nothing to test; describe the tranches under "
|
|
1571
|
+
"'debt' and the rules under 'structure'"
|
|
1572
|
+
)
|
|
1573
|
+
if grid is None:
|
|
1574
|
+
raise DealSpecError(
|
|
1575
|
+
"covenants: a maintenance test is measured against an operating "
|
|
1576
|
+
"case, so a projection is required"
|
|
1577
|
+
)
|
|
1578
|
+
covenants = tuple(
|
|
1579
|
+
_parse_covenant(item, i, grid.years, frequency)
|
|
1580
|
+
for i, item in enumerate(covenants_raw)
|
|
1581
|
+
)
|
|
1582
|
+
|
|
1583
|
+
recapitalisations: tuple[Recapitalisation, ...] = ()
|
|
1584
|
+
recaps_raw = data.get("recapitalisations")
|
|
1585
|
+
if recaps_raw is not None:
|
|
1586
|
+
if not isinstance(recaps_raw, list):
|
|
1587
|
+
raise DealSpecError("recapitalisations: expected a list of events")
|
|
1588
|
+
if structure is None:
|
|
1589
|
+
raise DealSpecError(
|
|
1590
|
+
"recapitalisations: there is nothing to draw on; describe the "
|
|
1591
|
+
"tranches under 'debt' and the rules under 'structure'"
|
|
1592
|
+
)
|
|
1593
|
+
if grid is None:
|
|
1594
|
+
raise DealSpecError(
|
|
1595
|
+
"recapitalisations: an event lands in a period, so a projection "
|
|
1596
|
+
"is required"
|
|
1597
|
+
)
|
|
1598
|
+
recapitalisations = tuple(
|
|
1599
|
+
_parse_recapitalisation(item, i, frequency)
|
|
1600
|
+
for i, item in enumerate(recaps_raw)
|
|
1601
|
+
)
|
|
1602
|
+
|
|
1603
|
+
acquisitions: tuple[AddOn, ...] = ()
|
|
1604
|
+
acquisitions_raw = data.get("acquisitions")
|
|
1605
|
+
if acquisitions_raw is not None:
|
|
1606
|
+
if not isinstance(acquisitions_raw, list):
|
|
1607
|
+
raise DealSpecError("acquisitions: expected a list of purchases")
|
|
1608
|
+
if grid is None or assumptions is None:
|
|
1609
|
+
raise DealSpecError(
|
|
1610
|
+
"acquisitions: a purchase adds earnings to the case, so a "
|
|
1611
|
+
"projection and an operating block are required"
|
|
1612
|
+
)
|
|
1613
|
+
acquisitions = tuple(
|
|
1614
|
+
_parse_acquisition(item, i, grid.years, frequency)
|
|
1615
|
+
for i, item in enumerate(acquisitions_raw)
|
|
1616
|
+
)
|
|
1617
|
+
|
|
1618
|
+
refinancings: tuple[Refinancing, ...] = ()
|
|
1619
|
+
refinancings_raw = data.get("refinancings")
|
|
1620
|
+
if refinancings_raw is not None:
|
|
1621
|
+
if not isinstance(refinancings_raw, list):
|
|
1622
|
+
raise DealSpecError("refinancings: expected a list of events")
|
|
1623
|
+
if structure is None:
|
|
1624
|
+
raise DealSpecError(
|
|
1625
|
+
"refinancings: there is nothing to take out; describe the tranches "
|
|
1626
|
+
"under 'debt' and the rules under 'structure'"
|
|
1627
|
+
)
|
|
1628
|
+
if grid is None:
|
|
1629
|
+
raise DealSpecError(
|
|
1630
|
+
"refinancings: an event lands in a period, so a projection is required"
|
|
1631
|
+
)
|
|
1632
|
+
refinancings = tuple(
|
|
1633
|
+
_parse_refinancing(item, i, frequency)
|
|
1634
|
+
for i, item in enumerate(refinancings_raw)
|
|
1635
|
+
)
|
|
1636
|
+
|
|
1637
|
+
exit_multiple: Money | None = None
|
|
1638
|
+
exit_fee_rate: Money = ZERO_RATE
|
|
1639
|
+
equity = EquityPlan()
|
|
1640
|
+
incentive: IncentivePlan | None = None
|
|
1641
|
+
if data.get("exit") is not None:
|
|
1642
|
+
if structure is None or grid is None:
|
|
1643
|
+
raise DealSpecError(
|
|
1644
|
+
"exit: there is nothing to exit from; describe the capital "
|
|
1645
|
+
"structure and the projection first"
|
|
1646
|
+
)
|
|
1647
|
+
exit_multiple, exit_fee_rate, equity, incentive = _parse_exit(data["exit"])
|
|
1648
|
+
|
|
1649
|
+
return Deal(
|
|
1650
|
+
name=name,
|
|
1651
|
+
close_date=close,
|
|
1652
|
+
transaction=transaction,
|
|
1653
|
+
grid=grid,
|
|
1654
|
+
operating=assumptions,
|
|
1655
|
+
opening_revenue=opening_revenue,
|
|
1656
|
+
opening_net_working_capital=opening_nwc,
|
|
1657
|
+
interest_limit=interest_limit,
|
|
1658
|
+
book=book,
|
|
1659
|
+
accounting=accounting,
|
|
1660
|
+
structure=structure,
|
|
1661
|
+
opening_cash=opening_cash,
|
|
1662
|
+
covenants=covenants,
|
|
1663
|
+
exit_multiple=exit_multiple,
|
|
1664
|
+
exit_fee_rate=exit_fee_rate,
|
|
1665
|
+
equity=equity,
|
|
1666
|
+
incentive=incentive,
|
|
1667
|
+
recapitalisations=recapitalisations,
|
|
1668
|
+
acquisitions=acquisitions,
|
|
1669
|
+
refinancings=refinancings,
|
|
1670
|
+
)
|
|
1671
|
+
|
|
1672
|
+
|
|
1673
|
+
def load_deal(path: str | Path) -> Deal:
|
|
1674
|
+
"""Read and parse a deal file."""
|
|
1675
|
+
p = Path(path)
|
|
1676
|
+
try:
|
|
1677
|
+
text = p.read_text(encoding="utf-8")
|
|
1678
|
+
except OSError as exc:
|
|
1679
|
+
raise DealSpecError(f"cannot read {p}: {exc}") from exc
|
|
1680
|
+
try:
|
|
1681
|
+
# parse_float keeps decimal literals exact; parse_int leaves ints alone
|
|
1682
|
+
# because Decimal(int) is already exact.
|
|
1683
|
+
data = json.loads(text, parse_float=Decimal)
|
|
1684
|
+
except json.JSONDecodeError as exc:
|
|
1685
|
+
raise DealSpecError(f"{p}: invalid JSON at line {exc.lineno}: {exc.msg}") from exc
|
|
1686
|
+
return parse_deal(data)
|