# Where PERP/DESK gets its numbers

No API key, no backend, no proxy. Every request below is public and answers
with `Access-Control-Allow-Origin: *`, so the browser talks to the exchange
directly. The only server involved is the static host serving these files.

## Three layers

| Layer | Source | Refresh | If it fails |
|---|---|---|---|
| **Live** | Binance spot REST + WebSocket | 1 s | Board goes stale, status turns red |
| **Slow** | CoinGecko `/coins/markets` | 6 h, cached in `localStorage` | Falls back to the bundled snapshot |
| **Static** | `seed.js` (`PD_META`), `assets/coins/*.svg` | ships with the site | — |

## Live — Binance spot

| What | Endpoint | Notes |
|---|---|---|
| Snapshot of every pair | `GET /api/v3/ticker/24hr` | one call, ~282 KB gzip, weight 80 |
| Price stream | `wss://stream.binance.com:9443/ws/!miniTicker@arr` | one message a second, every symbol that traded |
| Large-trade tape | `wss://.../stream?streams=<sym>@aggTrade/...` | top 8 coins, trades over $25k |
| 7-day sparkline, 1h and 7d change | `GET /api/v3/klines?interval=1h&limit=168` | one call per coin, fetched progressively after first paint |
| Expanded row | `klines` 1h × 168 and 1d × 30 | daily volume and taker buy share |

A REST snapshot every two minutes heals anything the stream missed and
refreshes trade counts and spreads, which `miniTicker` does not carry.

### Picking the universe

Binance keeps answering for **delisted** pairs with their final day's figures
frozen: `XMRUSDT` still reports a price of $118 and $574k of 24 h volume. So
volume alone cannot identify a live market. Two conditions do:

1. `closeTime` within 30 minutes of the newest `closeTime` in the same response
2. `quoteVolume` above $200k

Measured against `exchangeInfo`'s `status == "TRADING"` list, that keeps 372 of
740 USDT tickers with **zero** delisted pairs slipping through — and it costs
nothing, since both fields are already in the snapshot. Leveraged tokens
(`…UP`, `…DOWN`, `…BULL`, `…BEAR`) and stablecoins are dropped as well, the
latter because they would otherwise head every volume sort as a row of
motionless $1.0000.

## Slow — CoinGecko

One call every six hours for circulating supply, all-time high and rank. The
free endpoint needs no key but rate-limits hard: the fourth call in quick
succession already returns 429, and the data itself is a median of 182 seconds
old. One call per visitor per six hours stays far inside that.

## Market cap is computed here, not fetched

`market cap = live Binance price × circulating supply`

Supply moves in days, price moves in seconds, so the cap ticks live rather than
lagging three minutes behind. It also stays consistent with the price column:
dividing the cap by the price gives back the supply, which would not hold if
the cap came from a different venue's average.

Checked against CoinGecko's own ranking over the 84 coins both cover:

| | |
|---|---|
| Top 20 order | identical |
| Positions differing at all | 9 of 84, never by more than 2 places |
| Market cap difference | median 0.15 %, p90 0.62 % |

That residual is the gap between Binance's last trade and CoinGecko's
cross-exchange average — for 63 of the 84 coins the cap difference equals the
price difference to within 0.05 pp, so the arithmetic itself contributes
nothing.

## Known limits

- Binance restricts some regions; visitors there will see the board fail to
  load. A Worker proxy would be the fix, and none is deployed today.
- Rate limits apply per visitor IP, not per site: 6000 weight a minute against
  a first load costing roughly 80 + one weight-2 call per coin.
- Market cap is missing for coins outside CoinGecko's top 250; those rank below
  everything that has one.
- `Vol / cap` uses Binance volume only, so it reads lower than the same column
  on aggregators.

## Switching sources

`app.js` picks the source from the query string: default is Binance,
`?src=mock` runs the deterministic simulator in `mock.js` that the smoke suite
and screenshots use. Both implement the same five methods — `meta`, `summary`,
`marketsAll`, `detail`, `stream`.
