Using Pint for currency conversions

Currency conversion tends to be substantially more complex than physical units. The exact exchange rate between two currencies:

  • changes every minute,
  • changes depending on the place,
  • changes depending on who you are and who changes the money,
  • may be not reversible, e.g. EUR->USD may not be the same as 1/(USD->EUR),
  • different rates may apply to different amounts of money, e.g. in a BUY/SELL ledger,
  • frequently involves fees, whose calculation can be more or less sophisticated. For example, a typical credit card contract may state that the bank will charge you a fee on all purchases in foreign currency of 1 USD or 2%, whichever is higher, for all amounts less than 1000 USD, and then 1.5% for anything in excess.

You may implement currencies in two ways, both of which require you to be familiar with Contexts.

Simplified model

This model implies a few strong assumptions:

  • There are no conversion fees
  • All exchange rates are reversible
  • Any amount of money can be exchanged at the same rate
  • All exchanges can happen at the same time, between the same actors.

In this simplified scenario, you can perform any round-trip across currencies and always come back with the original money; e.g. 1 USD -> EUR -> JPY -> GBP -> USD will always give you 1 USD.

In reality, these assumptions almost never happen but can be a reasonable approximation, for example in the case of large financial institutions, which can use interbank exchange rates and have nearly-limitless liquidity and sub-second trading systems.

This can be implemented by putting all currencies on the same dimension, with a default conversion rate of NaN, and then setting the rate within contexts:

USD = [currency]
EUR = nan USD
JPY = nan USD
GBP = nan USD

@context FX
    EUR = 1.11254 USD
    GBP = 1.16956 EUR

Note how, in the example above:

  • USD is our base currency. It is arbitrary, only matters for the purpose of invoking to_base_units(), and can be changed with Different Unit Systems (and default units).
  • We did not set a value for JPY - maybe because the trader has no availability, or because the data source was for some reason missing up-to-date data. Any conversion involving JPY will return NaN.
  • We redefined GBP to be a function of EUR instead of USD. This is fine as long as there is a path between two currencies.

Full model

If any of the assumptions of the simplified model fails, one can resort to putting each currency on its own dimension, and then implement transformations:

EUR = [currency_EUR]
GBP = [currency_GBP]

@context FX
    GBP -> EUR: value * 1.11108 EUR/GBP
    EUR -> GBP: value * 0.81227 GBP/EUR
>>> q = ureg.Quantity("1 EUR")
>>> with ureg.context("FX"):
... q ="GBP").to("EUR")
>>> q
0.9024969516 EUR

More sophisticated formulas, e.g. dealing with flat fees and thresholds, can be implemented with arbitrary python code by programmatically defining a context (see Contexts).