Skip to content

Currency

Design: simweave.currency

Status: implemented in v0.2 (module: src/simweave/currency/). Purpose: support monetary quantities in simulations for finance practitioners, mirroring the dimensional-analysis discipline of simweave.units without falsely implying that currency is a physical dimension.

This document records the argument that drove the design and the decisions taken. For day-to-day usage see SIMWEAVE_API.md §Currency and the demo at demos/13_money_cashflow.py.

Implementation summary

The module layout is:

File Responsibility
currency/codes.py ISO 4217 registry, register_custom escape hatch.
currency/money.py Money frozen dataclass, CurrencyMismatchError.
currency/fx.py FXConverter protocol, StaticFXConverter, CallableFXConverter.
currency/format.py format_money, ASCII default + optional babel locale path.

Tests: tests/test_currency.py (construction, arithmetic, comparison, conversion, registry, formatting, edge cases).

Locale-aware formatting is gated behind the simweave[intl] extra (babel>=2.14).


Why it's worth considering

  1. There's real demand. Monte Carlo over portfolios, queueing models where each served customer produces revenue, supply-chain models with holding and stock-out costs — all of these want a first-class "money" type, both for correctness and for pretty printing.
  2. The SI machinery already gives us the mistake-prevention story. Adding USD to GBP without explicit conversion is exactly the kind of error Distance + Velocity is meant to catch. Currency is not dimensional, but the enforcement pattern transfers cleanly.
  3. The upside of getting it wrong is non-trivial. A finance simulation that silently sums mixed-currency flows will give a confident, completely meaningless answer. Typed money makes that a loud TypeError.

Why it's worth being cautious

  1. FX rates are live data. Hard-coding them ages badly; pulling them from an API couples simweave to network, keys, and rate-limits.
  2. Currencies aren't truly fungible. $1 today ≠ $1 next year; adding a time dimension (discount rates) is a second conversation that an over-eager API can pretend doesn't exist.
  3. Operators get awkward. price = £ + %VAT works; £ + $ should not. But £ * scalar and £ / Time (for a rate) should. Getting this right means more operator overloading, which tends to invite subtle bugs.
  4. Formatting carries culture. £1,234.56 vs 1.234,56 € vs ¥1235 (no decimals). Locale-aware output via babel is out of scope for a zero-dep core library; a sensible fallback with an optional dep extra is the pragmatic compromise.

Recommendation

Ship it, scoped tightly. Do the three things simweave is uniquely positioned to do well: - tag values with an immutable currency code, - refuse cross-currency arithmetic unless a converter is explicitly supplied, - format cleanly without opinions about timezone or inflation.

Leave these out of the core: - live FX data, - discount rates / NPV, - tax treatment.

Users who need those things bring them as a strategy object.


Proposed surface (simweave.currency)

from simweave.currency import Money, FXConverter, format_money

Money

@dataclass(frozen=True, slots=True)
class Money:
    amount: Decimal | float       # stored as Decimal internally
    currency: str                 # ISO 4217, e.g. "GBP", "USD", "JPY"

    # arithmetic
    def __add__(self, other: Money) -> Money                # same-currency only
    def __sub__(self, other: Money) -> Money
    def __mul__(self, scalar: int | float | Decimal) -> Money
    def __rmul__(self, scalar) -> Money
    def __truediv__(self, scalar_or_money) -> Money | float # money/money -> ratio

    def to(self, target: str, converter: FXConverter) -> Money
    def __format__(self, spec: str) -> str                  # locale-aware

Invariants: - Same-currency +/- succeed; cross-currency raise CurrencyMismatchError (subclass of TypeError for compatibility). - Money(10, "GBP") * 3 returns Money(30, "GBP") — scalar only, no integer/float on the right that means a different currency. - Money(10, "GBP") / Money(2, "GBP") returns 5.0 (dimensionless ratio); Money(10, "GBP") / Money(2, "USD") raises unless a converter is bound. - Stored as decimal.Decimal internally with a default precision appropriate to each currency (JPY → 0 decimals, most → 2, BTC/ETH → 8 if we ever include crypto).

FXConverter

Protocol — users provide the implementation.

@runtime_checkable
class FXConverter(Protocol):
    def rate(self, source: str, target: str, at: datetime | None = None) -> Decimal: ...

class StaticFXConverter:
    """Developer-supplied fixed-rate table. Never hits the network."""
    def __init__(self, rates: dict[tuple[str, str], Decimal]): ...

class CallableFXConverter:
    """Wrap any callable of signature (src, tgt, at) -> rate."""

The point: simweave never ships live rates. If the user wants real rates, they build a converter that calls their broker/API and inject it.

format_money

Pluggable formatter. Default is ASCII-safe:

format_money(Money(1234.5, "GBP"))   # "GBP 1,234.50"
format_money(Money(1234.5, "USD"))   # "USD 1,234.50"
format_money(Money(1235, "JPY"))     # "JPY 1,235"

With optional extra:

pip install simweave[intl]             # pulls in babel
format_money(Money(1234.5, "GBP"), locale="en_GB")  # "£1,234.50"
format_money(Money(1234.5, "GBP"), locale="de_DE")  # "1.234,50 £"

Why not overload the SI machinery?

Tempting, since the shape is similar. Don't.

  • Currency is not a physical dimension. Bolting it onto SIUnit muddies the meaning of "dimensional analysis."
  • SI's _KNOWN_BY_EXP cleverness (auto-retyping m/s to Velocity) makes no sense for currency — we don't have derived currency types.
  • Keeping simweave.currency as a parallel, narrower module means we can evolve it (discount curves, time-value-of-money) without breaking simweave.units.

There's one exception worth flagging: rates like "£/hour" arise naturally (billing). A later extension can compose Money with TimeUnit:

rate = Money(50, "GBP") / TimeUnit(1, "hrs")   # -> Rate(50, "GBP/h")
rate * TimeUnit(2, "hrs")                       # -> Money(100, "GBP")

This would be a Rate class in simweave.currency that stores a Money and a TimeUnit and exposes __mul__(TimeUnit) -> Money. Worth scoping but not in the first cut.


Minimal first-cut scope

If we do only one thing, do this:

  1. Money(amount, currency) frozen dataclass, Decimal-backed.
  2. Same-currency +, -, scalar *, scalar /, money/money → float.
  3. CurrencyMismatchError.
  4. format_money ASCII default.
  5. to(target, converter) with StaticFXConverter.
  6. No live-rate integrations; no NPV; no babel dep.

That's a tight ~200 lines of code + thorough tests. It solves 90% of finance-simulation use cases and leaves the hard, opinionated bits to user code.


Open questions for Stuart

  1. Do you want Decimal-everywhere semantics by default (banker's rounding, slower), or let users opt in via a module-level flag? Recommendation: Decimal default. Speed hit is negligible compared to the integrator inner loop.
  2. Do you care about negative money (debts)? Recommendation: yes, let it pass through — finance code has signed flows constantly.
  3. Currency code validation — strict ISO 4217 (~180 codes, list baked in) or permissive (any uppercase string)? Recommendation: strict list, with an escape hatch `Money.register_custom("XYZ", decimals=