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.

8 min readUpdated 6 August 2026prices, fiat, rates, cron, currencies, runbook

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-api

Unset, 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 configured

If 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:

  1. Two or more other independent families have the currency.
  2. They agree with each other closely (within 5%).
  3. The priority value sits outside their band by more than 1%.
  4. 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.
  5. 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>, or CODE=retired if 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, ZWL

and 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