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