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/tax.py ADDED
@@ -0,0 +1,834 @@
1
+ """Tax on a business that is actually financed, rather than on one that is not.
2
+
3
+ The operating case computes tax on EBIT, which is the right thing for it to do:
4
+ it is an unlevered layer, and taxing it as though the structure did not exist
5
+ keeps it independent of the structure. But a leveraged company does not pay tax
6
+ on EBIT. It deducts its interest and pays tax on what is left, and for a deal
7
+ carrying five turns of debt that deduction is the second-largest cash line in
8
+ the model after EBITDA itself.
9
+
10
+ This module is the levered computation. It takes an interest series as its
11
+ input rather than a capital structure, which keeps it a pure sequential pass
12
+ over periods and makes it testable against interest written down by hand. The
13
+ circularity that follows — the deduction frees cash, the cash repays debt, the
14
+ smaller balance earns less interest, the smaller interest earns a smaller
15
+ deduction — is solved a layer up.
16
+
17
+ Three things make this domain logic rather than a subtraction.
18
+
19
+ *The deduction is capped, and a leveraged deal is where the cap binds.* A limit
20
+ of thirty per cent of earnings sounds generous until it is set against a
21
+ structure deliberately levered to the point where interest is a large fraction
22
+ of earnings, which is the entire design of the instrument. The cap is the
23
+ difference between a model that says leverage is free after tax and one that
24
+ says it stops being free somewhere, and where that somewhere is.
25
+
26
+ *The earnings the cap is struck against are a convention, not a fact.* The same
27
+ deal tested against thirty per cent of EBITDA and thirty per cent of EBIT gives
28
+ two different answers, because depreciation sits between them, and in a
29
+ capital-intensive business it sits between them heavily. Both bases are live
30
+ regimes. A model that hard-codes one of them cannot be asked the question.
31
+
32
+ *Disallowed interest is deferred, not lost, and it competes.* Interest the cap
33
+ refuses joins a pool, and the pool is tested again next period against next
34
+ period's cap, alongside next period's interest. So a deal can be inside the cap
35
+ on its own current interest and still have a disallowed balance, and a pool
36
+ accumulated in the early years can stay stuck for the whole hold. Treating the
37
+ disallowed amount as permanently lost overstates tax; treating it as freely
38
+ deductible later understates it.
39
+
40
+ *And the order against the loss pool matters.* The earnings base for the
41
+ interest cap is struck before any loss relief, so the cap is applied first, and
42
+ the carried-forward loss then shelters what the interest deduction has left
43
+ behind. Running the two the other way round gives a different cash tax in every
44
+ period where both bite, and the difference does not wash out over the hold
45
+ because the pools close at different balances.
46
+
47
+ Every figure here is an exact ``Decimal``. The pool movements are conservation
48
+ identities and are tested as equalities rather than tolerances: a unit of
49
+ interest is either deducted or in the disallowed pool, never approximately one
50
+ or the other.
51
+ """
52
+
53
+ from __future__ import annotations
54
+
55
+ from collections.abc import Callable, Iterator, Sequence
56
+ from dataclasses import dataclass
57
+ from enum import Enum
58
+
59
+ from .debt import CapitalStructure, DebtSchedule
60
+ from .money import ZERO, Money, Numeric, money, safe_div
61
+ from .operating import DEFAULT_NOL_USAGE_LIMIT, OperatingAssumptions, OperatingModel
62
+
63
+ __all__ = [
64
+ "DEFAULT_INTEREST_LIMIT_RATE",
65
+ "DEFAULT_SOLVE_TOLERANCE",
66
+ "EarningsBase",
67
+ "InterestDeduction",
68
+ "InterestLimit",
69
+ "LeveredSolution",
70
+ "LeveredTaxPeriod",
71
+ "LeveredTaxSchedule",
72
+ "ScheduleRunner",
73
+ "TaxCircularityError",
74
+ "TaxPolicy",
75
+ "TaxPolicyError",
76
+ "levered_schedule",
77
+ "levered_tax",
78
+ "levered_tax_for_model",
79
+ "levered_tax_schedule",
80
+ "solve_levered",
81
+ ]
82
+
83
+ #: How close two successive saving series must be for the solve to stop. The
84
+ #: quantity is money, so the default is a hundredth of a currency unit's
85
+ #: hundredth: far below anything a deal file expresses and far above the
86
+ #: precision the arithmetic carries.
87
+ DEFAULT_SOLVE_TOLERANCE = money("0.0001")
88
+
89
+ #: The maximum number of schedule runs a solve will make before giving up. The
90
+ #: measured worst case across the structures in the suite is well inside this;
91
+ #: see :func:`solve_levered`.
92
+ MAX_SOLVE_ITERATIONS = 100
93
+
94
+ #: The share of earnings a net interest deduction is commonly limited to. Thirty
95
+ #: per cent is the figure in the regime most leveraged deals are now struck
96
+ #: under; set the limit to ``None`` to model a regime with no cap at all.
97
+ DEFAULT_INTEREST_LIMIT_RATE = money("0.30")
98
+
99
+
100
+ class TaxPolicyError(ValueError):
101
+ """A tax policy that cannot be applied as written."""
102
+
103
+
104
+ class TaxCircularityError(RuntimeError):
105
+ """The deduction and the debt it is charged on did not settle.
106
+
107
+ Distinct from :class:`~capstack.debt.CircularityError`, which is the inner
108
+ fixed point between a period's balance and the interest accrued on it. This
109
+ is the outer one, between the whole hold's tax and the whole hold's debt,
110
+ and a structure can solve the inner one in every period and still fail
111
+ here.
112
+ """
113
+
114
+
115
+ class EarningsBase(Enum):
116
+ """Which earnings figure the interest cap is struck against.
117
+
118
+ The two differ by depreciation and amortisation, which is not a detail: in a
119
+ business running D&A at a tenth of revenue on a mid-single-digit margin, the
120
+ EBIT base is a small fraction of the EBITDA one and a structure comfortably
121
+ inside the cap on the first measure is nowhere near it on the second.
122
+ """
123
+
124
+ #: Earnings before interest, tax, depreciation and amortisation.
125
+ EBITDA = "ebitda"
126
+ #: Earnings before interest and tax, so after depreciation and amortisation.
127
+ EBIT = "ebit"
128
+
129
+
130
+ @dataclass(frozen=True, slots=True)
131
+ class InterestLimit:
132
+ """A cap on the interest deductible in a period.
133
+
134
+ ``rate`` is applied to whichever earnings figure ``base`` names, and the
135
+ result is the most interest a period may deduct however much it incurred.
136
+ The earnings figure is taken before any loss relief, which is what fixes the
137
+ ordering against the loss pool.
138
+ """
139
+
140
+ rate: Money
141
+ base: EarningsBase = EarningsBase.EBITDA
142
+
143
+ @classmethod
144
+ def of(
145
+ cls,
146
+ rate: Numeric = DEFAULT_INTEREST_LIMIT_RATE,
147
+ base: EarningsBase = EarningsBase.EBITDA,
148
+ ) -> InterestLimit:
149
+ return cls(rate=money(rate), base=base)
150
+
151
+ def __post_init__(self) -> None:
152
+ if self.rate < 0:
153
+ raise TaxPolicyError("the interest limit rate must not be negative")
154
+
155
+ def allowance(self, *, ebitda: Money, ebit: Money) -> Money:
156
+ """The most interest this period may deduct.
157
+
158
+ Negative earnings give an allowance of zero rather than a negative one:
159
+ a loss-making period does not owe the revenue authority a deduction, and
160
+ a negative allowance would turn the cap into a charge.
161
+ """
162
+ earnings = ebitda if self.base is EarningsBase.EBITDA else ebit
163
+ return max(ZERO, earnings * self.rate)
164
+
165
+
166
+ @dataclass(frozen=True, slots=True)
167
+ class TaxPolicy:
168
+ """The tax regime a case is run under.
169
+
170
+ ``interest_limit`` of ``None`` means an uncapped deduction, which is both a
171
+ real historic regime and the useful comparison: running a case twice, once
172
+ capped and once not, prices the cap.
173
+ """
174
+
175
+ rate: Money
176
+ nol_usage_limit: Money = DEFAULT_NOL_USAGE_LIMIT
177
+ interest_limit: InterestLimit | None = None
178
+ #: Whether interest that accrues without being paid is deductible as it
179
+ #: accrues. A PIK margin rolled into the balance is an expense the period
180
+ #: incurred, and the common regimes deduct it on that basis; a regime that
181
+ #: waits for the cash sets this to ``False`` and defers it to the pool.
182
+ deduct_accrued_interest: bool = True
183
+
184
+ @classmethod
185
+ def from_assumptions(
186
+ cls,
187
+ assumptions: OperatingAssumptions,
188
+ *,
189
+ interest_limit: InterestLimit | None = None,
190
+ deduct_accrued_interest: bool = True,
191
+ ) -> TaxPolicy:
192
+ """Take the rate and the loss limit from the operating case itself.
193
+
194
+ The usual way to build a policy, and the safe one. The saving a solve
195
+ credits to the cash flow is measured against the operating case's own
196
+ tax line, so a policy whose rate disagrees with that case measures the
197
+ saving off the wrong base. Reading both off the assumptions makes the
198
+ disagreement impossible rather than merely detectable.
199
+ """
200
+ return cls(
201
+ rate=assumptions.tax_rate,
202
+ nol_usage_limit=assumptions.nol_usage_limit,
203
+ interest_limit=interest_limit,
204
+ deduct_accrued_interest=deduct_accrued_interest,
205
+ )
206
+
207
+ @classmethod
208
+ def of(
209
+ cls,
210
+ rate: Numeric,
211
+ *,
212
+ nol_usage_limit: Numeric = DEFAULT_NOL_USAGE_LIMIT,
213
+ interest_limit: InterestLimit | None = None,
214
+ deduct_accrued_interest: bool = True,
215
+ ) -> TaxPolicy:
216
+ return cls(
217
+ rate=money(rate),
218
+ nol_usage_limit=money(nol_usage_limit),
219
+ interest_limit=interest_limit,
220
+ deduct_accrued_interest=deduct_accrued_interest,
221
+ )
222
+
223
+ def __post_init__(self) -> None:
224
+ if self.rate < 0 or self.rate > 1:
225
+ raise TaxPolicyError("the tax rate must be between 0 and 1")
226
+ if self.nol_usage_limit < 0 or self.nol_usage_limit > 1:
227
+ raise TaxPolicyError("the loss usage limit must be between 0 and 1")
228
+
229
+
230
+ @dataclass(frozen=True, slots=True)
231
+ class InterestDeduction:
232
+ """What the cap did to one period's interest.
233
+
234
+ The whole movement is reported rather than only the deductible figure,
235
+ because the pool is the part a reader cannot reconstruct: a period deducting
236
+ its full current interest tells you nothing about whether a balance from
237
+ three years ago is still stuck behind the cap.
238
+ """
239
+
240
+ #: Interest the period incurred and offered up for deduction.
241
+ arising: Money
242
+ #: Interest disallowed in earlier periods and tested again here.
243
+ carried_in: Money
244
+ #: The cap, or ``None`` where the regime has no cap.
245
+ allowance: Money | None
246
+ #: What was actually deducted against this period's income.
247
+ deductible: Money
248
+ #: What the cap refused, which joins the pool.
249
+ disallowed: Money
250
+ #: The pool after this period.
251
+ carried_out: Money
252
+
253
+ @property
254
+ def offered(self) -> Money:
255
+ """Everything tested against the cap: this period's interest and the pool."""
256
+ return self.arising + self.carried_in
257
+
258
+ @property
259
+ def binding(self) -> bool:
260
+ """Whether the cap refused anything that was offered to it."""
261
+ return self.disallowed > ZERO
262
+
263
+ @property
264
+ def headroom(self) -> Money:
265
+ """Allowance left unused. Zero where the cap bound, or where there is none."""
266
+ if self.allowance is None:
267
+ return ZERO
268
+ return max(ZERO, self.allowance - self.offered)
269
+
270
+
271
+ @dataclass(frozen=True, slots=True)
272
+ class LeveredTaxPeriod:
273
+ """One period of the levered tax computation, every line shown."""
274
+
275
+ index: int
276
+ ebitda: Money
277
+ ebit: Money
278
+ deduction: InterestDeduction
279
+ #: EBIT less the interest actually deducted, before any loss relief.
280
+ taxable_income: Money
281
+ loss_relief_used: Money
282
+ taxable_after_relief: Money
283
+ cash_tax: Money
284
+ closing_carryforward: Money
285
+ #: What this period would have paid with no interest deduction at all, from
286
+ #: the same opening loss pool. Carried rather than derived because it is the
287
+ #: only honest way to state the saving; see :attr:`shield`.
288
+ cash_tax_unlevered: Money
289
+ #: The rate the period was taxed at, kept so :attr:`naive_shield` can be
290
+ #: stated without dividing a tax by an income that may be zero.
291
+ rate: Money
292
+
293
+ @property
294
+ def interest(self) -> Money:
295
+ """Interest the period incurred, whether or not it was deductible."""
296
+ return self.deduction.arising
297
+
298
+ @property
299
+ def shield(self) -> Money:
300
+ """Cash tax the interest deduction actually saved this period.
301
+
302
+ Not the deduction multiplied by the rate. That figure is right only
303
+ where the period is profitable before and after the deduction and the
304
+ loss pool is not in play, and it overstates the saving everywhere else:
305
+ a deduction that takes a period from a small profit into a loss saves
306
+ tax on the profit and nothing on the rest of itself, and a deduction
307
+ taken in a period already sheltered by relief saves a fraction of the
308
+ rate. So the saving is the difference against the same period computed
309
+ with no deduction from the same opening pool, which is exact.
310
+ """
311
+ return self.cash_tax_unlevered - self.cash_tax
312
+
313
+ @property
314
+ def naive_shield(self) -> Money:
315
+ """Deduction times rate: what a model that stops at the interest line books.
316
+
317
+ Kept so the two can be compared rather than argued about. It is an upper
318
+ bound on :attr:`shield`, and the gap between them is the saving such a
319
+ model books and never receives.
320
+ """
321
+ return self.deduction.deductible * self.rate
322
+
323
+
324
+ @dataclass(frozen=True, slots=True)
325
+ class LeveredTaxSchedule:
326
+ """The levered tax computation across the hold, with the pools rolled."""
327
+
328
+ periods: tuple[LeveredTaxPeriod, ...]
329
+ opening_carryforward: Money
330
+ opening_disallowed_interest: Money
331
+
332
+ def __len__(self) -> int:
333
+ return len(self.periods)
334
+
335
+ def __iter__(self) -> Iterator[LeveredTaxPeriod]:
336
+ return iter(self.periods)
337
+
338
+ def __getitem__(self, index: int) -> LeveredTaxPeriod:
339
+ return self.periods[index]
340
+
341
+ @property
342
+ def cash_taxes(self) -> tuple[Money, ...]:
343
+ """The cash tax series, which is what the cash flow needs from here."""
344
+ return tuple(p.cash_tax for p in self.periods)
345
+
346
+ @property
347
+ def total_cash_tax(self) -> Money:
348
+ return sum((p.cash_tax for p in self.periods), ZERO)
349
+
350
+ @property
351
+ def total_cash_tax_unlevered(self) -> Money:
352
+ """What the hold would have paid with no interest deduction at all."""
353
+ return sum((p.cash_tax_unlevered for p in self.periods), ZERO)
354
+
355
+ @property
356
+ def total_shield(self) -> Money:
357
+ """Cash tax the structure saved over the hold.
358
+
359
+ The difference of two totals, not the sum of the per-period
360
+ differences. The two are equal in exact arithmetic and are not quite
361
+ equal here: five roundings of a period difference against two roundings
362
+ of a total, in a context carrying 34 digits, leave the two orderings
363
+ disagreeing in the last places — measured at 7e-32 on the reference
364
+ case. Which is nothing, but it is not zero, and a reader reconciling
365
+ this against a cash flow should know which ordering produced it.
366
+
367
+ This ordering is the one chosen because it is the one that ties: both
368
+ totals are figures the model reports elsewhere, so the saving can be
369
+ checked against two numbers a reader already has.
370
+ """
371
+ return self.total_cash_tax_unlevered - self.total_cash_tax
372
+
373
+ @property
374
+ def total_naive_shield(self) -> Money:
375
+ """The saving a model that stops at the interest line would book."""
376
+ return sum((p.naive_shield for p in self.periods), ZERO)
377
+
378
+ @property
379
+ def total_interest_arising(self) -> Money:
380
+ return sum((p.deduction.arising for p in self.periods), ZERO)
381
+
382
+ @property
383
+ def total_interest_deducted(self) -> Money:
384
+ return sum((p.deduction.deductible for p in self.periods), ZERO)
385
+
386
+ @property
387
+ def closing_disallowed_interest(self) -> Money:
388
+ """Interest still stuck behind the cap at the end of the hold."""
389
+ if not self.periods:
390
+ return self.opening_disallowed_interest
391
+ return self.periods[-1].deduction.carried_out
392
+
393
+ @property
394
+ def closing_carryforward(self) -> Money:
395
+ if not self.periods:
396
+ return self.opening_carryforward
397
+ return self.periods[-1].closing_carryforward
398
+
399
+ @property
400
+ def deducted_share(self) -> Money:
401
+ """Share of the interest arising over the hold that was ever deducted.
402
+
403
+ The honest headline for a capped structure. A deal deducting four fifths
404
+ of its interest is paying tax on the other fifth of it, and that fifth
405
+ does not appear anywhere in a model that stops at the cash interest line.
406
+ """
407
+ return safe_div(self.total_interest_deducted, self.total_interest_arising, default=ZERO)
408
+
409
+ @property
410
+ def binding_periods(self) -> tuple[int, ...]:
411
+ """The indices of the periods where the cap refused something."""
412
+ return tuple(p.index for p in self.periods if p.deduction.binding)
413
+
414
+
415
+ def _limit_interest(
416
+ *,
417
+ arising: Money,
418
+ carried_in: Money,
419
+ ebitda: Money,
420
+ ebit: Money,
421
+ limit: InterestLimit | None,
422
+ ) -> InterestDeduction:
423
+ """Test a period's interest, and the pool behind it, against the cap.
424
+
425
+ Current interest and the pool are tested together rather than in order,
426
+ because the pool carries forward indefinitely and is not tracked by vintage:
427
+ with no expiry to race, which of the two is deemed deducted first changes no
428
+ cash tax in any period and no closing balance.
429
+ """
430
+ offered = arising + carried_in
431
+ if limit is None:
432
+ return InterestDeduction(
433
+ arising=arising,
434
+ carried_in=carried_in,
435
+ allowance=None,
436
+ deductible=offered,
437
+ disallowed=ZERO,
438
+ carried_out=ZERO,
439
+ )
440
+
441
+ allowance = limit.allowance(ebitda=ebitda, ebit=ebit)
442
+ deductible = min(offered, allowance)
443
+ return InterestDeduction(
444
+ arising=arising,
445
+ carried_in=carried_in,
446
+ allowance=allowance,
447
+ deductible=deductible,
448
+ disallowed=offered - deductible,
449
+ carried_out=offered - deductible,
450
+ )
451
+
452
+
453
+ @dataclass(frozen=True, slots=True)
454
+ class _Relief:
455
+ """The loss pool's effect on one income figure."""
456
+
457
+ used: Money
458
+ after_relief: Money
459
+ cash_tax: Money
460
+ closing_carryforward: Money
461
+
462
+
463
+ def _apply_relief(taxable_income: Money, pool: Money, policy: TaxPolicy) -> _Relief:
464
+ """Shelter an income figure with the loss pool, subject to the usage limit.
465
+
466
+ Shared by the real computation and the no-deduction counterfactual beside
467
+ it, so that the saving the deduction produces is a difference between two
468
+ runs of the same arithmetic rather than between two implementations of it.
469
+ """
470
+ if taxable_income <= 0:
471
+ return _Relief(
472
+ used=ZERO,
473
+ after_relief=ZERO,
474
+ cash_tax=ZERO,
475
+ closing_carryforward=pool - taxable_income,
476
+ )
477
+ used = min(pool, taxable_income * policy.nol_usage_limit)
478
+ after_relief = taxable_income - used
479
+ return _Relief(
480
+ used=used,
481
+ after_relief=after_relief,
482
+ cash_tax=after_relief * policy.rate,
483
+ closing_carryforward=pool - used,
484
+ )
485
+
486
+
487
+ def levered_tax(
488
+ *,
489
+ index: int,
490
+ ebitda: Money,
491
+ ebit: Money,
492
+ cash_interest: Money,
493
+ accrued_interest: Money = ZERO,
494
+ opening_carryforward: Money = ZERO,
495
+ opening_disallowed_interest: Money = ZERO,
496
+ policy: TaxPolicy,
497
+ ) -> LeveredTaxPeriod:
498
+ """Compute one period's cash tax on a levered basis.
499
+
500
+ The order is fixed and is the point of the function: the cap is struck
501
+ against earnings taken before relief, the interest deduction is taken
502
+ against EBIT, and the loss pool then shelters what remains. Each step reads
503
+ the output of the one before it, and swapping any two changes the answer.
504
+ """
505
+ if cash_interest < 0 or accrued_interest < 0:
506
+ raise TaxPolicyError("interest must not be negative")
507
+ if opening_carryforward < 0:
508
+ raise TaxPolicyError("the carryforward pool must not be negative")
509
+ if opening_disallowed_interest < 0:
510
+ raise TaxPolicyError("the disallowed interest pool must not be negative")
511
+
512
+ # Interest that accrues without being paid is an expense the period incurred.
513
+ # Where the regime declines to recognise it until it is paid, it is not
514
+ # offered to the cap at all and waits in the pool instead, which is why it
515
+ # is added to the carried balance rather than dropped.
516
+ if policy.deduct_accrued_interest:
517
+ arising = cash_interest + accrued_interest
518
+ deferred = ZERO
519
+ else:
520
+ arising = cash_interest
521
+ deferred = accrued_interest
522
+
523
+ deduction = _limit_interest(
524
+ arising=arising,
525
+ carried_in=opening_disallowed_interest,
526
+ ebitda=ebitda,
527
+ ebit=ebit,
528
+ limit=policy.interest_limit,
529
+ )
530
+ if deferred:
531
+ deduction = InterestDeduction(
532
+ arising=deduction.arising,
533
+ carried_in=deduction.carried_in,
534
+ allowance=deduction.allowance,
535
+ deductible=deduction.deductible,
536
+ disallowed=deduction.disallowed,
537
+ carried_out=deduction.carried_out + deferred,
538
+ )
539
+
540
+ # EBIT less the interest the cap allowed. A loss here joins the pool as the
541
+ # levered loss: interest the cap allowed is what made it a loss, so it is
542
+ # what the pool inherits.
543
+ taxable_income = ebit - deduction.deductible
544
+ relief = _apply_relief(taxable_income, opening_carryforward, policy)
545
+
546
+ # The same period with nothing deducted, from the same opening pool. This is
547
+ # the counterfactual the saving is measured against, and it is the figure
548
+ # the operating layer already computes for itself.
549
+ unlevered = _apply_relief(ebit, opening_carryforward, policy)
550
+
551
+ return LeveredTaxPeriod(
552
+ index=index,
553
+ ebitda=ebitda,
554
+ ebit=ebit,
555
+ deduction=deduction,
556
+ taxable_income=taxable_income,
557
+ loss_relief_used=relief.used,
558
+ taxable_after_relief=relief.after_relief,
559
+ cash_tax=relief.cash_tax,
560
+ closing_carryforward=relief.closing_carryforward,
561
+ cash_tax_unlevered=unlevered.cash_tax,
562
+ rate=policy.rate,
563
+ )
564
+
565
+
566
+ def levered_tax_schedule(
567
+ *,
568
+ ebitda: Sequence[Money],
569
+ ebit: Sequence[Money],
570
+ cash_interest: Sequence[Money],
571
+ accrued_interest: Sequence[Money] | None = None,
572
+ indices: Sequence[int] | None = None,
573
+ policy: TaxPolicy,
574
+ opening_carryforward: Numeric = 0,
575
+ opening_disallowed_interest: Numeric = 0,
576
+ ) -> LeveredTaxSchedule:
577
+ """Run the levered computation across periods, rolling both pools.
578
+
579
+ Takes bare series rather than an operating model so that the pass can be
580
+ driven by interest written down by hand, which is how the cap and the pool
581
+ are tested without a solver in the way. See
582
+ :func:`levered_tax_for_model` for the usual entry point.
583
+ """
584
+ accrued = list(accrued_interest) if accrued_interest is not None else [ZERO] * len(ebit)
585
+ if not (len(ebitda) == len(ebit) == len(cash_interest) == len(accrued)):
586
+ raise TaxPolicyError("the earnings and interest series must be the same length")
587
+ keys = list(indices) if indices is not None else list(range(1, len(ebit) + 1))
588
+ if len(keys) != len(ebit):
589
+ raise TaxPolicyError("one index per period is required")
590
+
591
+ pool = money(opening_carryforward)
592
+ disallowed = money(opening_disallowed_interest)
593
+ rows: list[LeveredTaxPeriod] = []
594
+ for i, key in enumerate(keys):
595
+ row = levered_tax(
596
+ index=key,
597
+ ebitda=ebitda[i],
598
+ ebit=ebit[i],
599
+ cash_interest=cash_interest[i],
600
+ accrued_interest=accrued[i],
601
+ opening_carryforward=pool,
602
+ opening_disallowed_interest=disallowed,
603
+ policy=policy,
604
+ )
605
+ pool = row.closing_carryforward
606
+ disallowed = row.deduction.carried_out
607
+ rows.append(row)
608
+
609
+ return LeveredTaxSchedule(
610
+ periods=tuple(rows),
611
+ opening_carryforward=money(opening_carryforward),
612
+ opening_disallowed_interest=money(opening_disallowed_interest),
613
+ )
614
+
615
+
616
+ def levered_tax_for_model(
617
+ model: OperatingModel,
618
+ *,
619
+ cash_interest: Sequence[Money],
620
+ accrued_interest: Sequence[Money] | None = None,
621
+ policy: TaxPolicy,
622
+ opening_carryforward: Numeric = 0,
623
+ opening_disallowed_interest: Numeric = 0,
624
+ ) -> LeveredTaxSchedule:
625
+ """Run the levered computation against an operating case's earnings.
626
+
627
+ The earnings come from the model and the interest from the caller, which is
628
+ the shape the circularity above this layer needs: the solver holds the
629
+ operating case still and varies the interest.
630
+ """
631
+ return levered_tax_schedule(
632
+ ebitda=[p.ebitda for p in model],
633
+ ebit=[p.ebit for p in model],
634
+ cash_interest=cash_interest,
635
+ accrued_interest=accrued_interest,
636
+ indices=[p.index for p in model],
637
+ policy=policy,
638
+ opening_carryforward=opening_carryforward,
639
+ opening_disallowed_interest=opening_disallowed_interest,
640
+ )
641
+
642
+
643
+ # -- The circularity -----------------------------------------------------
644
+
645
+ #: A function that runs the capital structure against a cash-flow series.
646
+ #:
647
+ #: The solve varies the cash flow and reads back the interest, and has no other
648
+ #: business with the schedule. Taking a runner rather than a structure is what
649
+ #: keeps recapitalisations, add-ons, refinancings and fee write-offs out of this
650
+ #: layer: the caller configures them once, closes over them, and the fixed point
651
+ #: never learns they exist.
652
+ ScheduleRunner = Callable[[Sequence[Money]], DebtSchedule]
653
+
654
+
655
+ @dataclass(frozen=True, slots=True)
656
+ class LeveredSolution:
657
+ """A capital structure and a tax computation that agree with each other."""
658
+
659
+ schedule: DebtSchedule
660
+ tax: LeveredTaxSchedule
661
+ #: The cash flow the schedule was finally run against: the operating case's
662
+ #: unlevered free cash flow plus the saving the deduction produced.
663
+ cash_flows: tuple[Money, ...]
664
+ #: How many times the schedule was run, the first pass included.
665
+ iterations: int
666
+ #: The largest period-on-period movement in the saving when the solve
667
+ #: stopped. Reported rather than discarded, because a caller comparing two
668
+ #: structures to a hundredth of a turn should be able to see it.
669
+ residual: Money
670
+
671
+ @property
672
+ def total_shield(self) -> Money:
673
+ """Cash tax the structure saved, net of the debt the saving repaid."""
674
+ return self.tax.total_shield
675
+
676
+ @property
677
+ def shields(self) -> tuple[Money, ...]:
678
+ return tuple(p.shield for p in self.tax)
679
+
680
+
681
+ def _shield_series(model: OperatingModel, tax: LeveredTaxSchedule) -> list[Money]:
682
+ """The saving per period, measured against the operating case's own tax line.
683
+
684
+ Not against the tax layer's internal counterfactual. The two agree — that
685
+ is checked before the solve starts — but the unlevered free cash flow the
686
+ saving is added to was struck net of the *model's* figure, so the model's
687
+ figure is the one the adjustment has to be measured from or the cash flow
688
+ double-counts the difference between them.
689
+ """
690
+ return [period.tax.cash_tax - row.cash_tax for period, row in zip(model, tax, strict=True)]
691
+
692
+
693
+ def solve_levered(
694
+ *,
695
+ model: OperatingModel,
696
+ run: ScheduleRunner,
697
+ policy: TaxPolicy,
698
+ opening_carryforward: Numeric = 0,
699
+ opening_disallowed_interest: Numeric = 0,
700
+ tolerance: Numeric = DEFAULT_SOLVE_TOLERANCE,
701
+ max_iterations: int = MAX_SOLVE_ITERATIONS,
702
+ ) -> LeveredSolution:
703
+ """Settle the interest deduction against the debt it is charged on.
704
+
705
+ Two sequential passes, alternated. The tax pass has to see the whole hold at
706
+ once because both pools carry forward, which is why this cannot be folded
707
+ into the schedule's own per-period fixed point: it sits outside it, and a
708
+ structure can solve the inner one in every period and still fail here.
709
+
710
+ The map contracts hard. One turn of the loop moves the saving by the tax
711
+ rate times the interest on the cash the last turn freed, so the residual
712
+ falls by roughly ``rate x coupon x year_fraction`` each time — a few tenths
713
+ of a per cent at any realistic combination — and the feedback is negative:
714
+ a larger deduction frees cash, the cash repays debt, and the smaller balance
715
+ earns the smaller interest that shrinks the next deduction. Across the
716
+ structures in the suite no solve has taken more than four passes.
717
+
718
+ Before iterating, the solve checks its own counterfactual: run with no
719
+ interest at all, the tax pass must reproduce the operating case's tax line
720
+ period for period. A policy whose rate or loss limit disagrees with the case
721
+ it is handed fails that check and is refused, because the saving credited to
722
+ the cash flow is a difference against that line and measuring it off a
723
+ different base would quietly misstate every period.
724
+ """
725
+ if max_iterations < 1:
726
+ raise TaxPolicyError("a solve needs at least one iteration")
727
+ limit = money(tolerance)
728
+ if limit < 0:
729
+ raise TaxPolicyError("the tolerance must not be negative")
730
+
731
+ unlevered = levered_tax_for_model(
732
+ model,
733
+ cash_interest=[ZERO] * len(model),
734
+ policy=policy,
735
+ opening_carryforward=opening_carryforward,
736
+ opening_disallowed_interest=opening_disallowed_interest,
737
+ )
738
+ for period, row in zip(model, unlevered, strict=True):
739
+ if row.cash_tax != period.tax.cash_tax:
740
+ raise TaxPolicyError(
741
+ "this tax policy disagrees with the operating case it is being run "
742
+ f"against: with no interest at all, period {period.index} pays "
743
+ f"{row.cash_tax} here and {period.tax.cash_tax} in the projection. "
744
+ "Build the policy with TaxPolicy.from_assumptions so the rate and "
745
+ "the loss limit come from the case itself."
746
+ )
747
+
748
+ base = [p.unlevered_free_cash_flow for p in model]
749
+ shields = [ZERO] * len(model)
750
+ # The series the schedule in hand was actually run against, so that what is
751
+ # returned is self-consistent: the schedule, the tax read off it, and the
752
+ # cash flow that produced it all describe the same pass.
753
+ applied = list(base)
754
+ schedule = run(applied)
755
+ tax = unlevered
756
+ residual = ZERO
757
+ iterations = 1
758
+
759
+ while True:
760
+ tax = levered_tax_for_model(
761
+ model,
762
+ cash_interest=[p.cash_interest for p in schedule],
763
+ accrued_interest=[p.pik_interest for p in schedule],
764
+ policy=policy,
765
+ opening_carryforward=opening_carryforward,
766
+ opening_disallowed_interest=opening_disallowed_interest,
767
+ )
768
+ fresh = _shield_series(model, tax)
769
+ residual = max((abs(a - b) for a, b in zip(fresh, shields, strict=True)), default=ZERO)
770
+ shields = fresh
771
+ if residual <= limit:
772
+ # The schedule in hand was run against the previous saving series,
773
+ # which this one is within tolerance of, so it is the answer. The
774
+ # flows returned are the ones that produced it, not the ones just
775
+ # computed, which no schedule has been run against.
776
+ return LeveredSolution(
777
+ schedule=schedule,
778
+ tax=tax,
779
+ cash_flows=tuple(applied),
780
+ iterations=iterations,
781
+ residual=residual,
782
+ )
783
+ flows = [cf + s for cf, s in zip(base, shields, strict=True)]
784
+ if iterations >= max_iterations:
785
+ raise TaxCircularityError(
786
+ f"the interest deduction and the debt it is charged on did not "
787
+ f"settle in {max_iterations} passes; the saving was still moving "
788
+ f"by {residual} when the solve stopped"
789
+ )
790
+ applied = flows
791
+ schedule = run(applied)
792
+ iterations += 1
793
+
794
+
795
+ def levered_schedule(
796
+ structure: CapitalStructure,
797
+ model: OperatingModel,
798
+ *,
799
+ policy: TaxPolicy,
800
+ opening_cash: Numeric = 0,
801
+ opening_ebitda: Numeric | None = None,
802
+ opening_carryforward: Numeric = 0,
803
+ opening_disallowed_interest: Numeric = 0,
804
+ tolerance: Numeric = DEFAULT_SOLVE_TOLERANCE,
805
+ max_iterations: int = MAX_SOLVE_ITERATIONS,
806
+ ) -> LeveredSolution:
807
+ """Run a structure against a case, with the deduction settled against it.
808
+
809
+ The plain entry point, for a structure with no events attached to it. A deal
810
+ with recapitalisations, add-ons or refinancings builds its own runner and
811
+ calls :func:`solve_levered` directly, which is what ``Deal.levered`` does.
812
+ """
813
+ ebitda = [model.trailing_ebitda(i) for i in range(len(model))]
814
+ anchor = opening_ebitda if opening_ebitda is not None else model.entry_ebitda
815
+
816
+ def run(cash_flows: Sequence[Money]) -> DebtSchedule:
817
+ return DebtSchedule.run(
818
+ structure,
819
+ [p.period for p in model],
820
+ cash_flows,
821
+ opening_cash=opening_cash,
822
+ ebitda=ebitda,
823
+ opening_ebitda=anchor,
824
+ )
825
+
826
+ return solve_levered(
827
+ model=model,
828
+ run=run,
829
+ policy=policy,
830
+ opening_carryforward=opening_carryforward,
831
+ opening_disallowed_interest=opening_disallowed_interest,
832
+ tolerance=tolerance,
833
+ max_iterations=max_iterations,
834
+ )