# PERP/DESK — Backend API Contract (v1)

Status: **planned, not implemented**. The frontend ships with a `MockSource`
that generates data matching every shape below. Switching to a real backend is
`DATA_SOURCE = 'http'` in `app.js` plus `API_BASE`.

Scope: Binance USDT-M perpetual futures universe. Field names are ours, not
Binance's, so a second exchange can be added without renaming anything. The
last section maps every field to the Binance endpoint that produces it and
tags how expensive it is to obtain.

Conventions

- All timestamps: Unix **milliseconds**, UTC. Field suffix `At`.
- All prices / sizes: JSON **strings** to avoid float rounding on the wire
  (`"63512.40"`). The frontend parses once per message.
- Percentages: decimal fraction, not `%`. `0.0123` = 1.23 %. Suffix `Pct`.
- Funding rate: decimal fraction per settlement (8 h). `0.0001` = 0.01 %.
- Sizes are in **base asset** unless the field ends in `Usd`.
- Errors: `{ "error": { "code": "NOT_FOUND", "message": "..." } }` with the
  matching HTTP status. Codes: `NOT_FOUND`, `BAD_REQUEST`, `RATE_LIMITED`,
  `UPSTREAM_DOWN`.
- Every response carries `generatedAt` so the client can show data age.
- CORS: `Access-Control-Allow-Origin: https://perpdesk.pages.dev`.

---

## GET /v1/meta

Backend heartbeat. Polled every 30 s; drives the `data age` indicator.

```json
{
  "generatedAt": 1756800000000,
  "exchange": "binance-usdm",
  "symbols": 412,
  "lastTickAt": 1756799999120,
  "lastKlineAt": 1756796400000,
  "lastFundingAt": 1756771200000,
  "version": "1.0.0"
}
```

## GET /v1/summary

Whole-market strip at the top of the page. Refresh: 5 s.

```json
{
  "generatedAt": 1756800000000,
  "openInterestUsd": "38412000000",
  "volume24hUsd": "71230000000",
  "btcDominancePct": 0.4712,
  "avgFundingRate": 0.000083,
  "liquidations24hUsd": "184300000",
  "liquidations24hLongUsd": "121000000",
  "liquidations24hShortUsd": "63300000",
  "longShortRatio": 1.84,
  "advancers": 231,
  "decliners": 181
}
```

- `btcDominancePct`: BTC OI / total OI (not market-cap dominance; this is a
  derivatives desk).
- `longShortRatio`: OI-weighted mean of per-symbol `longShortRatio`.

## GET /v1/markets

Full table snapshot. The frontend loads **everything once** (`limit=all`)
and sorts / filters client-side. Pagination params exist for other clients.

Query

| param   | type   | default | notes |
|---------|--------|---------|-------|
| `limit` | int \| `all` | `100` | max 500 unless `all` |
| `cursor`| string | –       | opaque, from previous `nextCursor` |
| `sort`  | enum   | `volume24hUsd` | any numeric field name below |
| `order` | `asc`\|`desc` | `desc` | |
| `q`     | string | –       | case-insensitive prefix match on `symbol` / `base` (the board's own client-side filter also allows a substring of `base` for 2+ chars) |

```json
{
  "generatedAt": 1756800000000,
  "nextCursor": null,
  "items": [
    {
      "symbol": "BTCUSDT",
      "base": "BTC",
      "quote": "USDT",
      "name": "Bitcoin",
      "rank": 1,
      "price": "63512.40",
      "markPrice": "63510.15",
      "indexPrice": "63498.02",
      "change1hPct": 0.0021,
      "change24hPct": -0.0134,
      "change7dPct": 0.0412,
      "high24h": "64980.00",
      "low24h": "62710.50",
      "volume24hUsd": "18234000000",
      "openInterest": "84213.520",
      "openInterestUsd": "5348000000",
      "fundingRate": 0.000100,
      "fundingIntervalHours": 8,
      "nextFundingAt": 1756800000000,
      "basisPct": 0.00019,
      "longShortRatio": 1.92,
      "liquidations24hUsd": "41200000",
      "liquidations24hLongUsd": "28100000",
      "liquidations24hShortUsd": "13100000",
      "maxLeverage": 125,
      "tickSize": "0.10",
      "stepSize": "0.001",
      "pricePrecision": 1,
      "sparkline7d": ["61230.1", "61388.4", "..."],
      "updatedAt": 1756799999120
    }
  ]
}
```

- `rank`: position by `openInterestUsd` desc, recomputed hourly. Stable
  between recomputes so the `#` column does not jitter.
- `sparkline7d`: 168 hourly closes, oldest first. Included in the snapshot so
  the table needs one request. `null` for symbols listed < 7 d.
- `basisPct`: `(markPrice − indexPrice) / indexPrice`.
- `nextFundingAt`: most symbols settle at 00/08/16 UTC; a few use 4 h
  (`fundingIntervalHours: 4`). The client counts down from this value using
  its own clock; the server does not push countdowns.
- `pricePrecision`: decimals to render, derived from `tickSize`. Sent
  explicitly so the client never parses tick sizes.

## GET /v1/markets/{symbol}

```json
{ "generatedAt": 1756800000000, "market": { "...": "one items[] entry" } }
```

Exists for deep links and single-symbol refresh. 404 on unknown symbol.

## GET /v1/markets/{symbol}/klines

| param      | type | default | notes |
|------------|------|---------|-------|
| `interval` | `1m`\|`5m`\|`15m`\|`1h`\|`4h`\|`1d` | `1h` | |
| `limit`    | int  | `168`   | max 1000 |
| `endAt`    | ms   | now     | |

```json
{
  "generatedAt": 1756800000000,
  "symbol": "BTCUSDT",
  "interval": "1h",
  "candles": [
    { "openAt": 1756195200000, "open": "61230.1", "high": "61410.0", "low": "61100.0", "close": "61388.4", "volumeUsd": "412000000" }
  ]
}
```

Oldest first. Cache: `max-age` = remaining seconds of the current interval.

## GET /v1/markets/{symbol}/funding

| param   | type | default | notes |
|---------|------|---------|-------|
| `limit` | int  | `21`    | max 500; 21 × 8 h = 7 d |

```json
{
  "generatedAt": 1756800000000,
  "symbol": "BTCUSDT",
  "settlements": [
    { "settledAt": 1756771200000, "rate": 0.000100, "markPrice": "63201.0" }
  ]
}
```

Oldest first. Cache until next settlement.

## GET /v1/markets/{symbol}/oi

| param      | type | default | notes |
|------------|------|---------|-------|
| `interval` | `5m`\|`15m`\|`1h`\|`4h`\|`1d` | `1h` | Binance floor is 5 m |
| `limit`    | int  | `168`   | max 500 |

```json
{
  "generatedAt": 1756800000000,
  "symbol": "BTCUSDT",
  "points": [
    { "at": 1756195200000, "openInterest": "82110.4", "openInterestUsd": "5021000000" }
  ]
}
```

Cache: 60 s.

## GET /v1/markets/{symbol}/detail

**Aggregate** for the expanded row: one request instead of three.

```json
{
  "generatedAt": 1756800000000,
  "market": { "...": "same as /v1/markets/{symbol}" },
  "klines": { "interval": "1h", "candles": [ "..." ] },
  "funding": { "settlements": [ "..." ] },
  "oi": { "interval": "1h", "points": [ "..." ] }
}
```

Fixed shape: 168 × 1h klines, 21 fundings, 168 × 1h OI. Cache = the
**shortest** of the three components (OI, 60 s). Components are still served
separately so a client that only needs klines is not charged for the rest.

## WS /v1/stream

`wss://<host>/v1/stream?symbols=BTCUSDT,ETHUSDT` — omit `symbols` for all.
In the `subscribe` op an empty or omitted `symbols` array likewise means all.

Per-symbol **ticker events**, not table snapshots. The client applies each
event to one row. Server sends at most one event per symbol per 250 ms
(coalesced); the client batches DOM writes per animation frame regardless.

Client → server

```json
{ "op": "subscribe",   "symbols": ["BTCUSDT"] }
{ "op": "unsubscribe", "symbols": ["BTCUSDT"] }
{ "op": "ping" }
```

Server → client

```json
{ "type": "ticker", "at": 1756799999120, "symbol": "BTCUSDT",
  "price": "63512.40", "markPrice": "63510.15", "indexPrice": "63498.02",
  "change1hPct": 0.0021, "change24hPct": -0.0134, "change7dPct": 0.0412,
  "high24h": "64980.00", "low24h": "62710.50",
  "volume24hUsd": "18234000000", "openInterestUsd": "5348000000",
  "openInterest": "84213.520", "basisPct": 0.00019 }

{ "type": "funding", "at": 1756771200000, "symbol": "BTCUSDT",
  "fundingRate": 0.000100, "nextFundingAt": 1756800000000 }

{ "type": "liquidation", "at": 1756799998000, "symbol": "BTCUSDT",
  "side": "long", "sizeUsd": "212000", "price": "63480.0" }

{ "type": "kline", "at": 1756800000000, "symbol": "BTCUSDT", "interval": "1h", "close": "63512.40" }

{ "type": "summary", "at": 1756800000000, "...": "same as GET /v1/summary" }

{ "type": "pong", "at": 1756800000000 }
```

- `ticker` fields are all optional except `symbol` / `at` / `price`; only
  changed fields are sent.
- `liquidation` relays Binance's force-order feed, which is **sampled**: at
  most one order per symbol per second is pushed upstream, so the tape is
  indicative and any 24 h total built from it is a lower bound. The client
  accumulates it into the 24h column between snapshot refreshes; summary
  `liquidations24hUsd` is authoritative and overrides the accumulator every 5 s.
- `kline` fires once per closed 1h bucket; the client pushes the close onto
  `sparkline7d` and drops the oldest point, so the 7d window keeps sliding
  without a snapshot refresh.
- Reconnect: client re-sends `subscribe`, then re-fetches `/v1/markets` to
  heal any gap (`HttpSource` emits a local `{ type: "resync" }` event that
  triggers this). No server-side replay.

---

## Field → Binance mapping and cost tags

Tags

- **proxy** — one public Binance REST/WS call returns it directly. A
  Cloudflare Worker can serve it with no storage.
- **derive** — needs a computation over another proxy response (usually
  klines). Still stateless, but more upstream calls per request.
- **aggregate** — needs our own storage (a stream must be consumed
  continuously, or history must be accumulated). Forces a persistent backend.
- **key** — requires an authenticated Binance API key.

| field | source | tag |
|-------|--------|-----|
| `name` | not on Binance; our own static table (`seed.js` `PD_NAMES`), fall back to `base` | static |
| `symbol` `base` `quote` `tickSize` `stepSize` `pricePrecision` | `GET /fapi/v1/exchangeInfo` → `symbols[].filters` (`PRICE_FILTER.tickSize`, `LOT_SIZE.stepSize`) | proxy |
| `price` `high24h` `low24h` `change24hPct` `volume24hUsd` | `GET /fapi/v1/ticker/24hr` (`lastPrice`, `highPrice`, `lowPrice`, `priceChangePercent`/100, `quoteVolume`); live via WS `<sym>@ticker` | proxy |
| `markPrice` `indexPrice` `fundingRate` `nextFundingAt` | `GET /fapi/v1/premiumIndex` (`markPrice`, `indexPrice`, `lastFundingRate`, `nextFundingTime`); live via WS `<sym>@markPrice@1s` | proxy |
| `fundingIntervalHours` | `GET /fapi/v1/fundingInfo` (`fundingIntervalHours`; only lists non-8h symbols, default 8) | proxy |
| `openInterest` | `GET /fapi/v1/openInterest` (one call per symbol — 400 calls for a full table; poll on a schedule, do not call per request) | proxy → aggregate at scale |
| `openInterestUsd` | `openInterest × markPrice` | derive |
| `change1hPct` `change7dPct` `sparkline7d` | `GET /fapi/v1/klines?interval=1h&limit=168` (`close`) | derive |
| `basisPct` | `(markPrice − indexPrice) / indexPrice`; alt. `GET /futures/data/basis` | derive |
| `longShortRatio` | `GET /futures/data/globalLongShortAccountRatio?period=5m&limit=1` (`longShortRatio`) — note this is *accounts*, not OI-weighted positions; `topLongShortPositionRatio` is the position variant | proxy |
| `liquidations24hUsd` (+ long/short) | WS `!forceOrder@arr` only. **No REST history exists**, and the stream is sampled (max one order per symbol per 1 s), so totals are a lower bound. Must consume it 24/7 and keep a rolling 24 h window. | aggregate |
| `maxLeverage` | `GET /fapi/v1/leverageBracket` (`brackets[0].initialLeverage`) | key |
| `rank` | sort by `openInterestUsd` hourly | derive |
| `/funding` history | `GET /fapi/v1/fundingRate?limit=21` | proxy |
| `/oi` history | `GET /futures/data/openInterestHist?period=1h&limit=168` (Binance keeps only the last 30 days) | proxy |
| summary `openInterestUsd` `volume24hUsd` `btcDominancePct` `avgFundingRate` `longShortRatio` | sum / weighted mean over `/v1/markets` items | derive |
| summary `liquidations24h*` `advancers` `decliners` | from the aggregate / ticker set | aggregate / derive |

Consequences for the backend choice

- **Worker-only (no DB)** can serve everything except the liquidation
  columns and `maxLeverage`. `openInterest` needs one upstream call per
  symbol, so a Worker must cache it (KV, 60 s) rather than fan out per hit.
- **Persistent backend** (Render, Python or Java, next to `dagoo-crypto`) is
  required the moment liquidations are wanted. It also lets `/oi` history
  outlive Binance's 30-day retention (funding history has no such limit but
  shares a 500-requests-per-5-minutes budget).
- Rate limits: Binance USDM weight budget is 2400/min per IP. A full-table
  refresh costs about 40 (`ticker/24hr`, all symbols) + 10 (`premiumIndex`,
  all symbols) + 400×1 (`openInterest`, one call per symbol) + 400×2
  (`klines` with `limit=168`; weight is 1 / 2 / 5 / 10 for limits
  <100 / <500 / <1000 / ≥1000) ≈ 1,250. That is half the budget for a single
  refresh, so klines must be cached hourly and OI polled on a schedule, never
  fetched per request.

Recommended first implementation: Worker + KV proxy for everything tagged
`proxy`/`derive`, liquidation columns rendered as `—` until the aggregate
service exists. The frontend already handles `null` in those fields.
