3 # © Copyright 2021-2022, Scott Gasch
5 """An amount of money represented as an integral count of cents so as
6 to avoid floating point artifacts. Multiplication and division are
7 performed using floating point arithmetic but the quotient is cast
8 back to an integer number thus truncating the result and
9 avoiding floating point arithmetic artifacts. See details below.
11 The type guards against inadvertent aggregation of instances with
12 non-matching currencies, the division of one CentCount by another, and
13 has a strict mode which disallows comparison or aggregation with
14 non-CentCount operands (i.e. no comparison or aggregation with literal
19 Multiplication and division are performed by converting the
20 `CentCount` into a float and operating on two floating point
21 numbers. The result is then cast back to an int which loses
22 precision beyond the 1-cent granularity in order to avoid floating
23 point representation artifacts.
25 This can cause "problems" such as the one illustrated
28 >>> c = CentCount(100.00)
38 Two-thirds of $100.00 is $66.66666... which might be
39 expected to round upwards to $66.67 but it does not
40 because the `int` cast truncates the result. Be aware
41 of this and decide whether it's suitable for your
44 See also the :class:`pyutils.types.Money` class which uses Python
45 Decimals (see: https://docs.python.org/3/library/decimal.html) to
46 represent monetary amounts.
50 from typing import Optional, Tuple, Union
53 class CentCount(object):
54 """A class for representing monetary amounts potentially with
55 different currencies meant to avoid floating point rounding
56 issues by treating amount as a simple integral count of cents.
61 centcount: Union[int, float, str, "CentCount"] = 0,
62 currency: str = "USD",
68 centcount: the amount of money being represented; this can be
69 a float, int, CentCount or str.
70 currency: optionally declare the currency being represented by
71 this instance. If provided it will guard against operations
72 such as attempting to add it to non-matching currencies.
73 strict_mode: if True, the instance created will object if you
74 compare or aggregate it with non-CentCount objects; that is,
75 strict_mode disallows comparison with literal numbers or
76 aggregation with literal numbers.
78 self.strict_mode = strict_mode
79 if isinstance(centcount, str):
80 ret = CentCount._parse(centcount)
82 raise Exception(f'Unable to parse money string "{centcount}"')
85 if isinstance(centcount, float):
86 centcount = int(centcount * 100.0)
87 if not isinstance(centcount, int):
88 centcount = int(centcount)
89 self.centcount = centcount
91 self.currency: Optional[str] = None
93 self.currency = currency
96 w = self.centcount // 100
97 p = self.centcount % 100
99 if self.currency is not None:
100 return f"{s} {self.currency}"
105 return CentCount(centcount=self.centcount, currency=self.currency)
108 return CentCount(centcount=-self.centcount, currency=self.currency)
110 def __add__(self, other):
111 if isinstance(other, CentCount):
112 if self.currency == other.currency:
114 centcount=self.centcount + other.centcount,
115 currency=self.currency,
118 raise TypeError("Incompatible currencies in add expression")
121 raise TypeError("In strict_mode only two moneys can be added")
123 return self.__add__(CentCount(other, self.currency))
125 def __sub__(self, other):
126 if isinstance(other, CentCount):
127 if self.currency == other.currency:
129 centcount=self.centcount - other.centcount,
130 currency=self.currency,
133 raise TypeError("Incompatible currencies in add expression")
136 raise TypeError("In strict_mode only two moneys can be added")
138 return self.__sub__(CentCount(other, self.currency))
140 def __mul__(self, other):
144 Multiplication and division are performed by converting the
145 CentCount into a float and operating on two floating point
146 numbers. But the result is then cast back to an int which
147 loses precision beyond the 1-cent granularity in order to
148 avoid floating point representation artifacts.
150 This can cause "problems" such as the one illustrated
153 >>> c = CentCount(100.00)
159 Two-thirds of $100.00 is $66.66666... which might be
160 expected to round upwards to $66.67 but it does not
161 because the int cast truncates the result. Be aware
162 of this and decide whether it's suitable for your
165 if isinstance(other, CentCount):
166 raise TypeError("can not multiply monetary quantities")
169 centcount=int(self.centcount * float(other)),
170 currency=self.currency,
173 def __truediv__(self, other):
177 Multiplication and division are performed by converting the
178 CentCount into a float and operating on two floating point
179 numbers. But the result is then cast back to an int which
180 loses precision beyond the 1-cent granularity in order to
181 avoid floating point representation artifacts.
183 This can cause "problems" such as the one illustrated
186 >>> c = CentCount(100.00)
192 Two-thirds of $100.00 is $66.66666... which might be
193 expected to round upwards to $66.67 but it does not
194 because the int cast truncates the result. Be aware
195 of this and decide whether it's suitable for your
198 if isinstance(other, CentCount):
199 raise TypeError("can not divide monetary quantities")
202 centcount=int(float(self.centcount) / float(other)),
203 currency=self.currency,
207 return self.centcount.__int__()
210 return self.centcount.__float__() / 100.0
214 def __rsub__(self, other):
215 if isinstance(other, CentCount):
216 if self.currency == other.currency:
218 centcount=other.centcount - self.centcount,
219 currency=self.currency,
222 raise TypeError("Incompatible currencies in sub expression")
225 raise TypeError("In strict_mode only two moneys can be added")
228 centcount=int(other) - self.centcount,
229 currency=self.currency,
235 # Override comparison operators to also compare currency.
237 def __eq__(self, other):
240 if isinstance(other, CentCount):
241 return self.centcount == other.centcount and self.currency == other.currency
243 raise TypeError("In strict mode only two CentCounts can be compared")
245 return self.centcount == int(other)
247 def __ne__(self, other):
248 result = self.__eq__(other)
249 if result is NotImplemented:
253 def __lt__(self, other):
254 if isinstance(other, CentCount):
255 if self.currency == other.currency:
256 return self.centcount < other.centcount
258 raise TypeError("can not directly compare different currencies")
261 raise TypeError("In strict mode, only two CentCounts can be compated")
263 return self.centcount < int(other)
265 def __gt__(self, other):
266 if isinstance(other, CentCount):
267 if self.currency == other.currency:
268 return self.centcount > other.centcount
270 raise TypeError("can not directly compare different currencies")
273 raise TypeError("In strict mode, only two CentCounts can be compated")
275 return self.centcount > int(other)
277 def __le__(self, other):
278 return self < other or self == other
280 def __ge__(self, other):
281 return self > other or self == other
283 def __hash__(self) -> int:
284 return hash(self.__repr__)
286 CENTCOUNT_RE = re.compile(r"^([+|-]?)(\d+)(\.\d+)$")
287 CURRENCY_RE = re.compile(r"^[A-Z][A-Z][A-Z]$")
290 def _parse(cls, s: str) -> Optional[Tuple[int, str]]:
294 chunks = s.split(" ")
297 if CentCount.CENTCOUNT_RE.match(chunk) is not None:
298 centcount = int(float(chunk) * 100.0)
299 elif CentCount.CURRENCY_RE.match(chunk) is not None:
303 if centcount is not None and currency is not None:
304 return (centcount, currency)
305 elif centcount is not None:
306 return (centcount, "USD")
310 def parse(cls, s: str) -> "CentCount":
311 """Parses a string format monetary amount and returns a CentCount
315 s: the string to be parsed
317 chunks = CentCount._parse(s)
318 if chunks is not None:
319 return CentCount(chunks[0], chunks[1])
320 raise Exception(f'Unable to parse money string "{s}"')
323 if __name__ == "__main__":