kandlo Docs

2. Serve your own market data

Last updated · 14 min read

On this page

The chart does not read a database or call an HTTP API. It opens one Socket.IO connection to a feed server and expects a small set of events on it. To show your own candles you run a feed server that answers those events from your data.

A complete, tested feed server is in examples/custom-feed/ (examples/custom-feed/). It serves candles and live ticks from an in-memory store that stands in for your database. Most teams copy it and replace one file (memory-source.mjs) with code that reads their own tables.

This page covers:

  1. Run the example feed
  2. Connect it to your data, including bars in a SQL database
  3. Point the chart at your feed
  4. The protocol: every event, payload and rule the chart enforces
  5. Checklist and common mistakes

1. Run the example feed#

From the kandlo root. After npm install there, the root node_modules provides socket.io, so the example needs nothing of its own:

Shell
node examples/custom-feed/server.mjs     # feed on ws://localhost:8443
npm run serve                            # chart page on http://localhost:9000

Open http://localhost:9000/. The chart shows EURUSD, BTCUSD, XAUUSD and IDX500 from the in-memory store, with a new tick every 500 ms. Port 8443 is the feed URL already listed in apps/web/index.html, so stop npm run relay first if it is running.

Run the protocol tests (a real Socket.IO server and client, no browser):

Shell
node --test examples/custom-feed/test/feed.test.mjs examples/custom-feed/test/sql-source.test.mjs

npm test at the root runs both, with the chart's own tests. The SQL tests use the SQLite module built into Node.js 22.5 and later; on older Node.js they are skipped.

The example's only runtime dependency is socket.io (socket.io-client is needed for the tests). Outside the kandlo tree, run npm install in the example folder first, then npm start.

What was checked in a browser#

The example feed was run against the chart page in Chromium, both on its own and embedded through the postMessage bridge:

  • 1000 M1 candles on load, and every candle on screen equal to the candle in the store (open, high, low, close compared exactly, 999 of 999).
  • Paging back three pages took the chart to 2800 candles, all equal to the store. No synthetic history was added (__kiHistoryPrefetch.status().synthFloorTime stayed 0).
  • On IDX500, which does not trade at weekends, paging stepped over a 48-hour closure: 8800 candles, all equal to the store, with the gap where the market was closed.
  • No errors in the page console.

2. Connect it to your data#

feed-adapter.mjs implements the protocol. It needs a source object with four methods:

JavaScript
const source = {
  // Every symbol the chart may show.
  listAssets() {
    return [{ symbol: 'EURUSD', label: 'EUR/USD', type: 'currency', digits: 5 }];
  },

  // Candles of `period` seconds, oldest first: the `limit` most recent ones
  // whose open time is earlier than `before`. before = Infinity means "up to
  // now, including the candle that is still forming".
  async getCandles(symbol, period, { before, limit }) {
    return [{ time: 1792108800, open: 1.0677, high: 1.0692, low: 1.0644, close: 1.0647, volume: 1563 }];
  },

  // The last trade, or null. Used to place the newest candle's close.
  lastTick(symbol) {
    return { time: 1792195199.5, price: 1.0647 };
  },

  // Call fn({ symbol, time, price }) for every new trade. Return an unsubscribe function.
  subscribe(fn) {
    myTickBus.on('tick', fn);
    return () => myTickBus.off('tick', fn);
  },
};

Then start the server with your source instead of the memory one:

JavaScript
import { createFeedServer } from './feed-adapter.mjs';
import { source } from './my-db-source.mjs';

await createFeedServer({
  source,
  port: 8443,
  allowedOrigins: ['https://charts.example.com'],   // the chart page's origin
  // authorize: async (req) => ...,                 // your session check, see 06-security.md
});

2.1 Bars in a SQL database#

examples/custom-feed/sql-source.mjs (examples/custom-feed/sql-source.mjs) is a ready source for bar tables in PostgreSQL, MySQL 8, SQLite 3.25+ or any database with window functions. It imports no driver: you give it a query(sql, params) function that runs a statement with your driver and returns the rows as objects.

Tables it expects (names configurable, columns fixed):

SQL
CREATE TABLE bars_1m (
  symbol  text    NOT NULL,
  time    bigint  NOT NULL,   -- bar open time, unix seconds, UTC
  open    numeric NOT NULL, high numeric NOT NULL, low numeric NOT NULL, close numeric NOT NULL,
  volume  bigint  NOT NULL DEFAULT 0,
  PRIMARY KEY (symbol, time)
);
-- Optional: the symbol list. Or pass `assets` to createSqlSource().
CREATE TABLE assets (symbol text PRIMARY KEY, label text, type text, digits integer);

A feed server for PostgreSQL (npm install pg; feed-adapter.mjs and sql-source.mjs copied next to it):

JavaScript
import pg from 'pg';
import { createFeedServer } from './feed-adapter.mjs';
import { createSqlSource } from './sql-source.mjs';

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const source = await createSqlSource({
  query: async (sql, params) => (await pool.query(sql, params)).rows,
  placeholders: 'dollar',                       // $1, $2, ...; leave out for ? drivers
  tables: [{ base: 60, name: 'bars_1m' }],      // add { base: 1, name: 'bars_1s' } for S1 to S30
  pollMs: 1000,
});
source.start();
await createFeedServer({ source, port: 8443, allowedOrigins: ['https://charts.example.com'] });

For MySQL with mysql2/promise, use query: async (sql, params) => (await pool.query(sql, params))[0] and keep the default ? placeholders.

What it does:

  • History and paging: the aggregation query below, run on the coarsest table whose bar length divides the requested period. Listing a one-hour or one-day table in tables makes H4 and D1 requests cheap on long histories. Each symbol is offered only the timeframes some table can serve.
  • Live candles: every pollMs it reads the finest table from one bar before the newest bar it has seen (so a late correction to the previous bar is caught), and turns each new or changed bar into ticks (the open of a new bar, then a new high, a new low and the close). The chart builds the forming candle from those ticks, so its high and low match the table. If your ingest process can tell the feed about trades directly (a queue, a database notification channel), call source.pushTick({ symbol, time, price }) and set pollMs: 0.
  • Numbers that the driver returns as strings (bigint and numeric in PostgreSQL) are converted.

Checked: examples/custom-feed/test/sql-source.test.mjs (run by npm test) fills SQLite with the memory store's bars and compares every candle for nine timeframes, latest and paged, plus the weekend gap, polling and the feed adapter end to end. The same comparison was run once on PostgreSQL 18 with an integer time column, numeric prices and a bigint volume: 25,701 candles identical, and polling produced the expected ticks. MySQL was not run.

2.2 The query#

getCandles is one query. With one-minute bars in a table bars_1m(symbol, time, open, high, low, close, volume) and a period that is a multiple of 60 seconds:

SQL
WITH b AS (
  SELECT time - time % :period AS t, time, open, high, low, close, volume
  FROM bars_1m
  WHERE symbol = :symbol AND time < :before_aligned
),
w AS (
  SELECT t, high, low, volume,
         FIRST_VALUE(open)  OVER (PARTITION BY t ORDER BY time) AS o,
         FIRST_VALUE(close) OVER (PARTITION BY t ORDER BY time DESC) AS c
  FROM b
)
SELECT t AS time, MIN(o) AS open, MAX(high) AS high, MIN(low) AS low,
       MIN(c) AS close, SUM(volume) AS volume
FROM w
GROUP BY t
ORDER BY t DESC
LIMIT :limit;          -- reverse the rows to get them oldest first

:before_aligned is before - before % period. This query was run with SQLite on the example's own one-minute data and returned the same 700 candles as memory-source.mjs (M5, M15, H1, H4 and D1, open, high, low, close and volume identical). For the timeframes below one minute (S1 to S30) you need bars or trades at one-second resolution; the example keeps a one-second table for the last 12 hours.

Two rules matter more than the query itself:

  • Ask for "the N candles before time", not "the candles between time - offset and time". Weekends, holidays and trading halts leave windows with no data while older data exists. An empty reply tells the chart there is no more history, and it stops asking until the pane loads a new symbol or timeframe.
  • Return times in unix seconds, oldest first, aligned to the period. A D1 candle opens at 00:00 UTC (time % 86400 === 0), an H4 candle at a multiple of 14400, and so on. Store and send true UTC times, not local times: the chart converts them for display (each viewer's local time by default, or the zone set by AppData['userTimeZoneOffset'], see API reference).

3. Point the chart at your feed#

The feed URL is static configuration in apps/web/index.html, in the inline var AppData = {...} object that runs before app.js:

JavaScript
"is-relay-feed": 1,
"relay-servers-list": [{ "server": "wss://feed.example.com", "name": "Primary" }],
  • is-relay-feed must stay 1; it selects relay-servers-list.
  • server is the Socket.IO URL without the /socket.io/ path.
  • Use wss:// when the chart page is served over https://. The browser refuses a ws:// connection from an https page.
  • Several entries are dialled at the same time and the first one to connect is used; the others are closed. A server that fails to connect, or does not answer auth within 12 seconds, is skipped and the rest are tried again.
  • The URL is read once, when app.js starts. It cannot be changed from the embedding page or through the postMessage API. Edit index.html before npm run build:static (or edit dist/index.html afterwards).

4. The protocol#

Transport: Socket.IO v4, websocket transport only, default path /socket.io/. The client connects with io.connect(url, { transports: ['websocket'], timeout: 10000 }).

4.1 Binary frames#

Send these events as binary frames holding UTF-8 JSON, as the example does:

Event Direction
updateAssets server to chart
updatePayouts server to chart
successupdateBalance server to chart
updateHistoryNewFast server to chart
updateStream server to chart
loadHistoryPeriodFast server to chart (the history-page reply)

In Node:

JavaScript
const emitBinary = (socket, event, payload) =>
  socket.emit(event, Buffer.from(JSON.stringify(payload), 'utf8'));

The chart decodes a binary payload as UTF-8 JSON. It also takes a plain object or array as it is, so socket.emit(event, payload) works too.

successauth (no payload) and updateTime (a number) are plain events.

4.2 Handshake#

Text
chart  -> server   auth                  { token, balance, isFastHistory: true }   (plain)
server -> chart    successauth           (no payload)
server -> chart    updateAssets          binary: [assetRow, ...]
server -> chart    updatePayouts         binary: []
server -> chart    updateTime            1792195200            (unix seconds, plain number)
server -> chart    successupdateBalance  binary: { balance: 0, isDemo: 1 }
  • Answer auth with successauth within 12 seconds, or the chart drops the connection and tries the next server in the list.
  • The auth payload carries a token the chart generates itself. It is not a user credential; do not treat it as one. See Security for access control.
  • Never emit NotAuthorized. The chart reloads the whole page when it receives it.
  • Send updateTime again every second or two. It is the server clock the chart uses between ticks.
  • updatePayouts and successupdateBalance carry no chart data. They are part of the sequence the client expects; send them as shown.

Asset rows. updateAssets is an array with one 19-slot row per symbol:

Slot Field Notes
0 id Unique number.
1 symbol Unique string, the key everything else uses (changeSymbol, ticks, the embed API).
2 label Display name, for example EUR/USD.
3 type currency, cryptocurrency, commodity, stock or index. Groups the symbol in the picker.
4 digits Price decimals shown on the axis.
5 to 13 not used by the chart Send 0 (the example sends id in slots 10 and 11).
14 active true.
15 timeframes Seconds offered for this symbol, for example [60, 300, 3600, 86400]. Reported to embedding pages by listSymbols().
16 to 18 not used by the chart Send 0, 5, 0.

Example row:

JSON
[1000, "EURUSD", "EUR/USD", "currency", 5, 0, 0, 0, 0, 0, 1000, 1000, 0, 0, true,
 [1, 5, 10, 15, 30, 60, 120, 180, 300, 600, 900, 1800, 3600, 14400, 86400], 0, 5, 0]

If the chart has no saved symbol and none is passed to it (see API reference), it picks one from this list by itself. Embedding pages should pass the symbol they want.

4.3 Selecting a symbol or timeframe: history#

Text
chart  -> server   changeSymbol           { asset: "EURUSD", period: 60 }
server -> chart    updateHistoryNewFast   binary: { asset, period, history, candles }

The chart sends changeSymbol after successauth, and again whenever a pane changes symbol or timeframe. period is in seconds, one of 1, 5, 10, 15, 30, 60, 120, 180, 300, 600, 900, 1800, 3600, 14400, 86400.

JSON
{
  "asset": "EURUSD",
  "period": 60,
  "candles": [
    [1792255980, 1.06912, 1.06920, 1.06925, 1.06905, 214],
    [1792256040, 1.06920, 1.06918, 1.06931, 1.06911, 187]
  ],
  "history": [[1792256099.5, 1.06918]]
}

candles rows are [time, open, CLOSE, high, low, volume].

  • Slot 5, volume, is optional. Without it, the volume-based indicators count price changes instead. Slots 6 to 9 (buy volume, sell volume, exact-split flag, trade count) are optional order-flow data.
  • Times are unix seconds, ascending, aligned to the period. Send up to 1000 rows; include the candle that is still forming.

history is a list of [time, price] points, and it decides what is drawn:

  • For period >= 5, the chart turns each candle into four price points and keeps only the points that are older than the newest history point. An empty history therefore drops every candle. (Measured: 1000 candles with history: [] drew 51 candles, built from the live ticks that came later.) Send at least one point: the last trade, or the newest candle's close at time + period - 1.
  • For period < 5 (S1) the chart ignores candles and draws history alone. Send one [time, price] point per second (the example sends the close of each one-second bar).
  • history must always be an array; candles must always be an array (send [] for S1).

Rules the chart applies to the reply:

  • It is accepted only when asset and period equal the pane's current symbol and timeframe.
  • It is accepted once per selection. Sending it again for the same symbol and timeframe is ignored until the symbol or timeframe changes or the connection re-authenticates.
  • An unknown symbol or an unsupported period: do not reply. The chart keeps what it was showing. (The chart's own symbol list only offers your symbols, and the embed API refuses unknown symbols before they reach the feed.)

With more than one pane on screen (VERTICAL_2 and the other layouts), each pane sends its own changeSymbol. Keep sending ticks for every symbol a connection has selected, not only the last one; the example keeps the eight most recent.

4.4 Live ticks#

Text
server -> chart    updateStream   binary: [[symbol, time, price], ...]
JSON
[["EURUSD", 1792256101.243, 1.06921]]
  • time is unix seconds and may have a fraction.
  • The chart builds the current candle from the ticks and opens a new candle when a tick crosses the period boundary. There is no separate "new candle" message.
  • Ticks for symbols a pane is not showing are ignored, so a batch may mix symbols.
  • A tick also advances the chart's clock, like updateTime.

4.5 History paging (panning left)#

Text
chart  -> server   loadHistoryPeriod       { asset, index, time, offset, period }   (plain)
server -> chart    loadHistoryPeriodFast   binary: { asset, index, period, data }

The chart asks for older data when the user pans or zooms towards the start of what it has, and also ahead of time while the user approaches it.

Request field Meaning
asset Symbol.
index Request id. Echo it unchanged. It can be a large integer, for example 17923160337.
time Open time of the oldest candle the chart has. Return candles older than this.
offset The span the chart would like, in seconds: 800 * period below one minute, 600 * period from one minute up.
period Seconds. Echo it. A reply without period is read as S1.

Reply data:

  • period > 1: rows { time, open, high, low, close, volume? } (plus the optional buyVolume, sellVolume, deltaIsExact, tradeCount), oldest first.
  • period === 1: rows { time, price }.
  • data: [] means there is nothing older. The pane stops requesting history until it loads a new symbol or timeframe. Only send it when the data really ends; see the weekend rule in 2.2.

The reply is matched by index and asset; a reply for a symbol the pane no longer shows is ignored. The client sends loadHistoryPeriod; the example also answers loadHistoryPeriodFast sent by older clients, at no cost.

4.6 Events the chart sends that you can ignore#

ps (a ping, every 60 seconds of server time), subscribeSymbol, unSubscribeSymbol, subfor, unsubfor, chafor, updateBalance, saveCharts, deleteChart, indicator/*, drawing/*. Socket.IO drops events that have no handler, so none of them need code. The example registers empty handlers for the common ones to make that visible.

Indicators and drawings are not stored by the feed: they do not survive a reload of the chart page. An embedding page can re-apply indicators after ready (the React component's indicators prop does this for you).


5. Checklist and common mistakes#

Symptom Cause
SyntaxError: Unexpected end of JSON input in the page console One of the events in 4.1 was sent as a JSON string. Send a binary frame or the plain value.
Only a few dozen candles although you sent 1000 history is empty, or its newest point is older than your candles (4.3).
Candles with wicks on the wrong side, bodies inverted Candle slots in the order open, high, low, close. The order is open, close, high, low.
The chart stays empty and keeps reconnecting No successauth within 12 seconds, or the URL in relay-servers-list is wrong (ws:// from an https page is refused).
The page reloads by itself The server sent NotAuthorized.
Panning left stops at a weekend or holiday The paging query returned [] for an empty window. Return the N candles before time instead.
Panning left shows prices your database does not have The page reply never arrived (wrong event name, a JSON string, missing index or period), and the standalone chart page filled the gap with generated data after 1.5 seconds. Fix the reply. In an embed, generated history is off by default (synth=1 turns it on for demos). On the standalone page, add `window.__kiFeature = Object.assign(window.__kiFeature
A second pane does not move Ticks are only sent for the last symbol selected on the connection (4.3).

About the bundled relay#

apps/gateway/src/relay.js (npm run relay) is a demonstration feed for public market data and the offline FEED=otc simulator. It is not a template for your server: it accepts connections from any origin and has no authorisation hook. Start from examples/custom-feed/ instead.

Type to search all guides.