HPInc / HP-Digital-Microfluidics

HP Digital Microfluidics Software Platform and Libraries
MIT License
2 stars 0 forks source link

Add money support to quantities #255

Closed EvanKirshenbaum closed 5 months ago

EvanKirshenbaum commented 5 months ago

It would be nice to have support for money in the quantities package. The difficulty here is that we have to deal with a floating exchange rate between currencies as well as different denominations within a given currency, and I don't want to hard-code an exchange rate.

My initial thought is to generate a Money base dimension, with its associated MoneyUnitExpr and MoneyUnit, but add a Denomination class below MoneyUnit. This would override the quantity attribute with a cached property that computes it based on a Currency and a multiplier.

The first time the quantity is referenced, it looks up the value (as Money) in the Currency class. The user can establish exchange rates something like

ukp.exchange_rate(1.2*usd)

where ukp and usd are Currencys defined in the quantities package. A Currency is a Denomination which is the base of its scheme. Care will have to be taken when making other denominations from it. For example, 10*dollars will have to be something that isn't quite a Money, because the value of dollars may not have been established yet.

Whichever currency is used first for its actual value will become the base of the system. This allows you to use several currencies without specifying an exchange rate between them. This does mean that when an exchange rate is specified, we need to account for the case in which the left-hand-side already has a value, but the right-hand side doesn't.

Migrated from internal repository. Originally created by @EvanKirshenbaum on Feb 22, 2023 at 12:09 PM PST. Closed on Feb 24, 2023 at 1:57 PM PST.
EvanKirshenbaum commented 5 months ago

This issue was referenced by the following commits before migration:

EvanKirshenbaum commented 5 months ago

One thing that would be nice for money would be the ability to have a method on Currency (and perhaps on Money, using a default or provided Currency) that formats the value with a prefixed symbol to a set number of decimal places (e.g., $10 or $2.00) or even a weird decomposition like £10/7/6¾.

It might be nice to get that just by printing with some sort of "alternate" indication in the format spec, but that's probably too much to hope for.

Migrated from internal repository. Originally created by @EvanKirshenbaum on Feb 22, 2023 at 2:57 PM PST.
EvanKirshenbaum commented 5 months ago

Money support appears to work. The generated Money class, in addition to normal Quantity stuff, also holds an optional Currency, which is a Denomination (that also holds a Currency), which is a MoneyUnit.

The logic is that if a Money holds a Currency, its magnitude is only meaningful relative to others also in that Currency. If it doesn't (i.e., it holds None), then its relative to whatever the base currency is. Whenever it's meaningful (e.g., when multiplying or dividing by a float or Scalar), the optional currency is passed on (by Money.same_dim()).

When it isn't okay to do so (e.g., when the result wouldn't be a Money), it calls Money._force_magnitude() to change its magnitude to units in the base currency and remove its Currency. The magnitude is scaled by the Currency's exchange_rate, if one exists. If the Currency has no exchange_rate, but no other exchange rates have been established, this Currency becomes the base currency, and its exchange rate becomes 1. Otherwise, an exception is raised.

For things like addition/subtraction, checking division with Money, or ordering operators, Money overrides _ensure_dim_match() to force both sides unless they are both associated with the same Currency. (If that works, both magnitudes will be in the base currency, so the operation can proceed. If it doesn't, an exception will have been thrown.)

To establish the exchange rate between currencies, you can say, e.g.,

UKP.set_exchange_rate(1.2*USD)

This is bidirectional. If UKP had an exchange rate but USD did not, this would establish the exchange rate for USD relative to it.

The basics of setting up a currency can be exemplified by this, for euros:

eur = EUR = Currency("EUR")
euro = euros = EUR.new_denomination(EUR, "euros", singular="euro", prefix_symbol="€", 
                                    decimal_places=2)
cent = cents = pence = EUR.new_denomination(EUR/100, "cents", singular="cent",
                                            postfix_symbol="c")
eur.currency_formatter = euros

The symbols and decimal places are used by Money.as_currency():

price = 12.40*euros
print(price.as_currency(EUR))
print(price.as_currency(cents))
print(price.as_currency())

The first form will format the amount as euros (unless eur.currency_formatter has been changed): €12.40. The second form will format the amount as cents: 1240c. The third form will format it using Money.default_currency_formatter, if one has been established, otherwise it will use the one for the base currency.

Currency formatters will generally be a Currency (which indirects through its currency_formatter) or a Denomination. It can also be a Callable[[Money], str] or an object subclassing Money.CurrencyFormatter:

    class CurrencyFormatter(ABC):
        @abstractmethod
        def format_currency(self, money: Money, *,
                            force_prefix: bool = False,
                            force_suffix: bool = False,
                            decimal_places: Optional[int] = None) -> str: ...

force_prefix/force_suffix force the symbol (if there is one, otherwise the abbreviation) to be before or after the number rather than whatever is the default (e.g., as specified by the Denomination).

By establishing a formatter that's a Callable, you can do things like the "£/s/d" notation for pre-decimal British currency. (This is provided as quantities.currency.pre_decimal_GBP.slashed.

I added a quantities.currency package and added modules for usd (US dollars), gbp (British pounds), pre_decimal_GBP, eur (euros), jpy (Japanese yen), mxn (Mexican pesos), and krw (South Korean won).

Migrated from internal repository. Originally created by @EvanKirshenbaum on Feb 24, 2023 at 1:57 PM PST.