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