Market data, charts and the pool indexer
The four layers behind a price — provider, cache, refresh sweep and pool indexer — every polling and indexer setting, and the ordered triage for a blank chart or a stale pair list.
"The chart is blank" and "the pair list shows yesterday's price" are the same class of ticket with four possible causes, and they are not interchangeable. This page is the map.
Four layers, and which one you are looking at
| Layer | Serves | Lives |
|---|---|---|
| Provider | Bars, tickers and the trade tape from a third party | dexMarketProvider, plus a fallback |
| Cache | A read-through Redis layer in front of every provider call, plus the persistent candle store | dexChartCacheTtlSec, and a per-call TTL just under the poll period |
| Refresh sweep | The denormalised lastPrice / change24h / volume24hUsd / liquidityUsd on dexPair |
The runDexMarketRefresh cron job |
| Pool indexer | 1m candles derived from a pool's own Swap logs |
A main-thread timer behind a process lease |
The live surface is the /api/dex/market websocket, which starts a poller
per subscribed symbol. The durable surfaces are GET /api/dex/chart and the
dexPair statistics columns. Neither is a fallback for the other, and diagnosing
from the wrong one is the usual reason a fault looks intermittent.
Providers
The registry is a literal switch over three names, and no vendor is named anywhere outside its own adapter file and that switch — so changing provider is a settings change, never a rewrite.
| Value | Credential | Notes |
|---|---|---|
geckoterminal |
None. Keyless by design. | The default, and what makes a fresh install chart on day one. |
codex |
APP_CODEX_API_KEY |
GraphQL, ~600 req/min, a real trade tape. Addresses a market as {pairAddress}:{networkId}, which is what findPools returns as indexerId. |
mock |
None. | Returns fiction. Deterministic, seeded candles that are a pure function of (symbol, interval, bar time). |
It exists so the cache, the interval derivation, the volume cursor and the
websocket route can be exercised with zero vendor quota and zero network. The
bars are correct in shape — millisecond epochs on the interval grid,
low <= open,close <= high, per-bar volume — which is exactly what makes them
indistinguishable from real ones on screen. Never leave it selected on a live
install.
The API key is read from the environment and never from the settings table —
a market-data key is a bearer credential for a paid account, and putting it in
settings would place it behind an operator-editable, plain-text-audited PUT.
The console is only ever told the boolean. See
Environment variables.
A fallback equal to the active provider is treated as no fallback — it would be the same cool-off twice.
The cool-off policy
A provider that fails is paused for as long as its failure can plausibly last, and you are told once rather than every minute. This is what stops a rate-limited vendor being hammered once per cron tick for as long as the cron runs.
| Failure | Pause | Scope |
|---|---|---|
RATE_LIMIT |
The vendor's own Retry-After, floored at 1 minute and capped at 15 |
Account-wide |
AUTH — the key was rejected |
60 minutes | Account-wide |
PLAN_LIMIT — the plan does not sell this data |
60 minutes | History only |
NETWORK, TIMEOUT, BAD_POOL, UNSUPPORTED_* |
None | Per request or per pool |
The floors matter: a vendor asking for a 1-second retry would keep the storm
running at 1 Hz, and AUTH and PLAN_LIMIT are configuration states that no
amount of retrying clears, so each retry is a guaranteed-failing round trip.
The two scopes are the important part. Cool-offs are held in Redis under
dex:md_ban:<provider> (account-wide — tickers and the tape honour it) and
dex:md_ban:history:<provider> (OHLCV only). Merged into one key, a single 403
on a chart timeframe would take the whole price feed down for the cool-off: a
PLAN_LIMIT is an endpoint verdict and only ever originates from the chart
path, while a rejected key is an account verdict.
While the active provider is cooling off, getActiveProvider() returns null and
the refresh sweep logs once and does nothing. Redis being unavailable degrades
the cool-off to "not banned" rather than taking the data path down with it.
Which timeframes the chart offers
The chart engine can draw thirteen timeframes, 1m to 1w, and GET /api/dex/chart
accepts all thirteen: an interval the active provider does not serve natively is
derived by rolling a finer native series up (3m from 1m, 30m from 15m, 2h and 6h
from 1h, 3d and 1w from 1d), and gaps stay gaps rather than being carried forward
as flat bars.
The swap terminal offers only the intervals the market's source serves natively, and opens on 1h. Derivation is honest for the bars it has, but a derived timeframe is several pages of a finer series the vendor may not retain — GeckoTerminal keeps roughly six months of minute bars — so it is exactly the timeframe whose chart goes blank when scrolled back, with nothing on screen to say why.
| Source | Timeframes offered |
|---|---|
geckoterminal |
1m, 5m, 15m, 1h, 4h, 12h, 1d |
codex |
1m, 5m, 15m, 30m, 1h, 4h, 12h, 1d, 1w |
mock |
All thirteen |
An ONCHAIN pair (the pool indexer) |
1m only |
The list is the union of the active provider's set and the fallback's, so a
codex fallback beside a GeckoTerminal primary adds 30m and 1w to every
indexer-backed market. It is a capability, not a health reading: a provider
serving out a cool-off still serves 4h, so the menu does not lose entries during
a rate-limit pause. Each market carries its own list as metadata.chartIntervals
on GET /api/dex/market, because marketDataSource is a per-pair choice.
Two things follow for a trader:
- The chart opens on 1h wherever the source serves it; on an
ONCHAINpair it opens on 1m, the nearest served timeframe. A timeframe the trader has already chosen for that market is remembered in the browser for 24 hours and wins over the default. - Zooming steps between offered timeframes only. Zooming out of 1h on a GeckoTerminal market lands on 4h, not 2h.
Integrators calling the chart route directly are unaffected: it still serves every interval in its enum.
The polling and cache settings
| Setting | Default | Drives |
|---|---|---|
dexTickerPollMs |
10000 | Ticker refresh, per subscribed symbol. Floored at 2000. |
dexOhlcvPollMs |
15000 | Live-candle refresh. Floored at 2000. |
dexTradesPollMs |
8000 | Trade-tape refresh. Floored at 2000. |
dexSymbolIdleMs |
300000 | Idle time before a symbol's pollers are reaped. Floored at 30000. |
dexChartCacheTtlSec |
86400 | Persistent candle cache lifetime. Floored at 60. |
dexMarketRefreshMs |
60000 | Declared interval for the pair-statistics sweep. |
The floors are not decoration: a mistyped 0 in the settings table would arm a
setInterval at 0 ms against a third party's rate limiter.
Poll periods are memoised for 60 seconds, so a change takes effect on the next symbol subscribed rather than instantly — putting a database round trip in front of a metered indexer call on every tick would cost more than the staleness.
Under a threaded install every worker loads the websocket route, so the manager
takes a short SET NX PX lease per (kind, symbol) and lets the losers read the
Redis cache. It is deliberately weaker than the confirmation poller's engine
lease: a market-data miss is a stale tick, a confirmation miss is a wrong
balance.
The Swap settings console renders 34 fields across seven tabs — General, Fees,
Quotes, Token Safety, Compliance, Execution and Liquidity. There is no market
data tab, and frontend/config/settings.ts carries only three dex* keys, so
none of dexMarketProvider, dexMarketProviderFallback, dexChartCacheTtlSec,
dexTickerPollMs, dexOhlcvPollMs, dexTradesPollMs, dexMarketRefreshMs or
any dexPoolIndex* key is editable from a page.
They are still writable through PUT /api/admin/dex/settings, which accepts any
key in DEX_SETTINGS_KEYS from a caller holding edit.dex.settings and clears
the settings cache afterwards. That is the only supported way to change one:
editing the row in a SQL client reaches neither cache layer, so the platform goes
on serving the old value and a restart reloads the same stale copy. Until a field
exists, assume every install is running these defaults.
The pair-statistics sweep
runDexMarketRefresh keeps dexPair.lastPrice, change24h, volume24hUsd and
liquidityUsd current so the market rail can sort and render without one indexer
call per row.
It is registered in the cron console as Refresh DEX Market Data, category
dex — see Scheduled jobs. What it does, in order:
- Returns immediately if
dexEnabledis off — that is the normal state on most installs, so it does not throw the 503 an HTTP handler would. - Gets the active provider, or logs once and stops.
- Reads every
dexPairwithstatus = 'ACTIVE'. - Resolves each row's chart source as
indexerId ?? poolAddress, and skips a row that has neither. - Groups by
chainIdand fetches in chunks of 25. - On a chunk failure it calls the shared cool-off policy and moves to the next chain, not the next chunk — whatever refused that chunk will refuse the rest of the chain's, and each attempt is a metered call.
One dead chain therefore costs its own rows and nothing else.
The websocket relay's route allow-list is short — three routes today, and
/api/dex/market is not one of them. A frame published from a CRON_MODE=only
process on any other route is dropped with no error and no log, and in a
single-process dev install the relay is bypassed entirely. So the feature would
work perfectly on a developer's machine and emit nothing in a split deployment.
The websocket's own per-symbol poller is the live path; this sweep is the durable
one.
The cron registry declares this job with a hard-coded 5-minute period, and no
code reads dexMarketRefreshMs. The setting is stored, coerced and defaulted to
60000, and it changes nothing. Read the actual cadence off Admin → System →
Cron, not off the setting.
Everything the sweep writes is display-only and lossy — DECIMAL columns carried
so admin SUM() and ORDER BY happen in SQL. They are never authoritative and
never used to reconstruct a transfer. See Pairs.
The pool indexer
A pool's own Swap logs, turned into 1m candles in the same chart cache the
provider path writes. It exists for one case the vendors cannot serve: a pool
created ten minutes ago is in no indexer, and that is exactly when an operator
most wants a chart.
It runs over dexPair rows where marketDataSource = 'ONCHAIN' and
status = 'ACTIVE', resolves each to its poolId, and skips any pool whose
dexPool.state is not ACTIVE.
It is not a cron job and it is not on the cron console
It is a setInterval on the main thread, armed at boot by
initializeDexPoolIndexer() after the chain registry seed, behind the
dex-pool-index engine lease. It does not appear on Admin → System → Cron,
and its cadence comes from dexPoolRefreshMs rather than from a cron period.
Both guards are load-bearing:
- Not a
*.ws.tsmodule. Every route module is loaded once per worker thread, so a module-scope timer there gives N pollers hammering one RPC endpoint against a shared provider-ban key, N sets of duplicate frames, and N writers racing the same cache key through N independent write queues that do not serialise against each other. - Not the cron-only process.
/api/dex/marketis not on the relay allow-list, so nothing published from there reaches a browser. isMainThreadand the lease, not one or the other. The lease is held per process, so every worker thread of a threaded install would otherwise win one.
Three things follow for an operator:
- It stands down when the venue class is off. It checks
dexDirectPoolsEnabledbefore taking the lease, so a disabled install does not hold one. - Boot failure is non-fatal. A deployment where it fails to start still serves the exchange; on-chain charts degrade to whatever is cached, which is a visible absence rather than a broken platform.
- A process that loses the lease logs it and serves from the shared cache:
"Pool indexer not armed: another process holds the dex-pool-index lease." It
is a different lease from
dex-confirmations, so a process that lost that race can still be the one indexing charts.
The indexer settings
| Setting | Default | What it does |
|---|---|---|
dexPoolRefreshMs |
300000 | Tick interval. Floored at 30000. |
dexPoolIndexChunkBlocks |
1000 | getLogs span per request. Many RPCs cap this at 1000, and the failure is a provider error rather than a truncated result — an uncapped request does not return less, it returns nothing. |
dexPoolIndexMaxBlocks |
50000 | Ceiling on one sweep's block range. A pool dark for a month backfills over several passes; the cap keeps the recent end, because a chart with a hole in the middle of last month is usable and one with a hole at the right edge is not. |
dexPoolIndexBootstrapBlocks |
200000 | How far back to start when the pool's creation block is unknown. |
dexPoolIndexReorgRewindBlocks |
50 | How far to rewind when the cursor block's hash has changed. |
Each tick also re-derives a 5-minute overlap plus dexPoolRefreshMs of
history, so a log arriving at the edge of the previous pass still lands in its
bucket. Re-deriving that bucket is correct where patching it would not be.
The reorg tripwire
Two columns on dexPool do the work:
indexedToBlockis the cursor.indexedToBlockHashis the tripwire — the hash of the block the cursor sits on, stamped on the same update as the cursor. A cursor with no hash silently degrades reorg detection to never.
The sweep never reads past head − requiredConfirmations. That single rule is
the whole defence: a reorg shallower than the chain's confirmation depth cannot
change an emitted candle, because the blocks it rewrote were never read.
A deeper reorg is detected rather than assumed away. Before extending, the block at the cursor is re-read and its hash compared. On a mismatch:
- A warning is logged naming the pool and the block.
- Every
dexPoolEventabovecursor − dexPoolIndexReorgRewindBlocksis deleted — the rows are the evidence behind the candles, and leaving them would make the re-derivation agree with a history that no longer exists. - The cursor rewinds and the affected buckets are re-derived, never patched. A partially-corrected bar is indistinguishable from a correct one afterwards.
An unreadable block is not treated as a reorg. Treating an RPC hiccup as one would rewind and re-derive on every transient failure — an RPC bill and a churned cache for nothing.
marketDataSource is per pair; dexMarketProvider is global
These are easy to conflate and they are not the same axis.
dexMarketProvider selects the vendor, and onchain is not one of its
values — the three are geckoterminal, codex and mock.
dexPair.marketDataSource selects where one market reads its chart:
| Value | Behaviour |
|---|---|
INDEXER |
Default. The active provider serves the chart. |
ONCHAIN |
The pool's own Swap logs, via the pool indexer. |
NONE |
No chart source. |
GET /api/dex/chart treats this as a provider override, not a second code
path: the same cache, the same bucket grid, the same derivation rules, the same
bare number[][] on the wire. The datafeed must never learn that two sources
exist, because a client that could tell would eventually branch on it.
On-chain volume is in base-token units, not dollars — pricing it would need a
USD rate for a token whose only price source may be that very pool. volume24hUsd
and liquidityUsd come back null rather than 0, because zero is
indistinguishable from unpriced.
Triage: a blank chart
Work down. Each step rules out a layer.
-
Does the pair have a chart source at all? Open the row on Admin → Swap → Listings → Pairs and check
indexerIdandpoolAddress. With neither, the sweep skips the row and the chart route answers404 Market has no chart source— a deliberately different message fromMarket not found. Binding one is an API call, not a control: the Pairs grid creates nothing and its edit form carries no chart-binding field, so usePOST /api/admin/dex/pair/discoverto list the candidate pools andPUT /api/admin/dex/pair/{id}to writepoolAddressorindexerId. See Pairs. -
Is the pair
ACTIVE?INACTIVEandDELISTEDboth answer404 Market not found.HIDDENis served on purpose — it is hidden from the rail, not from a deep link. -
Is it set to
ONCHAINwith nopoolAddress? That answers404 Market has no pool bound. -
Is the provider out? Check the DEX log for a
dex market-data provider … paused until …line, and note which cool-off scope it names. History-only means tickers still work and only the chart is dark — which is exactly what a "blank chart, live price" ticket looks like. -
Is it a caching artefact?
dexChartCacheTtlSecdefaults to a full day, so a series fetched during an outage stays cached. A gap in the middle of the series is normal — gaps stay gaps and are never carried forward as flat bars. -
On an
ONCHAINmarket, is the indexer running here? Look forPool indexer armed at Nsat boot. If instead you see "another process holds the dex-pool-index lease", this process is serving from the shared cache and the indexing is happening elsewhere — check that the process holding the lease is alive. If you see neither, checkdexDirectPoolsEnabled: the indexer declines to arm while the direct-pool venue is off.
Triage: the pair list shows yesterday's price
-
Check the cron console. Admin → System → Cron, job Refresh DEX Market Data. If it has not run, nothing below matters.
-
Check the pair is
ACTIVE. The sweep reads no other status. -
Check the chart binding. No
indexerIdand nopoolAddressmeans the row is skipped silently and its figures never move. -
Check whether the provider is cooling off, and remember the sweep aborts a whole chain's remaining chunks after one failure — so one network can be stale while the rest are current.
-
Remember what these columns are. They are a denormalised cache refreshed on a timer, never a live feed and never the basis of a transfer. A figure a few minutes old is the system working.
Related: Scheduled jobs for the sweep's place among the four cron jobs, Requirements for what else to check when a chain is not serving, and Troubleshooting for the quote-side symptoms.