Skip to content

Currency

Money, FX conversion, and locale-aware formatting.

Money

import simweave as sw

price = sw.Money(199, "GBP")
tax   = sw.Money(40, "GBP")
total = price + tax            # Money(239, "GBP")

Money enforces currency consistency. Adding Money(1, "GBP") and Money(1, "USD") raises CurrencyMismatchError.

FX conversion

fx = sw.StaticFXConverter({("GBP", "USD"): 1.27})
usd = fx.convert(sw.Money(100, "GBP"), to="USD")     # Money(127, "USD")

For dynamic rates, use CallableFXConverter(fn) where fn(base, quote) returns the rate.

Custom currencies

sw.register_custom("ZED", decimals=2)
sw.is_valid_currency("ZED")        # True
sw.unregister_custom("ZED")

Locale-aware formatting (with [intl])

sw.format_money(sw.Money(1234.5, "EUR"), locale="de_DE")
# '1.234,50 €'

API

simweave.currency: Decimal-backed Money, ISO 4217 codes, FX conversion.

Public surface
  • :class:Money — frozen, currency-tagged Decimal value with strict same-currency arithmetic and explicit FX conversion.
  • :class:CurrencyMismatchError — raised on cross-currency ops.
  • :class:FXConverter — Protocol for rate lookup.
  • :class:StaticFXConverter — in-memory rate table with automatic inverse lookup. Use for deterministic tests and fixed-rate models.
  • :class:CallableFXConverter — adapts an arbitrary callable to the :class:FXConverter protocol.
  • :func:format_money — ASCII default formatter, optional locale-aware path via simweave[intl] (babel).
  • :func:register_custom, :func:unregister_custom — escape hatch for crypto, in-game, or test-fixture currency codes.
  • :func:is_valid_currency, :func:get_decimals, :func:list_codes — registry introspection helpers.
Design contract in short
  • Decimal everywhere; float inputs routed via str() to avoid binary-float drift.
  • Banker's rounding (ROUND_HALF_EVEN) at display / quantisation time; full precision preserved during computation.
  • Cross-currency arithmetic refuses — use money.to(target, fx).
  • Scalar * Money allowed; Money * Money refused.
  • Money / Money (same currency) returns float; Money / scalar returns Money.
  • Negatives are legal (debts, refunds, signed cashflows).

CallableFXConverter

CallableFXConverter(fn: Callable[[str, str, 'datetime | None'], Decimal | float | int | str])

Wrap an arbitrary callable as an :class:FXConverter.

The wrapped callable must accept (source, target, at) and return something coercible to Decimal (via Decimal(str(...))). Useful when you already have a rate-lookup function.

Example

def my_lookup(src, tgt, at=None): ... return 1.27 if (src, tgt) == ("GBP", "USD") else 1.0 fx = CallableFXConverter(my_lookup) fx.rate("GBP", "USD") Decimal('1.27')

Source code in src/simweave/currency/fx.py
def __init__(
    self,
    fn: Callable[[str, str, "datetime | None"], Decimal | float | int | str],
) -> None:
    self._fn = fn

FXConverter

Bases: Protocol

Protocol for anything that can quote an FX rate.

rate(source, target, at=None) returns the number of target units per one unit of source. A rate of 1.27 for ("GBP", "USD") means one pound buys 1.27 dollars.

StaticFXConverter

StaticFXConverter(rates: Mapping[tuple[str, str], Decimal | float | int | str])

Fixed-rate converter backed by an in-memory table.

Automatically handles inverses: if ("GBP", "USD") -> 1.27 is registered, rate("USD", "GBP") returns 1 / 1.27 without needing an explicit entry.

Parameters:

Name Type Description Default
rates Mapping[tuple[str, str], Decimal | float | int | str]

Mapping of (source, target) tuples to the rate. Rates may be Decimal, float, int, or a numeric string; all are coerced to Decimal via Decimal(str(value)).

required
Source code in src/simweave/currency/fx.py
def __init__(
    self,
    rates: Mapping[tuple[str, str], Decimal | float | int | str],
) -> None:
    self._rates: dict[tuple[str, str], Decimal] = {}
    for (src, tgt), r in rates.items():
        key = (src.upper(), tgt.upper())
        if key[0] == key[1]:
            # Identity rate is always 1 — silently ignore explicit entry.
            continue
        self._rates[key] = Decimal(str(r))

CurrencyMismatchError

Bases: TypeError

Raised when an operation requires two Money values to share a currency but they don't (e.g. GBP + USD).

Subclasses :class:TypeError so that except TypeError catches it in legacy code, while new code can except CurrencyMismatchError for precise handling.

Money dataclass

Money(amount: Decimal, currency: str)

A currency-tagged monetary amount.

Parameters:

Name Type Description Default
amount Decimal

The numeric amount. Accepts int, float, Decimal, or str. Float inputs are routed via :class:str to avoid binary-float drift.

required
currency str

ISO 4217 three-letter code (case-insensitive; normalised to uppercase). Must be either a known ISO 4217 code or a code previously registered via :func:simweave.currency.codes.register_custom.

required

Examples:

>>> from simweave.currency import Money
>>> Money(100, "GBP") + Money("50.25", "GBP")
Money(Decimal('150.25'), 'GBP')
>>> Money(100, "GBP") * 3
Money(Decimal('300'), 'GBP')

decimals property

decimals: int

Canonical minor-unit decimal places for this currency.

round_to_currency

round_to_currency(rounding: str = ROUND_HALF_EVEN) -> 'Money'

Return a new Money rounded to the currency's minor-unit precision. Defaults to banker's rounding (ROUND_HALF_EVEN).

Source code in src/simweave/currency/money.py
def round_to_currency(self, rounding: str = ROUND_HALF_EVEN) -> "Money":
    """Return a new ``Money`` rounded to the currency's minor-unit
    precision. Defaults to banker's rounding (``ROUND_HALF_EVEN``)."""
    quant = Decimal(10) ** -self.decimals
    return Money(self.amount.quantize(quant, rounding=rounding), self.currency)

to

to(target: str, converter: 'FXConverter', at: 'datetime | None' = None) -> 'Money'

Convert to target currency using converter.rate(...).

Short-circuits to self (no-op) when the target currency already matches. Preserves full Decimal precision; call :meth:round_to_currency afterwards if you want to quantise to the target's minor-unit precision.

Parameters:

Name Type Description Default
target str

ISO 4217 code to convert to.

required
converter 'FXConverter'

Any object satisfying :class:simweave.currency.FXConverter.

required
at 'datetime | None'

Optional datetime passed through to the converter (for historical-rate lookups). Converters may ignore it.

None
Source code in src/simweave/currency/money.py
def to(
    self,
    target: str,
    converter: "FXConverter",
    at: "datetime | None" = None,
) -> "Money":
    """Convert to ``target`` currency using ``converter.rate(...)``.

    Short-circuits to ``self`` (no-op) when the target currency
    already matches. Preserves full Decimal precision; call
    :meth:`round_to_currency` afterwards if you want to quantise
    to the target's minor-unit precision.

    Parameters
    ----------
    target:
        ISO 4217 code to convert to.
    converter:
        Any object satisfying :class:`simweave.currency.FXConverter`.
    at:
        Optional datetime passed through to the converter (for
        historical-rate lookups). Converters may ignore it.
    """
    target_code = _normalise_code(target)
    if target_code == self.currency:
        return self
    rate = converter.rate(self.currency, target_code, at=at)
    rate_d = _coerce_amount(rate)
    return Money(self.amount * rate_d, target_code)

get_decimals

get_decimals(code: str) -> int

Return the canonical decimal places for code.

Raises:

Type Description
KeyError

If the code is not a known ISO 4217 or custom-registered code.

Source code in src/simweave/currency/codes.py
def get_decimals(code: str) -> int:
    """Return the canonical decimal places for ``code``.

    Raises
    ------
    KeyError
        If the code is not a known ISO 4217 or custom-registered code.
    """
    up = code.upper()
    if up in _CUSTOM:
        return _CUSTOM[up]
    if up in _ISO_4217:
        return _ISO_4217[up]
    raise KeyError(
        f"Unknown currency code: {code!r}. "
        f"Use simweave.currency.register_custom({code!r}, decimals=...) if intentional."
    )

is_valid_currency

is_valid_currency(code: str) -> bool

Return True if code is a known ISO 4217 or custom-registered code.

Source code in src/simweave/currency/codes.py
def is_valid_currency(code: str) -> bool:
    """Return True if ``code`` is a known ISO 4217 or custom-registered code."""
    if not isinstance(code, str):
        return False
    up = code.upper()
    return up in _ISO_4217 or up in _CUSTOM

list_codes

list_codes(*, include_custom: bool = True) -> tuple[str, ...]

Return a sorted tuple of all currently known codes.

Source code in src/simweave/currency/codes.py
def list_codes(*, include_custom: bool = True) -> tuple[str, ...]:
    """Return a sorted tuple of all currently known codes."""
    codes = set(_ISO_4217)
    if include_custom:
        codes.update(_CUSTOM)
    return tuple(sorted(codes))

register_custom

register_custom(code: str, decimals: int) -> None

Register a non-ISO currency code (crypto, in-game currency, test fixture).

Parameters:

Name Type Description Default
code str

Uppercase identifier. Overwrites a prior custom registration but refuses to shadow a real ISO 4217 code.

required
decimals int

Non-negative number of minor-unit decimal places.

required

Examples:

>>> from simweave.currency import register_custom, Money
>>> register_custom("BTC", decimals=8)
>>> Money("0.12345678", "BTC").amount
Decimal('0.12345678')
Source code in src/simweave/currency/codes.py
def register_custom(code: str, decimals: int) -> None:
    """Register a non-ISO currency code (crypto, in-game currency, test fixture).

    Parameters
    ----------
    code:
        Uppercase identifier. Overwrites a prior custom registration but
        refuses to shadow a real ISO 4217 code.
    decimals:
        Non-negative number of minor-unit decimal places.

    Examples
    --------
    >>> from simweave.currency import register_custom, Money
    >>> register_custom("BTC", decimals=8)
    >>> Money("0.12345678", "BTC").amount
    Decimal('0.12345678')
    """
    if not isinstance(code, str) or not code.strip():
        raise ValueError("Currency code must be a non-empty string.")
    up = code.upper()
    if up in _ISO_4217:
        raise ValueError(
            f"{up!r} is an ISO 4217 code; refuse to shadow with register_custom."
        )
    if not isinstance(decimals, int) or decimals < 0:
        raise ValueError("decimals must be a non-negative integer.")
    _CUSTOM[up] = decimals

unregister_custom

unregister_custom(code: str) -> None

Remove a previously registered custom code. No-op if absent.

Source code in src/simweave/currency/codes.py
def unregister_custom(code: str) -> None:
    """Remove a previously registered custom code. No-op if absent."""
    _CUSTOM.pop(code.upper(), None)

format_money

format_money(money: 'Money', spec: str = '', locale: str | None = None) -> str

Format a :class:Money for display.

Parameters:

Name Type Description Default
money 'Money'

The value to render.

required
spec str

Format specifier. See module docstring.

''
locale str | None

Optional locale identifier (e.g. "en_GB"). Overrides spec when provided. Requires simweave[intl] (babel).

None

Returns:

Type Description
str

The formatted string.

Source code in src/simweave/currency/format.py
def format_money(money: "Money", spec: str = "", locale: str | None = None) -> str:
    """Format a :class:`Money` for display.

    Parameters
    ----------
    money:
        The value to render.
    spec:
        Format specifier. See module docstring.
    locale:
        Optional locale identifier (e.g. ``"en_GB"``). Overrides ``spec``
        when provided. Requires ``simweave[intl]`` (babel).

    Returns
    -------
    str
        The formatted string.
    """
    if locale is not None:
        return _babel_format(money, locale)
    if not spec:
        return _ascii_format(money, round_first=True)
    if spec == "r":
        return _ascii_format(money, round_first=True)
    if spec == "raw":
        # Full-precision decimal text, no currency tag, no grouping.
        return str(money.amount)
    if "_" in spec:
        return _babel_format(money, spec)
    raise ValueError(
        f"Unknown Money format spec: {spec!r}. "
        f"Use '' / 'r' / 'raw' or a locale like 'en_GB'."
    )