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