sifting/io
Dashboards & Frontend
6 min readSiftingIO Team

Building a symbol price page: live quotes and charts for XAUUSD, BTCUSD, and EURUSD

How to build a symbol price page with live quotes, historical OHLCV charts, and WebSocket updates for XAUUSD, BTCUSD, and EURUSD from one market data API.

Building a symbol price page: live quotes and charts for XAUUSD, BTCUSD, and EURUSD

A symbol price page shows one instrument's live price, a recent chart, and a handful of stats, and almost every market-facing product ends up building a lot of them. A portfolio tracker needs one per holding. A research dashboard needs one per watchlist entry. The awkward part isn't the layout, it's the data: gold comes from one vendor, Bitcoin from another, and euro-dollar from a third, each with its own auth scheme, symbol format, and response shape. This post walks through the data layer for a symbol price page that works the same way for XAUUSD, BTCUSD, and EURUSD, using one API key.

What a symbol price page needs#

Strip any price page down and three data requirements remain: the current price (with bid and ask if you show a spread), a series of OHLCV bars to draw the chart, and a live update path so the number moves without a page refresh. Everything else derives from those three. The 24-hour change is the live price against yesterday's close from the bars. The day's range is the current bar's high and low. The sparkline in the header is the same bar series rendered smaller.

A fourth, optional ingredient is an open/closed badge. GET /v1/fnd/markets/{market}/status returns whether a given market is currently trading, which is useful context next to a forex price on a Saturday.

The trap is scope creep across asset classes. If commodities, crypto, and forex each need a separate integration, a 15-symbol pilot turns into three vendor contracts and three response parsers. A unified API collapses that: SiftingIO exposes the same endpoint families for every asset class, so the fetch code is identical and only the venue segment in the path changes.

One request shape across gold, Bitcoin, and forex#

The live snapshot lives under /v1/last. The venue goes in the path, the symbol follows, and auth is a single X-API-Key header:

curl -H "X-API-Key: $SIFTING_KEY" "https://api.sifting.io/v1/last/quote/commodities/XAUUSD"
curl -H "X-API-Key: $SIFTING_KEY" "https://api.sifting.io/v1/last/quote/crypto/BTCUSD"
curl -H "X-API-Key: $SIFTING_KEY" "https://api.sifting.io/v1/last/quote/forex/EURUSD"

Each call returns the best bid and ask with a timestamp, and the shape doesn't change between venues, so one parseQuote() function covers all fifteen symbols in a pilot list. Behind each number sits a consensus price aggregated from multiple independent venues rather than a single source's print, with an explicit quality flag on every tick. For a page whose whole job is to display one trustworthy number per instrument, that matters: you never have to decide which venue's price to show, and you can surface the quality flag when the feed degrades instead of rendering a suspect number silently.

Historical bars for the chart come from /v1/hist, again keyed by asset class:

curl, compressed -H "X-API-Key: $SIFTING_KEY" \
  "https://api.sifting.io/v1/hist/crypto/BTCUSD/bars?interval=1h"

Intervals run from 1m up to 1h for forex and crypto, and up to 1mo for US stocks. Note the , compressed flag: bar endpoints require gzip and return 406 gzip_required without it. More on that in the pitfalls.

Charting the history with Lightweight Charts#

Lightweight Charts wants candles as { time, open, high, low, close }, so mapping the bar response is short:

import { createChart } from "lightweight-charts";

const chart = createChart(document.getElementById("chart"), { height: 320 });
const candles = chart.addCandlestickSeries();

const res = await fetch(
  "https://api.sifting.io/v1/hist/forex/EURUSD/bars?interval=1h",
  { headers: { "X-API-Key": SIFTING_KEY } }
);
const { data } = await res.json();

candles.setData(data.map(b => ({
  time: b.t / 1000,
  open: b.o, high: b.h, low: b.l, close: b.c
})));

For the header sparkline, request the same endpoint and plot closes with an area series instead. Same data, one extra render call. Browsers and Node 18+ fetch negotiate gzip automatically, which is why no explicit header appears above.

Keeping the number live#

Polling /v1/last works and is the sensible place to start, but every visitor-second of polling spends REST quota. Once the page has real traffic, the WebSocket stream is the better path: one connection, one subscription per symbol, and the server pushes ticks as they happen.

const ws = new WebSocket(`wss://stream.sifting.io/ws/v1?key=${SIFTING_KEY}`);

ws.onopen = () => {
  ws.send(JSON.stringify({ op: "subscribe", product: "cex", symbols: ["BTCUSD"] }));
  ws.send(JSON.stringify({ op: "subscribe", product: "fx", symbols: ["EURUSD"] }));
  setInterval(() => ws.send(JSON.stringify({ op: "ping" })), 55_000);
};

ws.onmessage = (ev) => {
  const msg = JSON.parse(ev.data);
  if (msg.f === "tick") updatePrice(msg.s, msg.p, msg.t);
};

Two server behaviors help here. On subscribe, the stream first replays the last cached value before live updates begin, so the page paints a price immediately instead of waiting for the next tick. And tick frames carry an int64 epoch-millisecond timestamp in t, so the "as of" label under the price is a formatting call, not a guess.

Crypto streams under product cex and forex under fx. For commodities such as XAUUSD, poll the REST snapshot on an interval suited to the page; the quote endpoint is cheap and the response is small. Check the documentation for current streaming coverage per asset class.

Common pitfalls#

Three failures show up repeatedly on this kind of build.

The 406 that only happens in production. /v1/hist bar endpoints require gzip. Requests from a browser or from Node's fetch succeed in development because those clients send Accept-Encoding: gzip on their own, and then a cron job using plain curl or a minimal HTTP library fails with 406 gzip_required. Add , compressed to curl, or set the header explicitly in whatever client the job uses.

A flat volume histogram under every forex chart. FX bars always report v as 0. That's a property of the market (spot FX has no consolidated volume figure), not a data bug, but a chart component that renders a volume pane unconditionally gives every forex page a zero-height histogram that looks broken. Branch on asset class and skip the pane for FX.

A WebSocket that dies every 90 seconds. The server closes any connection that has been idle for 90 seconds, and idle means no client frames, not no traffic. A page subscribed to a quiet symbol keeps receiving ticks while sending nothing, so it gets disconnected on schedule. Send { "op": "ping" } at least every 60 seconds, and treat an unexpected close as a signal to reconnect and resubscribe. Related: the REST quote endpoint can return 503 stale_snapshot outside trading hours, with last_t in the body, so the page can show the last known price with an honest timestamp instead of an error state.

With those handled, the same data layer serves every symbol page added next: same auth, same shapes, one more entry in the symbol list. The free tier covers a 15-symbol pilot without a credit card. Start building free