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/money.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Exact currency arithmetic.
|
|
2
|
+
|
|
3
|
+
A debt schedule is a chain of dependent subtractions running many periods deep.
|
|
4
|
+
Each period's closing balance is the next period's opening balance, so an error
|
|
5
|
+
introduced in period one is still present in period twenty. Binary floating
|
|
6
|
+
point introduces such an error on the very first conversion: 0.1 is not
|
|
7
|
+
representable, and neither is any of the tenths and hundredths that make up an
|
|
8
|
+
interest rate or an amortisation percentage.
|
|
9
|
+
|
|
10
|
+
The visible symptom is a tranche that is supposed to amortise to exactly zero
|
|
11
|
+
and instead lands on a residue of a few billionths, which then draws a revolver
|
|
12
|
+
in the final period to repay it. So every currency amount here is a ``Decimal``,
|
|
13
|
+
carried at high precision and rounded only when it is presented.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections.abc import Sequence
|
|
19
|
+
from decimal import ROUND_HALF_EVEN, Context, Decimal, InvalidOperation, setcontext
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"ONE",
|
|
23
|
+
"ZERO",
|
|
24
|
+
"Money",
|
|
25
|
+
"Numeric",
|
|
26
|
+
"allocate_pro_rata",
|
|
27
|
+
"is_close",
|
|
28
|
+
"money",
|
|
29
|
+
"quantize",
|
|
30
|
+
"rate",
|
|
31
|
+
"safe_div",
|
|
32
|
+
"to_float",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
Money = Decimal
|
|
36
|
+
Numeric = int | str | float | Decimal
|
|
37
|
+
|
|
38
|
+
#: Working precision. Deal models run to the billions with cent-level detail,
|
|
39
|
+
#: which is about 12 significant digits; 34 leaves ample room for intermediate
|
|
40
|
+
#: products such as a balance multiplied by a rate multiplied by a year fraction.
|
|
41
|
+
PRECISION = 34
|
|
42
|
+
|
|
43
|
+
setcontext(Context(prec=PRECISION, rounding=ROUND_HALF_EVEN))
|
|
44
|
+
|
|
45
|
+
ZERO: Money = Decimal(0)
|
|
46
|
+
ONE: Money = Decimal(1)
|
|
47
|
+
|
|
48
|
+
#: Rounding used when an amount is presented rather than carried. Half-even
|
|
49
|
+
#: rather than half-up: a schedule rounds thousands of numbers and half-up would
|
|
50
|
+
#: bias every one of them upward.
|
|
51
|
+
DISPLAY_ROUNDING = ROUND_HALF_EVEN
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def money(value: Numeric) -> Money:
|
|
55
|
+
"""Coerce ``value`` to an exact decimal amount.
|
|
56
|
+
|
|
57
|
+
Floats are routed through ``repr`` rather than ``Decimal(float)``. The
|
|
58
|
+
direct construction is faithful to the binary value, which is the problem:
|
|
59
|
+
``Decimal(0.1)`` is ``0.1000000000000000055511151231257827021181583404541015625``
|
|
60
|
+
and carrying that through a model is exactly the drift this module exists to
|
|
61
|
+
avoid. ``repr`` gives the shortest string that round-trips, so a float that
|
|
62
|
+
was written as ``0.1`` becomes the decimal ``0.1``.
|
|
63
|
+
"""
|
|
64
|
+
if isinstance(value, Decimal):
|
|
65
|
+
return value
|
|
66
|
+
if isinstance(value, bool): # bool is an int subclass; almost never intended
|
|
67
|
+
raise TypeError("refusing to treat a bool as an amount")
|
|
68
|
+
if isinstance(value, int):
|
|
69
|
+
return Decimal(value)
|
|
70
|
+
if isinstance(value, float):
|
|
71
|
+
if value != value or value in (float("inf"), float("-inf")):
|
|
72
|
+
raise ValueError(f"cannot represent {value!r} as an amount")
|
|
73
|
+
return Decimal(repr(value))
|
|
74
|
+
if isinstance(value, str):
|
|
75
|
+
try:
|
|
76
|
+
return Decimal(value)
|
|
77
|
+
except InvalidOperation as exc:
|
|
78
|
+
raise ValueError(f"not a number: {value!r}") from exc
|
|
79
|
+
raise TypeError(f"cannot convert {type(value).__name__} to an amount")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def rate(value: Numeric) -> Money:
|
|
83
|
+
"""Coerce ``value`` to an exact decimal rate.
|
|
84
|
+
|
|
85
|
+
Identical mechanics to :func:`money`; the separate name records intent at
|
|
86
|
+
the call site, because a rate and an amount are not interchangeable even
|
|
87
|
+
though they share a representation.
|
|
88
|
+
"""
|
|
89
|
+
return money(value)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def to_float(value: Money) -> float:
|
|
93
|
+
"""Drop to floating point at a deliberate boundary.
|
|
94
|
+
|
|
95
|
+
Root finding wants floats. Calling this is a statement that the value is
|
|
96
|
+
about to leave exact arithmetic on purpose.
|
|
97
|
+
"""
|
|
98
|
+
return float(value)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def quantize(value: Money, places: int = 2) -> Money:
|
|
102
|
+
"""Round to ``places`` decimal places for presentation."""
|
|
103
|
+
if places < 0:
|
|
104
|
+
raise ValueError("places must not be negative")
|
|
105
|
+
exponent = Decimal(1).scaleb(-places)
|
|
106
|
+
return value.quantize(exponent, rounding=DISPLAY_ROUNDING)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def safe_div(numerator: Money, denominator: Money, *, default: Money | None = None) -> Money:
|
|
110
|
+
"""Divide, substituting ``default`` when the denominator is zero.
|
|
111
|
+
|
|
112
|
+
Financial ratios divide by quantities that are legitimately zero: a company
|
|
113
|
+
with no debt has no leverage ratio, and one with no interest expense has no
|
|
114
|
+
coverage ratio. Those are not errors, but they are also not numbers, so the
|
|
115
|
+
caller has to say what it wants in that case.
|
|
116
|
+
"""
|
|
117
|
+
if denominator == 0:
|
|
118
|
+
if default is None:
|
|
119
|
+
raise ZeroDivisionError("division by zero with no default supplied")
|
|
120
|
+
return default
|
|
121
|
+
return numerator / denominator
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def is_close(a: Money, b: Money, *, tolerance: Numeric = "0.01") -> bool:
|
|
125
|
+
"""Whether two amounts agree to within ``tolerance``.
|
|
126
|
+
|
|
127
|
+
Used for balance checks, where the question is whether two independently
|
|
128
|
+
computed totals agree to the cent, not whether they are bit-identical.
|
|
129
|
+
"""
|
|
130
|
+
return abs(a - b) <= money(tolerance)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def rescale(target: Money, amounts: Sequence[Money]) -> list[Money]:
|
|
134
|
+
"""Resize ``amounts`` so they sum to ``target`` exactly, holding their shape.
|
|
135
|
+
|
|
136
|
+
The same idiom as :func:`allocate_pro_rata` and for the same reason — a
|
|
137
|
+
ratio that does not terminate leaves a residue in the last few decimal
|
|
138
|
+
places, and a capital structure asked for six turns of leverage that comes
|
|
139
|
+
back at six turns plus a billionth is a structure whose stated leverage is
|
|
140
|
+
a lie, however small.
|
|
141
|
+
|
|
142
|
+
The difference is direction. An allocator never pays out more than is
|
|
143
|
+
claimed, which is right for a waterfall and wrong here: resizing a structure
|
|
144
|
+
has to be able to grow it as well as shrink it.
|
|
145
|
+
|
|
146
|
+
Zero amounts stay zero. An undrawn revolver is not a share of the debt to be
|
|
147
|
+
resized; it is a facility that happens to be at nil, and giving it a balance
|
|
148
|
+
because the term loan grew would put the business into a drawing it never
|
|
149
|
+
made.
|
|
150
|
+
"""
|
|
151
|
+
if target < 0:
|
|
152
|
+
raise ValueError("cannot resize to a negative total")
|
|
153
|
+
total = sum(amounts, ZERO)
|
|
154
|
+
if total <= 0:
|
|
155
|
+
raise ValueError("there is nothing to resize: the amounts sum to zero")
|
|
156
|
+
plug = max(i for i, amount in enumerate(amounts) if amount > 0)
|
|
157
|
+
|
|
158
|
+
scaled = [ZERO for _ in amounts]
|
|
159
|
+
remaining = target
|
|
160
|
+
for i, amount in enumerate(amounts):
|
|
161
|
+
if i == plug or amount <= 0:
|
|
162
|
+
continue
|
|
163
|
+
share = target * amount / total
|
|
164
|
+
scaled[i] = share
|
|
165
|
+
remaining -= share
|
|
166
|
+
scaled[plug] = remaining
|
|
167
|
+
return scaled
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def allocate_pro_rata(pot: Money, claims: Sequence[Money]) -> list[Money]:
|
|
171
|
+
"""Split ``pot`` across ``claims`` pro rata, exactly and without residue.
|
|
172
|
+
|
|
173
|
+
Every share but one is computed pro rata and the remaining claim takes what
|
|
174
|
+
is left over. Pro-rating all of them independently would leave a residue of
|
|
175
|
+
a few billionths whenever the ratio does not terminate, and a residue is
|
|
176
|
+
precisely what stops a balance from reaching zero — a tranche that will not
|
|
177
|
+
amortise away, or a security that is repaid in full and still shows a
|
|
178
|
+
fraction of a penny outstanding.
|
|
179
|
+
|
|
180
|
+
The remainder goes to the last claim with something outstanding, never
|
|
181
|
+
simply to the last in the list. A claim already at zero is entitled to
|
|
182
|
+
nothing, and handing it the rounding dust gives it a balance of a few
|
|
183
|
+
billionths — of either sign — that nothing afterwards can clear, because
|
|
184
|
+
both the payment and the balance are floored at zero.
|
|
185
|
+
|
|
186
|
+
Nobody is paid more than they are owed: the total distributed is the lesser
|
|
187
|
+
of ``pot`` and the claims against it.
|
|
188
|
+
"""
|
|
189
|
+
total = sum(claims, ZERO)
|
|
190
|
+
if total <= 0 or pot <= 0:
|
|
191
|
+
return [ZERO for _ in claims]
|
|
192
|
+
payable = min(pot, total)
|
|
193
|
+
plug = max(i for i, claim in enumerate(claims) if claim > 0)
|
|
194
|
+
|
|
195
|
+
shares = [ZERO for _ in claims]
|
|
196
|
+
remaining = payable
|
|
197
|
+
for i, claim in enumerate(claims):
|
|
198
|
+
if i == plug or claim <= 0:
|
|
199
|
+
continue
|
|
200
|
+
share = payable * claim / total
|
|
201
|
+
shares[i] = share
|
|
202
|
+
remaining -= share
|
|
203
|
+
shares[plug] = min(max(remaining, ZERO), claims[plug])
|
|
204
|
+
return shares
|