Custom Data Sources
A chart has one data adapter. That's usually right - the app knows where its data comes from. But sometimes the user knows: they have a Massive key, or an Alpaca account, or they want a spread of two instruments the adapter has never heard of.
ctx.registerDataSource lets a plugin claim symbols and serve them itself. A
source is the same interface the host's data adapter implements, with a claim on
top - so the chart treats its symbols exactly like the adapter's own. Same
picker, same drawings, same order entry, same replay, down to the same code path.
myExtension.onInstall = (ctx) => {
return ctx.registerDataSource({
symbols: ['SPREAD:ES-NQ'],
fetchBars: async ({ symbolInfo, range, timeframe }) => ({
bars: await buildSpread(symbolInfo.symbol, timeframe.barNs, range),
hasMore: true,
}),
})
}Requires the data:write permission.
Claiming symbols
Two ways, and which you want depends on whether you can list them up front.
| Prop | Type | Default | Description |
|---|---|---|---|
| symbols | string[] | - | Exact symbols this source owns. Right for derived instruments - you know there are three spreads because you defined three. |
| prefix | string | - | A namespace: every symbol starting with this is yours. Right for a venue, where the universe is thousands of tickers you're not going to enumerate. '' claims everything the adapter didn't. |
An exact match always beats a prefix, and the longest matching prefix wins - so
registering '' as a catch-all doesn't stop a later MASS: source from
claiming its own.
Note
Symbols are conventionally namespaced TYPE:DETAILS — SPREAD:ES-NQ,
MASS:AAPL. Nothing enforces it, but a bare AAPL from a plugin will collide
with the adapter's AAPL and one of them will lose.
Describing them
resolveSymbol is where the chart learns what it's drawing. Skip it and you get
a 24/7 two-decimal fallback, which means wrong price formatting, wrong tick
rounding, and an order ticket sizing against a tick value that isn't real.
resolveSymbol: (symbol) => ({
symbol,
description: symbol.slice(5),
type: 'stock',
dataLevel: 'ohlcv',
priceFormat: { minTick: 0.01, precision: 2 },
timezone: 'America/New_York',
contract: { tickSize: 0.01, multiplier: 1, currency: 'USD' },
})It's resolved once and cached, and it's emitted before the first bar - the price axis formats the first frame it sees.
Getting into the picker
Sources with a symbols list show up automatically; the picker matches against
them locally. A prefix source has to answer for itself:
searchSymbols: async ({ query, limit }) => {
const res = await ctx.fetch(
`https://api.massive.com/v3/reference/tickers?search=${query}&limit=${limit ?? 20}&apiKey=${key}`
)
const { results } = await res.json()
return results.map((r) => ({
symbol: `MASS:${r.ticker}`,
description: r.name,
type: 'stock',
dataLevel: 'ohlcv',
priceFormat: { minTick: 0.01, precision: 2 },
}))
}Results are merged with the adapter's and shown first - someone who installed a source is usually looking for its symbols, and a large adapter universe would bury them otherwise. If it throws, the search still returns the adapter's results; a rate-limited plugin shouldn't break the picker.
If you're searching a real API rather than filtering a list you already hold,
say so in getCapabilities:
getCapabilities: () => ({ symbolSearch: 'server', symbolSearchDebounceMs: 300 })That's one request per pause in typing instead of one per character, which matters when every request spends somebody's rate limit.
Serving the data
One method. fetchBars covers the first window, panning back, playback running
forward, and jumping to last March - direction tells you which, and plenty of
sources treat them all the same.
fetchBars: async ({ symbolInfo, range, timeframe, direction }) => {
const bars = await myApi(symbolInfo.symbol, timeframe.barNs, range.fromMs, range.toMs)
return { bars, hasMore: bars.length > 0 }
}| Prop | Type | Default | Description |
|---|---|---|---|
| rangerequired | TimeRange | - | The span to cover, half open. Offered as fromNs/toNs (nanoseconds), fromMs/toMs (unix milliseconds) and fromIso/toIso - use whichever your API speaks. |
| timeframe.barNsrequired | bigint | - | The bar size to return, in nanoseconds. Not necessarily the timeframe on screen: if you declare supportedResolutions, the chart asks for the coarsest one that fits. |
| directionrequired | 'initial' | 'forward' | 'backward' | - | Which edge is being extended. 'forward' is playback and prefetch, 'backward' is a pan into history, 'initial' is a fresh window. |
| supplementalBarNs | bigint | undefined | - | Set only if you declared supplementalResolutions and the timeframe is coarser. Return those finer bars too and playback moves at their resolution. |
What you hand back is bars and hasMore:
{
bars: [{ time, open, high, low, close, volume }], // time is unix ms
hasMore: true,
}Warning
Bars you return carry time in unix milliseconds. Bars you read off the
chart carry ts in nanoseconds. They are different types on purpose and
mixing them up is the usual first bug.
hasMore: false means there is nothing further out in the direction you were
asked for. Backward, that stops the chart asking for older bars. Forward, it's
the end of your data and playback pauses there rather than hanging at the edge.
If you stream live bars, a forward fetch that reaches now has to say hasMore: false
too. That's how the chart knows it caught up after a hole,
and can start your feed again.
Note
Every bar has to open on a multiple of barNs. The chart buckets what you
return onto the columns it draws, so two bars inside one bucket get merged into
one candle - which is what an unaligned start looks like when it goes wrong.
Round it down: const from = range.fromNs - (range.fromNs % timeframe.barNs).
Errors are fine to throw - a rejected fetchBars surfaces as a chart error state
rather than a spinner that never stops.
Replay
Playback asks for bars ahead of the playhead through the same fetchBars, with
direction: 'forward'. Serve those and replay works, at any speed, including
jumps of a month at a time. There is nothing else to implement.
Two things make it better, both optional:
| Prop | Type | Default | Description |
|---|---|---|---|
| getCapabilities().supplementalResolutions | bigint[] | - | Finer bar sizes you can serve alongside a normal fetch, finest first. [1_000_000_000n] says you have 1s data. The chart asks for it via supplementalBarNs and replays at that resolution instead of interpolating across the candle. |
| symbolInfo.supportedResolutions | bigint[] | - | Bar sizes you serve natively, ascending. The chart fetches at the coarsest one that fits the timeframe, and re-fetches finer when the user zooms in. Leave it out and it assumes you can serve any size. |
The rest of the interface is there when you need it, and ignorable when you don't:
| Prop | Type | Default | Description |
|---|---|---|---|
| subscribeRealtime | (request, onData) => () => void | - | Push live bars as they arrive - a socket, a poll. Return the teardown. request has the symbol, the bar size (timeframe.barNs) and since, where the chart's bars end. A new symbol or bar size subscribes again. |
| onTick | (params) => FetchResponse | null | - | A trade arrived on the host's feed. Fold it into a bar and return it, or return null to ignore it. This is the hook for a derived instrument, which has no feed of its own to subscribe to. |
| fetchPreview | (request) => Promise<{ symbol, bars }> | - | Cheap bars for the loading skeleton, drawn before the real fetch resolves. Skip it and the chart just waits. |
| onEvict | (evicted) => void | - | Bars the chart is about to drop from memory. Cache them and serve them back from fetchBars. See Memory. |
| connect | () => Promise<void> | - | Called before the first fetch. Open a socket, refresh a token. |
| destroy | () => void | - | Called when the source is unregistered. |
Bring your own API key
This is the case the whole feature exists for: the user pastes their key into
your plugin's settings, and the chart draws their data. Which means your plugin
is holding a credential, and ctx.fetch has to be worth trusting with it.
So it's an allowlist. Declare the origins you talk to:
const massive = plugin({
name: "Massive",
type: PluginType.extension,
permissions: ['data:write', 'network', 'storage'],
origins: ['https://api.massive.com'],
})ctx.fetch rejects anything not on that list. Path and query don't matter, only
the origin - and a different scheme or port is a different origin.
Warning
network and origins are separate on purpose. network says the plugin may
make requests at all; origins says where to. A plugin with network and no
origins can reach nothing, which is the safe default rather than an oversight
to work around.
Keep the key in ctx.storage (namespaced per plugin) and read it at install
time. Don't put it in the script - scripts get shared.
A complete one
const massive = plugin({
name: "Massive",
type: PluginType.extension,
permissions: ['data:write', 'network', 'storage'],
origins: ['https://api.massive.com'],
})
massive.onInstall = (ctx) => {
const key = ctx.storage.get('apiKey', '')
return ctx.registerDataSource({
prefix: 'MASS:',
resolveSymbol: (symbol) => ({
symbol,
description: symbol.slice(5),
type: 'stock',
dataLevel: 'ohlcv',
priceFormat: { minTick: 0.01, precision: 2 },
timezone: 'America/New_York',
contract: { tickSize: 0.01, multiplier: 1, currency: 'USD' },
}),
searchSymbols: async ({ query, limit }) => {
const res = await ctx.fetch(
`https://api.massive.com/v3/reference/tickers?search=${query}&limit=${limit ?? 20}&apiKey=${key}`
)
const { results } = await res.json()
return results.map((r) => ({
symbol: `MASS:${r.ticker}`,
description: r.name,
type: 'stock',
dataLevel: 'ohlcv',
priceFormat: { minTick: 0.01, precision: 2 },
}))
},
fetchBars: async ({ symbolInfo, range, timeframe }) => {
const ticker = symbolInfo.symbol.slice(5)
const minutes = Number(timeframe.barNs / 60_000_000_000n) || 1
const res = await ctx.fetch(
`https://api.massive.com/v2/aggs/ticker/${ticker}/range/${minutes}/minute/${range.fromMs}/${range.toMs}?apiKey=${key}`
)
const { results } = await res.json()
const bars = (results ?? []).map((b) => ({
time: b.t, open: b.o, high: b.h, low: b.l, close: b.c, volume: b.v,
}))
return { bars, hasMore: bars.length > 0 }
},
})
}Return the unregister function from onInstall - unregistering releases the
symbols, and the picker stops offering them.
Things worth knowing
- Everything goes through
data:write. A source can put any numbers it likes on the chart, so it's a permission, not a free capability. - A symbol you claim never reaches the host adapter. It isn't fetching in the background, and it is never asked to resolve a symbol that isn't its.
- Bars are the only level a source can serve. There's no way to feed L2 or L3 from a plugin yet.
- Nothing here is plugin-specific. The same object satisfies
IDataAdapter, so a source you write in a script is a real adapter you could hand a chart directly.