Skip to content
EliteChart

Building a datafeed

A complete production-grade Datafeed implementation — REST history, WebSocket realtime, symbol search, caching, error handling.

This guide builds a production-grade Datafeed from scratch — REST for history, WebSocket for live ticks, in-memory cache, symbol search, robust error handling. By the end you'll have a class you can drop into <EliteChart datafeed={...} /> and ship.

Quick example

code
import type {
  Bar, Datafeed, GetBarsRequest, SubscribeRequest, SymbolInfo,
} from '@elitechart/elitechart';
import { asPrice, asTimestampMs, asVolume } from '@elitechart/elitechart';

interface RawBar { readonly t: number; readonly o: number; readonly h: number; readonly l: number; readonly c: number; readonly v: number; }
interface RawSymbol { readonly id: string; readonly name: string; readonly tickSize: number; readonly description: string; }

export class ProductionDatafeed implements Datafeed {
  private ws: WebSocket | null = null;
  private subs = new Map<string, (b: Bar) => void>();
  private barCache = new Map<string, ReadonlyArray<Bar>>();
  private reconnectAttempt = 0;

  constructor(
    private readonly restBase: string,
    private readonly wsUrl: string,
  ) {}

  async getBars(req: GetBarsRequest): Promise<ReadonlyArray<Bar>> {
    const key = this.barKey(req);
    const cached = this.barCache.get(key);
    if (cached !== undefined) return cached;

    const url = `${this.restBase}/bars?symbol=${req.symbol}&from=${req.from}&to=${req.to}&tf=${req.resolution}`;
    const r = await fetch(url);
    if (!r.ok) throw new Error(`history fetch failed: ${r.status}`);
    const raw = (await r.json()) as ReadonlyArray<RawBar>;
    const bars = raw.map(this.normalize);
    this.barCache.set(key, bars);
    return bars;
  }

  subscribe(req: SubscribeRequest, onBar: (bar: Bar) => void): () => void {
    const key = `${req.symbol}|${req.resolution}`;
    this.subs.set(key, onBar);
    const ws = this.ensureWs();
    const send = () => ws.send(JSON.stringify({ op: 'sub', symbol: req.symbol, tf: req.resolution }));
    if (ws.readyState === WebSocket.OPEN) send();
    else ws.addEventListener('open', send, { once: true });

    return () => {
      this.subs.delete(key);
      this.ws?.send(JSON.stringify({ op: 'unsub', symbol: req.symbol, tf: req.resolution }));
    };
  }

  async searchSymbols(query: string): Promise<ReadonlyArray<SymbolInfo>> {
    const r = await fetch(`${this.restBase}/symbols?q=${encodeURIComponent(query)}`);
    if (!r.ok) return [];
    const raw = (await r.json()) as ReadonlyArray<RawSymbol>;
    return raw.map((s) => ({
      id: s.id, name: s.name, description: s.description, tickSize: s.tickSize,
    }));
  }

  async resolveSymbol(symbolId: string): Promise<SymbolInfo> {
    const r = await fetch(`${this.restBase}/symbols/${symbolId}`);
    if (!r.ok) throw new Error(`resolve failed: ${symbolId}`);
    const s = (await r.json()) as RawSymbol;
    return { id: s.id, name: s.name, description: s.description, tickSize: s.tickSize };
  }

  private ensureWs(): WebSocket {
    if (this.ws !== null && this.ws.readyState === WebSocket.OPEN) return this.ws;
    const ws = new WebSocket(this.wsUrl);
    ws.onmessage = (e) => {
      const m = JSON.parse(e.data) as RawBar & { symbol: string; tf: string };
      const cb = this.subs.get(`${m.symbol}|${m.tf}`);
      if (cb !== undefined) cb(this.normalize(m));
    };
    ws.onclose = () => {
      this.ws = null;
      this.reconnectAttempt += 1;
      const delay = Math.min(1000 * 2 ** this.reconnectAttempt, 30_000);
      setTimeout(() => {
        this.ensureWs();
        // re-send all active subscriptions on reopen
        for (const key of this.subs.keys()) {
          const [symbol, tf] = key.split('|');
          this.ws?.send(JSON.stringify({ op: 'sub', symbol, tf }));
        }
      }, delay);
    };
    ws.onopen = () => { this.reconnectAttempt = 0; };
    this.ws = ws;
    return ws;
  }

  private normalize = (raw: RawBar): Bar => ({
    time: asTimestampMs(raw.t),
    open: asPrice(raw.o),
    high: asPrice(raw.h),
    low:  asPrice(raw.l),
    close: asPrice(raw.c),
    volume: asVolume(raw.v),
  });

  private barKey(req: GetBarsRequest): string {
    return `${req.symbol}|${req.resolution}|${req.from}|${req.to}`;
  }
}

Use it:

code
'use client';
import { EliteChart } from '@elitechart/elitechart';
import { ProductionDatafeed } from '@/lib/datafeed';

const datafeed = new ProductionDatafeed('https://api.example.com', 'wss://stream.example.com');

export default function Page() {
  return <EliteChart datafeed={datafeed} symbol="BTCUSD" timeframe="1h" />;
}

How it works

History. getBars is called by the chart on every viewport change that crosses the cache boundary. Cache by (symbol, tf, from, to) and you'll typically pay one network round trip per pan into uncached territory.

Realtime. A single WebSocket fans out to all per-(symbol, tf) subscribers via the subs map. On disconnect, exponential backoff to a 30-second cap; on reopen, re-send every active subscription. This is what survives spotty mobile networks.

Search. Optional but strongly recommended — without it the Symbol Search modal renders an empty list. Return up to ~50 matches; the modal applies its own client-side filtering.

Resolve. Optional but required if you want the price axis to format with the right number of decimals. Called once per symbol on mount.

Error handling

Throw from getBars and the chart shows an inline retry banner. Throw from searchSymbols or resolveSymbol and the failure is swallowed (logged via the logger abstraction) — those code paths have to degrade gracefully.

For subscribe, never throw. Return a no-op unsubscribe and log internally; the chart doesn't surface async-stream errors today (Phase 2 will land an onError callback on the contract).

Variations

Cache invalidation

When the user explicitly refreshes (Phase 1b — Cmd-R keybinding), the chart calls datafeed.invalidate?.(symbol, tf). Implement that optional method to drop your cache:

code
invalidate(symbol: string, tf: string): void {
  for (const key of this.barCache.keys()) {
    if (key.startsWith(`${symbol}|${tf}|`)) this.barCache.delete(key);
  }
}

Authentication

Bearer-token your fetches:

code
const r = await fetch(url, { headers: { Authorization: `Bearer ${this.token}` } });

For the WebSocket, append the token as a query param or use a sub-protocol header on the upgrade request.

API

See concepts/datafeed-contract for the full interface.