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/periods.py ADDED
@@ -0,0 +1,431 @@
1
+ """Projection period grids.
2
+
3
+ An LBO model is a table whose columns are periods. Building that spine correctly
4
+ is unglamorous and easy to get wrong in one specific way: adding a month to
5
+ 31 January. There is no 31 February, so the naive answer overflows into March
6
+ and every subsequent period in the schedule is anchored to the wrong day.
7
+
8
+ The rule used here is the one credit agreements use. A date is clamped to the
9
+ last day of the target month, and — this is the part that is usually missed —
10
+ the clamping is applied to the *original* anchor each time rather than
11
+ compounding. A schedule anchored on 31 January runs 31 Jan, 28 Feb, 31 Mar, not
12
+ 31 Jan, 28 Feb, 28 Mar.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import calendar
18
+ import itertools
19
+ from collections.abc import Sequence
20
+ from dataclasses import dataclass
21
+ from datetime import date
22
+ from enum import Enum
23
+
24
+ from .daycount import DayCount, year_fraction
25
+ from .money import ONE, Money
26
+
27
+ __all__ = [
28
+ "Frequency",
29
+ "Period",
30
+ "PeriodGrid",
31
+ "TrailingWindow",
32
+ "add_months",
33
+ "end_of_month",
34
+ "trailing_window",
35
+ ]
36
+
37
+
38
+ class Frequency(Enum):
39
+ """How often the model steps."""
40
+
41
+ ANNUAL = 1
42
+ SEMI_ANNUAL = 2
43
+ QUARTERLY = 4
44
+ MONTHLY = 12
45
+
46
+ @property
47
+ def months(self) -> int:
48
+ """Calendar months in one step at this frequency."""
49
+ return 12 // self.value
50
+
51
+ @property
52
+ def periods_per_year(self) -> int:
53
+ return self.value
54
+
55
+ def period_ending_year(self, year: int) -> int:
56
+ """The period number whose end is the end of ``year``.
57
+
58
+ The translation a file needs in order to mean the same thing on any
59
+ frequency. A term loan maturing at the end of year seven matures in
60
+ period seven of an annual grid and period twenty-eight of a quarterly
61
+ one, and those are the same date — which is what a maturity is.
62
+ """
63
+ if year < 1:
64
+ raise ValueError("years are numbered from one")
65
+ return year * self.periods_per_year
66
+
67
+ def __str__(self) -> str:
68
+ return self.name.replace("_", "-").lower()
69
+
70
+
71
+ def end_of_month(year: int, month: int) -> int:
72
+ """Last day number of the given month."""
73
+ return calendar.monthrange(year, month)[1]
74
+
75
+
76
+ def add_months(anchor: date, months: int) -> date:
77
+ """Advance ``anchor`` by ``months``, clamping to the end of the target month.
78
+
79
+ Clamping always measures from the anchor's own day number, so the day does
80
+ not ratchet downwards over a long schedule.
81
+ """
82
+ total = anchor.month - 1 + months
83
+ year = anchor.year + total // 12
84
+ month = total % 12 + 1
85
+ day = min(anchor.day, end_of_month(year, month))
86
+ return date(year, month, day)
87
+
88
+
89
+ @dataclass(frozen=True, slots=True)
90
+ class Period:
91
+ """One column of the model.
92
+
93
+ ``index`` is 1-based: period 1 is the first full period after close, which
94
+ matches how deal teams talk about the model. The stub before it, if any, is
95
+ period 0.
96
+
97
+ ``periods_per_year`` is how the year this period belongs to is divided. A
98
+ period carries it rather than deriving it because a period is passed around
99
+ on its own — into a covenant test, into an interest accrual — and every one
100
+ of those callers needs to know what fraction of a year it is looking at.
101
+ The default of one keeps a period built by hand annual, which is what a
102
+ single period unattached to a grid has always meant.
103
+ """
104
+
105
+ index: int
106
+ start: date
107
+ end: date
108
+ periods_per_year: int = 1
109
+
110
+ def __post_init__(self) -> None:
111
+ if self.end < self.start:
112
+ raise ValueError(
113
+ f"period {self.index} ends ({self.end}) before it starts ({self.start})"
114
+ )
115
+ if self.index < 0:
116
+ raise ValueError("period index must not be negative")
117
+ if self.periods_per_year < 1:
118
+ raise ValueError("a year is divided into at least one period")
119
+
120
+ @property
121
+ def is_stub(self) -> bool:
122
+ """Whether this is the short period between close and the first reporting date."""
123
+ return self.index == 0
124
+
125
+ @property
126
+ def driver_index(self) -> int:
127
+ """Where to read an assumption series for this period, zero-based.
128
+
129
+ An assumption is written by year. Revenue growth of 8% means 8% over a
130
+ year, and a margin of 20% is the margin for the year, whether the year
131
+ is reported once or twelve times — so every period of the same year
132
+ reads the same entry, and the index counts years rather than columns.
133
+ Reading by column instead would exhaust a five-year case in five
134
+ quarters and hold the terminal assumption for the remaining fifteen.
135
+
136
+ A stub is not a year of its own. A deal closing in November trades six
137
+ weeks of the year it closed in, at that year's margin and that year's
138
+ capital intensity, and the first full period is still year one — so the
139
+ stub and the period after it read the same assumptions.
140
+
141
+ On an annual grid the mapping is the identity it always was: period 1
142
+ reads index 0, period 2 reads index 1, and a grid built the old way is
143
+ indistinguishable from one built today.
144
+ """
145
+ return max(self.index - 1, 0) // self.periods_per_year
146
+
147
+ @property
148
+ def days(self) -> int:
149
+ return (self.end - self.start).days
150
+
151
+ def year_fraction(self, convention: DayCount = DayCount.ACT_365F) -> Money:
152
+ """Length of this period as a fraction of a year."""
153
+ return year_fraction(self.start, self.end, convention)
154
+
155
+ def share_of_year(self, convention: DayCount = DayCount.ACT_365F) -> Money:
156
+ """How much of a year's trading this period carries.
157
+
158
+ A whole period takes an equal share of its year — a quarter is a
159
+ quarter — rather than its own count of days over 365. The two differ,
160
+ and the equal share is the right one for an operating case: a budget is
161
+ a year's trading divided into reporting periods, so the periods of a
162
+ year have to add back to the year exactly. Measuring by days would make
163
+ four quarters sum to 366/365 of a leap year and would silently restate
164
+ every annual model that happens to span one.
165
+
166
+ Interest is the other way round and is accrued the other way round: it
167
+ is earned per day, so the debt schedule uses :meth:`year_fraction` with
168
+ whatever day count the credit agreement negotiated. The two conventions
169
+ sit side by side on purpose.
170
+
171
+ A stub is the exception, because it has no natural share to take. It
172
+ gets the fraction of a year its days actually cover.
173
+ """
174
+ if self.is_stub:
175
+ return self.year_fraction(convention)
176
+ return ONE / Money(self.periods_per_year)
177
+
178
+ @property
179
+ def label(self) -> str:
180
+ """A short label of the kind that would head a column."""
181
+ if self.is_stub:
182
+ return f"Stub to {self.end.isoformat()}"
183
+ return f"P{self.index} to {self.end.isoformat()}"
184
+
185
+ @property
186
+ def short_label(self) -> str:
187
+ """What heads a narrow column: ``Stub`` or ``P3``."""
188
+ return "Stub" if self.is_stub else f"P{self.index}"
189
+
190
+ def __str__(self) -> str:
191
+ return self.label
192
+
193
+
194
+ @dataclass(frozen=True, slots=True)
195
+ class TrailingWindow:
196
+ """A run of consecutive periods covering an interval that ends on a date.
197
+
198
+ ``complete`` is the part that matters. A trailing-twelve-month figure taken
199
+ over nine months is not a conservative twelve-month figure, it is a
200
+ different measure, and a leverage ratio built on one reads a third too high.
201
+ Every consumer is expected to decide what to do about an incomplete window
202
+ rather than to average over the difference.
203
+ """
204
+
205
+ periods: tuple[Period, ...]
206
+ opens: date
207
+ closes: date
208
+ complete: bool
209
+
210
+ def __len__(self) -> int:
211
+ return len(self.periods)
212
+
213
+ def __iter__(self): # type: ignore[no-untyped-def]
214
+ return iter(self.periods)
215
+
216
+ @property
217
+ def indices(self) -> tuple[int, ...]:
218
+ """The period numbers in the window, in order."""
219
+ return tuple(p.index for p in self.periods)
220
+
221
+ @property
222
+ def days(self) -> int:
223
+ return sum(p.days for p in self.periods)
224
+
225
+
226
+ def trailing_window(
227
+ periods: Sequence[Period], position: int, months: int = 12
228
+ ) -> TrailingWindow:
229
+ """The periods making up the ``months`` ending with ``periods[position]``.
230
+
231
+ The unit a credit agreement measures in. Everything a covenant tests is a
232
+ flow — earnings, interest, contractual repayment — and a flow has to be
233
+ compared against a stock, the debt, over a stated interval. That interval is
234
+ a year, not a reporting period, which is the whole reason a quarterly model
235
+ cannot simply divide one column by another.
236
+
237
+ The window is defined by dates rather than by counting columns, so a stub at
238
+ the front needs no special case: it covers the twelve months back from this
239
+ period's end, and a period is in it when it lies wholly inside. A window is
240
+ ``complete`` when those periods cover the interval with nothing missing at
241
+ the front — which they do not, for the first three quarters of a quarterly
242
+ grid, and which is the distinction between a ratio worth reporting and a
243
+ fraction of a year pretending to be a year.
244
+
245
+ Taking a sequence rather than a grid is deliberate: the same window is
246
+ wanted over an operating case and over a debt schedule, and neither of them
247
+ is a grid.
248
+ """
249
+ if not periods:
250
+ raise ValueError("a window needs at least one period")
251
+ if not -len(periods) <= position < len(periods):
252
+ raise IndexError(f"period {position} is outside the {len(periods)} given")
253
+ last = periods[position]
254
+ opens = add_months(last.end, -months)
255
+ inside = tuple(p for p in periods if p.start >= opens and p.end <= last.end)
256
+ # ``inside`` is contiguous and ends at ``last`` whenever the periods handed
257
+ # in are themselves contiguous, which a grid guarantees. It is short only at
258
+ # the front, so completeness is a question about its first period alone.
259
+ complete = bool(inside) and inside[0].start == opens
260
+ return TrailingWindow(
261
+ periods=inside, opens=opens, closes=last.end, complete=complete
262
+ )
263
+
264
+
265
+ @dataclass(frozen=True, slots=True)
266
+ class PeriodGrid:
267
+ """An ordered, contiguous set of projection periods.
268
+
269
+ Contiguity is enforced rather than assumed: each period starts on the day
270
+ the previous one ended. Interest accrual and balance roll-forward both
271
+ depend on it, and a gap would silently lose a day of accrual.
272
+ """
273
+
274
+ periods: tuple[Period, ...]
275
+ frequency: Frequency
276
+
277
+ def __post_init__(self) -> None:
278
+ if not self.periods:
279
+ raise ValueError("a grid needs at least one period")
280
+ if any(p.is_stub for p in self.periods[1:]):
281
+ raise ValueError("a stub is the first period or it is not a stub")
282
+ mismatched = [
283
+ p for p in self.periods if p.periods_per_year != self.frequency.periods_per_year
284
+ ]
285
+ if mismatched:
286
+ raise ValueError(
287
+ f"period {mismatched[0].index} divides its year into "
288
+ f"{mismatched[0].periods_per_year} but the grid is {self.frequency}"
289
+ )
290
+ for earlier, later in itertools.pairwise(self.periods):
291
+ if later.start != earlier.end:
292
+ raise ValueError(
293
+ f"gap in grid: period {earlier.index} ends {earlier.end} "
294
+ f"but period {later.index} starts {later.start}"
295
+ )
296
+ if later.index != earlier.index + 1:
297
+ raise ValueError("period indices must run consecutively")
298
+
299
+ @classmethod
300
+ def build(
301
+ cls,
302
+ close: date,
303
+ years: int,
304
+ frequency: Frequency = Frequency.ANNUAL,
305
+ *,
306
+ stub_to: date | None = None,
307
+ ) -> PeriodGrid:
308
+ """Build ``years`` years of periods forward from ``close``.
309
+
310
+ ``close`` is the transaction date, so it is the start of period 1 rather
311
+ than a period in its own right.
312
+
313
+ ``stub_to`` is the first reporting date, for a deal that does not close
314
+ on one. A deal signing on 15 November against a 31 December year end
315
+ trades six weeks before its first accounts, and modelling that as a full
316
+ period puts the trading in the wrong place and dates every column after
317
+ it early — which moves the exit date, the holding period and therefore
318
+ the rate of return on a deal where nothing else changed.
319
+
320
+ The stub is period 0 and the whole periods that follow are anchored on
321
+ ``stub_to`` rather than on ``close``, so period ends fall on reporting
322
+ dates rather than on deal anniversaries. ``years`` counts the whole
323
+ periods; the stub is extra, being a fraction of the year it sits in.
324
+
325
+ A ``stub_to`` exactly one whole period after ``close`` describes no stub
326
+ at all, and is built as the ordinary grid rather than as a first period
327
+ of full length numbered zero.
328
+ """
329
+ if years <= 0:
330
+ raise ValueError("projection must cover at least one year")
331
+ count = years * frequency.periods_per_year
332
+ step = frequency.months
333
+ periods: list[Period] = []
334
+ anchor = close
335
+
336
+ if stub_to is not None:
337
+ if stub_to <= close:
338
+ raise ValueError(
339
+ f"the first reporting date ({stub_to}) is on or before the close "
340
+ f"date ({close}), so there is no stub between them"
341
+ )
342
+ if stub_to > add_months(close, step):
343
+ raise ValueError(
344
+ f"a stub is shorter than a whole period, and {close} to {stub_to} "
345
+ f"is longer than one {frequency}"
346
+ )
347
+ if stub_to != add_months(close, step):
348
+ periods.append(
349
+ Period(
350
+ index=0,
351
+ start=close,
352
+ end=stub_to,
353
+ periods_per_year=frequency.periods_per_year,
354
+ )
355
+ )
356
+ anchor = stub_to
357
+
358
+ # The whole periods are numbered from one whether or not a stub sits in
359
+ # front of them, so a maturity or an event stated as "period 2" means
360
+ # the same thing in either grid.
361
+ for i in range(count):
362
+ periods.append(
363
+ Period(
364
+ index=i + 1,
365
+ start=add_months(anchor, i * step),
366
+ end=add_months(anchor, (i + 1) * step),
367
+ periods_per_year=frequency.periods_per_year,
368
+ )
369
+ )
370
+ return cls(periods=tuple(periods), frequency=frequency)
371
+
372
+ def trailing(self, position: int, months: int = 12) -> TrailingWindow:
373
+ """The periods making up the ``months`` ending with period ``position``."""
374
+ return trailing_window(self.periods, position, months)
375
+
376
+ @property
377
+ def has_stub(self) -> bool:
378
+ return self.periods[0].is_stub
379
+
380
+ @property
381
+ def stub(self) -> Period | None:
382
+ """The short period at close, if the grid has one."""
383
+ return self.periods[0] if self.has_stub else None
384
+
385
+ @property
386
+ def whole_periods(self) -> tuple[Period, ...]:
387
+ """Every period but the stub."""
388
+ return tuple(p for p in self.periods if not p.is_stub)
389
+
390
+ @property
391
+ def years(self) -> int:
392
+ """How many years of assumptions this grid reads.
393
+
394
+ The length an assumption series is expanded to, which is not the number
395
+ of columns. A five-year case written as a ramp from 8.5% to 3.5% is five
396
+ numbers however often the years are reported; expanding it to twenty on
397
+ a quarterly grid and then reading it by year would use the first five of
398
+ them and taper the business over a quarter of the intended span.
399
+
400
+ A stub is not a year of its own, so it does not add one.
401
+ """
402
+ whole = len(self.whole_periods)
403
+ step = self.frequency.periods_per_year
404
+ return max((whole + step - 1) // step, 1)
405
+
406
+ @property
407
+ def start(self) -> date:
408
+ return self.periods[0].start
409
+
410
+ @property
411
+ def end(self) -> date:
412
+ return self.periods[-1].end
413
+
414
+ def __len__(self) -> int:
415
+ return len(self.periods)
416
+
417
+ def __iter__(self): # type: ignore[no-untyped-def]
418
+ return iter(self.periods)
419
+
420
+ def __getitem__(self, index: int) -> Period:
421
+ return self.periods[index]
422
+
423
+ def period_ending_on_or_after(self, when: date) -> Period:
424
+ """The first period whose end is on or after ``when``.
425
+
426
+ Used to locate an exit date on the grid.
427
+ """
428
+ for period in self.periods:
429
+ if period.end >= when:
430
+ return period
431
+ raise ValueError(f"{when} falls beyond the end of the grid ({self.end})")
capstack/py.typed ADDED
File without changes