Why a money type should refuse the division operator
A rounding mode is an input to a calculation, and Python's binary operators have nowhere to put it. a / b calls type(a).__truediv__(a, b): two operands, one result, no keyword arguments. So a money type that lets division succeed has already picked a rounding mode for every call site in the codebase, and recorded it nowhere the reader of that line can see it. The alternative is narrower operators plus a mandatory argument, and that trade is what this post is about.
The operator has two slots and the operation needs three#
Take a 0.15% fee on a BTC balance. The result almost never lands on a satoshi boundary, so something has to decide what happens to the fraction. There are eight reasonable answers (decimal ships exactly eight rounding modes, from ROUND_05UP to ROUND_UP), and the choice is not a detail. Rounding a fee down is a gift to the payer, rounding it up is a gift to the house, and over enough rows that difference becomes a reconciliation ticket.
Python does offer one place to keep the decision: the arithmetic context. decimal.localcontext() carries a precision and a rounding mode, and the operators read it. That works, and I think it is the wrong mechanism for money, because it makes the mode ambient. The line fee = amount * Decimal("0.0015") looks identical whether the enclosing context rounds half-even or up, and identical whether some caller three frames above changed it. I have spent more time than I would like chasing reconciliation drift in payment systems, and the pattern repeats: the bug sits not in the branch that compares two numbers but in a half-unit decided somewhere nobody wrote down.
So cryptomoney (https://github.com/polycratia/cryptomoney) takes the other route. Operators are allowed to do only the arithmetic that cannot leave the asset's grid. Everything else is a method, and the method's rounding argument is keyword-only with no default.
The grid#
An Asset is a symbol and the number of decimal places it is divisible into (BTC has 8, ETH has 18), and a Money is bound to exactly one of them. An amount is therefore an integer multiple of the asset's smallest unit, and the useful property here is closure: add or subtract two multiples of 10^-8 and you get a multiple of 10^-8. Multiply by a whole number and it still is. No decision is needed, because no result can fall between two base units.
from decimal import Decimal
from cryptomoney import BTC, ETH, Money, from_satoshi
balance = Money(Decimal("0.5"), BTC)
fee = Money("0.000125", BTC)
balance - fee # 0.49987500 BTC
3 * fee # 0.00037500 BTC
from_satoshi(12_500_000) # 0.12500000 BTCThose are plain operators because they are exact. __add__ and __sub__ return NotImplemented when the other operand is not a Money, so Python produces its ordinary unsupported-operand message. When both are Money but the assets differ, _require_same_asset raises CurrencyMismatch. The four ordering operators run through the same gate, which matters more than the sum does, because balance >= amount is the line that releases a payout:
Money("1", BTC) + Money("1", ETH) # CurrencyMismatch
Money("1", BTC) >= Money("1", ETH) # CurrencyMismatchCurrencyMismatch subclasses TypeError, because that is what it is: the operands have no common type, whatever the annotations claim.
Multiplication is two operations sharing one symbol#
* is where the line has to be drawn carefully, because fee * 3 and fee * Decimal("0.0015") look alike and behave nothing alike. The first is three fees, exactly. The second is a rate, and it falls between base units for almost every input you give it.
So __mul__ accepts an int and nothing else. A float is refused by _refuse_float. A Decimal or a str raises a TypeError that names the replacement instead of guessing, and __rmul__ delegates so the natural reading order still works:
from decimal import ROUND_HALF_UP, Decimal
fee * 3 # 0.00037500 BTC
3 * fee # the same, via __rmul__
fee * 0.0015 # TypeError: float is refused
fee * Decimal("0.0015") # TypeError: use .multiply(factor, rounding=...)
Money("1", BTC).multiply("0.015", rounding=ROUND_HALF_UP) # 0.01500000 BTCAccepting a str factor in multiply is deliberate. Rates arrive from config files and admin forms as text, so _operand parses them rather than making every caller round-trip through Decimal, refusing floats, bools, unparseable text and non-finite values on the way through.
Division is refused outright, and the error names the way out#
__truediv__ does not return NotImplemented. It raises TypeError unconditionally. The distinction is for whoever reads the traceback: NotImplemented produces "unsupported operand type(s) for /", which reads like an oversight somebody will helpfully fix by adding the method. A raise can say what to use instead, and the message names divide, split and allocate, because a remainder has three different questions sitting behind it.
from decimal import ROUND_DOWN, ROUND_UP
Money("0.5", BTC) / 3 # TypeError: / is refused
Money("1", BTC).divide(3, rounding=ROUND_DOWN) # 33_333_333 satoshi
Money("1", BTC).divide(3, rounding=ROUND_UP) # 33_333_334 satoshi
Money.from_base_units(-10, BTC).split(3) # -4, -3, -3 satoshi
Money("0.00000010", BTC).allocate([3, 1, 1]) # 6, 2, 2 satoshidivide needs a mode because a unit genuinely has to end up outside the result. split and allocate do not take one, and that asymmetry is the whole design. They return shares that sum back to the original, handing the remainder to the earliest shares, so nothing is lost and there is nothing to decide. split(3) on -10 satoshi gives you -4, -3, -3, still -10 in total, where integer floor division gives -4, -4, -4 and pays out twelve.
Rounding is mandatory exactly where a unit can disappear and absent exactly where it cannot. That is a rule a reviewer can apply to a diff without reading the implementation.
The argument is validated before any arithmetic happens#
A mandatory argument that accepts anything is a worse lie than a default. _check_rounding runs first, before the operand is even examined: a non-str is a TypeError, and a string outside _ROUNDING_MODES is a ValueError listing the eight accepted modes. A near-miss like a misspelled constant fails loudly rather than falling back to something plausible.
Then there is one rounding point, and only one. The product or quotient is computed inside a localcontext whose precision _working_precision sets to the longer operand's digit count (counting a positive exponent) plus the asset's decimals plus ten guard digits, with the context's rounding set to the mode you passed. _quantize then rounds the result once, to the asset's places, with that same mode. A zero divisor is a ZeroDivisionError, not a quantized nothing.
The guard digits are what stops the intermediate from being the thing that rounds. An 18-decimal asset near the top of its range still divides the way arithmetic says it should:
money = Money.from_base_units(10**38, ETH)
money.divide(3, rounding=ROUND_DOWN).to_base_units() == 10**38 // 3 # TrueParsing follows the same rule instead of inventing its own. parse_amount refuses text carrying more decimals than the asset has, with a ParseError, unless you pass a rounding mode to accept the loss. Reading "0.000000001 BTC" off an API is a lossy operation too, and it gets the same treatment as a quotient.
What I would change#
The cost is real. A fee calculation runs three lines where it used to be one, and your first week with the type feels like fighting it. At the operator level I do not see a way around that, but I would reconsider where the mode comes from. Writing rounding=ROUND_DOWN at forty call sites invites copy-paste, and copy-paste is how one of them ends up rounding the wrong way for its direction of money flow. What I would want is for a service to decide its modes once at its own boundary (fees one way, payouts the other) and pass them inward explicitly, so the per-call argument stays in the signature and stays visible in the diff, while the policy gets reviewed in one file. What I would not do is put that in a module-level default, because that is the ambient context again wearing a nicer name.
The other honest cost is that str factors make it easy to park a rate in a config file and never type-check it. The parse is strict, so a bad value fails at the first calculation rather than silently, but it fails at runtime, not at startup, and that is a boundary I would tighten in the service rather than in the money type.
Close#
An operator is a fixed-arity function with no room for policy. Money arithmetic needs policy for exactly those operations that can fall between two base units. Keeping the two sets disjoint, operators for the closed arithmetic and methods with a mandatory mode for everything else, costs you fluency and buys the property that actually matters: no balance was ever shortened by a decision nobody made.