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/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