capstack-lbo 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- capstack/__init__.py +243 -0
- capstack/balance_sheet.py +295 -0
- capstack/cli.py +1995 -0
- capstack/covenants.py +594 -0
- capstack/daycount.py +121 -0
- capstack/debt.py +2003 -0
- capstack/drivers.py +167 -0
- capstack/events.py +914 -0
- capstack/fees.py +484 -0
- capstack/incentive.py +517 -0
- capstack/money.py +204 -0
- capstack/operating.py +677 -0
- capstack/outcome.py +958 -0
- capstack/periods.py +431 -0
- capstack/py.typed +0 -0
- capstack/report.py +1188 -0
- capstack/returns.py +426 -0
- capstack/sensitivity.py +940 -0
- capstack/spec.py +1686 -0
- capstack/tax.py +834 -0
- capstack/transaction.py +430 -0
- capstack_lbo-0.1.0.dist-info/METADATA +1260 -0
- capstack_lbo-0.1.0.dist-info/RECORD +27 -0
- capstack_lbo-0.1.0.dist-info/WHEEL +5 -0
- capstack_lbo-0.1.0.dist-info/entry_points.txt +3 -0
- capstack_lbo-0.1.0.dist-info/licenses/LICENSE +21 -0
- capstack_lbo-0.1.0.dist-info/top_level.txt +1 -0
capstack/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}")
|