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/daycount.py ADDED
@@ -0,0 +1,121 @@
1
+ """Day-count conventions.
2
+
3
+ Interest is quoted as an annual rate and accrued over a period that is not a
4
+ year, so every accrual needs a rule for what fraction of a year has passed. The
5
+ rules disagree with each other by design, and the disagreement is worth real
6
+ money: a 500bp margin on a 500 million term loan accrued over a calendar year
7
+ differs by about 347,000 between ACT/360 and ACT/365F, because ACT/360 charges
8
+ 365 days against a 360-day year. Credit agreements name the convention
9
+ explicitly, so the model has to as well.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from datetime import date
15
+ from decimal import Decimal
16
+ from enum import Enum
17
+
18
+ from .money import Money, money
19
+
20
+ __all__ = ["DayCount", "days_between", "is_leap_year", "year_fraction"]
21
+
22
+
23
+ class DayCount(Enum):
24
+ """The conventions this engine supports.
25
+
26
+ ``ACT_360`` is the market standard for floating-rate bank debt in USD and
27
+ EUR, which is most of an LBO capital structure. ``THIRTY_360_US`` is bond
28
+ basis and is what fixed-rate notes and most seller paper use. ``ACT_365F``
29
+ is sterling convention and is also the natural choice for discounting.
30
+ ``ACT_ACT_ISDA`` splits an accrual across the year boundary so that each
31
+ part is divided by the length of its own year.
32
+ """
33
+
34
+ ACT_365F = "ACT/365F"
35
+ ACT_360 = "ACT/360"
36
+ THIRTY_360_US = "30/360 US"
37
+ ACT_ACT_ISDA = "ACT/ACT ISDA"
38
+
39
+ def __str__(self) -> str:
40
+ return self.value
41
+
42
+
43
+ def is_leap_year(year: int) -> bool:
44
+ """Whether ``year`` is a leap year under the Gregorian rules."""
45
+ return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
46
+
47
+
48
+ def days_between(start: date, end: date) -> int:
49
+ """Actual calendar days from ``start`` to ``end``, signed."""
50
+ return (end - start).days
51
+
52
+
53
+ def _thirty_360_us_days(start: date, end: date) -> int:
54
+ """Day count under 30/360 US bond basis.
55
+
56
+ The adjustment rules are asymmetric and the asymmetry matters. The end day
57
+ is only pulled back to 30 if the start day was already 30 or 31; otherwise a
58
+ period ending on the 31st genuinely earns the extra day. Applying the
59
+ adjustment unconditionally is the usual implementation bug.
60
+ """
61
+ d1, d2 = start.day, end.day
62
+ if d1 == 31:
63
+ d1 = 30
64
+ if d2 == 31 and d1 == 30:
65
+ d2 = 30
66
+ return 360 * (end.year - start.year) + 30 * (end.month - start.month) + (d2 - d1)
67
+
68
+
69
+ def _act_act_isda(start: date, end: date) -> Money:
70
+ """Year fraction under ACT/ACT ISDA.
71
+
72
+ Days falling in a leap year are divided by 366 and the rest by 365, so the
73
+ period is split at each year boundary.
74
+
75
+ The days are accumulated into two counters and divided once each, rather
76
+ than dividing year by year and summing the quotients. Both give the same
77
+ real number, but only the first is exact in decimal: 139/365 + 195/365
78
+ rounds twice and lands a unit in the last place away from 334/365, which is
79
+ enough to fail an equality check against a hand-computed figure.
80
+ """
81
+ if start == end:
82
+ return money(0)
83
+ if start > end:
84
+ return -_act_act_isda(end, start)
85
+
86
+ days_365 = 0
87
+ days_366 = 0
88
+ for year in range(start.year, end.year + 1):
89
+ year_start = max(start, date(year, 1, 1))
90
+ year_end = min(end, date(year + 1, 1, 1))
91
+ if year_end <= year_start:
92
+ continue
93
+ days = (year_end - year_start).days
94
+ if is_leap_year(year):
95
+ days_366 += days
96
+ else:
97
+ days_365 += days
98
+
99
+ total = Decimal(0)
100
+ if days_365:
101
+ total += Decimal(days_365) / Decimal(365)
102
+ if days_366:
103
+ total += Decimal(days_366) / Decimal(366)
104
+ return total
105
+
106
+
107
+ def year_fraction(start: date, end: date, convention: DayCount = DayCount.ACT_365F) -> Money:
108
+ """The fraction of a year between ``start`` and ``end`` under ``convention``.
109
+
110
+ A reversed pair returns a negative fraction rather than raising, so that
111
+ accrual arithmetic stays consistent when a schedule is walked backwards.
112
+ """
113
+ if convention is DayCount.ACT_365F:
114
+ return Decimal(days_between(start, end)) / Decimal(365)
115
+ if convention is DayCount.ACT_360:
116
+ return Decimal(days_between(start, end)) / Decimal(360)
117
+ if convention is DayCount.THIRTY_360_US:
118
+ return Decimal(_thirty_360_us_days(start, end)) / Decimal(360)
119
+ if convention is DayCount.ACT_ACT_ISDA:
120
+ return _act_act_isda(start, end)
121
+ raise ValueError(f"unsupported day-count convention: {convention!r}")