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/cli.py
ADDED
|
@@ -0,0 +1,1995 @@
|
|
|
1
|
+
"""Command line over the engine.
|
|
2
|
+
|
|
3
|
+
Grows a subcommand per layer as the layers land: ``returns`` over a set of dated
|
|
4
|
+
cash flows, and ``deal`` over a deal file. Every layer is reachable from here,
|
|
5
|
+
so nothing in the engine is only exercisable from a test.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
from datetime import date
|
|
15
|
+
from decimal import Decimal
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from . import __version__
|
|
19
|
+
from .covenants import CovenantObservation
|
|
20
|
+
from .daycount import DayCount
|
|
21
|
+
from .fees import FeeMethod
|
|
22
|
+
from .money import quantize
|
|
23
|
+
from .outcome import Outcome
|
|
24
|
+
from .report import Report, prepare
|
|
25
|
+
from .returns import AmbiguousIRRError, CashFlow, CashFlowStream, IRRError
|
|
26
|
+
from .sensitivity import Axis, Case, Grid, Metric, SensitivityError, format_value
|
|
27
|
+
from .spec import Deal, DealSpecError, load_deal
|
|
28
|
+
from .transaction import Transaction
|
|
29
|
+
|
|
30
|
+
__all__ = ["main"]
|
|
31
|
+
|
|
32
|
+
_CONVENTIONS = {c.value: c for c in DayCount}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _parse_flow(token: str) -> CashFlow:
|
|
36
|
+
"""Parse one ``YYYY-MM-DD:amount`` token, with an optional trailing label."""
|
|
37
|
+
parts = token.split(":")
|
|
38
|
+
if len(parts) < 2:
|
|
39
|
+
raise argparse.ArgumentTypeError(
|
|
40
|
+
f"expected DATE:AMOUNT[:LABEL], got {token!r}"
|
|
41
|
+
)
|
|
42
|
+
when_text, amount_text = parts[0], parts[1]
|
|
43
|
+
label = parts[2] if len(parts) > 2 else ""
|
|
44
|
+
try:
|
|
45
|
+
when = date.fromisoformat(when_text.strip())
|
|
46
|
+
except ValueError as exc:
|
|
47
|
+
raise argparse.ArgumentTypeError(f"not a date: {when_text!r}") from exc
|
|
48
|
+
try:
|
|
49
|
+
amount = Decimal(amount_text.strip().replace("_", "").replace(",", ""))
|
|
50
|
+
except Exception as exc:
|
|
51
|
+
raise argparse.ArgumentTypeError(f"not an amount: {amount_text!r}") from exc
|
|
52
|
+
return CashFlow(when=when, amount=amount, label=label)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _column(index: int) -> str:
|
|
56
|
+
"""What heads a column: ``Stub`` for period zero, ``P3`` otherwise."""
|
|
57
|
+
return "Stub" if index == 0 else f"P{index}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _format_money(value: Decimal) -> str:
|
|
61
|
+
return f"{value:,.2f}"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _amount_str(value: Decimal) -> str:
|
|
65
|
+
"""Serialise an amount at cent scale.
|
|
66
|
+
|
|
67
|
+
``Decimal`` carries the scale of whatever arithmetic produced it, so a fee
|
|
68
|
+
computed as ``2760.0 * 0.014`` stringifies as ``38.64000``. The value is
|
|
69
|
+
right; the presentation is not, and callers comparing against ``"38.64"``
|
|
70
|
+
would be surprised. Rounding happens here, at the edge, and nowhere earlier.
|
|
71
|
+
"""
|
|
72
|
+
return str(quantize(value, 2))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _returns_report(stream: CashFlowStream) -> dict[str, Any]:
|
|
76
|
+
report: dict[str, Any] = {
|
|
77
|
+
"flows": [
|
|
78
|
+
{
|
|
79
|
+
"date": f.when.isoformat(),
|
|
80
|
+
"amount": _amount_str(f.amount),
|
|
81
|
+
"label": f.label,
|
|
82
|
+
}
|
|
83
|
+
for f in stream
|
|
84
|
+
],
|
|
85
|
+
"convention": str(stream.convention),
|
|
86
|
+
"holding_period_years": float(stream.holding_period_years()),
|
|
87
|
+
"moic": float(stream.moic()),
|
|
88
|
+
"net": _amount_str(stream.total),
|
|
89
|
+
}
|
|
90
|
+
try:
|
|
91
|
+
report["irr"] = stream.xirr()
|
|
92
|
+
except AmbiguousIRRError as exc:
|
|
93
|
+
report["irr"] = None
|
|
94
|
+
report["irr_note"] = str(exc)
|
|
95
|
+
report["irr_candidates"] = list(exc.roots)
|
|
96
|
+
except IRRError as exc:
|
|
97
|
+
report["irr"] = None
|
|
98
|
+
report["irr_note"] = str(exc)
|
|
99
|
+
return report
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _print_returns(report: dict[str, Any]) -> None:
|
|
103
|
+
width = max((len(f["label"]) for f in report["flows"]), default=0)
|
|
104
|
+
print("Cash flows")
|
|
105
|
+
for flow in report["flows"]:
|
|
106
|
+
label = f" {flow['label']:<{width}}" if width else ""
|
|
107
|
+
print(
|
|
108
|
+
f" {flow['date']}{label} "
|
|
109
|
+
f"{_format_money(Decimal(flow['amount'])):>18}"
|
|
110
|
+
)
|
|
111
|
+
print()
|
|
112
|
+
print(f" {'Day count':<22}{report['convention']}")
|
|
113
|
+
print(f" {'Holding period':<22}{report['holding_period_years']:.2f} years")
|
|
114
|
+
print(f" {'Net':<22}{_format_money(Decimal(report['net']))}")
|
|
115
|
+
print(f" {'MoIC':<22}{report['moic']:.2f}x")
|
|
116
|
+
if report.get("irr") is None:
|
|
117
|
+
print(f" {'IRR':<22}not reported")
|
|
118
|
+
print(f" {'':<22}{report['irr_note']}")
|
|
119
|
+
else:
|
|
120
|
+
print(f" {'IRR':<22}{report['irr']:.2%}")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _cmd_returns(args: argparse.Namespace) -> int:
|
|
124
|
+
stream = CashFlowStream(
|
|
125
|
+
flows=tuple(args.flow), convention=_CONVENTIONS[args.convention]
|
|
126
|
+
)
|
|
127
|
+
report = _returns_report(stream)
|
|
128
|
+
if args.json:
|
|
129
|
+
print(json.dumps(report, indent=2))
|
|
130
|
+
else:
|
|
131
|
+
_print_returns(report)
|
|
132
|
+
return 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _deal_report(name: str, close: date | None, deal: Transaction) -> dict[str, Any]:
|
|
136
|
+
table = deal.sources_and_uses()
|
|
137
|
+
return {
|
|
138
|
+
"name": name,
|
|
139
|
+
"close_date": close.isoformat() if close else None,
|
|
140
|
+
"entry": {
|
|
141
|
+
"ltm_ebitda": _amount_str(deal.valuation.ltm_ebitda),
|
|
142
|
+
"multiple": float(deal.valuation.entry_multiple),
|
|
143
|
+
"enterprise_value": _amount_str(deal.valuation.enterprise_value),
|
|
144
|
+
"net_debt": _amount_str(deal.valuation.net_debt),
|
|
145
|
+
"equity_purchase_price": _amount_str(deal.valuation.equity_purchase_price),
|
|
146
|
+
},
|
|
147
|
+
"sources": [
|
|
148
|
+
{"label": i.label, "amount": _amount_str(i.amount), "note": i.note}
|
|
149
|
+
for i in table.sources
|
|
150
|
+
],
|
|
151
|
+
"uses": [
|
|
152
|
+
{"label": i.label, "amount": _amount_str(i.amount), "note": i.note}
|
|
153
|
+
for i in table.uses
|
|
154
|
+
],
|
|
155
|
+
"total_sources": _amount_str(table.total_sources),
|
|
156
|
+
"total_uses": _amount_str(table.total_uses),
|
|
157
|
+
"balanced": table.total_sources == table.total_uses,
|
|
158
|
+
"metrics": {
|
|
159
|
+
"entry_leverage": float(deal.entry_leverage),
|
|
160
|
+
"total_capitalisation": _amount_str(deal.total_capitalisation),
|
|
161
|
+
"equity_contribution_rate": float(deal.equity_contribution_rate),
|
|
162
|
+
"sponsor_ownership": float(deal.sponsor_ownership),
|
|
163
|
+
"sponsor_equity": _amount_str(deal.sponsor_equity),
|
|
164
|
+
"overfunded": deal.is_overfunded,
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _print_table(
|
|
170
|
+
title: str,
|
|
171
|
+
rows: list[dict[str, str]],
|
|
172
|
+
total: str,
|
|
173
|
+
label_width: int,
|
|
174
|
+
amount_width: int,
|
|
175
|
+
) -> None:
|
|
176
|
+
print(f" {title}")
|
|
177
|
+
for row in rows:
|
|
178
|
+
amount = _format_money(Decimal(row["amount"]))
|
|
179
|
+
note = f" {row['note']}" if row["note"] else ""
|
|
180
|
+
print(f" {row['label']:<{label_width}} {amount:>{amount_width}}{note}")
|
|
181
|
+
print(f" {'':<{label_width}} {'-' * amount_width}")
|
|
182
|
+
print(f" {'Total':<{label_width}} {_format_money(Decimal(total)):>{amount_width}}")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _print_deal(report: dict[str, Any]) -> None:
|
|
186
|
+
header = report["name"]
|
|
187
|
+
if report["close_date"]:
|
|
188
|
+
header += f" (close {report['close_date']})"
|
|
189
|
+
print(header)
|
|
190
|
+
print("=" * len(header))
|
|
191
|
+
print()
|
|
192
|
+
|
|
193
|
+
entry = report["entry"]
|
|
194
|
+
print(" Entry")
|
|
195
|
+
print(f" {'LTM EBITDA':<26}{_format_money(Decimal(entry['ltm_ebitda'])):>16}")
|
|
196
|
+
print(f" {'Entry multiple':<26}{entry['multiple']:>15.2f}x")
|
|
197
|
+
print(f" {'Enterprise value':<26}{_format_money(Decimal(entry['enterprise_value'])):>16}")
|
|
198
|
+
print(f" {'Net debt':<26}{_format_money(Decimal(entry['net_debt'])):>16}")
|
|
199
|
+
print(
|
|
200
|
+
f" {'Equity purchase price':<26}"
|
|
201
|
+
f"{_format_money(Decimal(entry['equity_purchase_price'])):>16}"
|
|
202
|
+
)
|
|
203
|
+
print()
|
|
204
|
+
|
|
205
|
+
# Both halves of the funding table share column widths, so the two totals
|
|
206
|
+
# sit in the same place on the page and can be read against each other.
|
|
207
|
+
rows = [*report["sources"], *report["uses"]]
|
|
208
|
+
label_width = max(max((len(r["label"]) for r in rows), default=0), len("Total"))
|
|
209
|
+
amount_width = max(
|
|
210
|
+
len(_format_money(Decimal(a)))
|
|
211
|
+
for a in [*(r["amount"] for r in rows), report["total_sources"], report["total_uses"]]
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
_print_table(
|
|
215
|
+
"Sources", report["sources"], report["total_sources"], label_width, amount_width
|
|
216
|
+
)
|
|
217
|
+
print()
|
|
218
|
+
_print_table("Uses", report["uses"], report["total_uses"], label_width, amount_width)
|
|
219
|
+
print()
|
|
220
|
+
|
|
221
|
+
metrics = report["metrics"]
|
|
222
|
+
print(" Entry metrics")
|
|
223
|
+
print(f" {'Total leverage':<26}{metrics['entry_leverage']:>15.2f}x")
|
|
224
|
+
print(f" {'Equity contribution':<26}{metrics['equity_contribution_rate']:>15.1%}")
|
|
225
|
+
print(f" {'Sponsor ownership':<26}{metrics['sponsor_ownership']:>15.1%}")
|
|
226
|
+
print(
|
|
227
|
+
f" {'Total capitalisation':<26}"
|
|
228
|
+
f"{_format_money(Decimal(metrics['total_capitalisation'])):>16}"
|
|
229
|
+
)
|
|
230
|
+
if metrics["overfunded"]:
|
|
231
|
+
print()
|
|
232
|
+
print(" Note: the debt raised exceeds what the deal requires, so the")
|
|
233
|
+
print(" sponsor takes a distribution at close rather than writing a cheque.")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
_PROJECTION_ROWS: tuple[tuple[str, str], ...] = (
|
|
237
|
+
("Revenue", "revenue"),
|
|
238
|
+
("EBITDA", "ebitda"),
|
|
239
|
+
("Depreciation & amortisation", "depreciation_and_amortisation"),
|
|
240
|
+
("EBIT", "ebit"),
|
|
241
|
+
("Cash tax", "cash_tax"),
|
|
242
|
+
("NOPAT", "nopat"),
|
|
243
|
+
("Add back D&A", "depreciation_and_amortisation"),
|
|
244
|
+
("Capital expenditure", "capital_expenditure"),
|
|
245
|
+
("Change in working capital", "change_in_net_working_capital"),
|
|
246
|
+
("Unlevered free cash flow", "unlevered_free_cash_flow"),
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
#: Rows shown as an outflow, so each column reads the way the arithmetic runs
|
|
250
|
+
#: down the page rather than requiring the reader to remember the signs.
|
|
251
|
+
_NEGATED_ROWS = frozenset({"Capital expenditure", "Change in working capital", "Cash tax"})
|
|
252
|
+
|
|
253
|
+
#: Rows after which a blank line separates one subtotal block from the next.
|
|
254
|
+
_RULED_ROWS = frozenset({"EBIT", "NOPAT"})
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _projection_report(deal: Deal) -> dict[str, Any]:
|
|
258
|
+
model = deal.project()
|
|
259
|
+
return {
|
|
260
|
+
"name": deal.name,
|
|
261
|
+
"opening_revenue": _amount_str(model.opening_revenue),
|
|
262
|
+
"opening_net_working_capital": _amount_str(model.opening_net_working_capital),
|
|
263
|
+
"periods": [
|
|
264
|
+
{
|
|
265
|
+
"index": p.index,
|
|
266
|
+
"ending": p.period.end.isoformat(),
|
|
267
|
+
"revenue": _amount_str(p.revenue),
|
|
268
|
+
"ebitda": _amount_str(p.ebitda),
|
|
269
|
+
"ebitda_margin": float(p.ebitda_margin),
|
|
270
|
+
"depreciation_and_amortisation": _amount_str(p.depreciation_and_amortisation),
|
|
271
|
+
"ebit": _amount_str(p.ebit),
|
|
272
|
+
"taxable_income": _amount_str(p.tax.taxable_income),
|
|
273
|
+
"loss_relief_used": _amount_str(p.tax.loss_relief_used),
|
|
274
|
+
"cash_tax": _amount_str(p.tax.cash_tax),
|
|
275
|
+
"closing_carryforward": _amount_str(p.tax.closing_carryforward),
|
|
276
|
+
"nopat": _amount_str(p.nopat),
|
|
277
|
+
"capital_expenditure": _amount_str(p.capital_expenditure),
|
|
278
|
+
"net_working_capital": _amount_str(p.net_working_capital),
|
|
279
|
+
"change_in_net_working_capital": _amount_str(p.change_in_net_working_capital),
|
|
280
|
+
"unlevered_free_cash_flow": _amount_str(p.unlevered_free_cash_flow),
|
|
281
|
+
"cash_conversion": float(p.cash_conversion),
|
|
282
|
+
}
|
|
283
|
+
for p in model
|
|
284
|
+
],
|
|
285
|
+
"totals": {
|
|
286
|
+
"entry_ebitda": _amount_str(model.entry_ebitda),
|
|
287
|
+
"exit_ebitda": _amount_str(model.exit_ebitda),
|
|
288
|
+
"unlevered_free_cash_flow": _amount_str(model.total_unlevered_free_cash_flow),
|
|
289
|
+
"cash_tax": _amount_str(model.total_cash_tax),
|
|
290
|
+
"capital_expenditure": _amount_str(model.total_capital_expenditure),
|
|
291
|
+
"working_capital_absorbed": _amount_str(model.working_capital_absorbed),
|
|
292
|
+
"closing_carryforward": _amount_str(model.closing_carryforward),
|
|
293
|
+
},
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _print_projection(report: dict[str, Any]) -> None:
|
|
298
|
+
periods = report["periods"]
|
|
299
|
+
header = f"{report['name']} - operating case"
|
|
300
|
+
print(header)
|
|
301
|
+
print("=" * len(header))
|
|
302
|
+
print()
|
|
303
|
+
|
|
304
|
+
label_width = max(len(label) for label, _ in _PROJECTION_ROWS) + 2
|
|
305
|
+
widest = max(
|
|
306
|
+
len(_format_money(Decimal(p[key]))) for p in periods for _, key in _PROJECTION_ROWS
|
|
307
|
+
)
|
|
308
|
+
column = max(widest, 7) + 2
|
|
309
|
+
|
|
310
|
+
def row(label: str, cells: list[str]) -> str:
|
|
311
|
+
return " " + label.ljust(label_width) + "".join(c.rjust(column) for c in cells)
|
|
312
|
+
|
|
313
|
+
print(row("", [_column(p["index"]) for p in periods]))
|
|
314
|
+
print(row("", [p["ending"][:7] for p in periods]))
|
|
315
|
+
print(" " + "-" * (label_width + column * len(periods)))
|
|
316
|
+
|
|
317
|
+
for label, key in _PROJECTION_ROWS:
|
|
318
|
+
cells = []
|
|
319
|
+
for p in periods:
|
|
320
|
+
value = Decimal(p[key])
|
|
321
|
+
if label in _NEGATED_ROWS:
|
|
322
|
+
value = -value
|
|
323
|
+
cells.append(_format_money(value))
|
|
324
|
+
print(row(label, cells))
|
|
325
|
+
if label in _RULED_ROWS:
|
|
326
|
+
print()
|
|
327
|
+
|
|
328
|
+
print()
|
|
329
|
+
print(row("EBITDA margin", [f"{p['ebitda_margin']:.1%}" for p in periods]))
|
|
330
|
+
print(row("Cash conversion", [f"{p['cash_conversion']:.1%}" for p in periods]))
|
|
331
|
+
|
|
332
|
+
totals = report["totals"]
|
|
333
|
+
print()
|
|
334
|
+
print(" Across the hold")
|
|
335
|
+
for label, key in (
|
|
336
|
+
("Entry EBITDA", "entry_ebitda"),
|
|
337
|
+
("Exit EBITDA", "exit_ebitda"),
|
|
338
|
+
("Cumulative unlevered FCF", "unlevered_free_cash_flow"),
|
|
339
|
+
("Cash tax paid", "cash_tax"),
|
|
340
|
+
("Capital expenditure", "capital_expenditure"),
|
|
341
|
+
("Working capital absorbed", "working_capital_absorbed"),
|
|
342
|
+
):
|
|
343
|
+
print(f" {label:<28}{_format_money(Decimal(totals[key])):>14}")
|
|
344
|
+
if Decimal(totals["closing_carryforward"]) > 0:
|
|
345
|
+
print(
|
|
346
|
+
f" {'Losses carried forward':<28}"
|
|
347
|
+
f"{_format_money(Decimal(totals['closing_carryforward'])):>14}"
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
#: The opening balance sheet, as it would be laid out on a page. Each row is a
|
|
352
|
+
#: label, the attribute behind it, and whether it belongs on the asset side.
|
|
353
|
+
_BALANCE_SHEET_ROWS: tuple[tuple[str, str, str], ...] = (
|
|
354
|
+
("assets", "Cash", "cash"),
|
|
355
|
+
("assets", "Identifiable assets", "identifiable_assets"),
|
|
356
|
+
("assets", "Goodwill", "goodwill"),
|
|
357
|
+
("assets", "Deferred financing costs", "deferred_financing_costs"),
|
|
358
|
+
("assets", "Unamortised issue discount", "unamortised_issue_discount"),
|
|
359
|
+
("liabilities", "Debt at face", "debt_at_face"),
|
|
360
|
+
("liabilities", "Operating liabilities", "operating_liabilities"),
|
|
361
|
+
("liabilities", "Deferred tax liability", "deferred_tax_liability"),
|
|
362
|
+
("equity", "Sponsor equity", "sponsor_equity"),
|
|
363
|
+
("equity", "Rollover equity", "rollover_equity"),
|
|
364
|
+
("equity", "Expensed at close", "expensed_at_close"),
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
#: Shown as a deduction, because it is one.
|
|
368
|
+
_BALANCE_SHEET_NEGATED = frozenset({"Expensed at close"})
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _balance_report(deal: Deal) -> dict[str, Any]:
|
|
372
|
+
sheet = deal.recapitalise()
|
|
373
|
+
return {
|
|
374
|
+
"name": deal.name,
|
|
375
|
+
"close_date": deal.close_date.isoformat() if deal.close_date else None,
|
|
376
|
+
"assets": {
|
|
377
|
+
key: _amount_str(getattr(sheet, key))
|
|
378
|
+
for side, _, key in _BALANCE_SHEET_ROWS
|
|
379
|
+
if side == "assets"
|
|
380
|
+
},
|
|
381
|
+
"liabilities": {
|
|
382
|
+
key: _amount_str(getattr(sheet, key))
|
|
383
|
+
for side, _, key in _BALANCE_SHEET_ROWS
|
|
384
|
+
if side == "liabilities"
|
|
385
|
+
},
|
|
386
|
+
"equity": {
|
|
387
|
+
key: _amount_str(getattr(sheet, key))
|
|
388
|
+
for side, _, key in _BALANCE_SHEET_ROWS
|
|
389
|
+
if side == "equity"
|
|
390
|
+
},
|
|
391
|
+
"total_assets": _amount_str(sheet.total_assets),
|
|
392
|
+
"total_liabilities": _amount_str(sheet.total_liabilities),
|
|
393
|
+
"total_equity": _amount_str(sheet.total_equity),
|
|
394
|
+
"total_liabilities_and_equity": _amount_str(sheet.total_liabilities_and_equity),
|
|
395
|
+
"balanced": sheet.total_assets == sheet.total_liabilities_and_equity,
|
|
396
|
+
"net_debt": _amount_str(sheet.net_debt),
|
|
397
|
+
"goodwill_share_of_assets": float(sheet.goodwill_share_of_assets),
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _print_balance(report: dict[str, Any]) -> None:
|
|
402
|
+
header = f"{report['name']} - opening balance sheet"
|
|
403
|
+
if report["close_date"]:
|
|
404
|
+
header += f" (close {report['close_date']})"
|
|
405
|
+
print(header)
|
|
406
|
+
print("=" * len(header))
|
|
407
|
+
print()
|
|
408
|
+
|
|
409
|
+
label_width = max(len(label) for _, label, _ in _BALANCE_SHEET_ROWS)
|
|
410
|
+
amount_width = max(
|
|
411
|
+
len(_format_money(Decimal(report[side][key])))
|
|
412
|
+
for side, _, key in _BALANCE_SHEET_ROWS
|
|
413
|
+
)
|
|
414
|
+
amount_width = max(amount_width, len(_format_money(Decimal(report["total_assets"]))))
|
|
415
|
+
|
|
416
|
+
def block(title: str, side: str, total_label: str, total_key: str) -> None:
|
|
417
|
+
print(f" {title}")
|
|
418
|
+
for row_side, label, key in _BALANCE_SHEET_ROWS:
|
|
419
|
+
if row_side != side:
|
|
420
|
+
continue
|
|
421
|
+
value = Decimal(report[side][key])
|
|
422
|
+
if label in _BALANCE_SHEET_NEGATED:
|
|
423
|
+
value = -value
|
|
424
|
+
elif value == 0:
|
|
425
|
+
continue
|
|
426
|
+
print(f" {label:<{label_width}} {_format_money(value):>{amount_width}}")
|
|
427
|
+
print(f" {'':<{label_width}} {'-' * amount_width}")
|
|
428
|
+
print(
|
|
429
|
+
f" {total_label:<{label_width}} "
|
|
430
|
+
f"{_format_money(Decimal(report[total_key])):>{amount_width}}"
|
|
431
|
+
)
|
|
432
|
+
print()
|
|
433
|
+
|
|
434
|
+
block("Assets", "assets", "Total assets", "total_assets")
|
|
435
|
+
block("Liabilities", "liabilities", "Total liabilities", "total_liabilities")
|
|
436
|
+
block("Equity", "equity", "Total equity", "total_equity")
|
|
437
|
+
|
|
438
|
+
print(
|
|
439
|
+
f" {'Liabilities and equity':<{label_width}} "
|
|
440
|
+
f"{_format_money(Decimal(report['total_liabilities_and_equity'])):>{amount_width}}"
|
|
441
|
+
)
|
|
442
|
+
print()
|
|
443
|
+
net_debt = _format_money(Decimal(report["net_debt"]))
|
|
444
|
+
print(f" {'Net debt':<{label_width}} {net_debt:>{amount_width}}")
|
|
445
|
+
print(
|
|
446
|
+
f" {'Goodwill share of assets':<{label_width}} "
|
|
447
|
+
f"{report['goodwill_share_of_assets']:>{amount_width}.1%}"
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _cmd_balance(args: argparse.Namespace) -> int:
|
|
452
|
+
deal = load_deal(args.file)
|
|
453
|
+
report = _balance_report(deal)
|
|
454
|
+
if args.json:
|
|
455
|
+
print(json.dumps(report, indent=2))
|
|
456
|
+
else:
|
|
457
|
+
_print_balance(report)
|
|
458
|
+
return 0
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
#: Summary rows above the per-tranche detail: label, attribute, and whether the
|
|
462
|
+
#: figure is money leaving the business.
|
|
463
|
+
_SCHEDULE_ROWS: tuple[tuple[str, str, bool], ...] = (
|
|
464
|
+
("Unlevered free cash flow", "unlevered_free_cash_flow", False),
|
|
465
|
+
("Cash interest", "cash_interest", True),
|
|
466
|
+
("Commitment fees", "undrawn_fees", True),
|
|
467
|
+
("Levered free cash flow", "levered_free_cash_flow", False),
|
|
468
|
+
("Mandatory repayment", "mandatory_repayment", True),
|
|
469
|
+
("Cash sweep", "sweep_repayment", True),
|
|
470
|
+
("Revolver draw", "revolver_draw", False),
|
|
471
|
+
("Recapitalisation", "recapitalisation", False),
|
|
472
|
+
("Distribution paid", "distribution", True),
|
|
473
|
+
("Closing cash", "closing_cash", False),
|
|
474
|
+
("Accrued to balances", "pik_interest", False),
|
|
475
|
+
("Closing debt", "closing_debt", False),
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
#: Rows that only appear on a schedule where something actually happened.
|
|
480
|
+
_EVENT_ROWS = frozenset({"recapitalisation", "distribution"})
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _schedule_report(deal: Deal) -> dict[str, Any]:
|
|
484
|
+
schedule = deal.schedule()
|
|
485
|
+
model = deal.project()
|
|
486
|
+
names = [t.name for t in schedule.structure]
|
|
487
|
+
return {
|
|
488
|
+
"name": deal.name,
|
|
489
|
+
"tranches": names,
|
|
490
|
+
"periods": [
|
|
491
|
+
{
|
|
492
|
+
"index": period.index,
|
|
493
|
+
"ending": period.period.end.isoformat(),
|
|
494
|
+
"base_rate": float(period.base_rate),
|
|
495
|
+
**{key: _amount_str(getattr(period, key)) for _, key, _ in _SCHEDULE_ROWS},
|
|
496
|
+
"funding_shortfall": _amount_str(period.funding_shortfall),
|
|
497
|
+
"iterations": period.iterations,
|
|
498
|
+
"residual": _amount_str(period.residual),
|
|
499
|
+
# A stub has no leverage reading. The debt is a whole balance
|
|
500
|
+
# and the earnings are a fraction of a year's, so the ratio is
|
|
501
|
+
# not several turns worse than the deal carries — it is not a
|
|
502
|
+
# ratio at all, for the same reason a covenant does not test
|
|
503
|
+
# there.
|
|
504
|
+
"leverage": (
|
|
505
|
+
None
|
|
506
|
+
if period.period.is_stub
|
|
507
|
+
else float(schedule.leverage_at(i, model[i].ebitda))
|
|
508
|
+
),
|
|
509
|
+
"balances": {
|
|
510
|
+
row.name: _amount_str(row.closing) for row in period.tranches
|
|
511
|
+
},
|
|
512
|
+
"interest": {
|
|
513
|
+
row.name: _amount_str(row.cash_interest) for row in period.tranches
|
|
514
|
+
},
|
|
515
|
+
"amortisation_basis": {
|
|
516
|
+
row.name: _amount_str(row.amortisation_basis)
|
|
517
|
+
for row in period.tranches
|
|
518
|
+
},
|
|
519
|
+
}
|
|
520
|
+
for i, period in enumerate(schedule)
|
|
521
|
+
],
|
|
522
|
+
"totals": {
|
|
523
|
+
"cash_interest": _amount_str(schedule.total_cash_interest),
|
|
524
|
+
"pik_interest": _amount_str(schedule.total_pik_interest),
|
|
525
|
+
"undrawn_fees": _amount_str(schedule.total_undrawn_fees),
|
|
526
|
+
"repaid": _amount_str(schedule.total_repaid),
|
|
527
|
+
"drawn": _amount_str(schedule.total_drawn),
|
|
528
|
+
"recapitalised": _amount_str(schedule.total_recapitalised),
|
|
529
|
+
"distributed": _amount_str(schedule.total_distributed),
|
|
530
|
+
"opening_debt": _amount_str(schedule.opening_debt),
|
|
531
|
+
"closing_debt": _amount_str(schedule.closing_debt),
|
|
532
|
+
"closing_net_debt": _amount_str(schedule.closing_net_debt),
|
|
533
|
+
"debt_repaid": _amount_str(schedule.debt_repaid),
|
|
534
|
+
"peak_revolver_drawn": _amount_str(schedule.peak_revolver_drawn),
|
|
535
|
+
},
|
|
536
|
+
"entry_leverage": float(deal.transaction.entry_leverage),
|
|
537
|
+
"exit_leverage": float(schedule.leverage_at(len(schedule) - 1, model.exit_ebitda)),
|
|
538
|
+
"funded": schedule.is_funded,
|
|
539
|
+
"max_iterations": schedule.max_iterations_used,
|
|
540
|
+
"max_residual": _amount_str(schedule.max_residual),
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _print_schedule(report: dict[str, Any]) -> None:
|
|
545
|
+
periods = report["periods"]
|
|
546
|
+
header = f"{report['name']} - debt schedule"
|
|
547
|
+
print(header)
|
|
548
|
+
print("=" * len(header))
|
|
549
|
+
print()
|
|
550
|
+
|
|
551
|
+
labels = [label for label, _, _ in _SCHEDULE_ROWS] + report["tranches"]
|
|
552
|
+
label_width = max(len(label) for label in labels) + 2
|
|
553
|
+
widest = max(
|
|
554
|
+
len(_format_money(Decimal(p[key])))
|
|
555
|
+
for p in periods
|
|
556
|
+
for _, key, _ in _SCHEDULE_ROWS
|
|
557
|
+
)
|
|
558
|
+
column = max(widest, 9) + 2
|
|
559
|
+
|
|
560
|
+
def row(label: str, cells: list[str]) -> str:
|
|
561
|
+
return " " + label.ljust(label_width) + "".join(c.rjust(column) for c in cells)
|
|
562
|
+
|
|
563
|
+
print(row("", [_column(p["index"]) for p in periods]))
|
|
564
|
+
print(row("", [p["ending"][:7] for p in periods]))
|
|
565
|
+
print(" " + "-" * (label_width + column * len(periods)))
|
|
566
|
+
|
|
567
|
+
for label, key, outflow in _SCHEDULE_ROWS:
|
|
568
|
+
values = [Decimal(p[key]) for p in periods]
|
|
569
|
+
# The two event rows are silent on a deal that had no events. A row of
|
|
570
|
+
# zeroes in every column tells a reader nothing and costs them a line
|
|
571
|
+
# of attention on every schedule they ever read.
|
|
572
|
+
if key in _EVENT_ROWS and not any(values):
|
|
573
|
+
continue
|
|
574
|
+
cells = [_format_money(-v if outflow else v) for v in values]
|
|
575
|
+
print(row(label, cells))
|
|
576
|
+
if label in ("Levered free cash flow", "Closing cash"):
|
|
577
|
+
print()
|
|
578
|
+
|
|
579
|
+
print()
|
|
580
|
+
print(" Closing balances")
|
|
581
|
+
for name in report["tranches"]:
|
|
582
|
+
print(row(name, [_format_money(Decimal(p["balances"][name])) for p in periods]))
|
|
583
|
+
|
|
584
|
+
# The face the instalments were struck against, shown only where it moved.
|
|
585
|
+
# On a deal with no draws after close it is the funding table repeated in a
|
|
586
|
+
# second place, which is a line of attention spent to learn nothing.
|
|
587
|
+
if any(
|
|
588
|
+
len({p["amortisation_basis"][name] for p in periods}) > 1
|
|
589
|
+
for name in report["tranches"]
|
|
590
|
+
):
|
|
591
|
+
print()
|
|
592
|
+
print(" Amortising face")
|
|
593
|
+
for name in report["tranches"]:
|
|
594
|
+
print(
|
|
595
|
+
row(
|
|
596
|
+
name,
|
|
597
|
+
[
|
|
598
|
+
_format_money(Decimal(p["amortisation_basis"][name]))
|
|
599
|
+
for p in periods
|
|
600
|
+
],
|
|
601
|
+
)
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
print()
|
|
605
|
+
print(
|
|
606
|
+
row(
|
|
607
|
+
"Leverage",
|
|
608
|
+
[
|
|
609
|
+
"-" if p["leverage"] is None else f"{p['leverage']:.2f}x"
|
|
610
|
+
for p in periods
|
|
611
|
+
],
|
|
612
|
+
)
|
|
613
|
+
)
|
|
614
|
+
print(row("Base rate", [f"{p['base_rate']:.2%}" for p in periods]))
|
|
615
|
+
|
|
616
|
+
totals = report["totals"]
|
|
617
|
+
print()
|
|
618
|
+
print(" Across the hold")
|
|
619
|
+
for label, key in (
|
|
620
|
+
("Cash interest paid", "cash_interest"),
|
|
621
|
+
("Interest accrued to balances", "pik_interest"),
|
|
622
|
+
("Commitment fees", "undrawn_fees"),
|
|
623
|
+
("Repayments made", "repaid"),
|
|
624
|
+
("Revolver drawn", "drawn"),
|
|
625
|
+
("Net reduction in debt", "debt_repaid"),
|
|
626
|
+
("Peak revolver balance", "peak_revolver_drawn"),
|
|
627
|
+
("Closing net debt", "closing_net_debt"),
|
|
628
|
+
):
|
|
629
|
+
print(f" {label:<30}{_format_money(Decimal(totals[key])):>14}")
|
|
630
|
+
print(f" {'Entry leverage':<30}{report['entry_leverage']:>13.2f}x")
|
|
631
|
+
print(f" {'Exit leverage':<30}{report['exit_leverage']:>13.2f}x")
|
|
632
|
+
|
|
633
|
+
if not report["funded"]:
|
|
634
|
+
short = next(p for p in periods if Decimal(p["funding_shortfall"]) > 0)
|
|
635
|
+
print()
|
|
636
|
+
print(
|
|
637
|
+
f" Period {short['index']} is short by "
|
|
638
|
+
f"{_format_money(Decimal(short['funding_shortfall']))} after the revolver is"
|
|
639
|
+
)
|
|
640
|
+
print(" fully drawn. The structure does not fund itself as described.")
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _cmd_schedule(args: argparse.Namespace) -> int:
|
|
644
|
+
deal = load_deal(args.file)
|
|
645
|
+
report = _schedule_report(deal)
|
|
646
|
+
if args.json:
|
|
647
|
+
print(json.dumps(report, indent=2))
|
|
648
|
+
else:
|
|
649
|
+
_print_schedule(report)
|
|
650
|
+
return 0
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _covenant_report(deal: Deal) -> dict[str, Any]:
|
|
654
|
+
report = deal.test_covenants()
|
|
655
|
+
return {
|
|
656
|
+
"name": deal.name,
|
|
657
|
+
"covenants": [
|
|
658
|
+
{"name": c.name, "measure": str(c.measure), "first_test_period": c.first_test_period}
|
|
659
|
+
for c in report.covenants
|
|
660
|
+
],
|
|
661
|
+
"observations": [_observation(o) for o in report],
|
|
662
|
+
"passes": report.passes,
|
|
663
|
+
"breaches": [_observation(o) for o in report.breaches],
|
|
664
|
+
"first_breach": (
|
|
665
|
+
_observation(report.first_breach) if report.first_breach is not None else None
|
|
666
|
+
),
|
|
667
|
+
"tightest": (
|
|
668
|
+
_observation(report.tightest) if report.tightest is not None else None
|
|
669
|
+
),
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _observation(o: CovenantObservation) -> dict[str, Any]:
|
|
674
|
+
"""One tested covenant, flattened.
|
|
675
|
+
|
|
676
|
+
Ratios are emitted as floats and amounts as strings, which is the split used
|
|
677
|
+
everywhere else in this layer: a multiple is a measurement and a balance is
|
|
678
|
+
a quantity of money that has to survive a round trip.
|
|
679
|
+
"""
|
|
680
|
+
return {
|
|
681
|
+
"covenant": o.covenant,
|
|
682
|
+
"measure": str(o.measure),
|
|
683
|
+
"period": o.index,
|
|
684
|
+
"ending": o.period.end.isoformat(),
|
|
685
|
+
"tested": o.tested,
|
|
686
|
+
"threshold": float(o.threshold),
|
|
687
|
+
"actual": None if o.actual is None else float(o.actual),
|
|
688
|
+
"passes": o.passes,
|
|
689
|
+
"breached": o.breached,
|
|
690
|
+
"headroom": None if o.headroom is None else float(o.headroom),
|
|
691
|
+
"ebitda": _amount_str(o.ebitda),
|
|
692
|
+
"ebitda_at_breach": (
|
|
693
|
+
None if o.ebitda_at_breach is None else _amount_str(o.ebitda_at_breach)
|
|
694
|
+
),
|
|
695
|
+
"ebitda_cushion": (
|
|
696
|
+
None if o.ebitda_cushion is None else float(o.ebitda_cushion)
|
|
697
|
+
),
|
|
698
|
+
# The interval the flows were measured over. On an annual grid it
|
|
699
|
+
# restates the column heading; on a quarterly one it is the difference
|
|
700
|
+
# between a ratio a lender would recognise and one four times too large,
|
|
701
|
+
# so it is emitted rather than left to be inferred.
|
|
702
|
+
"measured_over": o.interval,
|
|
703
|
+
"note": o.note,
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
#: What a cell shows when the ratio has no value. Distinct from a blank, which
|
|
708
|
+
#: would read as a number the report forgot to print.
|
|
709
|
+
_NO_RATIO = "n/a"
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def _ratio(value: float | None, suffix: str = "x") -> str:
|
|
713
|
+
return _NO_RATIO if value is None else f"{value:.2f}{suffix}"
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _print_covenants(report: dict[str, Any]) -> None:
|
|
717
|
+
header = f"{report['name']} - covenants"
|
|
718
|
+
print(header)
|
|
719
|
+
print("=" * len(header))
|
|
720
|
+
print()
|
|
721
|
+
# Stated once rather than on every row. A reader who does not know what
|
|
722
|
+
# interval a ratio was struck over cannot check it against a certificate,
|
|
723
|
+
# and on a sub-annual grid the interval is not the column.
|
|
724
|
+
print(" Ratios are measured over the twelve months to each date.")
|
|
725
|
+
print()
|
|
726
|
+
|
|
727
|
+
observations = report["observations"]
|
|
728
|
+
periods = sorted({o["period"] for o in observations})
|
|
729
|
+
endings = {o["period"]: o["ending"][:7] for o in observations}
|
|
730
|
+
|
|
731
|
+
label_width = max(
|
|
732
|
+
max((len(c["name"]) for c in report["covenants"]), default=0), len("Cushion")
|
|
733
|
+
) + 4
|
|
734
|
+
column = 11
|
|
735
|
+
|
|
736
|
+
def row(label: str, cells: list[str]) -> str:
|
|
737
|
+
return " " + label.ljust(label_width) + "".join(c.rjust(column) for c in cells)
|
|
738
|
+
|
|
739
|
+
print(row("", [_column(i) for i in periods]))
|
|
740
|
+
print(row("", [endings[i] for i in periods]))
|
|
741
|
+
print(" " + "-" * (label_width + column * len(periods)))
|
|
742
|
+
|
|
743
|
+
for covenant in report["covenants"]:
|
|
744
|
+
rows = {o["period"]: o for o in observations if o["covenant"] == covenant["name"]}
|
|
745
|
+
print(row(covenant["name"], [_ratio(rows[i]["actual"]) for i in periods]))
|
|
746
|
+
print(
|
|
747
|
+
row(
|
|
748
|
+
" covenant",
|
|
749
|
+
[
|
|
750
|
+
_NO_RATIO if not rows[i]["tested"] else _ratio(rows[i]["threshold"])
|
|
751
|
+
for i in periods
|
|
752
|
+
],
|
|
753
|
+
)
|
|
754
|
+
)
|
|
755
|
+
print(
|
|
756
|
+
row(
|
|
757
|
+
" cushion",
|
|
758
|
+
[
|
|
759
|
+
_NO_RATIO
|
|
760
|
+
if rows[i]["ebitda_cushion"] is None or not rows[i]["tested"]
|
|
761
|
+
else f"{rows[i]['ebitda_cushion']:.1%}"
|
|
762
|
+
for i in periods
|
|
763
|
+
],
|
|
764
|
+
)
|
|
765
|
+
)
|
|
766
|
+
print(
|
|
767
|
+
row(
|
|
768
|
+
" status",
|
|
769
|
+
[
|
|
770
|
+
"-" if not rows[i]["tested"] else ("ok" if rows[i]["passes"] else "BREACH")
|
|
771
|
+
for i in periods
|
|
772
|
+
],
|
|
773
|
+
)
|
|
774
|
+
)
|
|
775
|
+
print()
|
|
776
|
+
|
|
777
|
+
tightest = report["tightest"]
|
|
778
|
+
if tightest is not None:
|
|
779
|
+
print(" Tightest test")
|
|
780
|
+
print(f" {tightest['covenant']} in period {tightest['period']}")
|
|
781
|
+
print(
|
|
782
|
+
f" {'EBITDA projected':<26}"
|
|
783
|
+
f"{_format_money(Decimal(tightest['ebitda'])):>14}"
|
|
784
|
+
)
|
|
785
|
+
if tightest["ebitda_at_breach"] is not None:
|
|
786
|
+
print(
|
|
787
|
+
f" {'Breaches below':<26}"
|
|
788
|
+
f"{_format_money(Decimal(tightest['ebitda_at_breach'])):>14}"
|
|
789
|
+
)
|
|
790
|
+
if tightest["ebitda_cushion"] is not None:
|
|
791
|
+
print(f" {'Cushion':<26}{tightest['ebitda_cushion']:>13.1%}")
|
|
792
|
+
print()
|
|
793
|
+
|
|
794
|
+
first = report["first_breach"]
|
|
795
|
+
if first is None:
|
|
796
|
+
print(" No maintenance test is breached across the hold.")
|
|
797
|
+
else:
|
|
798
|
+
print(
|
|
799
|
+
f" {first['covenant']} is breached in period {first['period']} "
|
|
800
|
+
f"({first['ending']})."
|
|
801
|
+
)
|
|
802
|
+
if first["actual"] is not None:
|
|
803
|
+
print(
|
|
804
|
+
f" The test reads {first['actual']:.2f}x against a "
|
|
805
|
+
f"{first['threshold']:.2f}x covenant."
|
|
806
|
+
)
|
|
807
|
+
else:
|
|
808
|
+
print(f" {first['note'].capitalize()}.")
|
|
809
|
+
print(f" {len(report['breaches'])} of {len(observations)} tests fail.")
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def _cmd_covenants(args: argparse.Namespace) -> int:
|
|
813
|
+
deal = load_deal(args.file)
|
|
814
|
+
report = _covenant_report(deal)
|
|
815
|
+
if args.json:
|
|
816
|
+
print(json.dumps(report, indent=2))
|
|
817
|
+
else:
|
|
818
|
+
_print_covenants(report)
|
|
819
|
+
# A breached case is a finding, not a failure of the tool, so the exit code
|
|
820
|
+
# is only non-zero where a caller would want a script to stop.
|
|
821
|
+
return 0
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def _exit_report(deal: Deal, *, levered: bool = False) -> dict[str, Any]:
|
|
825
|
+
case = Case.run(deal, levered=levered)
|
|
826
|
+
outcome = case.outcome
|
|
827
|
+
v = outcome.valuation
|
|
828
|
+
a = outcome.attribution
|
|
829
|
+
return {
|
|
830
|
+
"name": deal.name,
|
|
831
|
+
"close_date": deal.close_date.isoformat() if deal.close_date else None,
|
|
832
|
+
"tax_basis": "levered" if levered else "unlevered",
|
|
833
|
+
"tax_saving": (
|
|
834
|
+
None if case.levered is None else _amount_str(case.levered.total_shield)
|
|
835
|
+
),
|
|
836
|
+
"exit": {
|
|
837
|
+
"date": v.when.isoformat(),
|
|
838
|
+
"ebitda": _amount_str(v.ebitda),
|
|
839
|
+
"multiple": float(v.multiple),
|
|
840
|
+
"enterprise_value": _amount_str(v.enterprise_value),
|
|
841
|
+
"debt": _amount_str(v.debt),
|
|
842
|
+
"cash": _amount_str(v.cash),
|
|
843
|
+
"net_debt": _amount_str(v.net_debt),
|
|
844
|
+
"fees": _amount_str(v.fees),
|
|
845
|
+
"equity_value": _amount_str(v.equity_value),
|
|
846
|
+
"exit_leverage": float(v.exit_leverage),
|
|
847
|
+
"wiped_out": v.is_wiped_out,
|
|
848
|
+
},
|
|
849
|
+
"securities": [
|
|
850
|
+
{
|
|
851
|
+
"name": row.name,
|
|
852
|
+
"kind": str(row.security.kind),
|
|
853
|
+
"invested": _amount_str(row.invested),
|
|
854
|
+
"accrued": _amount_str(row.accrued),
|
|
855
|
+
"preferred_paid": _amount_str(row.preferred_paid),
|
|
856
|
+
"residual_paid": _amount_str(row.residual_paid),
|
|
857
|
+
"proceeds": _amount_str(row.proceeds),
|
|
858
|
+
"shortfall": _amount_str(row.shortfall),
|
|
859
|
+
"ownership": float(row.security.ownership),
|
|
860
|
+
"interim": _amount_str(row.interim),
|
|
861
|
+
"received": _amount_str(row.received),
|
|
862
|
+
"moic": None if row.moic is None else float(row.moic),
|
|
863
|
+
"irr": row.irr,
|
|
864
|
+
"irr_note": row.irr_note,
|
|
865
|
+
}
|
|
866
|
+
for row in outcome
|
|
867
|
+
],
|
|
868
|
+
"incentive": _incentive_block(outcome),
|
|
869
|
+
"distributions": [
|
|
870
|
+
{
|
|
871
|
+
"date": d.when.isoformat(),
|
|
872
|
+
"label": d.label,
|
|
873
|
+
"amount": _amount_str(d.amount),
|
|
874
|
+
"to_preferred": _amount_str(d.to_preferred),
|
|
875
|
+
"to_common": _amount_str(d.to_common),
|
|
876
|
+
"years": float(d.years),
|
|
877
|
+
}
|
|
878
|
+
for d in outcome.distributions
|
|
879
|
+
],
|
|
880
|
+
"totals": {
|
|
881
|
+
"invested": _amount_str(outcome.invested),
|
|
882
|
+
"proceeds": _amount_str(outcome.proceeds),
|
|
883
|
+
"profit": _amount_str(outcome.profit),
|
|
884
|
+
"interim": _amount_str(outcome.interim),
|
|
885
|
+
"received": _amount_str(outcome.received),
|
|
886
|
+
"moic": None if outcome.moic is None else float(outcome.moic),
|
|
887
|
+
"irr": outcome.irr,
|
|
888
|
+
"holding_period_years": float(outcome.holding_period_years),
|
|
889
|
+
"distributed": _amount_str(outcome.distributed),
|
|
890
|
+
},
|
|
891
|
+
"attribution": {
|
|
892
|
+
"ebitda_growth": _amount_str(a.ebitda_growth),
|
|
893
|
+
"multiple_change": _amount_str(a.multiple_change),
|
|
894
|
+
"debt_paydown": _amount_str(a.debt_paydown),
|
|
895
|
+
"costs": _amount_str(a.costs),
|
|
896
|
+
"total": _amount_str(a.total),
|
|
897
|
+
"value_created": _amount_str(a.value_created),
|
|
898
|
+
"floored": _amount_str(a.floored),
|
|
899
|
+
"reconciles": a.reconciles(),
|
|
900
|
+
},
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def _incentive_block(outcome: Outcome) -> dict[str, Any] | None:
|
|
905
|
+
"""The management plan, or ``None`` where the deal describes none.
|
|
906
|
+
|
|
907
|
+
Absent rather than zeroed, because a deal with no plan and a deal whose
|
|
908
|
+
plan expired worthless are different facts and a reader consuming this as
|
|
909
|
+
JSON should not have to infer which one they are looking at.
|
|
910
|
+
"""
|
|
911
|
+
settled = outcome.incentive
|
|
912
|
+
if settled is None:
|
|
913
|
+
return None
|
|
914
|
+
return {
|
|
915
|
+
"name": settled.name,
|
|
916
|
+
"vested": float(settled.vested),
|
|
917
|
+
"residual_before": _amount_str(settled.residual),
|
|
918
|
+
"pot": _amount_str(settled.pot),
|
|
919
|
+
"entitlement": _amount_str(settled.entitlement),
|
|
920
|
+
"strike_paid": _amount_str(settled.strike_paid),
|
|
921
|
+
"paid": _amount_str(settled.paid),
|
|
922
|
+
"dilution": _amount_str(settled.dilution),
|
|
923
|
+
"effective_share": float(settled.effective_share),
|
|
924
|
+
"exercised": settled.exercised,
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
#: The bridge, in the order it is read: what the business earned, what the
|
|
929
|
+
#: market paid for it, what the lenders were given back, what it cost.
|
|
930
|
+
_ATTRIBUTION_ROWS: tuple[tuple[str, str], ...] = (
|
|
931
|
+
("EBITDA growth", "ebitda_growth"),
|
|
932
|
+
("Multiple change", "multiple_change"),
|
|
933
|
+
("Debt paydown", "debt_paydown"),
|
|
934
|
+
("Entry and exit costs", "costs"),
|
|
935
|
+
)
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def _print_distributions(blocks: list[dict[str, Any]]) -> None:
|
|
939
|
+
"""Print what was paid out during the hold, and when.
|
|
940
|
+
|
|
941
|
+
The elapsed years are the column that matters. An interim distribution is
|
|
942
|
+
worth what it is worth because of when it arrived, and a table that showed
|
|
943
|
+
only the amount would be a table about the wrong thing.
|
|
944
|
+
"""
|
|
945
|
+
if not blocks:
|
|
946
|
+
return
|
|
947
|
+
print(" Paid during the hold")
|
|
948
|
+
width = max(len(b["label"]) for b in blocks) + 2
|
|
949
|
+
for block in blocks:
|
|
950
|
+
print(
|
|
951
|
+
" "
|
|
952
|
+
+ block["date"]
|
|
953
|
+
+ " "
|
|
954
|
+
+ block["label"].ljust(width)
|
|
955
|
+
+ _format_money(Decimal(block["amount"])).rjust(13)
|
|
956
|
+
+ f"{block['years']:>9.2f}y"
|
|
957
|
+
)
|
|
958
|
+
total = sum((Decimal(b["amount"]) for b in blocks), Decimal(0))
|
|
959
|
+
print(" " + "-" * (width + 34))
|
|
960
|
+
print(" " + "Total distributed".ljust(width + 12) + _format_money(total).rjust(13))
|
|
961
|
+
print()
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def _print_incentive(block: dict[str, Any] | None) -> None:
|
|
965
|
+
"""Print the management plan, and say plainly when it was worth nothing."""
|
|
966
|
+
if block is None:
|
|
967
|
+
return
|
|
968
|
+
print(f" {block['name']}")
|
|
969
|
+
if not block["exercised"]:
|
|
970
|
+
print(
|
|
971
|
+
" Out of the money at this exit: the options lapse, management are"
|
|
972
|
+
)
|
|
973
|
+
print(" paid nothing, and the common are not diluted.")
|
|
974
|
+
print(f" {'Vested':<26}{block['vested']:>13.1%}")
|
|
975
|
+
print()
|
|
976
|
+
return
|
|
977
|
+
print(f" {'Vested':<26}{block['vested']:>13.1%}")
|
|
978
|
+
print(f" {'Share of the pot':<26}{block['effective_share']:>13.1%}")
|
|
979
|
+
for label, key in (
|
|
980
|
+
("Residual before the plan", "residual_before"),
|
|
981
|
+
("Strike paid in", "strike_paid"),
|
|
982
|
+
("Pot divided", "pot"),
|
|
983
|
+
("Entitlement", "entitlement"),
|
|
984
|
+
("Paid to management", "paid"),
|
|
985
|
+
):
|
|
986
|
+
print(f" {label:<26}{_format_money(Decimal(block[key])):>14}")
|
|
987
|
+
print(" What management are paid is what the common give up, to the penny.")
|
|
988
|
+
print()
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def _print_exit(report: dict[str, Any]) -> None:
|
|
992
|
+
header = f"{report['name']} - exit"
|
|
993
|
+
print(header)
|
|
994
|
+
print("=" * len(header))
|
|
995
|
+
print()
|
|
996
|
+
|
|
997
|
+
exit_ = report["exit"]
|
|
998
|
+
if report.get("tax_basis") == "levered":
|
|
999
|
+
saving = Decimal(report["tax_saving"])
|
|
1000
|
+
print(
|
|
1001
|
+
" Debt serviced out of cash flow taxed on a levered basis, saving "
|
|
1002
|
+
f"{_format_money(saving)} of cash tax"
|
|
1003
|
+
)
|
|
1004
|
+
print()
|
|
1005
|
+
print(f" Exit at {exit_['date']}")
|
|
1006
|
+
for label, key in (
|
|
1007
|
+
("Exit EBITDA", "ebitda"),
|
|
1008
|
+
("Enterprise value", "enterprise_value"),
|
|
1009
|
+
("Debt outstanding", "debt"),
|
|
1010
|
+
("Cash", "cash"),
|
|
1011
|
+
("Cost of sale", "fees"),
|
|
1012
|
+
("Equity value", "equity_value"),
|
|
1013
|
+
):
|
|
1014
|
+
print(f" {label:<26}{_format_money(Decimal(exit_[key])):>14}")
|
|
1015
|
+
print(f" {'Exit multiple':<26}{exit_['multiple']:>13.2f}x")
|
|
1016
|
+
print(f" {'Exit leverage':<26}{exit_['exit_leverage']:>13.2f}x")
|
|
1017
|
+
if exit_["wiped_out"]:
|
|
1018
|
+
print()
|
|
1019
|
+
print(" The business is worth less than it owes, so the equity is wiped")
|
|
1020
|
+
print(" out and the lenders take what there is.")
|
|
1021
|
+
print()
|
|
1022
|
+
|
|
1023
|
+
rows = report["securities"]
|
|
1024
|
+
totals = report["totals"]
|
|
1025
|
+
# The interim column only exists where something was paid before the exit.
|
|
1026
|
+
# Without it the multiple would be quoted against a proceeds figure that
|
|
1027
|
+
# does not produce it, which is the sort of table a reader stops trusting.
|
|
1028
|
+
paid_early = any(Decimal(r["interim"]) != 0 for r in rows)
|
|
1029
|
+
columns: tuple[tuple[str, str], ...] = (
|
|
1030
|
+
(("invested", "invested"), ("during hold", "interim"), ("at exit", "proceeds"),
|
|
1031
|
+
("received", "received"))
|
|
1032
|
+
if paid_early
|
|
1033
|
+
else (("invested", "invested"), ("proceeds", "proceeds"))
|
|
1034
|
+
)
|
|
1035
|
+
|
|
1036
|
+
label_width = max(max(len(r["name"]) for r in rows), len("Total")) + 2
|
|
1037
|
+
ruler = label_width + 13 * (len(columns) + 2)
|
|
1038
|
+
print(" Equity")
|
|
1039
|
+
print(
|
|
1040
|
+
" "
|
|
1041
|
+
+ "".ljust(label_width)
|
|
1042
|
+
+ "".join(h.rjust(13) for h, _ in columns)
|
|
1043
|
+
+ "MoIC".rjust(13)
|
|
1044
|
+
+ "IRR".rjust(13)
|
|
1045
|
+
)
|
|
1046
|
+
for row in rows:
|
|
1047
|
+
print(
|
|
1048
|
+
" "
|
|
1049
|
+
+ row["name"].ljust(label_width)
|
|
1050
|
+
+ "".join(_format_money(Decimal(row[key])).rjust(13) for _, key in columns)
|
|
1051
|
+
+ (_NO_RATIO if row["moic"] is None else f"{row['moic']:.2f}x").rjust(13)
|
|
1052
|
+
+ (_NO_RATIO if row["irr"] is None else f"{row['irr']:.1%}").rjust(13)
|
|
1053
|
+
)
|
|
1054
|
+
if Decimal(row["shortfall"]) > 0:
|
|
1055
|
+
print(
|
|
1056
|
+
f" unpaid preferred claim of "
|
|
1057
|
+
f"{_format_money(Decimal(row['shortfall']))}"
|
|
1058
|
+
)
|
|
1059
|
+
print(" " + "-" * ruler)
|
|
1060
|
+
print(
|
|
1061
|
+
" "
|
|
1062
|
+
+ "Total".ljust(label_width)
|
|
1063
|
+
+ "".join(_format_money(Decimal(totals[key])).rjust(13) for _, key in columns)
|
|
1064
|
+
+ (_NO_RATIO if totals["moic"] is None else f"{totals['moic']:.2f}x").rjust(13)
|
|
1065
|
+
+ (_NO_RATIO if totals["irr"] is None else f"{totals['irr']:.1%}").rjust(13)
|
|
1066
|
+
)
|
|
1067
|
+
print(f" over {totals['holding_period_years']:.2f} years")
|
|
1068
|
+
print()
|
|
1069
|
+
|
|
1070
|
+
_print_distributions(report["distributions"])
|
|
1071
|
+
_print_incentive(report["incentive"])
|
|
1072
|
+
|
|
1073
|
+
attribution = report["attribution"]
|
|
1074
|
+
print(" Where the value came from")
|
|
1075
|
+
for label, key in _ATTRIBUTION_ROWS:
|
|
1076
|
+
print(f" {label:<26}{_format_money(Decimal(attribution[key])):>14}")
|
|
1077
|
+
print(f" {'':<26}{'-' * 14}")
|
|
1078
|
+
print(f" {'Value created':<26}{_format_money(Decimal(attribution['total'])):>14}")
|
|
1079
|
+
if Decimal(attribution["floored"]) > 0:
|
|
1080
|
+
print()
|
|
1081
|
+
print(
|
|
1082
|
+
f" Of that loss, "
|
|
1083
|
+
f"{_format_money(Decimal(attribution['floored']))} falls on the lenders "
|
|
1084
|
+
f"rather than"
|
|
1085
|
+
)
|
|
1086
|
+
print(" the shareholders, who owe nothing beyond their capital.")
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
# -- Sensitivity ---------------------------------------------------------
|
|
1090
|
+
|
|
1091
|
+
#: Marks in the table, kept to one column each so the grid stays aligned.
|
|
1092
|
+
_BREACH_MARK = "!"
|
|
1093
|
+
_BASE_MARK = "*"
|
|
1094
|
+
_NO_CELL = "-"
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
def _sensitivity_report(
|
|
1098
|
+
deal: Deal, rows: Axis, columns: Axis, metric: Metric, *, levered: bool = False
|
|
1099
|
+
) -> dict[str, Any]:
|
|
1100
|
+
grid = Grid.run(deal, rows, columns, metric, levered=levered)
|
|
1101
|
+
return {
|
|
1102
|
+
"name": deal.name,
|
|
1103
|
+
"tax_basis": "levered" if levered else "unlevered",
|
|
1104
|
+
"metric": {
|
|
1105
|
+
"name": str(metric),
|
|
1106
|
+
"label": metric.label,
|
|
1107
|
+
"unit": str(metric.unit),
|
|
1108
|
+
},
|
|
1109
|
+
"rows": {
|
|
1110
|
+
"dimension": str(rows.dimension),
|
|
1111
|
+
"label": rows.dimension.label,
|
|
1112
|
+
"unit": str(rows.dimension.unit),
|
|
1113
|
+
"values": [_amount_str(v) for v in rows],
|
|
1114
|
+
"labels": [rows.format(v) for v in rows],
|
|
1115
|
+
"base": [rows.is_base(v, deal) for v in rows],
|
|
1116
|
+
},
|
|
1117
|
+
"columns": {
|
|
1118
|
+
"dimension": str(columns.dimension),
|
|
1119
|
+
"label": columns.dimension.label,
|
|
1120
|
+
"unit": str(columns.dimension.unit),
|
|
1121
|
+
"values": [_amount_str(v) for v in columns],
|
|
1122
|
+
"labels": [columns.format(v) for v in columns],
|
|
1123
|
+
"base": [columns.is_base(v, deal) for v in columns],
|
|
1124
|
+
},
|
|
1125
|
+
"base": {
|
|
1126
|
+
"value": None if grid.base is None else _amount_str(grid.base),
|
|
1127
|
+
"label": (
|
|
1128
|
+
None if grid.base is None else format_value(metric.unit, grid.base)
|
|
1129
|
+
),
|
|
1130
|
+
"note": grid.base_note,
|
|
1131
|
+
},
|
|
1132
|
+
"cells": [
|
|
1133
|
+
[
|
|
1134
|
+
{
|
|
1135
|
+
"value": None if cell.value is None else _amount_str(cell.value),
|
|
1136
|
+
"label": (
|
|
1137
|
+
None
|
|
1138
|
+
if cell.value is None
|
|
1139
|
+
else format_value(metric.unit, cell.value)
|
|
1140
|
+
),
|
|
1141
|
+
"note": cell.note,
|
|
1142
|
+
"breached": cell.breached,
|
|
1143
|
+
"breach": cell.breach_note,
|
|
1144
|
+
}
|
|
1145
|
+
for cell in line
|
|
1146
|
+
]
|
|
1147
|
+
for line in grid
|
|
1148
|
+
],
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
def _print_sensitivity(report: dict[str, Any]) -> None:
|
|
1153
|
+
header = f"{report['name']} - sensitivity"
|
|
1154
|
+
print(header)
|
|
1155
|
+
print("=" * len(header))
|
|
1156
|
+
print()
|
|
1157
|
+
|
|
1158
|
+
metric = report["metric"]
|
|
1159
|
+
rows, columns = report["rows"], report["columns"]
|
|
1160
|
+
print(f" {metric['label']}")
|
|
1161
|
+
print(f" {columns['label'].lower()} across, {rows['label'].lower()} down")
|
|
1162
|
+
base = report["base"]
|
|
1163
|
+
if base["label"] is not None:
|
|
1164
|
+
print(f" base case {base['label']}, marked {_BASE_MARK}")
|
|
1165
|
+
elif base["note"]:
|
|
1166
|
+
print(f" base case: {base['note']}")
|
|
1167
|
+
print()
|
|
1168
|
+
|
|
1169
|
+
cells = report["cells"]
|
|
1170
|
+
# Every cell carries a mark or a space in the same position, so the columns
|
|
1171
|
+
# line up whether or not anything in them is flagged.
|
|
1172
|
+
rendered = [
|
|
1173
|
+
[
|
|
1174
|
+
(_NO_CELL if c["label"] is None else c["label"])
|
|
1175
|
+
+ (_BREACH_MARK if c["breached"] else " ")
|
|
1176
|
+
for c in line
|
|
1177
|
+
]
|
|
1178
|
+
for line in cells
|
|
1179
|
+
]
|
|
1180
|
+
# The base mark sits on the axis label rather than in the cell, so a
|
|
1181
|
+
# reader finds the file's own case by following one row and one column in
|
|
1182
|
+
# rather than hunting for a decorated cell in the middle of the table.
|
|
1183
|
+
headings = [
|
|
1184
|
+
label + (_BASE_MARK if flag else "")
|
|
1185
|
+
for label, flag in zip(columns["labels"], columns["base"], strict=True)
|
|
1186
|
+
]
|
|
1187
|
+
stub = max((len(label) for label in rows["labels"]), default=0) + 2
|
|
1188
|
+
width = max(
|
|
1189
|
+
max((len(text) for line in rendered for text in line), default=0),
|
|
1190
|
+
max((len(h) for h in headings), default=0),
|
|
1191
|
+
) + 3
|
|
1192
|
+
|
|
1193
|
+
print((" " + "".ljust(stub) + "".join(h.rjust(width) for h in headings)).rstrip())
|
|
1194
|
+
for i, line in enumerate(rendered):
|
|
1195
|
+
label = rows["labels"][i] + (_BASE_MARK if rows["base"][i] else "")
|
|
1196
|
+
print(
|
|
1197
|
+
(" " + label.ljust(stub) + "".join(t.rjust(width) for t in line)).rstrip()
|
|
1198
|
+
)
|
|
1199
|
+
|
|
1200
|
+
legend = []
|
|
1201
|
+
if any(c["breached"] for line in cells for c in line):
|
|
1202
|
+
legend.append(f"{_BREACH_MARK} a covenant breaches on this case")
|
|
1203
|
+
if any(c["label"] is None for line in cells for c in line):
|
|
1204
|
+
legend.append(f"{_NO_CELL} no answer; see below")
|
|
1205
|
+
if any(rows["base"]) or any(columns["base"]):
|
|
1206
|
+
legend.append(f"{_BASE_MARK} the assumption the file describes")
|
|
1207
|
+
if legend:
|
|
1208
|
+
print()
|
|
1209
|
+
for entry in legend:
|
|
1210
|
+
print(f" {entry}")
|
|
1211
|
+
|
|
1212
|
+
# Distinct reasons only. A row of cells that all failed for the same reason
|
|
1213
|
+
# is one fact about the deal, not eight.
|
|
1214
|
+
reasons: list[str] = []
|
|
1215
|
+
for line in cells:
|
|
1216
|
+
for cell in line:
|
|
1217
|
+
if cell["label"] is None and cell["note"] not in reasons:
|
|
1218
|
+
reasons.append(cell["note"])
|
|
1219
|
+
if reasons:
|
|
1220
|
+
print()
|
|
1221
|
+
for reason in reasons:
|
|
1222
|
+
print(f" {_NO_CELL} {reason}")
|
|
1223
|
+
|
|
1224
|
+
|
|
1225
|
+
def _cmd_sensitivity(args: argparse.Namespace) -> int:
|
|
1226
|
+
deal = load_deal(args.file)
|
|
1227
|
+
report = _sensitivity_report(
|
|
1228
|
+
deal,
|
|
1229
|
+
Axis.parse(args.rows),
|
|
1230
|
+
Axis.parse(args.columns),
|
|
1231
|
+
Metric(args.metric),
|
|
1232
|
+
levered=args.levered,
|
|
1233
|
+
)
|
|
1234
|
+
if args.json:
|
|
1235
|
+
print(json.dumps(report, indent=2))
|
|
1236
|
+
else:
|
|
1237
|
+
_print_sensitivity(report)
|
|
1238
|
+
return 0
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
# -- The memo ------------------------------------------------------------
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
def _report_document(report: Report) -> dict[str, Any]:
|
|
1245
|
+
"""The memo as data, for anything that would rather lay it out itself."""
|
|
1246
|
+
return {
|
|
1247
|
+
"name": report.name,
|
|
1248
|
+
"close_date": report.close.isoformat() if report.close else None,
|
|
1249
|
+
"title": report.title,
|
|
1250
|
+
"sections": [
|
|
1251
|
+
{
|
|
1252
|
+
"title": section.title,
|
|
1253
|
+
"summary": section.summary,
|
|
1254
|
+
"lines": [
|
|
1255
|
+
{"label": line.label, "value": line.value, "note": line.note}
|
|
1256
|
+
for line in section.lines
|
|
1257
|
+
],
|
|
1258
|
+
"table": (
|
|
1259
|
+
None
|
|
1260
|
+
if section.table is None
|
|
1261
|
+
else {
|
|
1262
|
+
"headings": list(section.table.headings),
|
|
1263
|
+
"rows": [list(row) for row in section.table.rows],
|
|
1264
|
+
"align": list(section.table.alignment),
|
|
1265
|
+
}
|
|
1266
|
+
),
|
|
1267
|
+
"notes": list(section.notes),
|
|
1268
|
+
}
|
|
1269
|
+
for section in report
|
|
1270
|
+
],
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
def _cmd_report(args: argparse.Namespace) -> int:
|
|
1275
|
+
report = prepare(load_deal(args.file), breakevens=not args.no_breakevens)
|
|
1276
|
+
if args.json:
|
|
1277
|
+
print(json.dumps(_report_document(report), indent=2))
|
|
1278
|
+
elif args.markdown:
|
|
1279
|
+
print(report.as_markdown(), end="")
|
|
1280
|
+
else:
|
|
1281
|
+
print(report.as_text(), end="")
|
|
1282
|
+
return 0
|
|
1283
|
+
|
|
1284
|
+
|
|
1285
|
+
def _cmd_exit(args: argparse.Namespace) -> int:
|
|
1286
|
+
deal = load_deal(args.file)
|
|
1287
|
+
report = _exit_report(deal, levered=args.levered)
|
|
1288
|
+
if args.json:
|
|
1289
|
+
print(json.dumps(report, indent=2))
|
|
1290
|
+
else:
|
|
1291
|
+
_print_exit(report)
|
|
1292
|
+
return 0
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
_TAX_ROWS: tuple[tuple[str, str], ...] = (
|
|
1296
|
+
("EBITDA", "ebitda"),
|
|
1297
|
+
("EBIT", "ebit"),
|
|
1298
|
+
("Interest arising", "interest_arising"),
|
|
1299
|
+
("Brought forward", "interest_carried_in"),
|
|
1300
|
+
("Deduction allowance", "allowance"),
|
|
1301
|
+
("Interest deducted", "interest_deducted"),
|
|
1302
|
+
("Disallowed, carried", "interest_carried_out"),
|
|
1303
|
+
("Taxable income", "taxable_income"),
|
|
1304
|
+
("Loss relief used", "loss_relief_used"),
|
|
1305
|
+
("Cash tax", "cash_tax"),
|
|
1306
|
+
("Tax if unlevered", "cash_tax_unlevered"),
|
|
1307
|
+
("Saving", "shield"),
|
|
1308
|
+
)
|
|
1309
|
+
|
|
1310
|
+
#: Rows shown as an outflow so the column reads down the page.
|
|
1311
|
+
_NEGATED_TAX_ROWS = frozenset({"Interest deducted", "Loss relief used", "Cash tax"})
|
|
1312
|
+
|
|
1313
|
+
_RULED_TAX_ROWS = frozenset({"EBIT", "Disallowed, carried", "Cash tax"})
|
|
1314
|
+
|
|
1315
|
+
|
|
1316
|
+
def _tax_report(deal: Deal) -> dict[str, Any]:
|
|
1317
|
+
solution = deal.levered()
|
|
1318
|
+
limit = deal.interest_limit
|
|
1319
|
+
return {
|
|
1320
|
+
"name": deal.name,
|
|
1321
|
+
"limit": (
|
|
1322
|
+
None
|
|
1323
|
+
if limit is None
|
|
1324
|
+
else {"rate": float(limit.rate), "base": limit.base.value}
|
|
1325
|
+
),
|
|
1326
|
+
"iterations": solution.iterations,
|
|
1327
|
+
"residual": _amount_str(solution.residual),
|
|
1328
|
+
"periods": [
|
|
1329
|
+
{
|
|
1330
|
+
"index": row.index,
|
|
1331
|
+
"ending": period.period.end.isoformat(),
|
|
1332
|
+
"ebitda": _amount_str(row.ebitda),
|
|
1333
|
+
"ebit": _amount_str(row.ebit),
|
|
1334
|
+
"interest_arising": _amount_str(row.deduction.arising),
|
|
1335
|
+
"interest_carried_in": _amount_str(row.deduction.carried_in),
|
|
1336
|
+
"allowance": (
|
|
1337
|
+
"" if row.deduction.allowance is None else _amount_str(row.deduction.allowance)
|
|
1338
|
+
),
|
|
1339
|
+
"interest_deducted": _amount_str(row.deduction.deductible),
|
|
1340
|
+
"interest_carried_out": _amount_str(row.deduction.carried_out),
|
|
1341
|
+
"taxable_income": _amount_str(row.taxable_income),
|
|
1342
|
+
"loss_relief_used": _amount_str(row.loss_relief_used),
|
|
1343
|
+
"cash_tax": _amount_str(row.cash_tax),
|
|
1344
|
+
"cash_tax_unlevered": _amount_str(row.cash_tax_unlevered),
|
|
1345
|
+
"shield": _amount_str(row.shield),
|
|
1346
|
+
"binding": row.deduction.binding,
|
|
1347
|
+
}
|
|
1348
|
+
for row, period in zip(solution.tax, deal.project(), strict=True)
|
|
1349
|
+
],
|
|
1350
|
+
"totals": {
|
|
1351
|
+
"interest_arising": _amount_str(solution.tax.total_interest_arising),
|
|
1352
|
+
"interest_deducted": _amount_str(solution.tax.total_interest_deducted),
|
|
1353
|
+
"closing_disallowed_interest": _amount_str(
|
|
1354
|
+
solution.tax.closing_disallowed_interest
|
|
1355
|
+
),
|
|
1356
|
+
"deducted_share": float(solution.tax.deducted_share),
|
|
1357
|
+
"cash_tax": _amount_str(solution.tax.total_cash_tax),
|
|
1358
|
+
"cash_tax_unlevered": _amount_str(solution.tax.total_cash_tax_unlevered),
|
|
1359
|
+
"shield": _amount_str(solution.total_shield),
|
|
1360
|
+
"binding_periods": list(solution.tax.binding_periods),
|
|
1361
|
+
},
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
|
|
1365
|
+
def _print_tax(report: dict[str, Any]) -> None:
|
|
1366
|
+
periods = report["periods"]
|
|
1367
|
+
header = f"{report['name']} - tax on the financed business"
|
|
1368
|
+
print(header)
|
|
1369
|
+
print("=" * len(header))
|
|
1370
|
+
print()
|
|
1371
|
+
|
|
1372
|
+
limit = report["limit"]
|
|
1373
|
+
if limit is None:
|
|
1374
|
+
print(" Deduction uncapped")
|
|
1375
|
+
else:
|
|
1376
|
+
# Trailing zeros stripped by hand rather than with normalize(), which
|
|
1377
|
+
# turns 30.00 into 3E+1 and would print the cap in scientific notation.
|
|
1378
|
+
# The rate is carried as a rate rather than through _amount_str, which
|
|
1379
|
+
# rounds to cent scale and would report a 7.5% cap as 8%.
|
|
1380
|
+
rate = (
|
|
1381
|
+
format(Decimal(str(limit["rate"])) * 100, "f").rstrip("0").rstrip(".") or "0"
|
|
1382
|
+
)
|
|
1383
|
+
print(f" Deduction capped at {rate}% of {limit['base'].upper()}")
|
|
1384
|
+
print(f" Settled in {report['iterations']} passes, residual {Decimal(report['residual']):.2e}")
|
|
1385
|
+
print()
|
|
1386
|
+
|
|
1387
|
+
label_width = max(len(label) for label, _ in _TAX_ROWS) + 2
|
|
1388
|
+
cells_all = [p[key] for p in periods for _, key in _TAX_ROWS if p[key] != ""]
|
|
1389
|
+
widest = max((len(_format_money(Decimal(c))) for c in cells_all), default=7)
|
|
1390
|
+
column = max(widest, 7) + 2
|
|
1391
|
+
|
|
1392
|
+
def row(label: str, cells: list[str]) -> str:
|
|
1393
|
+
return " " + label.ljust(label_width) + "".join(c.rjust(column) for c in cells)
|
|
1394
|
+
|
|
1395
|
+
print(row("", [_column(p["index"]) for p in periods]))
|
|
1396
|
+
print(row("", [p["ending"][:7] for p in periods]))
|
|
1397
|
+
print(" " + "-" * (label_width + column * len(periods)))
|
|
1398
|
+
|
|
1399
|
+
for label, key in _TAX_ROWS:
|
|
1400
|
+
cells = []
|
|
1401
|
+
for p in periods:
|
|
1402
|
+
raw = p[key]
|
|
1403
|
+
if raw == "":
|
|
1404
|
+
cells.append("-")
|
|
1405
|
+
continue
|
|
1406
|
+
value = Decimal(raw)
|
|
1407
|
+
if label in _NEGATED_TAX_ROWS:
|
|
1408
|
+
value = -value
|
|
1409
|
+
cells.append(_format_money(value))
|
|
1410
|
+
print(row(label, cells))
|
|
1411
|
+
if label in _RULED_TAX_ROWS:
|
|
1412
|
+
print()
|
|
1413
|
+
|
|
1414
|
+
totals = report["totals"]
|
|
1415
|
+
print(" " + "-" * (label_width + column * len(periods)))
|
|
1416
|
+
print(f" Interest arising over the hold {_format_money(Decimal(totals['interest_arising']))}")
|
|
1417
|
+
print(f" Of which deducted {_format_money(Decimal(totals['interest_deducted']))}")
|
|
1418
|
+
share = totals["deducted_share"] * 100
|
|
1419
|
+
print(f" Deducted share {share:>13.2f}%")
|
|
1420
|
+
if Decimal(totals["closing_disallowed_interest"]) > 0:
|
|
1421
|
+
print(
|
|
1422
|
+
" Still disallowed at exit "
|
|
1423
|
+
f"{_format_money(Decimal(totals['closing_disallowed_interest']))}"
|
|
1424
|
+
)
|
|
1425
|
+
print(f" Cash tax paid {_format_money(Decimal(totals['cash_tax']))}")
|
|
1426
|
+
print(
|
|
1427
|
+
" Cash tax if unlevered "
|
|
1428
|
+
f"{_format_money(Decimal(totals['cash_tax_unlevered']))}"
|
|
1429
|
+
)
|
|
1430
|
+
print(f" Saved by the deduction {_format_money(Decimal(totals['shield']))}")
|
|
1431
|
+
if totals["binding_periods"]:
|
|
1432
|
+
periods_hit = ", ".join(_column(i) for i in totals["binding_periods"])
|
|
1433
|
+
print(f" Cap bound in {periods_hit}")
|
|
1434
|
+
|
|
1435
|
+
|
|
1436
|
+
def _cmd_tax(args: argparse.Namespace) -> int:
|
|
1437
|
+
deal = load_deal(args.file)
|
|
1438
|
+
report = _tax_report(deal)
|
|
1439
|
+
if args.json:
|
|
1440
|
+
print(json.dumps(report, indent=2))
|
|
1441
|
+
else:
|
|
1442
|
+
_print_tax(report)
|
|
1443
|
+
return 0
|
|
1444
|
+
|
|
1445
|
+
|
|
1446
|
+
def _cmd_project(args: argparse.Namespace) -> int:
|
|
1447
|
+
deal = load_deal(args.file)
|
|
1448
|
+
report = _projection_report(deal)
|
|
1449
|
+
if args.json:
|
|
1450
|
+
print(json.dumps(report, indent=2))
|
|
1451
|
+
else:
|
|
1452
|
+
_print_projection(report)
|
|
1453
|
+
return 0
|
|
1454
|
+
|
|
1455
|
+
|
|
1456
|
+
def _acquisitions_report(deal: Deal) -> dict[str, Any]:
|
|
1457
|
+
"""The acquisition programme: what each purchase cost and what it blended to.
|
|
1458
|
+
|
|
1459
|
+
The funding table is built from the events rather than from the schedule, so
|
|
1460
|
+
a price can be read out of a file with no operating case behind it. Where
|
|
1461
|
+
there is one, the schedule is run as well — it is the only thing that knows
|
|
1462
|
+
whether the business could actually pay its share of the price.
|
|
1463
|
+
"""
|
|
1464
|
+
if not deal.acquisitions:
|
|
1465
|
+
raise DealSpecError(
|
|
1466
|
+
'this deal buys nothing during the hold; add an "acquisitions" block '
|
|
1467
|
+
"describing the purchases"
|
|
1468
|
+
)
|
|
1469
|
+
blended = deal.blended_entry
|
|
1470
|
+
landed: dict[int, Any] = {}
|
|
1471
|
+
if deal.has_projection and deal.has_structure:
|
|
1472
|
+
landed = {o.event.period: o for o in deal.schedule().acquisitions}
|
|
1473
|
+
|
|
1474
|
+
purchases = []
|
|
1475
|
+
for add_on in deal.acquisitions:
|
|
1476
|
+
outcome = landed.get(add_on.period)
|
|
1477
|
+
purchases.append(
|
|
1478
|
+
{
|
|
1479
|
+
"label": add_on.label,
|
|
1480
|
+
"period": add_on.period,
|
|
1481
|
+
"ebitda": str(add_on.ebitda),
|
|
1482
|
+
"multiple": str(add_on.multiple),
|
|
1483
|
+
"enterprise_value": str(add_on.enterprise_value),
|
|
1484
|
+
"fees": str(add_on.fees),
|
|
1485
|
+
"integration_cost": str(add_on.integration_cost),
|
|
1486
|
+
"uses": str(add_on.uses),
|
|
1487
|
+
"face_drawn": str(add_on.face),
|
|
1488
|
+
"debt_proceeds": str(add_on.debt_proceeds),
|
|
1489
|
+
"from_cash": str(add_on.from_cash),
|
|
1490
|
+
"total_cost": str(add_on.total_cost),
|
|
1491
|
+
"synergies": str(add_on.synergies),
|
|
1492
|
+
"synergy_phase_in": add_on.synergy_phase_in,
|
|
1493
|
+
"synergised_multiple": str(add_on.synergised_multiple),
|
|
1494
|
+
"cash_after": None if outcome is None else str(outcome.cash_after),
|
|
1495
|
+
"leverage_after": (
|
|
1496
|
+
None
|
|
1497
|
+
if outcome is None or outcome.leverage_after is None
|
|
1498
|
+
else str(outcome.leverage_after)
|
|
1499
|
+
),
|
|
1500
|
+
"turns_added": (
|
|
1501
|
+
None
|
|
1502
|
+
if outcome is None or outcome.turns_added is None
|
|
1503
|
+
else str(outcome.turns_added)
|
|
1504
|
+
),
|
|
1505
|
+
}
|
|
1506
|
+
)
|
|
1507
|
+
|
|
1508
|
+
return {
|
|
1509
|
+
"name": deal.name,
|
|
1510
|
+
"purchases": purchases,
|
|
1511
|
+
"blended": {
|
|
1512
|
+
"platform_enterprise_value": str(blended.platform_enterprise_value),
|
|
1513
|
+
"platform_ebitda": str(blended.platform_ebitda),
|
|
1514
|
+
"platform_multiple": str(blended.platform_multiple),
|
|
1515
|
+
"acquired_enterprise_value": str(blended.acquired_enterprise_value),
|
|
1516
|
+
"acquired_ebitda": str(blended.acquired_ebitda),
|
|
1517
|
+
"synergies": str(blended.synergies),
|
|
1518
|
+
"enterprise_value": str(blended.enterprise_value),
|
|
1519
|
+
"ebitda": str(blended.ebitda),
|
|
1520
|
+
"capital_deployed": str(blended.capital_deployed),
|
|
1521
|
+
"blended_multiple": str(blended.blended_multiple),
|
|
1522
|
+
"synergised_multiple": str(blended.synergised_multiple),
|
|
1523
|
+
"all_in_multiple": str(blended.all_in_multiple),
|
|
1524
|
+
"arbitrage": str(blended.arbitrage),
|
|
1525
|
+
"acquired_share": str(blended.acquired_share),
|
|
1526
|
+
},
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
|
|
1530
|
+
def _print_acquisitions(report: dict[str, Any]) -> None:
|
|
1531
|
+
header = f"{report['name']} - acquisitions"
|
|
1532
|
+
print(header)
|
|
1533
|
+
print("=" * len(header))
|
|
1534
|
+
print()
|
|
1535
|
+
|
|
1536
|
+
for p in report["purchases"]:
|
|
1537
|
+
title = f"{p['label']} (end of period {p['period']})"
|
|
1538
|
+
print(f" {title}")
|
|
1539
|
+
print(" " + "-" * len(title))
|
|
1540
|
+
rows: list[tuple[str, str]] = [
|
|
1541
|
+
("EBITDA acquired", _format_money(Decimal(p["ebitda"]))),
|
|
1542
|
+
("Multiple paid", f"{Decimal(p['multiple']):.2f}x"),
|
|
1543
|
+
("Enterprise value", _format_money(Decimal(p["enterprise_value"]))),
|
|
1544
|
+
]
|
|
1545
|
+
if Decimal(p["synergies"]) > 0:
|
|
1546
|
+
phase = p["synergy_phase_in"]
|
|
1547
|
+
over = "immediate" if phase == 1 else f"over {phase} periods"
|
|
1548
|
+
rows.append((f"Synergies, {over}", _format_money(Decimal(p["synergies"]))))
|
|
1549
|
+
rows.append(
|
|
1550
|
+
("Multiple after synergies", f"{Decimal(p['synergised_multiple']):.2f}x")
|
|
1551
|
+
)
|
|
1552
|
+
rows.extend(
|
|
1553
|
+
[
|
|
1554
|
+
("Transaction fees", _format_money(Decimal(p["fees"]))),
|
|
1555
|
+
("Integration cost", _format_money(Decimal(p["integration_cost"]))),
|
|
1556
|
+
("Total uses", _format_money(Decimal(p["uses"]))),
|
|
1557
|
+
("Face drawn", _format_money(Decimal(p["face_drawn"]))),
|
|
1558
|
+
("Debt proceeds", _format_money(Decimal(p["debt_proceeds"]))),
|
|
1559
|
+
("Funded from cash", _format_money(Decimal(p["from_cash"]))),
|
|
1560
|
+
("Capital deployed", _format_money(Decimal(p["total_cost"]))),
|
|
1561
|
+
]
|
|
1562
|
+
)
|
|
1563
|
+
if p["cash_after"] is not None:
|
|
1564
|
+
rows.append(("Cash after", _format_money(Decimal(p["cash_after"]))))
|
|
1565
|
+
if p["leverage_after"] is not None:
|
|
1566
|
+
rows.append(("Leverage after", f"{Decimal(p['leverage_after']):.2f}x"))
|
|
1567
|
+
if p["turns_added"] is not None:
|
|
1568
|
+
rows.append(("Turns added", f"{Decimal(p['turns_added']):+.2f}x"))
|
|
1569
|
+
for label, value in rows:
|
|
1570
|
+
print(f" {label:<28}{value:>14}")
|
|
1571
|
+
print()
|
|
1572
|
+
|
|
1573
|
+
b = report["blended"]
|
|
1574
|
+
print(" Blended entry")
|
|
1575
|
+
for label, value in (
|
|
1576
|
+
(
|
|
1577
|
+
"Platform enterprise value",
|
|
1578
|
+
_format_money(Decimal(b["platform_enterprise_value"])),
|
|
1579
|
+
),
|
|
1580
|
+
("Platform EBITDA", _format_money(Decimal(b["platform_ebitda"]))),
|
|
1581
|
+
("Platform multiple", f"{Decimal(b['platform_multiple']):.2f}x"),
|
|
1582
|
+
(
|
|
1583
|
+
"Acquired enterprise value",
|
|
1584
|
+
_format_money(Decimal(b["acquired_enterprise_value"])),
|
|
1585
|
+
),
|
|
1586
|
+
("EBITDA acquired", _format_money(Decimal(b["acquired_ebitda"]))),
|
|
1587
|
+
("Combined EBITDA", _format_money(Decimal(b["ebitda"]))),
|
|
1588
|
+
("Capital deployed", _format_money(Decimal(b["capital_deployed"]))),
|
|
1589
|
+
):
|
|
1590
|
+
print(f" {label:<28}{value:>14}")
|
|
1591
|
+
print()
|
|
1592
|
+
for label, value in (
|
|
1593
|
+
("Blended multiple", f"{Decimal(b['blended_multiple']):.2f}x"),
|
|
1594
|
+
("After synergies", f"{Decimal(b['synergised_multiple']):.2f}x"),
|
|
1595
|
+
("On capital deployed", f"{Decimal(b['all_in_multiple']):.2f}x"),
|
|
1596
|
+
("Multiple arbitrage", f"{Decimal(b['arbitrage']):+.2f}x"),
|
|
1597
|
+
("Bought, not built", f"{Decimal(b['acquired_share']):.1%}"),
|
|
1598
|
+
):
|
|
1599
|
+
print(f" {label:<28}{value:>14}")
|
|
1600
|
+
|
|
1601
|
+
|
|
1602
|
+
def _fees_report(deal: Deal, method: FeeMethod) -> dict[str, Any]:
|
|
1603
|
+
schedule = deal.fee_schedule(method)
|
|
1604
|
+
grid = deal.grid
|
|
1605
|
+
assert grid is not None # fee_schedule refuses a deal without one
|
|
1606
|
+
return {
|
|
1607
|
+
"name": deal.name,
|
|
1608
|
+
"method": str(method),
|
|
1609
|
+
"periods": [p.end.isoformat() for p in grid],
|
|
1610
|
+
"period_indices": [p.index for p in grid],
|
|
1611
|
+
"tranches": [
|
|
1612
|
+
{
|
|
1613
|
+
"name": t.name,
|
|
1614
|
+
"capitalised": _amount_str(t.capitalised),
|
|
1615
|
+
"method": str(t.method),
|
|
1616
|
+
"coupon": float(t.coupon),
|
|
1617
|
+
"effective_rate": float(t.effective_rate),
|
|
1618
|
+
"rate_uplift": float(t.rate_uplift),
|
|
1619
|
+
"charges": [_amount_str(p.charge) for p in t],
|
|
1620
|
+
"balances": [_amount_str(p.closing) for p in t],
|
|
1621
|
+
}
|
|
1622
|
+
for t in schedule
|
|
1623
|
+
],
|
|
1624
|
+
"total_capitalised": _amount_str(schedule.total_capitalised),
|
|
1625
|
+
"total_charged": _amount_str(schedule.total_charged),
|
|
1626
|
+
"unreleased": _amount_str(schedule.unreleased),
|
|
1627
|
+
"write_offs": [
|
|
1628
|
+
{
|
|
1629
|
+
"label": event.label,
|
|
1630
|
+
"period": event.period,
|
|
1631
|
+
"tranche": event.tranche,
|
|
1632
|
+
"amount": _amount_str(amount),
|
|
1633
|
+
}
|
|
1634
|
+
for event in deal.refinancings
|
|
1635
|
+
for amount in [deal.write_offs().get(event.period)]
|
|
1636
|
+
if amount is not None
|
|
1637
|
+
],
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
|
|
1641
|
+
def _print_fees(report: dict[str, Any]) -> None:
|
|
1642
|
+
header = f"{report['name']} - capitalised financing costs"
|
|
1643
|
+
print(header)
|
|
1644
|
+
print("=" * len(header))
|
|
1645
|
+
print()
|
|
1646
|
+
|
|
1647
|
+
rows = [t for t in report["tranches"] if Decimal(t["capitalised"]) > 0]
|
|
1648
|
+
if not rows:
|
|
1649
|
+
print(" Nothing was capitalised: every tranche was placed at par with no fee.")
|
|
1650
|
+
return
|
|
1651
|
+
|
|
1652
|
+
label_width = max(len(t["name"]) for t in rows) + 2
|
|
1653
|
+
column = 11
|
|
1654
|
+
periods = report["periods"]
|
|
1655
|
+
|
|
1656
|
+
def line(label: str, cells: list[str]) -> str:
|
|
1657
|
+
return " " + label.ljust(label_width) + "".join(c.rjust(column) for c in cells)
|
|
1658
|
+
|
|
1659
|
+
print(line("", [_column(i) for i in report["period_indices"]]))
|
|
1660
|
+
print(line("", [p[:7] for p in periods]))
|
|
1661
|
+
print(" " + "-" * (label_width + column * len(periods)))
|
|
1662
|
+
|
|
1663
|
+
print(" Charge for the period")
|
|
1664
|
+
for t in rows:
|
|
1665
|
+
print(line(t["name"], [_format_money(Decimal(c)) for c in t["charges"]]))
|
|
1666
|
+
print()
|
|
1667
|
+
print(" Balance remaining")
|
|
1668
|
+
for t in rows:
|
|
1669
|
+
print(line(t["name"], [_format_money(Decimal(b)) for b in t["balances"]]))
|
|
1670
|
+
|
|
1671
|
+
print()
|
|
1672
|
+
print(" Cost of the money")
|
|
1673
|
+
width = max(len(t["name"]) for t in rows) + 2
|
|
1674
|
+
print(f" {'':<{width}}{'coupon':>10}{'effective':>12}{'uplift':>10} method")
|
|
1675
|
+
for t in rows:
|
|
1676
|
+
print(
|
|
1677
|
+
f" {t['name']:<{width}}{t['coupon']:>10.2%}{t['effective_rate']:>12.2%}"
|
|
1678
|
+
f"{t['rate_uplift'] * 10000:>9.0f}bp {t['method']}"
|
|
1679
|
+
)
|
|
1680
|
+
|
|
1681
|
+
print()
|
|
1682
|
+
for label, key in (
|
|
1683
|
+
("Capitalised at close", "total_capitalised"),
|
|
1684
|
+
("Released if the paper runs to term", "total_charged"),
|
|
1685
|
+
("Still capitalised at the end", "unreleased"),
|
|
1686
|
+
):
|
|
1687
|
+
print(f" {label:<34}{_format_money(Decimal(report[key])):>12}")
|
|
1688
|
+
|
|
1689
|
+
if report["write_offs"]:
|
|
1690
|
+
print()
|
|
1691
|
+
print(" Charged off early at a takeout")
|
|
1692
|
+
for w in report["write_offs"]:
|
|
1693
|
+
print(
|
|
1694
|
+
f" P{w['period']} {w['label']} ({w['tranche']}): "
|
|
1695
|
+
f"{_format_money(Decimal(w['amount']))}"
|
|
1696
|
+
)
|
|
1697
|
+
print()
|
|
1698
|
+
print(" The takeout ends the release. What is charged off above is")
|
|
1699
|
+
print(" incurred at the takeout instead of over the periods after it,")
|
|
1700
|
+
print(" which is why this schedule runs past the event rather than")
|
|
1701
|
+
print(" stopping at it.")
|
|
1702
|
+
|
|
1703
|
+
|
|
1704
|
+
def _cmd_fees(args: argparse.Namespace) -> int:
|
|
1705
|
+
deal = load_deal(args.file)
|
|
1706
|
+
method = (
|
|
1707
|
+
FeeMethod.STRAIGHT_LINE
|
|
1708
|
+
if args.method == "straight-line"
|
|
1709
|
+
else FeeMethod.EFFECTIVE_INTEREST
|
|
1710
|
+
)
|
|
1711
|
+
report = _fees_report(deal, method)
|
|
1712
|
+
if args.json:
|
|
1713
|
+
print(json.dumps(report, indent=2))
|
|
1714
|
+
else:
|
|
1715
|
+
_print_fees(report)
|
|
1716
|
+
return 0
|
|
1717
|
+
|
|
1718
|
+
|
|
1719
|
+
def _cmd_acquisitions(args: argparse.Namespace) -> int:
|
|
1720
|
+
deal = load_deal(args.file)
|
|
1721
|
+
report = _acquisitions_report(deal)
|
|
1722
|
+
if args.json:
|
|
1723
|
+
print(json.dumps(report, indent=2))
|
|
1724
|
+
else:
|
|
1725
|
+
_print_acquisitions(report)
|
|
1726
|
+
return 0
|
|
1727
|
+
|
|
1728
|
+
|
|
1729
|
+
def _cmd_deal(args: argparse.Namespace) -> int:
|
|
1730
|
+
deal = load_deal(args.file)
|
|
1731
|
+
report = _deal_report(deal.name, deal.close_date, deal.transaction)
|
|
1732
|
+
if args.json:
|
|
1733
|
+
print(json.dumps(report, indent=2))
|
|
1734
|
+
else:
|
|
1735
|
+
_print_deal(report)
|
|
1736
|
+
return 0
|
|
1737
|
+
|
|
1738
|
+
|
|
1739
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
1740
|
+
parser = argparse.ArgumentParser(
|
|
1741
|
+
prog="capstack",
|
|
1742
|
+
description="A leveraged buyout engine.",
|
|
1743
|
+
)
|
|
1744
|
+
# An installed copy has to be able to say what it is. Read off the package
|
|
1745
|
+
# rather than restated here, so the two cannot disagree.
|
|
1746
|
+
parser.add_argument(
|
|
1747
|
+
"--version",
|
|
1748
|
+
action="version",
|
|
1749
|
+
version=f"capstack {__version__}",
|
|
1750
|
+
)
|
|
1751
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
1752
|
+
|
|
1753
|
+
returns = sub.add_parser(
|
|
1754
|
+
"returns",
|
|
1755
|
+
help="return measures for a dated cash-flow stream",
|
|
1756
|
+
description=(
|
|
1757
|
+
"Compute IRR, MoIC and holding period for a set of dated flows. "
|
|
1758
|
+
"Signs are from the investor's point of view: negative is money out."
|
|
1759
|
+
),
|
|
1760
|
+
)
|
|
1761
|
+
returns.add_argument(
|
|
1762
|
+
"flow",
|
|
1763
|
+
nargs="+",
|
|
1764
|
+
type=_parse_flow,
|
|
1765
|
+
metavar="DATE:AMOUNT[:LABEL]",
|
|
1766
|
+
help="a dated cash flow, e.g. 2026-06-30:-420000000:equity",
|
|
1767
|
+
)
|
|
1768
|
+
returns.add_argument(
|
|
1769
|
+
"--convention",
|
|
1770
|
+
choices=sorted(_CONVENTIONS),
|
|
1771
|
+
default=DayCount.ACT_365F.value,
|
|
1772
|
+
help="day-count convention used to annualise (default: %(default)s)",
|
|
1773
|
+
)
|
|
1774
|
+
returns.add_argument("--json", action="store_true", help="emit JSON instead of a table")
|
|
1775
|
+
returns.set_defaults(handler=_cmd_returns)
|
|
1776
|
+
|
|
1777
|
+
deal = sub.add_parser(
|
|
1778
|
+
"deal",
|
|
1779
|
+
help="entry valuation and the sources and uses table",
|
|
1780
|
+
description=(
|
|
1781
|
+
"Read a deal file and print what the business costs, how the purchase "
|
|
1782
|
+
"is funded, and the entry metrics that fall out of it."
|
|
1783
|
+
),
|
|
1784
|
+
)
|
|
1785
|
+
deal.add_argument("file", help="path to a deal file (JSON)")
|
|
1786
|
+
deal.add_argument("--json", action="store_true", help="emit JSON instead of a table")
|
|
1787
|
+
deal.set_defaults(handler=_cmd_deal)
|
|
1788
|
+
|
|
1789
|
+
balance = sub.add_parser(
|
|
1790
|
+
"balance",
|
|
1791
|
+
help="the opening balance sheet after the recapitalisation",
|
|
1792
|
+
description=(
|
|
1793
|
+
"Apply the transaction to the target's book position and print the "
|
|
1794
|
+
"balance sheet the business carries out of close."
|
|
1795
|
+
),
|
|
1796
|
+
)
|
|
1797
|
+
balance.add_argument("file", help="path to a deal file (JSON)")
|
|
1798
|
+
balance.add_argument("--json", action="store_true", help="emit JSON instead of a table")
|
|
1799
|
+
balance.set_defaults(handler=_cmd_balance)
|
|
1800
|
+
|
|
1801
|
+
project = sub.add_parser(
|
|
1802
|
+
"project",
|
|
1803
|
+
help="the operating case, from revenue to unlevered free cash flow",
|
|
1804
|
+
description=(
|
|
1805
|
+
"Run the operating case in a deal file and print the projection, "
|
|
1806
|
+
"before any of it is claimed by lenders."
|
|
1807
|
+
),
|
|
1808
|
+
)
|
|
1809
|
+
project.add_argument("file", help="path to a deal file (JSON)")
|
|
1810
|
+
project.add_argument("--json", action="store_true", help="emit JSON instead of a table")
|
|
1811
|
+
project.set_defaults(handler=_cmd_project)
|
|
1812
|
+
|
|
1813
|
+
tax = sub.add_parser(
|
|
1814
|
+
"tax",
|
|
1815
|
+
help="cash tax on the financed business: the deduction, its cap, and its pool",
|
|
1816
|
+
description=(
|
|
1817
|
+
"Settle the interest deduction against the debt it is charged on and "
|
|
1818
|
+
"print what the structure actually pays. The operating case taxes "
|
|
1819
|
+
"EBIT, which is unlevered on purpose; this closes the loop between "
|
|
1820
|
+
"the deduction, the cash it frees, and the debt that cash repays."
|
|
1821
|
+
),
|
|
1822
|
+
)
|
|
1823
|
+
tax.add_argument("file", help="path to a deal file (JSON)")
|
|
1824
|
+
tax.add_argument("--json", action="store_true", help="emit JSON instead of a table")
|
|
1825
|
+
tax.set_defaults(handler=_cmd_tax)
|
|
1826
|
+
|
|
1827
|
+
schedule = sub.add_parser(
|
|
1828
|
+
"schedule",
|
|
1829
|
+
help="the debt schedule: interest, amortisation, the sweep and the revolver",
|
|
1830
|
+
description=(
|
|
1831
|
+
"Run the capital structure against the operating case and print what "
|
|
1832
|
+
"the debt costs, what gets repaid, and where the leverage ends up."
|
|
1833
|
+
),
|
|
1834
|
+
)
|
|
1835
|
+
schedule.add_argument("file", help="path to a deal file (JSON)")
|
|
1836
|
+
schedule.add_argument("--json", action="store_true", help="emit JSON instead of a table")
|
|
1837
|
+
schedule.set_defaults(handler=_cmd_schedule)
|
|
1838
|
+
|
|
1839
|
+
covenants = sub.add_parser(
|
|
1840
|
+
"covenants",
|
|
1841
|
+
help="maintenance tests, headroom, and the first breach",
|
|
1842
|
+
description=(
|
|
1843
|
+
"Test the maintenance covenants in a deal file against the schedule "
|
|
1844
|
+
"and the operating case, and report how far EBITDA can fall before "
|
|
1845
|
+
"each one trips."
|
|
1846
|
+
),
|
|
1847
|
+
)
|
|
1848
|
+
covenants.add_argument("file", help="path to a deal file (JSON)")
|
|
1849
|
+
covenants.add_argument("--json", action="store_true", help="emit JSON instead of a table")
|
|
1850
|
+
covenants.set_defaults(handler=_cmd_covenants)
|
|
1851
|
+
|
|
1852
|
+
exit_ = sub.add_parser(
|
|
1853
|
+
"exit",
|
|
1854
|
+
help="the exit: equity value, returns by security, and the value bridge",
|
|
1855
|
+
description=(
|
|
1856
|
+
"Value the exit, run the equity waterfall through it, and decompose "
|
|
1857
|
+
"the return into earnings growth, multiple change and debt paydown."
|
|
1858
|
+
),
|
|
1859
|
+
)
|
|
1860
|
+
exit_.add_argument("file", help="path to a deal file (JSON)")
|
|
1861
|
+
exit_.add_argument("--json", action="store_true", help="emit JSON instead of a table")
|
|
1862
|
+
exit_.add_argument(
|
|
1863
|
+
"--levered",
|
|
1864
|
+
action="store_true",
|
|
1865
|
+
help=(
|
|
1866
|
+
"service the debt out of cash flow taxed on a levered basis, settling "
|
|
1867
|
+
"the interest deduction against the debt it is charged on; worth 90 to "
|
|
1868
|
+
"189 basis points of return on the shipped deals"
|
|
1869
|
+
),
|
|
1870
|
+
)
|
|
1871
|
+
exit_.set_defaults(handler=_cmd_exit)
|
|
1872
|
+
|
|
1873
|
+
acquisitions = sub.add_parser(
|
|
1874
|
+
"acquisitions",
|
|
1875
|
+
help="businesses bought during the hold, and the entry multiple they blend to",
|
|
1876
|
+
description=(
|
|
1877
|
+
"Show the funding table for each acquisition and the multiple the "
|
|
1878
|
+
"platform and its add-ons together were bought at, before and after "
|
|
1879
|
+
"the synergies underwritten for them."
|
|
1880
|
+
),
|
|
1881
|
+
)
|
|
1882
|
+
acquisitions.add_argument("file", help="path to a deal file (JSON)")
|
|
1883
|
+
acquisitions.add_argument(
|
|
1884
|
+
"--json", action="store_true", help="emit JSON instead of a table"
|
|
1885
|
+
)
|
|
1886
|
+
acquisitions.set_defaults(handler=_cmd_acquisitions)
|
|
1887
|
+
|
|
1888
|
+
fees = sub.add_parser(
|
|
1889
|
+
"fees",
|
|
1890
|
+
help="capitalised financing costs, and the balance a takeout writes off",
|
|
1891
|
+
description=(
|
|
1892
|
+
"Release the arrangement fees and the original issue discount each "
|
|
1893
|
+
"tranche was placed with across the life of the paper, and report "
|
|
1894
|
+
"what a refinancing would charge off. The effective method solves "
|
|
1895
|
+
"the rate the contractual flows discount back to net proceeds; the "
|
|
1896
|
+
"straight line spreads the balance evenly."
|
|
1897
|
+
),
|
|
1898
|
+
)
|
|
1899
|
+
fees.add_argument("file", help="path to a deal file (JSON)")
|
|
1900
|
+
fees.add_argument(
|
|
1901
|
+
"--method",
|
|
1902
|
+
choices=("effective-interest", "straight-line"),
|
|
1903
|
+
default="effective-interest",
|
|
1904
|
+
help="how the balance is released (default: %(default)s)",
|
|
1905
|
+
)
|
|
1906
|
+
fees.add_argument("--json", action="store_true", help="emit JSON instead of a table")
|
|
1907
|
+
fees.set_defaults(handler=_cmd_fees)
|
|
1908
|
+
|
|
1909
|
+
sensitivity = sub.add_parser(
|
|
1910
|
+
"sensitivity",
|
|
1911
|
+
help="a metric across two assumptions, with the deal rebuilt per cell",
|
|
1912
|
+
description=(
|
|
1913
|
+
"Run the whole engine at every intersection of two assumptions and "
|
|
1914
|
+
"report one figure per cell. An axis is written as "
|
|
1915
|
+
"DIMENSION:v1,v2,v3 - levels as they are said (entry-multiple:11,"
|
|
1916
|
+
"11.5,12), shifts in percentage points off the file's own case "
|
|
1917
|
+
"(ebitda-margin:-1.5,0,1.5)."
|
|
1918
|
+
),
|
|
1919
|
+
)
|
|
1920
|
+
sensitivity.add_argument("file", help="path to a deal file (JSON)")
|
|
1921
|
+
sensitivity.add_argument(
|
|
1922
|
+
"--rows",
|
|
1923
|
+
required=True,
|
|
1924
|
+
metavar="DIMENSION:VALUES",
|
|
1925
|
+
help="the axis down the side, as exit-multiple:9,10,11",
|
|
1926
|
+
)
|
|
1927
|
+
sensitivity.add_argument(
|
|
1928
|
+
"--columns",
|
|
1929
|
+
required=True,
|
|
1930
|
+
metavar="DIMENSION:VALUES",
|
|
1931
|
+
help="the axis across the top, as entry-multiple:11,11.5,12",
|
|
1932
|
+
)
|
|
1933
|
+
sensitivity.add_argument(
|
|
1934
|
+
"--metric",
|
|
1935
|
+
default=Metric.IRR.value,
|
|
1936
|
+
choices=[m.value for m in Metric],
|
|
1937
|
+
help="what each cell reports (default: %(default)s)",
|
|
1938
|
+
)
|
|
1939
|
+
sensitivity.add_argument(
|
|
1940
|
+
"--levered",
|
|
1941
|
+
action="store_true",
|
|
1942
|
+
help=(
|
|
1943
|
+
"run every cell with the interest deduction settled against the debt "
|
|
1944
|
+
"it is charged on; required by the cash-tax and tax-saving metrics, "
|
|
1945
|
+
"and worth 90 to 189 basis points of return on the shipped deals"
|
|
1946
|
+
),
|
|
1947
|
+
)
|
|
1948
|
+
sensitivity.add_argument("--json", action="store_true", help="emit JSON instead of a table")
|
|
1949
|
+
sensitivity.set_defaults(handler=_cmd_sensitivity)
|
|
1950
|
+
|
|
1951
|
+
report = sub.add_parser(
|
|
1952
|
+
"report",
|
|
1953
|
+
help="the whole deal as one memo, including where the case stops working",
|
|
1954
|
+
description=(
|
|
1955
|
+
"Assemble every layer into one document: the transaction, the "
|
|
1956
|
+
"operating case, the debt schedule, the covenants, the exit and "
|
|
1957
|
+
"the value bridge, followed by the break-evens - the exit multiple "
|
|
1958
|
+
"that returns capital and no more, and the assumptions at which "
|
|
1959
|
+
"the first covenant trips."
|
|
1960
|
+
),
|
|
1961
|
+
)
|
|
1962
|
+
report.add_argument("file", help="path to a deal file (JSON)")
|
|
1963
|
+
output = report.add_mutually_exclusive_group()
|
|
1964
|
+
output.add_argument(
|
|
1965
|
+
"--markdown", action="store_true", help="emit markdown instead of aligned text"
|
|
1966
|
+
)
|
|
1967
|
+
output.add_argument(
|
|
1968
|
+
"--json", action="store_true", help="emit the memo as structured data"
|
|
1969
|
+
)
|
|
1970
|
+
report.add_argument(
|
|
1971
|
+
"--no-breakevens",
|
|
1972
|
+
action="store_true",
|
|
1973
|
+
help="skip the break-evens, which are the expensive part",
|
|
1974
|
+
)
|
|
1975
|
+
report.set_defaults(handler=_cmd_report)
|
|
1976
|
+
|
|
1977
|
+
return parser
|
|
1978
|
+
|
|
1979
|
+
|
|
1980
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
1981
|
+
parser = build_parser()
|
|
1982
|
+
args = parser.parse_args(argv)
|
|
1983
|
+
try:
|
|
1984
|
+
result: int = args.handler(args)
|
|
1985
|
+
return result
|
|
1986
|
+
except (DealSpecError, SensitivityError) as exc:
|
|
1987
|
+
print(f"capstack: {exc}", file=sys.stderr)
|
|
1988
|
+
return 1
|
|
1989
|
+
except (ValueError, ZeroDivisionError) as exc:
|
|
1990
|
+
print(f"capstack: {exc}", file=sys.stderr)
|
|
1991
|
+
return 2
|
|
1992
|
+
|
|
1993
|
+
|
|
1994
|
+
if __name__ == "__main__":
|
|
1995
|
+
raise SystemExit(main())
|