Prices or fiat rates look wrong
The three pricing jobs, how to read the fiat provider merge log in the cron console, the staleness and disagreement rules, and why a stored fiat rate is the inverse of what you expect.
Customers notice a bad rate long before an operator does, and the platform gives you no alert for one: pricing runs on a schedule, writes quietly, and a wrong number looks exactly like a right one on every screen it appears on.
Almost all of the diagnosis is in one place — the live log for
fetchFiatCurrencyPrices in the scheduler console. That job reports each
provider by name, how many rates it returned, how old its data is, which
currencies its sources disagree about, and which of your enabled currencies it
could not price at all.
Three jobs, three different things
| Job | Cadence | Writes |
|---|---|---|
fetchFiatCurrencyPrices |
30 minutes | currency.price for every enabled fiat currency, merged from several providers |
processCurrenciesPrices |
2 minutes | exchangeCurrency.price for every crypto with an enabled X/USDT market, from the exchange provider |
cacheExchangeCurrencies |
hourly | Warms the Redis exchangeCurrencies blob, 120-second TTL |
They share nothing. A wrong fiat rate and a wrong crypto price have separate causes, separate logs and separate fixes — start by deciding which one you are looking at.
The third is a cache warmer, not a pricing job. Its TTL is deliberately tied to the crypto price cadence so a cached blob can never be more than one tick behind the row; the endpoints fall back to the database on a miss. This job stopping does not break the currency list.
Nothing clears a stale price. If the scheduler is dead, the last good value stays on screen indefinitely and every screen keeps rendering it as current.
So the first check is always System → System Monitoring → Scheduled Tasks
(/admin/system/cron, gated on access.cron): is the heartbeat alive, and when
did fetchFiatCurrencyPrices last run? A rate that has not moved in a day and a
scheduler that has not ticked in a day are the same incident. See
the scheduler console.
Reading the fiat provider log
Open /admin/system/cron, filter the live log to fetchFiatCurrencyPrices,
and either wait for the half-hourly tick or use Run now if this process is
allowed to trigger it.
The run opens by naming the pool it will query:
Querying 4 provider(s) in priority order: openexchangerates > exchangerate-api > currency-api > open-er-api (base USD)Every provider is queried concurrently, and one failing does not stop the others — partial coverage beats no update. Each answers with its own line:
OpenExchangeRates: 168 rates, published 42m ago
currency-api (keyless): 158 rates (2 rejected as implausible), published 1d 4h ago — STALE
Frankfurter / ECB (keyless) failed: Too Many Requests: Rate limit exceeded.| Part of the line | Means |
|---|---|
| N rates | How many currency codes that provider returned |
| N rejected as implausible | Values outside the sane band (below 1e-9 or above 1e12) were dropped before the merge. A handful is normal; a large count means a malformed payload |
| published X ago | The publication stamp the provider itself put in the payload, not when we fetched it |
| — STALE | That age is past this provider's own refresh cadence |
Every provider is queried with base=USD, and the request times out after 30
seconds.
The staleness rule
A feed that answers 200 OK with month-old numbers is indistinguishable from a
healthy one at the merge layer — it just quietly wins or loses against the others.
Reading the publication stamp is what turns "these two sources disagree" into
"this source stopped updating four days ago".
Each provider carries its own staleAfterHours, because their cadences differ by
an order of magnitude:
| Provider (id) | Key required | Stale after |
|---|---|---|
OpenExchangeRates (openexchangerates) |
APP_OPENEXCHANGERATES_APP_ID |
6h — the free plan refreshes hourly |
ExchangeRate-API keyed (exchangerate-api) |
APP_EXCHANGERATE_API_KEY |
30h — publishes daily |
open.er-api keyless (open-er-api) |
none | 30h |
currency-api keyless (currency-api) |
none | 30h |
Frankfurter / ECB (frankfurter) |
none | 96h — no publication at weekends or on TARGET holidays |
Past its own limit the console line gains — STALE, and the backend log
(pm2 logs cron) carries the full sentence with the reason and the fix:
Fiat rate provider currency-api answered with data published 1d 4h ago — past its
30h refresh cadence. Its quotes are being merged as if current; move it down
APP_FIAT_RATES_PROVIDERS if it stays behind.Note what it does not do: a stale provider is still merged. Dropping it would be worse — a currency only that provider covers would fall out of the pool entirely and keep yesterday's price with no explanation at all.
open-er-api and exchangerate-api share one upstream feed and are marked as
one family, so the two of them agreeing is not independent corroboration.
Only cross-family agreement counts.
Fixing a bad feed means reordering it, not switching pricing off
APP_FIAT_RATES_PROVIDERS is a comma-separated, ordered list of provider ids.
It is the priority order: the first provider in the list that carries a currency
supplies that currency's rate.
APP_FIAT_RATES_PROVIDERS=exchangerate-api,openexchangerates,frankfurter,currency-apiUnset, the default order is openexchangerates, exchangerate-api,
currency-api, open-er-api, frankfurter. The older singular
APP_FIAT_RATES_PROVIDER still works and pins that provider to the front
rather than disabling the rest — the platform used to query exactly one provider,
and a missing key or an outage silently froze every fiat rate at its last value.
Providers are skipped, with the reason logged, when the id is unknown or when the key the provider needs is absent:
Skipping provider openexchangerates: APP_OPENEXCHANGERATES_APP_ID not configuredIf every entry is skipped the job fails outright with
No usable fiat rate providers. Set APP_FIAT_RATES_PROVIDERS to one or more of: …
and rethrows, so the scheduler records a failed run rather than reporting green
while rates go stale.
The merge strategy
APP_FIAT_RATES_MERGE selects how a currency present in several sources is
resolved. Default is consensus; set it to priority to always take the
earliest-listed provider that has the currency.
Consensus exists because the highest-priority provider is not always right. It overrides the priority pick only when all of these hold:
- Two or more other independent families have the currency.
- They agree with each other closely (within 5%).
- The priority value sits outside their band by more than 1%.
- They agree with each other by a clear margin over their distance to the priority value — a three-way split proves nothing and falls back to priority.
- The gap is larger than publication lag explains.
That last test is the one that keeps it honest. Four of the five sources publish once a day; eighteen hours after their daily fix the whole pool "disagrees" with an hourly feed on every floating currency at once. Without the age test, a majority of stale feeds would outvote one fresh source and price every major currency at yesterday's close.
Cross-source disagreement, and the loud/quiet split
Every provider returns roughly 160 currencies; a typical install enables a handful. Reporting a disagreement over a code that is then thrown away is what buries the real ones, so the log separates them:
| Class | How it is reported |
|---|---|
| Explained by publication lag | One summary line naming the codes — "normal cadence drift, not reported individually" |
| Substantive, on a currency this install does not price | One summary line: "…so no price is written for them — enable one and its disagreement is reported in full" |
| Substantive, on a currency this install prices | One warning line each, in the log and in the console stream |
A priced disagreement reads like this:
SCR: sources disagree by 6.1% (openexchangerates=13.44 (2h old), exchangerate-api=14.25 (20h old, STALE), currency-api=14.22 (20h old)) — using exchangerate-api (outvoted openexchangerates)Each source's publication age is attached deliberately: "these two disagree" is unactionable, "the one we picked is four days old" is not.
The same disagreement is written to the backend log in a longer form that adds the
spread, the number of independent source families, and a hint chosen from the
size and kind of the gap. That hint is the actionable half, so read
pm2 logs cron as well as the console:
- Spread over 100% — "the sources disagree about which UNIT the code names".
Codes reused after a redenomination do this. Declare the unit with
APP_FIAT_RATES_UNITS=CODE=<units per USD>, orCODE=retiredif it no longer trades. - A declared multi-rate currency — several official or parallel rates coexist
and each source tracks a different one. Nothing is broken; pin the window you
mean by reordering
APP_FIAT_RATES_PROVIDERS, or disable the currency. - Anything else — "too large to be publication lag, so one source is genuinely off the market". Move that provider down the order.
Codes the merge refuses to vote on
Some codes cannot be resolved by voting because the sources are not answering the same question. Those carry a policy, and the log states it:
| Policy | Behaviour |
|---|---|
retired |
The ISO code no longer names a traded currency. Left unpriced, with a note, rather than resolved to whichever ghost value wins |
redenominated |
The code names two units at different scales. Quotes on the wrong scale are discarded before the vote |
multi-rate |
Several concurrent official rates. The pick stays on priority order and the log says why |
ZWL, SYP, CUP and SSP ship with a policy. APP_FIAT_RATES_UNITS is a
comma-separated list of CODE=value — where value is retired, multi-rate, a
number declaring units-per-USD, or auto/none to remove a shipped policy — so
you can correct a code without a code change. These facts change by government
decree and on no schedule.
The two summary lines to read first
Every run ends with a summary. This is the line to look at before anything else:
Merged 168 rates from 2/5 provider(s) using "consensus" strategy; 1 enabled
currencies with unexplained cross-source disagreement (plus 12 explained by
publication lag); STALE feeds: currency-api (1d 4h)An X well below Y means most of your feeds are failing — and a price is
still being written, so nothing else on the platform will tell you. Two out of
five is a configuration problem to fix today, not an incident to wait out.
The other line names your gaps. In the console stream it reads:
3 enabled currency(ies) had no usable rate: NGN, SSP, ZWLand in pm2 logs cron it spells out the consequence — "They keep their previous
price."
That is the one that catches a currency sitting on a frozen rate indefinitely while the job reports a clean run every 30 minutes. Anything named there is either unpriced by policy, absent from every provider that answered, or was rejected as implausible.
Fiat prices are stored upside down
Every provider is queried with base=USD, and the stored column keeps that
orientation. NGN = 1365 means 1 USD = 1365 NGN, not "one naira is worth
1365".
Read the whole table and it is unmistakable: the currencies stronger than the dollar are the ones below 1 — KWD 0.31, BHD 0.377 — and the weak ones are enormous — IRR 1,375,250.
getFiatPriceInUSD inverts it (1 / stored) for every caller that wants "what is
one unit worth in dollars". Getting this backwards is not cosmetic: returning the
stored figure as-is made every from / to conversion come out as the reciprocal
of the true rate, which credited 1365× the value transferred in one direction and
1/1365 in the other.
So before you conclude a rate is wrong, check which way round you are reading it.
An invalid or zero price does not silently default to 1 — the conversion
throws Price not configured for currency X, because valuing a 1,000,000 IRR
balance at $1,000,000 is the worse failure.
The fiat list is Finance → Currency Management → Fiat Currencies
(/admin/finance/currency/fiat). It carries name, symbol, precision, price and a
status toggle. You can enable and disable currencies there; you cannot create or
delete one from the table, and the price column is rewritten by the job every 30
minutes regardless.
Only currencies with status: true are priced. The enabled list is cached in
Redis under currencies with a 300-second TTL, so a currency you have just
disabled can still be priced for up to five more minutes.
The crypto side
processCurrenciesPrices runs every two minutes and prices crypto completely
differently: it asks the exchange provider for tickers on your enabled
X/USDT markets and writes exchangeCurrency.price for every currency whose
price actually moved. USDT itself is pinned to exactly 1.
It is silent — a skip, not a failure — in several normal situations, and each one prints its own line:
| Log line | Means |
|---|---|
Exchange instance not available; exiting |
No provider is active, or its credentials failed. See the exchange provider runbook |
Currently banned; waiting for … |
The exchange rate-limited or banned this server. The job waits out the ban rather than hammering |
No exchange market is enabled; skipping price update |
The normal state of a fresh install. Not an error |
None of the active market symbols are listed as spot on the active exchange |
Your enabled markets do not exist as spot pairs on the current provider |
Skipping N symbol(s) not listed as spot on the active exchange: … |
Some do not; the rest are still priced |
Disabled N non-spot market row(s): … |
Swap/futures rows (a : in the symbol) that can never be valid spot symbols, permanently disabled |
Invalid or missing price for symbol: X/USDT |
The exchange returned no usable last price for that pair |
The screen is Finance → Currency Management → Cryptocurrencies
(/admin/finance/currency/spot).
The list is whatever the exchange provider supplies. Create and delete are both
off on the table; the row edit takes only name and chains, and the status
toggle is the switch you actually use.
Import currencies re-reads the provider and is preview-first: without confirming, it writes nothing and returns a plan — how many to create, how many to update, how many to delete, a sample of the codes it would remove, and how many currencies you currently have enabled. Confirming applies it in one transaction, and the deletions are real: any currency your provider no longer lists is removed. Read the delete count before you agree to it.
Prices are not part of the import — it triggers processCurrenciesPrices
afterwards to fill them in.
The red Missing currencies banner on that page means something different from "unpriced": it lists currencies that appear in an enabled market but are not themselves enabled as currencies. Its button activates them in bulk. A market whose base or quote currency is disabled is a market that cannot be priced.
Fault to first screen
| Symptom | Look at |
|---|---|
| One fiat rate is wildly wrong | The fetchFiatCurrencyPrices disagreement warning for that code; then reorder APP_FIAT_RATES_PROVIDERS |
| One fiat rate never changes | The "No usable rate this run" line — it is unpriced, not mispriced |
| Every fiat rate is stale by the same amount | Merged N rates from X/Y with a low X, or the scheduler |
| A fiat rate looks like its reciprocal | It is stored units-per-USD. Read the section above before changing anything |
| Crypto prices frozen, fiat fine | processCurrenciesPrices log: exchange unavailable, banned, or no enabled markets |
| Everything frozen | The scheduler heartbeat |
| A currency shows no price at all | It is disabled, or it appears in a market but is not an enabled currency |
Related
- The scheduler console — the heartbeat, the live log, per-job filtering and Run now.
- The exchange provider is degraded — the half-failure that stops crypto pricing while charts keep working.
- Environment variables — where the
.envkeys above live. - Scheduled jobs — every job and its cadence.