The vike-io/demo-trading-apps monorepo ships five token-price trackers. They split cleanly into two families: three exchange trackers (Binance, Bybit, OKX) that show that venue's live spot + perpetuals book, and two market-data trackers (CoinGecko, CoinMarketCap) that show the global top-100 by market cap. Each runs as a separate slug under one Cloudflare Worker; the code per tracker is one HTML template and a manifest. Everything else is shared.

This is a tour — what each one does, what it's pulling, and the bit of code that makes it interesting.

Exchange trackers — Binance, Bybit, OKX

Same shape across all three: a markets list (top N USDT pairs by 24h quote volume), a spot/perp toggle, and a drill-in trade.html page that pulls candles + order book + recent trades for the selected pair. The only thing that changes between them is the upstream API surface — Binance's api.binance.com, Bybit's api.bybit.com/v5, OKX's www.okx.com/api/v5.

Binance Tracker on USDT-Perp mode — BTC/USDT-PERP at $73,671 with the live funding rate column on the right (+0.0098 %), sorted by 24h quote volume.

The interesting move in the exchange trackers is that they ship a loadMarkets() that uses one endpoint to get every symbol on the host, then sorts client-side. For Binance that's /ticker/24hr returning ~1.5k spot symbols and ~200 perp symbols in a single response:

async function loadMarkets() {
  const res = await fetch(`${API_BASE}/${state.mode}/ticker/24hr`);
  if (!res.ok) throw new Error(`Binance ${res.status}`);
  const all = await res.json();
  const usdt = all
    .filter(r => r.symbol.endsWith("USDT") && Number(r.quoteVolume) > 0)
    .sort((a, b) => Number(b.quoteVolume) - Number(a.quoteVolume))
    .slice(0, CONFIG.topNPairs);
  state.rows = usdt;

  // Perp mode: fetch all funding rates in one shot, then render.
  if (state.mode === "perp") {
    try {
      const fundRes = await fetch(`${API_BASE}/perp/premiumIndex`);
      if (fundRes.ok) {
        const fund = await fundRes.json();
        state.funding = {};
        for (const f of fund) state.funding[f.symbol] = f.lastFundingRate;
      }
    } catch (e) { /* funding is best-effort */ }
  }
  renderRows();
}

Two HTTP calls in perp mode, one in spot mode — for the entire top-50. No pagination, no per-pair fan-out. The funding fetch is wrapped in a try/catch because if it 429s the markets table should still render. That "render even when one feed is down" pattern shows up in every tracker.

Market-data trackers — CoinGecko, CoinMarketCap

Both wrap CoinGecko's /coins/markets?vs_currency=…&sparkline=true endpoint, which returns the top N coins with a 7-day price array per coin baked into the response. Same upstream, different chrome.

CoinGecko Tracker top-100 — BTC at $73,558 / 1.47T market cap, with red/green 7-day sparklines drawn from the per-coin sparkline_in_7d.price array.

The sparklines are SVG generated inline — no charting library:

function sparklineSVG(prices) {
  if (!prices || prices.length < 2) return "";
  const w = 80, h = 24, pad = 1;
  const min = Math.min(...prices);
  const max = Math.max(...prices);
  const range = max - min || 1;
  const stepX = (w - pad * 2) / (prices.length - 1);
  const pts = prices.map((p, i) => {
    const x = pad + i * stepX;
    const y = h - pad - ((p - min) / range) * (h - pad * 2);
    return `${x.toFixed(1)},${y.toFixed(1)}`;
  }).join(" ");
  const color = prices[prices.length - 1] >= prices[0] ? "var(--green)" : "var(--red)";
  return `<svg viewBox="0 0 ${w} ${h}" width="${w}" height="${h}">`
    + `<polyline fill="none" stroke="${color}" stroke-width="1.4" points="${pts}"/></svg>`;
}

CoinGecko returns 168 points per coin (7 days × 24 hours). 100 coins × 168 points × one polyline each = 100 tiny SVGs, all drawn in one render pass. No <canvas>, no D3, no observer — the markup is just <svg><polyline></svg> per row.

What the trackers actually share

Each tracker is a separate slug, but they all live in the same Worker and share the same plumbing:

Concern Where it lives
Request proxying (path → upstream + key headers) The umbrella Worker, driven by each tracker's manifest.json
manifest.json schema (slug, name, tagline, upstream, pages, config) Same across all 5 trackers
Template substitution ({{brand_name}}, {{api_base}}, etc.) build.py walks each tracker folder, renders templates/*.html into the case's dist/, written to be served by the Worker
Theme toggle (cg-theme localStorage key, ☀️/🌙 button) Same script copied across templates
Error banner + retry button Same script copied across templates

That's why a brand-new tracker is a single PR: drop a folder under the monorepo root with a manifest.json and a templates/index.html, and the Worker picks it up. The build step doesn't care about its name.

Family at a glance

Tracker Family Upstream Notable column
Binance Exchange api.binance.com, fapi.binance.com Funding (perp mode)
Bybit Exchange api.bybit.com/v5 Funding (perp mode)
OKX Exchange www.okx.com/api/v5 Funding (swap mode)
CoinGecko Market data api.coingecko.com/api/v3 7d sparkline
CoinMarketCap Market data api.coingecko.com/api/v3 7d sparkline

Gotchas

Five trackers, one Worker, one manifest schema. The repo is vike-io/demo-trading-apps — the umbrella build.py and worker.js are at the root, each tracker is a sibling folder.