<!-- Public documentation export; not library implementation source. -->
# Build candles from ticks

`createTickCandleAggregator` turns a stream of price ticks into the complete OHLCV bars that `subscribeBars` needs. You'll find it in the licensed browser bundle as `LumenChartingLibrary.createTickCandleAggregator`, and as a named export from the ESM and CommonJS entry points. It doesn't need any provider SDK or framework. The licensed distribution also includes a small standalone script, `assets/tick-candle-aggregator.js`. It exposes `LumenChartingLibraryDatafeed.createTickCandleAggregator` so you can use the helper without loading a chart widget.

The helper only builds candles. Your adapter still handles symbol mapping, WebSocket sign-in, reconnects, history requests and subscription cleanup. Create **one aggregator for each subscriber ID**, even when subscribers share a socket.

## Quick start

If your provider already sends complete candles for the requested timeframe, skip this helper and use the [complete-candle example](https://docs.lumencharts.com/charting-library/datafeed/realtime#example-complete-candles). Both paths feed the same realtime callback.

```js
const candles = LumenChartingLibrary.createTickCandleAggregator({
  intervalMs: 5 * 60_000,
  volumeMode: 'incremental',
});

candles.push({ time: 1_000, price: 100, volume: 2 });
candles.push({ time: 2_000, price: 103, volume: 1 });
const current = candles.push({ time: 3_000, price: 101, volume: 4 });
// current.bar: { time: 0, open: 100, high: 103, low: 100, close: 101, volume: 7 }
// current.isNewBar: false

const next = candles.push({ time: 300_000, price: 102, volume: 1 });
// next.bar.time: 300000; next.isNewBar: true
// next.closedBar: the completed candle starting at 0
```

All times are Unix **milliseconds**, candle timestamps included. By default a candle starts at `floor(tick.time / intervalMs) * intervalMs`. The open stays the same, high and low stretch to the extremes, and close follows the latest accepted tick. The first tick in a later interval returns the finished candle as `closedBar`. The helper has no timer, so it never calls back during a quiet interval and never invents candles to fill gaps.

## Match your history boundaries

The helper's default UTC buckets must match the historical candles you supply through `getBars`. With `intervalMs: 3_600_000`, ticks at `11:06` and `11:59` update the H1 candle starting at `11:00`. The first tick at or after `12:00` starts the next candle.

Align historical aggregation requests to those boundaries too. For example, a Polygon `60/minute` request starting at `11:06` can produce offset candles that don't match the helper's whole-hour buckets. See the [historical alignment example](https://docs.lumencharts.com/charting-library/datafeed/history#example-polygon-minute-multipliers) for the request conversion.

Don't fix a mismatch by rounding returned candle timestamps or passing a misaligned seed to `reset` or `reconcile`. Those methods don't rebuild OHLCV from the original trades or quotes. Request correctly aligned history, or aggregate the underlying data using the same boundary policy. The chart itself doesn't realign your provider's candles.

For exchange-session or calendar-based intervals, configure [custom boundaries](https://docs.lumencharts.com/charting-library/datafeed/tick-aggregation#exchange-sessions-and-calendar-bars) with `bucketStart` and use the same calendar policy in your history adapter.

## API

| Method / option | Behavior |
| --- | --- |
| `intervalMs` | Required positive integer. Supports fixed durations, including seconds, minutes, and UTC days. |
| `volumeMode` | `'none'` (default) ignores tick volume; `'incremental'` adds each tick's volume delta. |
| `bucketStart(time)` | Optional function returning an integer bucket start at or before the tick. Use for session or calendar alignment. |
| `push({ time, price, volume? })` | Returns `{ bar, isNewBar, closedBar? }`, or `null` for an older tick/bucket. |
| `reset(lastBar?)` | Clears ordering and current state, optionally seeding a complete history bar. |
| `reconcile(bar, preserveLive = true)` | Merges a history/provider snapshot. Same-bucket live high/low and close are preserved by default; volume is replaced. Older bars return `null`. |
| `snapshot()` | Returns a frozen copy of the current bar, or `null`. |

`bar` contains `time`, `open`, `high`, `low`, `close` and `volume`. Changing an update or snapshot can't affect the helper's internal state. Invalid timestamps, non-finite prices, negative volume, impossible OHLC values or invalid options all throw an error before anything changes. Negative prices are allowed for markets that have them, as long as they're finite.

Ticks with the same timestamp are applied in the order they arrive. The helper can't spot duplicate provider events without an ID, so remove duplicate trade IDs before passing incremental volume. Don't pass a running session or per-minute volume total as if it were the volume of a single tick.

## Connect it to subscribeBars

This example assumes your history adapter caches its latest bar per symbol/resolution, and your transport's `subscribe` method returns an unsubscribe function. The socket can be shared; each subscriber still gets its own aggregator.

```js
const subscriptions = new Map();
const lastHistoryBars = new Map(); // Populate from successful getBars responses.
const intervals = { '1': 60_000, '5': 300_000, '15': 900_000, '60': 3_600_000, '1D': 86_400_000 };

function subscribeBars(symbolInfo, resolution, onRealtime, subscriberId) {
  unsubscribeBars(subscriberId);
  const symbol = symbolInfo.ticker || symbolInfo.name;
  const intervalMs = intervals[resolution];
  if (!intervalMs) throw new Error(`Unsupported resolution: ${resolution}`);
  const candles = LumenChartingLibrary.createTickCandleAggregator({ intervalMs });
  const last = lastHistoryBars.get(`${symbol}|${resolution}`);
  if (last) candles.reset({ ...last, volume: last.volume ?? 0 });

  const entry = { candles, close: () => {} };
  subscriptions.set(subscriberId, entry);
  entry.close = transport.subscribe(symbol, tick => {
    if (subscriptions.get(subscriberId) !== entry) return;
    const update = candles.push(tick);
    if (update) onRealtime({ ...update.bar });
  });
}

function unsubscribeBars(subscriberId) {
  const entry = subscriptions.get(subscriberId);
  subscriptions.delete(subscriberId);
  entry?.close();
}
```

The library gets a full bar every time. If the timestamp is the same, the current candle is updated; if it's newer, a new candle is added. How often ticks arrive has nothing to do with the selected timeframe.

## Normalize different providers

The aggregator never sees provider-specific messages. Your transport translates them first:

```js
// Provider A: executed trade, already timestamped in milliseconds.
const tradeTick = { time: message.timestampMs, price: message.tradePrice, volume: message.tradeSize };

// Provider B: bid/ask quote, timestamped in seconds. Choose a consistent price basis.
const quoteTick = { time: Math.trunc(message.timestampSeconds * 1000), price: (message.bid + message.ask) / 2 };

// Provider C: cumulative volume is NOT a per-trade delta. Omit it here.
const priceTick = { time: message.timeMs, price: message.lastPrice };
```

Check provider message formats and trade conditions in your transport. Keep in mind that a forex quote's midpoint isn't an actual trade. Also, providers sometimes leave certain stock trades out of their aggregate bars. If you include those trades, your live OHLC can differ from the provider's historical bars until they're reconciled.

## Volume and history reconciliation

For quote feeds, or feeds that mix ticks with aggregate bars, keep the default volume mode. Apply provider OHLCV snapshots with `reconcile`. Their volume replaces the current volume, so it isn't counted twice.

```js
const merged = candles.reconcile(providerBar);
if (merged) onRealtime({ ...merged });
```

Sometimes a snapshot for the current interval should win over the live values. In that case, call `reconcile(providerBar, false)`. This also resets tick ordering, so only do it once you've decided the snapshot replaces the live data.

Don't call `reset` on every tick. Seed the helper once before subscribing, then use `reconcile` only for history or aggregate updates. When you reconnect, fetch any missing history and use your subscription ID or a generation counter to ignore results that arrive too late. The helper can't tell whether a REST response was fetched before or after a given WebSocket tick. If older candles need correcting, resetting the chart's history cache is still up to your adapter.

## Exchange sessions and calendar bars

By default, intervals are fixed blocks of UTC time. That doesn't account for exchange holidays, daylight-saving changes, weekly or monthly calendars, or bars based on tick counts. If any of those apply, pass a `bucketStart` function that uses your market calendar. Its results must match the timestamps your history provider uses.

```js
const candles = LumenChartingLibrary.createTickCandleAggregator({
  intervalMs: 5 * 60_000,
  bucketStart(time) {
    const sessionStart = marketCalendar.sessionStartFor(time);
    return sessionStart + Math.floor((time - sessionStart) / 300_000) * 300_000;
  },
});
```

Filter out ticks from outside the trading session before calling `push`. The helper keeps prices exactly as precise as your adapter sends them. Rounding and display precision are up to your provider and display settings.

---
Documentation page: https://docs.lumencharts.com/charting-library/datafeed/tick-aggregation
