Skip to main content
ntro.capabilities.fx converts a monetary amount from one currency to another at the exchange rate for a given value date. Use it from an activity when a runbook handles multi-currency figures — e.g. normalising invoices to a reporting currency before export. It complements CurrencyCode, which only validates ISO-4217 codes; fx is what actually converts.

Convert

from datetime import date
from decimal import Decimal
from ntro.capabilities import fx

res = await fx.convert(Decimal("100"), "EUR", "GBP", on=date(2026, 3, 14))

res.amount       # Decimal("84.50") — converted, rounded to 2dp
res.rate         # Decimal("0.845") — units of GBP per 1 EUR (full precision)
res.rate_date    # date(2026, 3, 14)
res.source       # "frankfurter.dev"
res.original_amount  # Decimal("100")
  • on is the value date. Pass it for a historical rate (typically the document/transaction date); omit it for the latest available rate.
  • Same-currency conversions are a local no-op (rate=1, source="identity") — no network call.
  • The converted amount is quantised to 2 decimal places; the raw rate is returned at full precision so callers can re-round (minor-unit-aware rounding, e.g. JPY = 0dp, is a future refinement).
  • Raises fx.FxError on an unsupported currency pair or a feed failure.
Just need the rate? fx.rate(from_ccy, to_ccy, on=...) returns an FxRate (rate, rate_date, source).

Rates + caching

Rates come from a free, key-less dated feed (frankfurter.dev, backed by the ECB reference rates), keyed by the value date. Rates for a past date are immutable, so resolution is write-once-read-many across two layers — the external feed is hit at most once per (base, quote, date):
LayerWhereLifetime
L1in-process dict in the capabilitythe worker process
L2system.fx_rates in the tenant data planedurable; shared across replicas
L2 is opt-in: pass tenant_slug to enable the persisted cache.
# L1 + L2 (durable, write-through) — typical in a worker activity:
res = await fx.convert(amount, ccy, "GBP", on=expense_date, tenant_slug=ctx.tenant_slug)

# L1 only (e.g. a managed-agent sandbox with no data plane):
res = await fx.convert(amount, ccy, "GBP", on=expense_date)
The cache is not the audit record. Stamp the applied rate on the consuming row — e.g. ExpenseRow.fx_rate / fx_rate_date / source — so the figure stays reproducible even if the cache is cleared. The two-layer cache is purely a performance + rate-limit concern.

Return shapes

class ConversionResult(BaseModel):
    amount: Decimal          # converted, rounded to minor units
    original_amount: Decimal
    from_ccy: str
    to_ccy: str
    rate: Decimal            # units of to_ccy per 1 from_ccy
    rate_date: date
    source: str

class FxRate(BaseModel):
    base: str
    quote: str
    rate: Decimal
    rate_date: date
    source: str

Typing

CurrencyCode — the ISO-4217 validator the amounts carry.

expenses

Where converted amounts land + the audit-stamp pattern.

Data capability

The tenant data plane the L2 cache (system.fx_rates) is written through.