Multi-Currency Exchange Rates#
Sure converts all multi-currency account values to a family's base currency using a set of pluggable exchange rate providers. The architecture has three moving parts: a provider registry that selects which API fetches rates, an importer that persists and gapfills those rates, and per-dashboard-section SQL logic that applies them at query time — with inconsistent fallback strategies across sections being a known source of bugs.
Provider Registry#
Four providers are registered under the :exchange_rates concept in Provider::Registry#available_providers:
| Provider | Key | API key required? |
|---|---|---|
| TwelveData | twelve_data | Yes (TWELVE_DATA_API_KEY) |
| Yahoo Finance | yahoo_finance | No |
| MOEX Public | moex_public | No |
| Frankfurter | frankfurter | No |
Only one provider is active at a time for exchange rates — there is no multi-provider fallback chain (unlike the securities concept). The active provider is resolved from ENV["EXCHANGE_RATE_PROVIDER"] first, then from Setting.exchange_rate_provider, with twelve_data as the default . The registry returns nil for any provider whose required API key is absent, so a misconfigured key silently disables that provider .
Frankfurter was added in PR #2640 as a free, keyless alternative to Yahoo Finance and TwelveData. It is backed by ECB daily reference rates and requires no auth. Self-hosters running their own Frankfurter instance can override the endpoint via FRANKFURTER_URL . MOEX Public (moex_public) is a keyless provider for Russian equities rates via the ISS API.
To configure via environment variable :
EXCHANGE_RATE_PROVIDER=yahoo_finance # or: twelve_data | frankfurter | moex_public
Rate Import, LOCF Gapfill, and Inverse Rates#
Rates are fetched and persisted by ExchangeRate::Importer. Key behaviors:
- LOCF gapfill: weekends and ECB holidays have no provider entry; the importer carries the last valid rate forward for any gap dates .
- Inverse rates: after persisting a fetched pair (e.g. USD→EUR), the importer immediately computes and upserts the reciprocal (EUR→USD) so reverse lookups never need a separate API call .
- 3-tier runtime fallback: at query time,
ExchangeRate::Provided#find_or_fetch_ratechecks (1) exact DB match → (2) nearest rate within 5 days → (3) live provider API call.
The full market data sync runs daily on a schedule and also fires on-demand when individual accounts sync.
How FX Is Applied Per Dashboard Section#
Different sections apply exchange rates differently, which is the root cause of the known multi-currency dashboard bug :
Balance Sheet / Net Worth (correct)#
Balance::ChartSeriesBuilder uses a dual-direction LATERAL join: it first looks backward for the nearest rate on or before the chart date, then forward for the nearest rate after. It only defaults to 1 after exhausting both directions. This makes net worth resilient to missing rates for any specific date.
Cashflow / Income Statement (known bug)#
IncomeStatement::Totals joins exchange rates with an exact-date match (er.date = ae.date) and immediately falls back to COALESCE(er.rate, 1). Any transaction on a date where no rate was stored gets treated as 1:1, producing silent conversion errors .
Investment Balances (cached at sync time)#
Holdings conversion happens inside Balance::SyncCache, which performs a single aggregation pass over account.holdings at the start of every balance calculation. Each holding's amount is converted to the account currency at sync time using the historical rate for that holding's date; if the rate is missing, it silently falls back to 1:1 .
This means: if you import new exchange rates after the last sync, investment valuations will not update until the account is re-synced. To force immediate recalculation, trigger a sync via the UI or from the Rails console:
# Re-sync all investment accounts
Account.where(accountable_type: "Investment").find_each(&:sync_later)
Key Source Files#
| File | Role |
|---|---|
app/models/provider/registry.rb | Registers exchange rate providers |
app/models/exchange_rate/provided.rb | Provider selection + runtime 3-tier fallback |
app/models/exchange_rate/importer.rb | Fetch, LOCF gapfill, inverse rate upsert |
app/models/balance/sync_cache.rb | Holdings FX conversion at sync time |
app/models/balance/chart_series_builder.rb | Dual-direction FX LATERAL join for charts |
app/models/provider/frankfurter.rb | Frankfurter provider implementation |