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¶
- 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.
- The SI machinery already gives us the mistake-prevention story.
Adding USD to GBP without explicit conversion is exactly the kind
of error
Distance + Velocityis meant to catch. Currency is not dimensional, but the enforcement pattern transfers cleanly. - 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¶
- FX rates are live data. Hard-coding them ages badly; pulling them from an API couples simweave to network, keys, and rate-limits.
- 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.
- Operators get awkward.
price = £ + %VATworks;£ + $should not. But£ * scalarand£ / Time(for a rate) should. Getting this right means more operator overloading, which tends to invite subtle bugs. - Formatting carries culture.
£1,234.56vs1.234,56 €vs¥1235(no decimals). Locale-aware output viababelis 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)¶
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:
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
SIUnitmuddies the meaning of "dimensional analysis." - SI's
_KNOWN_BY_EXPcleverness (auto-retypingm/stoVelocity) makes no sense for currency — we don't have derived currency types. - Keeping
simweave.currencyas a parallel, narrower module means we can evolve it (discount curves, time-value-of-money) without breakingsimweave.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:
Money(amount, currency)frozen dataclass, Decimal-backed.- Same-currency
+,-, scalar*, scalar/, money/money → float. CurrencyMismatchError.format_moneyASCII default.to(target, converter)withStaticFXConverter.- 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¶
- 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.
- Do you care about negative money (debts)? Recommendation: yes, let it pass through — finance code has signed flows constantly.
- 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=