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.
@@ -0,0 +1,940 @@
1
+ """The same deal, run again under a different assumption.
2
+
3
+ Every layer below this one reports one case. A deal team does not argue about
4
+ one case. It argues about the boundary between the cases that work and the cases
5
+ that do not, and that boundary is a surface rather than a point: the return
6
+ holds at eleven times and a five-year hold, and does not hold at eleven times
7
+ and four years, and the interesting question is where in between it stops.
8
+
9
+ *Why the whole engine runs per cell.* The cheap way to build a grid is to
10
+ differentiate the base case once and step along the gradient. It is also wrong,
11
+ and wrong asymmetrically. Raising the entry multiple raises the purchase price,
12
+ which the funding table absorbs into the sponsor cheque, which changes the
13
+ capital every multiple in the column is measured against — and, because the debt
14
+ did not move, changes opening leverage and therefore the sweep step and
15
+ therefore the debt outstanding at exit. Lowering the *exit* multiple does none of
16
+ that: it touches the last period and nothing upstream. A linearisation reports
17
+ those two as mirror images. They are not, and the deals that get done live in
18
+ exactly that asymmetry.
19
+
20
+ So a cell is a deal rebuilt from its assumptions and run end to end. It costs
21
+ more and it is the only way the table means what it says.
22
+
23
+ *Why a cell can fail without the table failing.* Flex far enough and the engine
24
+ runs out of deal: the equity is wiped out, the fixed point will not converge, the
25
+ structure raises more than the purchase needs. Those cells are worth seeing —
26
+ they are the edge of the surface, which is the thing being looked for — so a cell
27
+ holds either a number or the reason there is not one, and the rest of the grid
28
+ prints either way.
29
+
30
+ *Why every cell carries a covenant flag.* A cell that clears the return
31
+ threshold and trips a maintenance test in year three is not a cell that works.
32
+ The lenders can accelerate from that date and everything after it is
33
+ hypothetical. The metric answers the question that was asked; the flag says
34
+ whether the answer was reachable.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import dataclasses
40
+ from collections.abc import Iterator, Sequence
41
+ from dataclasses import dataclass
42
+ from decimal import Decimal, InvalidOperation
43
+ from enum import Enum
44
+
45
+ from .covenants import CovenantReport
46
+ from .debt import CircularityError, DebtSchedule
47
+ from .drivers import Driver
48
+ from .money import ONE, ZERO, Money, money, quantize, rescale, safe_div
49
+ from .operating import OperatingAssumptions, OperatingModel
50
+ from .outcome import Outcome
51
+ from .periods import PeriodGrid
52
+ from .spec import Deal
53
+ from .tax import EarningsBase, InterestLimit, LeveredSolution
54
+
55
+ __all__ = [
56
+ "Axis",
57
+ "Breakeven",
58
+ "Case",
59
+ "Cell",
60
+ "Dimension",
61
+ "Grid",
62
+ "Metric",
63
+ "SensitivityError",
64
+ "Unit",
65
+ "format_value",
66
+ "solve",
67
+ ]
68
+
69
+
70
+ class SensitivityError(ValueError):
71
+ """The grid cannot be built as described."""
72
+
73
+
74
+ #: Failures that mean *this cell has no answer* rather than *the request was
75
+ #: malformed*. ``ArithmeticError`` covers both a division by zero and a decimal
76
+ #: operation that overflows, which a far enough flex will eventually produce.
77
+ CELL_FAILURES = (ValueError, ArithmeticError, CircularityError)
78
+
79
+
80
+ class Unit(Enum):
81
+ """How a number on an axis or in a cell should be read."""
82
+
83
+ RATE = "rate"
84
+ MULTIPLE = "multiple"
85
+ TURNS = "turns"
86
+ AMOUNT = "amount"
87
+ SHARE = "share"
88
+ YEARS = "years"
89
+ POINTS = "points"
90
+
91
+ def __str__(self) -> str:
92
+ return self.value
93
+
94
+
95
+ def _round(value: Money, places: int) -> Money:
96
+ """Round for display, without carrying a sign onto a zero.
97
+
98
+ A rate of minus four thousandths of a percent rounds to nought, and
99
+ printing that as ``-0.0%`` reads as a defect in the model rather than as a
100
+ small number. The sign is dropped only once the magnitude has already gone.
101
+ """
102
+ rounded = quantize(value, places)
103
+ # ``abs`` rather than ZERO, so a zero keeps the scale of its neighbours and
104
+ # the column still reads 0.0% beside 17.1% rather than a bare 0%.
105
+ return abs(rounded) if rounded == 0 else rounded
106
+
107
+
108
+ def format_value(unit: Unit, value: Money) -> str:
109
+ """Render ``value`` the way its unit is spoken.
110
+
111
+ Shared between the grid and whatever prints it, because an axis header and
112
+ the cells beneath it disagreeing about how many decimal places a turn of
113
+ leverage has is the kind of thing that gets noticed in a committee.
114
+ """
115
+ if unit is Unit.RATE or unit is Unit.SHARE:
116
+ return f"{_round(value * money(100), 1)}%"
117
+ if unit is Unit.MULTIPLE or unit is Unit.TURNS:
118
+ return f"{_round(value, 2)}x"
119
+ if unit is Unit.YEARS:
120
+ return f"{_round(value, 0)}y"
121
+ if unit is Unit.POINTS:
122
+ points = _round(value * money(100), 2)
123
+ # Trailing zeros are stripped by hand rather than by ``normalize``,
124
+ # which turns fifty into 5E+1 and makes a committee pack look broken.
125
+ digits = f"{points:f}".rstrip("0").rstrip(".") or "0"
126
+ sign = "+" if points > 0 else ""
127
+ return f"{sign}{digits}pp"
128
+ return f"{_round(value, 1):,}"
129
+
130
+
131
+ # -- Dimensions ----------------------------------------------------------
132
+
133
+
134
+ def _shift(driver: Driver, by: Money) -> Driver:
135
+ """Move every period of an assumption by the same amount.
136
+
137
+ A parallel shift rather than a scaling. "Margin 150bp lower throughout" is
138
+ the sentence an investment committee says; "margin 8% lower than plan"
139
+ means something different in year one than in year five and is almost never
140
+ what was meant.
141
+ """
142
+ return Driver(values=tuple(v + by for v in driver.values))
143
+
144
+
145
+ def _with_entry_multiple(deal: Deal, value: Money) -> Deal:
146
+ valuation = dataclasses.replace(deal.transaction.valuation, entry_multiple=value)
147
+ return dataclasses.replace(
148
+ deal, transaction=dataclasses.replace(deal.transaction, valuation=valuation)
149
+ )
150
+
151
+
152
+ def _with_exit_multiple(deal: Deal, value: Money) -> Deal:
153
+ return dataclasses.replace(deal, exit_multiple=value)
154
+
155
+
156
+ def _with_interest_limit(deal: Deal, rate: Money) -> Deal:
157
+ """Cap the interest deduction at ``rate`` of earnings.
158
+
159
+ Applied to a deal whose file caps nothing, this *creates* the cap, which is
160
+ deliberate: "what would a cap cost this deal" is the question most worth
161
+ asking of a structure that was underwritten without one. The earnings base
162
+ comes from the file where it has one and defaults otherwise, because the
163
+ axis moves the rate and the base is a different question.
164
+ """
165
+ if rate < 0:
166
+ raise SensitivityError("a deduction cap is a share of earnings, so not below zero")
167
+ base = deal.interest_limit.base if deal.interest_limit is not None else EarningsBase.EBITDA
168
+ return dataclasses.replace(deal, interest_limit=InterestLimit(rate=rate, base=base))
169
+
170
+
171
+ def _with_leverage(deal: Deal, turns: Money) -> Deal:
172
+ """Resize the funded debt to ``turns`` of LTM EBITDA.
173
+
174
+ Every tranche is scaled by the same factor, which holds the shape of the
175
+ structure — the split between term loan, notes and mezzanine — while moving
176
+ its size. Revolver commitments are left alone: a commitment is negotiated
177
+ against the working-capital swing of the business, not against how much term
178
+ debt sits above it, and scaling it would quietly change how much liquidity
179
+ the case assumes at the same time as the leverage.
180
+
181
+ The sponsor cheque absorbs the difference, because it is the plug.
182
+ """
183
+ if turns < 0:
184
+ raise SensitivityError("leverage is measured in turns of EBITDA, so not below zero")
185
+ if deal.transaction.total_debt <= 0:
186
+ raise SensitivityError(
187
+ "this deal funds no debt, so there is no leverage to flex; the "
188
+ "structure would have to be described before it can be resized"
189
+ )
190
+ target = deal.transaction.valuation.ltm_ebitda * turns
191
+ faces = rescale(target, [t.face for t in deal.transaction.debt])
192
+ sized = dict(zip((t.name for t in deal.transaction.debt), faces, strict=True))
193
+ transaction = dataclasses.replace(
194
+ deal.transaction,
195
+ debt=tuple(
196
+ dataclasses.replace(t, face=sized[t.name]) for t in deal.transaction.debt
197
+ ),
198
+ )
199
+ structure = deal.structure
200
+ if structure is not None:
201
+ # The funding table and the schedule hold the same tranche twice, in two
202
+ # shapes. Resizing one and not the other would produce a deal that funds
203
+ # one amount of debt and services another, which balances nowhere.
204
+ structure = dataclasses.replace(
205
+ structure,
206
+ tranches=tuple(
207
+ dataclasses.replace(t, face=sized.get(t.name, t.face))
208
+ for t in structure.tranches
209
+ ),
210
+ )
211
+ return dataclasses.replace(deal, transaction=transaction, structure=structure)
212
+
213
+
214
+ def _with_revenue_growth(deal: Deal, shift: Money) -> Deal:
215
+ operating = _operating(deal)
216
+ return dataclasses.replace(
217
+ deal,
218
+ operating=dataclasses.replace(
219
+ operating, revenue_growth=_shift(operating.revenue_growth, shift)
220
+ ),
221
+ )
222
+
223
+
224
+ def _with_ebitda_margin(deal: Deal, shift: Money) -> Deal:
225
+ operating = _operating(deal)
226
+ return dataclasses.replace(
227
+ deal,
228
+ operating=dataclasses.replace(
229
+ operating, ebitda_margin=_shift(operating.ebitda_margin, shift)
230
+ ),
231
+ )
232
+
233
+
234
+ def _with_exit_year(deal: Deal, years: Money) -> Deal:
235
+ """Hold the case and move the exit.
236
+
237
+ The grid is rebuilt from close rather than sliced, so this works in both
238
+ directions: an assumption series shorter than the new grid holds its final
239
+ value, which is what extending a case means, and one longer than it is
240
+ simply not reached.
241
+ """
242
+ if deal.grid is None:
243
+ raise SensitivityError(
244
+ "this deal has no projection, so there is no exit year to move"
245
+ )
246
+ if years != years.to_integral_value():
247
+ raise SensitivityError(
248
+ f"the exit year is a whole number of years, so {years} is not one"
249
+ )
250
+ count = int(years)
251
+ if count <= 0:
252
+ raise SensitivityError("a hold has to be at least one year long")
253
+ return dataclasses.replace(
254
+ deal, grid=PeriodGrid.build(deal.grid.start, count, deal.grid.frequency)
255
+ )
256
+
257
+
258
+ def _with_base_rate(deal: Deal, shift: Money) -> Deal:
259
+ structure = deal.structure
260
+ if structure is None or structure.base_rate is None:
261
+ raise SensitivityError(
262
+ "this structure prices off no floating base rate, so there is "
263
+ "nothing to shift; the tranches are fixed-rate"
264
+ )
265
+ return dataclasses.replace(
266
+ deal,
267
+ structure=dataclasses.replace(
268
+ structure, base_rate=_shift(structure.base_rate, shift)
269
+ ),
270
+ )
271
+
272
+
273
+ def _operating(deal: Deal) -> OperatingAssumptions:
274
+ if deal.operating is None:
275
+ raise SensitivityError(
276
+ "this deal has no operating case, so there are no drivers to flex"
277
+ )
278
+ return deal.operating
279
+
280
+
281
+ class Dimension(Enum):
282
+ """An assumption the grid can move, and what moving it means.
283
+
284
+ Two kinds live here and the distinction matters when reading an axis. A
285
+ *level* dimension is set to the value given — an entry multiple of 11.5 is
286
+ 11.5. A *shift* dimension is added to whatever the file says, across every
287
+ period, so its base case sits at zero and its axis reads as a departure.
288
+ """
289
+
290
+ ENTRY_MULTIPLE = "entry-multiple"
291
+ EXIT_MULTIPLE = "exit-multiple"
292
+ LEVERAGE = "leverage"
293
+ REVENUE_GROWTH = "revenue-growth"
294
+ EBITDA_MARGIN = "ebitda-margin"
295
+ EXIT_YEAR = "exit-year"
296
+ BASE_RATE = "base-rate"
297
+ INTEREST_LIMIT = "interest-limit"
298
+
299
+ def __str__(self) -> str:
300
+ return self.value
301
+
302
+ @property
303
+ def label(self) -> str:
304
+ return {
305
+ Dimension.ENTRY_MULTIPLE: "Entry multiple",
306
+ Dimension.EXIT_MULTIPLE: "Exit multiple",
307
+ Dimension.LEVERAGE: "Opening leverage",
308
+ Dimension.REVENUE_GROWTH: "Revenue growth",
309
+ Dimension.EBITDA_MARGIN: "EBITDA margin",
310
+ Dimension.EXIT_YEAR: "Exit year",
311
+ Dimension.BASE_RATE: "Base rate",
312
+ Dimension.INTEREST_LIMIT: "Interest deduction cap",
313
+ }[self]
314
+
315
+ @property
316
+ def unit(self) -> Unit:
317
+ return {
318
+ Dimension.ENTRY_MULTIPLE: Unit.MULTIPLE,
319
+ Dimension.EXIT_MULTIPLE: Unit.MULTIPLE,
320
+ Dimension.LEVERAGE: Unit.TURNS,
321
+ Dimension.REVENUE_GROWTH: Unit.POINTS,
322
+ Dimension.EBITDA_MARGIN: Unit.POINTS,
323
+ Dimension.EXIT_YEAR: Unit.YEARS,
324
+ # A level and not a shift: a cap of 30% is 30% of earnings, and an
325
+ # axis reading "two points off whatever the file says" would be
326
+ # meaningless on a file that caps nothing.
327
+ Dimension.INTEREST_LIMIT: Unit.RATE,
328
+ Dimension.BASE_RATE: Unit.POINTS,
329
+ }[self]
330
+
331
+ @property
332
+ def is_shift(self) -> bool:
333
+ """Whether the value is added to the file's assumption or replaces it."""
334
+ return self.unit is Unit.POINTS
335
+
336
+ @property
337
+ def is_continuous(self) -> bool:
338
+ """Whether values between two points on this dimension mean anything.
339
+
340
+ The exit year does not qualify. A hold of four and a half periods is
341
+ not a deal that could be done, and halving the interval towards one
342
+ would report a break-even nobody can act on.
343
+ """
344
+ return self is not Dimension.EXIT_YEAR
345
+
346
+ def apply(self, deal: Deal, value: Money) -> Deal:
347
+ """``deal`` as it would be with this dimension set to ``value``."""
348
+ return _APPLY[self](deal, value)
349
+
350
+ def read(self, deal: Deal) -> Money:
351
+ """Where ``deal`` already sits on this dimension.
352
+
353
+ A shift dimension always reads zero: the file is its own base case, and
354
+ the axis measures distance from it.
355
+ """
356
+ if self.is_shift:
357
+ return ZERO
358
+ if self is Dimension.ENTRY_MULTIPLE:
359
+ return deal.transaction.valuation.entry_multiple
360
+ if self is Dimension.EXIT_MULTIPLE:
361
+ return (
362
+ deal.exit_multiple
363
+ if deal.exit_multiple is not None
364
+ else deal.transaction.valuation.entry_multiple
365
+ )
366
+ if self is Dimension.LEVERAGE:
367
+ return safe_div(
368
+ deal.transaction.total_debt,
369
+ deal.transaction.valuation.ltm_ebitda,
370
+ default=ZERO,
371
+ )
372
+ if self is Dimension.INTEREST_LIMIT:
373
+ if deal.interest_limit is None:
374
+ raise SensitivityError(
375
+ "this deal's file caps nothing, so there is no cap to centre an "
376
+ 'axis on; add an "interest_limit" to the operating block, or '
377
+ "state the rates for the axis outright"
378
+ )
379
+ return deal.interest_limit.rate
380
+ if deal.grid is None:
381
+ raise SensitivityError(
382
+ "this deal has no projection, so there is no exit year to read"
383
+ )
384
+ return money(len(deal.grid)) / money(deal.grid.frequency.periods_per_year)
385
+
386
+
387
+ _APPLY = {
388
+ Dimension.ENTRY_MULTIPLE: _with_entry_multiple,
389
+ Dimension.EXIT_MULTIPLE: _with_exit_multiple,
390
+ Dimension.LEVERAGE: _with_leverage,
391
+ Dimension.REVENUE_GROWTH: _with_revenue_growth,
392
+ Dimension.EBITDA_MARGIN: _with_ebitda_margin,
393
+ Dimension.EXIT_YEAR: _with_exit_year,
394
+ Dimension.BASE_RATE: _with_base_rate,
395
+ Dimension.INTEREST_LIMIT: _with_interest_limit,
396
+ }
397
+
398
+
399
+ # -- Metrics -------------------------------------------------------------
400
+
401
+
402
+ @dataclass(frozen=True, slots=True)
403
+ class Case:
404
+ """One deal, run end to end, with everything a metric might ask of it."""
405
+
406
+ deal: Deal
407
+ model: OperatingModel
408
+ schedule: DebtSchedule
409
+ outcome: Outcome
410
+ covenants: CovenantReport | None
411
+ #: The settled solve, where the case was run on a levered tax basis.
412
+ #: ``None`` on the default basis, where there is nothing to settle.
413
+ levered: LeveredSolution | None = None
414
+
415
+ @classmethod
416
+ def run(cls, deal: Deal, *, levered: bool = False) -> Case:
417
+ """Run every layer once.
418
+
419
+ The case and the schedule are computed here and handed down rather than
420
+ recomputed by each caller. The schedule solves a fixed point, and a grid
421
+ that solved it twice per cell would take twice as long to say the same
422
+ thing.
423
+
424
+ ``levered`` chooses which tax basis the structure is serviced out of.
425
+ The default taxes EBIT and services the debt out of unlevered free cash
426
+ flow, which is the convention the engine has always used and the one
427
+ every figure in the repository is struck on. Asking for the other
428
+ settles the interest deduction against the debt it is charged on, which
429
+ on the shipped deals is worth between 90 and 189 basis points of return
430
+ — so this is a choice about what the model means, not a refinement, and
431
+ it is made explicitly rather than by default.
432
+ """
433
+ model = deal.project()
434
+ solution = deal.levered(model) if levered else None
435
+ schedule = deal.schedule(model) if solution is None else solution.schedule
436
+ outcome = deal.realise(model, schedule)
437
+ covenants = (
438
+ deal.test_covenants(model, schedule) if deal.has_covenants else None
439
+ )
440
+ return cls(
441
+ deal=deal,
442
+ model=model,
443
+ schedule=schedule,
444
+ outcome=outcome,
445
+ covenants=covenants,
446
+ levered=solution,
447
+ )
448
+
449
+ @property
450
+ def breached(self) -> bool:
451
+ return self.covenants is not None and not self.covenants.passes
452
+
453
+
454
+ class Metric(Enum):
455
+ """What a cell reports."""
456
+
457
+ IRR = "irr"
458
+ MOIC = "moic"
459
+ EQUITY_VALUE = "equity-value"
460
+ SPONSOR_EQUITY = "sponsor-equity"
461
+ ENTRY_LEVERAGE = "entry-leverage"
462
+ EXIT_LEVERAGE = "exit-leverage"
463
+ CUSHION = "cushion"
464
+ CASH_TAX = "cash-tax"
465
+ TAX_SAVING = "tax-saving"
466
+
467
+ def __str__(self) -> str:
468
+ return self.value
469
+
470
+ @property
471
+ def label(self) -> str:
472
+ return {
473
+ Metric.IRR: "IRR",
474
+ Metric.MOIC: "MoIC",
475
+ Metric.EQUITY_VALUE: "Equity value at exit",
476
+ Metric.SPONSOR_EQUITY: "Sponsor equity at close",
477
+ Metric.ENTRY_LEVERAGE: "Net leverage at close",
478
+ Metric.EXIT_LEVERAGE: "Net leverage at exit",
479
+ Metric.CUSHION: "Tightest covenant cushion",
480
+ Metric.CASH_TAX: "Cash tax over the hold",
481
+ Metric.TAX_SAVING: "Cash tax saved by the deduction",
482
+ }[self]
483
+
484
+ @property
485
+ def unit(self) -> Unit:
486
+ return {
487
+ Metric.IRR: Unit.RATE,
488
+ Metric.MOIC: Unit.MULTIPLE,
489
+ Metric.EQUITY_VALUE: Unit.AMOUNT,
490
+ Metric.SPONSOR_EQUITY: Unit.AMOUNT,
491
+ Metric.ENTRY_LEVERAGE: Unit.TURNS,
492
+ Metric.EXIT_LEVERAGE: Unit.TURNS,
493
+ Metric.CUSHION: Unit.SHARE,
494
+ Metric.CASH_TAX: Unit.AMOUNT,
495
+ Metric.TAX_SAVING: Unit.AMOUNT,
496
+ }[self]
497
+
498
+ @property
499
+ def needs_covenants(self) -> bool:
500
+ return self is Metric.CUSHION
501
+
502
+ @property
503
+ def needs_levered(self) -> bool:
504
+ """Whether this metric can only be read off a settled solve.
505
+
506
+ There is nothing to report for either of these on the unlevered basis:
507
+ the structure's cash tax is the operating case's own tax line, and the
508
+ saving is zero by construction. Refusing the grid up front is better
509
+ than filling it with the same excuse in every cell.
510
+ """
511
+ return self in (Metric.CASH_TAX, Metric.TAX_SAVING)
512
+
513
+ def read(self, case: Case) -> tuple[Money | None, str]:
514
+ """The figure, or the reason there is not one.
515
+
516
+ The reason travels with the missing number. A blank cell in a committee
517
+ pack gets read as a zero unless something says otherwise.
518
+ """
519
+ outcome = case.outcome
520
+ if self is Metric.IRR:
521
+ if outcome.irr is None:
522
+ return None, _no_rate(case)
523
+ return money(repr(outcome.irr)), ""
524
+ if self is Metric.MOIC:
525
+ if outcome.moic is None:
526
+ return None, "no capital was invested"
527
+ return outcome.moic, ""
528
+ if self is Metric.EQUITY_VALUE:
529
+ return outcome.valuation.equity_value, ""
530
+ if self is Metric.SPONSOR_EQUITY:
531
+ return case.deal.transaction.sponsor_equity, ""
532
+ if self is Metric.ENTRY_LEVERAGE:
533
+ net_debt = case.deal.transaction.total_debt - case.schedule.opening_cash
534
+ return (
535
+ safe_div(
536
+ net_debt, case.deal.transaction.valuation.ltm_ebitda, default=ZERO
537
+ ),
538
+ "",
539
+ )
540
+ if self is Metric.EXIT_LEVERAGE:
541
+ return outcome.valuation.exit_leverage, ""
542
+ if self in (Metric.CASH_TAX, Metric.TAX_SAVING):
543
+ if case.levered is None:
544
+ return None, "this case was not run on a levered tax basis"
545
+ if self is Metric.CASH_TAX:
546
+ return case.levered.tax.total_cash_tax, ""
547
+ return case.levered.total_shield, ""
548
+ if case.covenants is None:
549
+ return None, "this deal describes no covenants"
550
+ cushion = case.covenants.minimum_cushion
551
+ if cushion is None:
552
+ return None, "no covenant is testable on this case"
553
+ return cushion, ""
554
+
555
+
556
+ def _no_rate(case: Case) -> str:
557
+ if case.outcome.valuation.is_wiped_out:
558
+ return "the equity is wiped out"
559
+ return "there is no rate on these flows"
560
+
561
+
562
+ # -- The grid ------------------------------------------------------------
563
+
564
+
565
+ @dataclass(frozen=True, slots=True)
566
+ class Axis:
567
+ """One side of the table: a dimension and the values stepped along it."""
568
+
569
+ dimension: Dimension
570
+ values: tuple[Money, ...]
571
+
572
+ def __post_init__(self) -> None:
573
+ if not self.values:
574
+ raise SensitivityError(
575
+ f"{self.dimension}: an axis needs at least one value on it"
576
+ )
577
+ if len(set(self.values)) != len(self.values):
578
+ raise SensitivityError(f"{self.dimension}: an axis repeats a value")
579
+
580
+ def __len__(self) -> int:
581
+ return len(self.values)
582
+
583
+ def __iter__(self) -> Iterator[Money]:
584
+ return iter(self.values)
585
+
586
+ @classmethod
587
+ def of(cls, dimension: Dimension, values: Sequence[Money | str | int]) -> Axis:
588
+ return cls(dimension=dimension, values=tuple(money(v) for v in values))
589
+
590
+ @classmethod
591
+ def parse(cls, text: str) -> Axis:
592
+ """Read ``dimension:v1,v2,v3``.
593
+
594
+ Percentage-point axes are written the way they are said — ``-1.5`` for a
595
+ hundred and fifty basis points off — and divided by a hundred here. An
596
+ axis of raw decimals for a shift would be ``-0.015``, which is easy to
597
+ mistype by a factor of ten and hard to spot once it is in the table.
598
+ """
599
+ name, _, values_text = text.partition(":")
600
+ name = name.strip().lower()
601
+ try:
602
+ dimension = Dimension(name)
603
+ except ValueError:
604
+ known = ", ".join(d.value for d in Dimension)
605
+ raise SensitivityError(
606
+ f"unknown dimension {name!r}; expected one of {known}"
607
+ ) from None
608
+ if not values_text.strip():
609
+ raise SensitivityError(
610
+ f"{name}: say which values to step along, as {name}:1,2,3"
611
+ )
612
+ values = []
613
+ for token in values_text.split(","):
614
+ token = token.strip()
615
+ if not token:
616
+ continue
617
+ try:
618
+ value = Decimal(token)
619
+ except InvalidOperation:
620
+ raise SensitivityError(f"{name}: not a number: {token!r}") from None
621
+ values.append(value / money(100) if dimension.is_shift else value)
622
+ return cls(dimension=dimension, values=tuple(values))
623
+
624
+ def format(self, value: Money) -> str:
625
+ return format_value(self.dimension.unit, value)
626
+
627
+ def is_base(self, value: Money, deal: Deal) -> bool:
628
+ """Whether ``value`` is where the file already sits on this dimension."""
629
+ try:
630
+ return self.dimension.read(deal) == value
631
+ except SensitivityError:
632
+ return False
633
+
634
+
635
+ @dataclass(frozen=True, slots=True)
636
+ class Cell:
637
+ """One cell: the coordinates, the figure, and whether it was reachable."""
638
+
639
+ row: Money
640
+ column: Money
641
+ value: Money | None
642
+ note: str = ""
643
+ breached: bool = False
644
+ breach_note: str = ""
645
+
646
+ @property
647
+ def ok(self) -> bool:
648
+ return self.value is not None
649
+
650
+
651
+ @dataclass(frozen=True, slots=True)
652
+ class Grid:
653
+ """A metric read across two axes, plus the base case it moves around."""
654
+
655
+ rows: Axis
656
+ columns: Axis
657
+ metric: Metric
658
+ cells: tuple[tuple[Cell, ...], ...]
659
+ base: Money | None
660
+ base_note: str = ""
661
+
662
+ def __len__(self) -> int:
663
+ return len(self.cells)
664
+
665
+ def __iter__(self) -> Iterator[tuple[Cell, ...]]:
666
+ return iter(self.cells)
667
+
668
+ def at(self, row: int, column: int) -> Cell:
669
+ return self.cells[row][column]
670
+
671
+ @property
672
+ def populated(self) -> tuple[Cell, ...]:
673
+ return tuple(c for line in self.cells for c in line if c.ok)
674
+
675
+ @property
676
+ def failures(self) -> tuple[Cell, ...]:
677
+ return tuple(c for line in self.cells for c in line if not c.ok)
678
+
679
+ @property
680
+ def breaches(self) -> tuple[Cell, ...]:
681
+ return tuple(c for line in self.cells for c in line if c.breached)
682
+
683
+ @classmethod
684
+ def run(
685
+ cls,
686
+ deal: Deal,
687
+ rows: Axis,
688
+ columns: Axis,
689
+ metric: Metric,
690
+ *,
691
+ levered: bool = False,
692
+ ) -> Grid:
693
+ """Rebuild and run ``deal`` at every intersection of the two axes.
694
+
695
+ ``levered`` passes the tax basis down to every cell. It costs real time
696
+ — a settled cell solves the schedule several times over rather than
697
+ once — and it moves the answer by 90 to 189 basis points on the shipped
698
+ deals, which is more than the width of most of the bands a grid like
699
+ this is read for.
700
+ """
701
+ if rows.dimension is columns.dimension:
702
+ raise SensitivityError(
703
+ f"both axes move the {rows.dimension.label.lower()}; a grid needs "
704
+ f"two different assumptions to be a grid"
705
+ )
706
+ if metric.needs_covenants and not deal.has_covenants:
707
+ raise SensitivityError(
708
+ f"{metric.label.lower()} is measured against maintenance tests, "
709
+ f'and this deal describes none; add a "covenants" block'
710
+ )
711
+ if metric.needs_levered and not levered:
712
+ raise SensitivityError(
713
+ f"{metric.label.lower()} is a figure only a levered tax basis "
714
+ "produces; on the default basis the structure's cash tax is the "
715
+ "operating case's own and the saving is zero by construction. "
716
+ "Run the grid with levered=True, or from the command line with "
717
+ "--levered"
718
+ )
719
+ # The base case is run before anything is flexed, so a grid built on a
720
+ # deal that does not run at all fails outright instead of printing a
721
+ # table of identical excuses.
722
+ base_case = Case.run(deal, levered=levered)
723
+ base, base_note = metric.read(base_case)
724
+
725
+ lines = []
726
+ for row in rows:
727
+ line = []
728
+ for column in columns:
729
+ line.append(_cell(deal, rows, row, columns, column, metric, levered=levered))
730
+ lines.append(tuple(line))
731
+ return cls(
732
+ rows=rows,
733
+ columns=columns,
734
+ metric=metric,
735
+ cells=tuple(lines),
736
+ base=base,
737
+ base_note=base_note,
738
+ )
739
+
740
+
741
+ def _cell(
742
+ deal: Deal,
743
+ rows: Axis,
744
+ row: Money,
745
+ columns: Axis,
746
+ column: Money,
747
+ metric: Metric,
748
+ *,
749
+ levered: bool = False,
750
+ ) -> Cell:
751
+ """Rebuild the deal at one intersection and read the metric off it."""
752
+ try:
753
+ flexed = columns.dimension.apply(rows.dimension.apply(deal, row), column)
754
+ case = Case.run(flexed, levered=levered)
755
+ except CELL_FAILURES as exc:
756
+ return Cell(row=row, column=column, value=None, note=str(exc))
757
+ value, note = metric.read(case)
758
+ breach = case.covenants.first_breach if case.covenants is not None else None
759
+ return Cell(
760
+ row=row,
761
+ column=column,
762
+ value=value,
763
+ note=note,
764
+ breached=breach is not None,
765
+ breach_note=(
766
+ "" if breach is None else f"{breach.covenant} in period {breach.index}"
767
+ ),
768
+ )
769
+
770
+
771
+
772
+ # -- Break-evens ---------------------------------------------------------
773
+
774
+
775
+ #: Halvings of the bracket. Twenty-four takes a ten-turn bracket below a
776
+ #: millionth, which is far past what any of these dimensions is quoted to, and
777
+ #: costs a hundred-odd engine runs rather than the thousands an open-ended
778
+ #: search could.
779
+ BISECTION_STEPS = 24
780
+
781
+
782
+ @dataclass(frozen=True, slots=True)
783
+ class Breakeven:
784
+ """Where a metric crosses a level, along one dimension.
785
+
786
+ ``value`` is ``None`` when there is no crossing inside the bracket, and the
787
+ note says which of the several reasons applied. A break-even that does not
788
+ exist and a break-even that was not looked for hard enough are different
789
+ facts about a deal, and a reader is entitled to know which one they have.
790
+ """
791
+
792
+ dimension: Dimension
793
+ metric: Metric
794
+ target: Money
795
+ low: Money
796
+ high: Money
797
+ value: Money | None
798
+ note: str = ""
799
+
800
+ @property
801
+ def found(self) -> bool:
802
+ return self.value is not None
803
+
804
+ def format(self) -> str:
805
+ if self.value is None:
806
+ return "none"
807
+ return format_value(self.dimension.unit, self.value)
808
+
809
+
810
+ def solve(
811
+ deal: Deal,
812
+ dimension: Dimension,
813
+ metric: Metric,
814
+ *,
815
+ target: Money = ZERO,
816
+ low: Money,
817
+ high: Money,
818
+ steps: int = BISECTION_STEPS,
819
+ ) -> Breakeven:
820
+ """Find where ``metric`` crosses ``target`` as ``dimension`` moves.
821
+
822
+ Bisection rather than a secant or a Brent step, and deliberately. Each
823
+ evaluation rebuilds and re-runs the whole engine, so the function is
824
+ expensive; more to the point it is not smooth. A cash sweep that steps at a
825
+ leverage rung and a covenant that starts testing in period two put real
826
+ discontinuities in these curves, and a method that assumes local linearity
827
+ will happily extrapolate across one and return a crossing that is not
828
+ there. Halving an interval assumes nothing but a sign change.
829
+
830
+ The bracket is required rather than guessed. An open-ended search over an
831
+ entry multiple would eventually wander into prices at which the deal does
832
+ not describe a transaction at all, and a break-even reported from out there
833
+ is worse than no break-even.
834
+ """
835
+ if not dimension.is_continuous:
836
+ raise SensitivityError(
837
+ f"{dimension.label.lower()} takes whole values, so a crossing "
838
+ f"between two of them is not a case anyone could do"
839
+ )
840
+ if low >= high:
841
+ raise SensitivityError(
842
+ f"the bracket runs from {low} to {high}, which is not a bracket"
843
+ )
844
+ if steps < 1:
845
+ raise SensitivityError("a bisection needs at least one step")
846
+ if metric.needs_covenants and not deal.has_covenants:
847
+ raise SensitivityError(
848
+ f"{metric.label.lower()} is measured against maintenance tests, "
849
+ f'and this deal describes none; add a "covenants" block'
850
+ )
851
+
852
+ def at(value: Money) -> tuple[Money | None, str]:
853
+ try:
854
+ case = Case.run(dimension.apply(deal, value))
855
+ except CELL_FAILURES as exc:
856
+ return None, str(exc)
857
+ reading, note = metric.read(case)
858
+ return (None, note) if reading is None else (reading - target, "")
859
+
860
+ def absent(note: str) -> Breakeven:
861
+ return Breakeven(
862
+ dimension=dimension,
863
+ metric=metric,
864
+ target=target,
865
+ low=low,
866
+ high=high,
867
+ value=None,
868
+ note=note,
869
+ )
870
+
871
+ at_low, low_note = at(low)
872
+ if at_low is None:
873
+ return absent(
874
+ f"the case cannot be valued at {format_value(dimension.unit, low)}: "
875
+ f"{low_note}"
876
+ )
877
+ at_high, high_note = at(high)
878
+ if at_high is None:
879
+ return absent(
880
+ f"the case cannot be valued at {format_value(dimension.unit, high)}: "
881
+ f"{high_note}"
882
+ )
883
+ if at_low == 0:
884
+ return _found(dimension, metric, target, low, high, low)
885
+ if at_high == 0:
886
+ return _found(dimension, metric, target, low, high, high)
887
+ if (at_low > 0) == (at_high > 0):
888
+ return absent(
889
+ f"{metric.label.lower()} does not cross "
890
+ f"{format_value(metric.unit, target)} between "
891
+ f"{format_value(dimension.unit, low)} and "
892
+ f"{format_value(dimension.unit, high)}"
893
+ )
894
+
895
+ lo, hi, sign_low = low, high, at_low > 0
896
+ two = money(2)
897
+ for _ in range(steps):
898
+ mid = (lo + hi) / two
899
+ value, note = at(mid)
900
+ if value is None:
901
+ # The engine stopped answering partway into the bracket, which
902
+ # means the crossing is somewhere in a region the model does not
903
+ # describe. Reporting the last iterate would dress that up as an
904
+ # answer.
905
+ return absent(
906
+ f"the case cannot be valued at "
907
+ f"{format_value(dimension.unit, mid)}, inside the bracket: {note}"
908
+ )
909
+ if value == 0:
910
+ return _found(dimension, metric, target, low, high, mid)
911
+ if (value > 0) == sign_low:
912
+ lo = mid
913
+ else:
914
+ hi = mid
915
+ return _found(dimension, metric, target, low, high, (lo + hi) / two)
916
+
917
+
918
+ def _found(
919
+ dimension: Dimension,
920
+ metric: Metric,
921
+ target: Money,
922
+ low: Money,
923
+ high: Money,
924
+ value: Money,
925
+ ) -> Breakeven:
926
+ return Breakeven(
927
+ dimension=dimension,
928
+ metric=metric,
929
+ target=target,
930
+ low=low,
931
+ high=high,
932
+ value=value,
933
+ )
934
+
935
+
936
+ #: A neutral shift, named because ``money(0)`` at a call site reads as an amount.
937
+ NO_SHIFT: Money = ZERO
938
+
939
+ #: A multiple of one, for the same reason.
940
+ UNCHANGED: Money = ONE