SPB Git

spb/hfmarketdata

Public

Open high-frequency market data platform — FirstRate full-history downloader, DuckDB/Parquet lake, open REST API and React docs platform (www.hfmarketdata.io)

web: charts — couche data « zéro limite » : en-tête X-HFMD-Client sur tous les appels, fenêtres initiales 10 000–20 000 barres, pagination arrière chaînée (prefetch < 1 000 barres) pour la série et les comparaisons, plus de /v1/limits ni de clé API, erreurs 429/5xx génériques ; état multi-charts (layout=, s2=) et templates localStorage ; fixtures e2e sans en-têtes X-RateLimit

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Sep 7, 2026) parent 00cc7af Browse files

7 changed files +256 −120

modified hfmarketdata/web/e2e/fixtures/charts.js +17 −16
@@ -1,9 +1,7 @@
1 // Synthetic OHLCV fixtures + route handler for the /charts E2E spec. Mirrors the real shapes: 1 // Synthetic OHLCV fixtures + route handler for the /charts E2E spec. Mirrors the real shapes:
2 // GET /v1/{asset}/tickers → { asset, timeframe, adjustment, count, tickers } · GET /v1/bars/{asset}/{ticker} (legacy 2 // GET /v1/{asset}/tickers → { asset, timeframe, adjustment, count, tickers } · GET /v1/bars/{asset}/{ticker} (legacy
3−// envelope { count, data }, `order=desc`, `end` inclusive, `limit`) · GET /v1/futures/roots → { data, meta } · 3+// envelope { count, data }, `order=desc`, `end` inclusive, `limit` ≤ 50 000) · GET /v1/futures/roots → { data, meta }.
4−// GET /v1/limits → { data: { tiers, principal } }. 4+// The site's own requests carry `X-HFMD-Client: charts` and get NO X-RateLimit headers (principal `site`).
5−import { RATE } from '../mocks.js'
6−
7 const pad2 = n => String(n).padStart(2, '0') 5 const pad2 = n => String(n).padStart(2, '0')
8 const stamp = (ms, daily) => { 6 const stamp = (ms, daily) => {
9 const d = new Date(ms) 7 const d = new Date(ms)
@@ -37,37 +35,40 @@ export function makeSeries(ticker, timeframe, n, seed = 7) {
37 35
38 const TICKERS = { stock: ['AAPL', 'AAPG', 'AAP', 'MSFT', 'TSLA', 'NVDA', 'AMZN'], etf: ['SPY', 'QQQ', 'IWM'], index: ['SPX', 'NDX', 'VIX'], crypto: ['BTC', 'ETH'], fx: ['EURUSD', 'USDJPY'], futures: ['ES', 'CL', 'GC'] } 36 const TICKERS = { stock: ['AAPL', 'AAPG', 'AAP', 'MSFT', 'TSLA', 'NVDA', 'AMZN'], etf: ['SPY', 'QQQ', 'IWM'], index: ['SPX', 'NDX', 'VIX'], crypto: ['BTC', 'ETH'], fx: ['EURUSD', 'USDJPY'], futures: ['ES', 'CL', 'GC'] }
39 const ROOTS = [{ root: 'ES', name: 'E-mini S&P 500', exchange: 'CME', tick_size: 0.25, tick_value: 12.5 }, { root: 'CL', name: 'Crude Oil WTI', exchange: 'NYMEX', tick_size: 0.01, tick_value: 10 }, { root: 'GC', name: 'Gold', exchange: 'COMEX', tick_size: 0.1, tick_value: 10 }] 37 const ROOTS = [{ root: 'ES', name: 'E-mini S&P 500', exchange: 'CME', tick_size: 0.25, tick_value: 12.5 }, { root: 'CL', name: 'Crude Oil WTI', exchange: 'NYMEX', tick_size: 0.01, tick_value: 10 }, { root: 'GC', name: 'Gold', exchange: 'COMEX', tick_size: 0.1, tick_value: 10 }]
40−const LIMITS = { data: { tiers: {}, principal: { principal: 'ip:x', kind: 'keyless', tier: 'keyless', window_seconds: 3600, max_rows_per_request: 5000, requests: { limit: 30, remaining: 27, reset: Math.floor(Date.now() / 1000) + 1800 }, rows: { limit: 100000, remaining: 99000, reset: Math.floor(Date.now() / 1000) + 1800 } } }, meta: { count: 1 } }
41 38
42−const json = (body, status = 200, headers = {}) => ({ status, contentType: 'application/json', headers: { ...RATE, 'X-Row-Count': String(Array.isArray(body?.data) ? body.data.length : 0), ...headers }, body: JSON.stringify(body) }) 39+// site requests: no X-RateLimit-* header at all
40+const json = (body, status = 200, headers = {}) => ({ status, contentType: 'application/json', headers: { 'X-Row-Count': String(Array.isArray(body?.data) ? body.data.length : 0), ...headers }, body: JSON.stringify(body) })
43 const err = (code, message, status, headers = {}) => json({ error: { code, message, docs: `https://www.hfmarketdata.io/docs/errors#${code.toLowerCase()}` }, detail: message }, status, headers) 41 const err = (code, message, status, headers = {}) => json({ error: { code, message, docs: `https://www.hfmarketdata.io/docs/errors#${code.toLowerCase()}` }, detail: message }, status, headers)
44 42
45 /** 43 /**
46− * Route handler factory. `state.calls` collects every bars request; `state.total` = history depth (bars available); 44+ * Route handler factory. `state.calls` collects every bars request (with the `client` header); `state.total` =
47− * `state.rateLimit` = true → every bars call answers 429 with Retry-After. 45+ * history depth (bars available); `state.rateLimit` = true → every bars call answers 429 (the server's burst guard);
46+ * `state.serverError` = true → 503.
48 */ 47 */
49 export function chartsHandler(state = {}) { 48 export function chartsHandler(state = {}) {
50 const series = new Map() 49 const series = new Map()
51 const seriesFor = (ticker, tf) => { const k = `${ticker}|${tf}`; if (!series.has(k)) series.set(k, makeSeries(ticker, tf, state.total ?? 4200, ticker.length * 13)); return series.get(k) } 50 const seriesFor = (ticker, tf) => { const k = `${ticker}|${tf}`; if (!series.has(k)) series.set(k, makeSeries(ticker, tf, state.total ?? 4200, ticker.length * 13)); return series.get(k) }
52 state.calls = state.calls || [] 51 state.calls = state.calls || []
53− return (url) => { 52+ state.listCalls = state.listCalls || []
53+ return (url, _route, req) => {
54 const p = url.pathname 54 const p = url.pathname
55− if (p === '/v1/limits') return json(LIMITS) 55+ const client = req ? req.headers()['x-hfmd-client'] || null : null
56− if (p === '/v1/futures/roots') return json({ data: ROOTS, meta: { count: ROOTS.length } }) 56+ if (p === '/v1/futures/roots') { state.listCalls.push({ path: p, client }); return json({ data: ROOTS, meta: { count: ROOTS.length } }) }
57 let m = /^\/v1\/(stock|etf|index|crypto|fx|futures)\/tickers$/.exec(p) 57 let m = /^\/v1\/(stock|etf|index|crypto|fx|futures)\/tickers$/.exec(p)
58− if (m) { const q = (url.searchParams.get('search') || '').toUpperCase(); const list = (TICKERS[m[1]] || []).filter(t => !q || t.includes(q)); return json({ asset: m[1], timeframe: '1day', adjustment: 'none', count: list.length, tickers: list }) } 58+ if (m) { state.listCalls.push({ path: p, client }); const q = (url.searchParams.get('search') || '').toUpperCase(); const list = (TICKERS[m[1]] || []).filter(t => !q || t.includes(q)); return json({ asset: m[1], timeframe: '1day', adjustment: 'none', count: list.length, tickers: list }) }
59 m = /^\/v1\/bars\/(\w+)\/([A-Z0-9.]+)$/.exec(p) 59 m = /^\/v1\/bars\/(\w+)\/([A-Z0-9.]+)$/.exec(p)
60 if (m) { 60 if (m) {
61 const [, asset, ticker] = m 61 const [, asset, ticker] = m
62 const tf = url.searchParams.get('timeframe') || '1day' 62 const tf = url.searchParams.get('timeframe') || '1day'
63− state.calls.push({ asset, ticker, tf, search: url.search }) 63+ state.calls.push({ asset, ticker, tf, search: url.search, client })
64− if (state.rateLimit) return err('RATE_LIMIT_EXCEEDED', 'Rate limit exceeded: 30 requests per hour (keyless).', 429, { 'Retry-After': '90', 'X-RateLimit-Remaining-Requests': '0' }) 64+ if (state.rateLimit) return err('RATE_LIMIT_EXCEEDED', 'Too many chart requests from this address (1200 per minute — an abuse guard, not a quota: the charts have no data limit). Retry in 90 s.', 429, { 'Retry-After': '90' })
65+ if (state.serverError) return err('INTERNAL_ERROR', 'Unexpected server error.', 503)
65 if (!(TICKERS[asset] || []).includes(ticker)) return err('TICKER_NOT_FOUND', `Ticker '${ticker}' not found in ${asset}/${tf}`, 404) 66 if (!(TICKERS[asset] || []).includes(ticker)) return err('TICKER_NOT_FOUND', `Ticker '${ticker}' not found in ${asset}/${tf}`, 404)
66 let rows = seriesFor(ticker, tf) 67 let rows = seriesFor(ticker, tf)
67 const end = url.searchParams.get('end'), start = url.searchParams.get('start') 68 const end = url.searchParams.get('end'), start = url.searchParams.get('start')
68− if (end) { const e = parse(end) + (tf === '1day' ? 0 : 0); rows = rows.filter(r => parse(r.datetime) <= e) } 69+ if (end) { const e = parse(end); rows = rows.filter(r => parse(r.datetime) <= e) }
69 if (start) rows = rows.filter(r => parse(r.datetime) >= parse(start)) 70 if (start) rows = rows.filter(r => parse(r.datetime) >= parse(start))
70− const limit = Math.min(Number(url.searchParams.get('limit') || 5000), 5000) 71+ const limit = Math.min(Number(url.searchParams.get('limit') || 5000), 50_000)
71 if (url.searchParams.get('order') === 'desc') rows = rows.slice().reverse() 72 if (url.searchParams.get('order') === 'desc') rows = rows.slice().reverse()
72 rows = rows.slice(0, limit) 73 rows = rows.slice(0, limit)
73 return json({ count: rows.length, data: rows }, 200, { 'X-Row-Count': String(rows.length) }) 74 return json({ count: rows.length, data: rows }, 200, { 'X-Row-Count': String(rows.length) })
modified hfmarketdata/web/src/charts/data/bars.js +52 −35
@@ -2,21 +2,31 @@
2 // naive wall-clock stamp, see the engine contract), paginates backwards, caches per (asset, ticker, tf, adjustment) 2 // naive wall-clock stamp, see the engine contract), paginates backwards, caches per (asset, ticker, tf, adjustment)
3 // with range merging, and raises typed errors (BarsError.kind = 'rate_limit' | 'not_found' | 'network' | 'http'). 3 // with range merging, and raises typed errors (BarsError.kind = 'rate_limit' | 'not_found' | 'network' | 'http').
4 // 4 //
5+// No quota for the site: every call carries `X-HFMD-Client: charts` — together with the browser's `Sec-Fetch-Site`
6+// and `Origin`, the API recognises the page's own requests (principal `site`, docs/accounts-ratelimit.md) and serves
7+// them without counting, without row limits and without X-RateLimit headers. The page therefore never shows quota
8+// UI; a 429 (the server's DoS burst guard) or a 5xx is a transient error with a retry.
9+//
5 // Endpoints (from /openapi.json): 10 // Endpoints (from /openapi.json):
6 // * legacy GET /v1/bars/{asset}/{ticker}?timeframe&adjustment&start&end&order=desc&limit → { count, data: [rows] } 11 // * legacy GET /v1/bars/{asset}/{ticker}?timeframe&adjustment&start&end&order=desc&limit → { count, data: [rows] }
7 // rows: { ticker, datetime ("2024-06-03" | "2024-06-03 09:30:00", US/Eastern naive), open, high, low, close, volume, open_interest? } 12 // rows: { ticker, datetime ("2024-06-03" | "2024-06-03 09:30:00", US/Eastern naive), open, high, low, close, volume, open_interest? }
8 // * contract GET /v1/futures/contract/{symbol}/bars?interval&from&to&limit → { data: [rows], meta: { next_cursor, timezone: 'UTC' } } 13 // * contract GET /v1/futures/contract/{symbol}/bars?interval&from&to&limit → { data: [rows], meta: { next_cursor, timezone: 'UTC' } }
9 // rows: { symbol, datetime ("2024-12-19T14:30:00Z" intraday UTC | "2024-12-19" daily), … } — no `order` parameter, 14 // rows: { symbol, datetime ("2024-12-19T14:30:00Z" intraday UTC | "2024-12-19" daily), … } — no `order` parameter,
10 // so backwards pagination uses a `from`/`to` window that is widened or shrunk until it holds ~limit rows. 15 // so backwards pagination uses a `from`/`to` window that is widened or shrunk until it holds ~limit rows.
11−import { api, TIERS } from '../../app/api.js' 16+import { api } from '../../app/api.js'
12 17
13 export const TIMEFRAMES = ['1min', '5min', '30min', '1hour', '1day'] 18 export const TIMEFRAMES = ['1min', '5min', '30min', '1hour', '1day']
14 export const TF_LABEL = { '1min': '1m', '5min': '5m', '30min': '30m', '1hour': '1h', '1day': '1D' } 19 export const TF_LABEL = { '1min': '1m', '5min': '5m', '30min': '30m', '1hour': '1h', '1day': '1D' }
15 export const TF_MS = { '1min': 60_000, '5min': 300_000, '30min': 1_800_000, '1hour': 3_600_000, '1day': 86_400_000 } 20 export const TF_MS = { '1min': 60_000, '5min': 300_000, '30min': 1_800_000, '1hour': 3_600_000, '1day': 86_400_000 }
16−/** Bars requested on the first load, per timeframe (capped by the tier's max rows per request). */ 21+/** Bars requested per call, per timeframe: daily = the whole history in one or two calls, intraday 10–20k bars. */
17−export const INITIAL_BARS = { '1day': 1500, '1hour': 1500, '30min': 2000, '5min': 3000, '1min': 3000 } 22+export const INITIAL_BARS = { '1day': 10_000, '1hour': 10_000, '30min': 10_000, '5min': 15_000, '1min': 20_000 }
23+/** Older history is prefetched as soon as fewer than this many bars remain off-screen to the left. */
24+export const PREFETCH_BARS = 1000
25+/** Concurrent history requests per chart (main series + one comparison, or two comparisons). */
26+export const MAX_INFLIGHT = 2
18 export const CONTRACT_ASSET = 'contract' 27 export const CONTRACT_ASSET = 'contract'
19−export const KEYLESS_MAX_ROWS = TIERS.find(t => t.id === 'keyless')?.maxRows || 5000 28+/** Header identifying the page's own requests to the API (principal `site`, no quota). */
29+export const SITE_HEADERS = { 'X-HFMD-Client': 'charts' }
20 30
21 const pad2 = n => String(n).padStart(2, '0') 31 const pad2 = n => String(n).padStart(2, '0')
22 32
@@ -84,8 +94,8 @@ export function toBarsError(e) {
84 const rate = e?.rate || {} 94 const rate = e?.rate || {}
85 if (e?.status === 429) { 95 if (e?.status === 429) {
86 const now = Math.floor(Date.now() / 1000) 96 const now = Math.floor(Date.now() / 1000)
87− const retryUntil = rate.retryAfter ? now + rate.retryAfter : rate.reset || now + 60 97+ const retryUntil = rate.retryAfter ? now + rate.retryAfter : now + 15
88− return new BarsError('rate_limit', e.message || 'Rate limited', { status: 429, code: e.code, retryUntil, rate }) 98+ return new BarsError('rate_limit', e.message || 'The API is busy', { status: 429, code: e.code, retryUntil, rate })
89 } 99 }
90 if (e?.status === 404) return new BarsError('not_found', e.message || 'Unknown symbol', { status: 404, code: e.code, rate }) 100 if (e?.status === 404) return new BarsError('not_found', e.message || 'Unknown symbol', { status: 404, code: e.code, rate })
91 if (!e?.status || e.code === 'NETWORK') return new BarsError('network', e?.message || 'Network error', { status: 0, code: 'NETWORK' }) 101 if (!e?.status || e.code === 'NETWORK') return new BarsError('network', e?.message || 'Network error', { status: 0, code: 'NETWORK' })
@@ -96,7 +106,7 @@ export function toBarsError(e) {
96 const cache = new Map() 106 const cache = new Map()
97 export const cacheKey = ({ asset, ticker, timeframe, adjustment }) => `${asset}|${ticker}|${timeframe}|${adjustment || ''}` 107 export const cacheKey = ({ asset, ticker, timeframe, adjustment }) => `${asset}|${ticker}|${timeframe}|${adjustment || ''}`
98 108
99−/** Cached series: { bars, startOfHistory, rate } or undefined. */ 109+/** Cached series: { bars, startOfHistory } or undefined. */
100 export function getCached(q) { return cache.get(cacheKey(q)) } 110 export function getCached(q) { return cache.get(cacheKey(q)) }
101 export function putCached(q, bars, extra = {}) { 111 export function putCached(q, bars, extra = {}) {
102 const key = cacheKey(q) 112 const key = cacheKey(q)
@@ -107,32 +117,19 @@ export function putCached(q, bars, extra = {}) {
107 } 117 }
108 export function clearBarsCache() { cache.clear() } 118 export function clearBarsCache() { cache.clear() }
109 119
110−// ---- limits -------------------------------------------------------------------------------------------------------
111−let limitsPromise = null
112−/** Row cap per request for the current principal (GET /v1/limits is free). Falls back to the keyless cap. */
113−export function loadMaxRows({ apiKey } = {}) {
114− if (!limitsPromise) {
115− limitsPromise = api('/v1/limits', { apiKey }).then(({ data }) => {
116− const p = data?.data?.principal || data?.principal
117− return { maxRows: p?.max_rows_per_request || KEYLESS_MAX_ROWS, tier: p?.tier || 'keyless', requests: p?.requests, rows: p?.rows }
118− }).catch(() => ({ maxRows: KEYLESS_MAX_ROWS, tier: 'keyless' }))
119− }
120− return limitsPromise
121−}
122−export function resetLimits() { limitsPromise = null }
123−
124 // ---- fetch -------------------------------------------------------------------------------------------------------- 120 // ---- fetch --------------------------------------------------------------------------------------------------------
125 const sleep = (ms, signal) => new Promise((res, rej) => { const t = setTimeout(res, ms); signal?.addEventListener('abort', () => { clearTimeout(t); rej(Object.assign(new Error('aborted'), { name: 'AbortError' })) }, { once: true }) }) 121 const sleep = (ms, signal) => new Promise((res, rej) => { const t = setTimeout(res, ms); signal?.addEventListener('abort', () => { clearTimeout(t); rej(Object.assign(new Error('aborted'), { name: 'AbortError' })) }, { once: true }) })
126 122
127−async function get(path, { apiKey, signal }) { 123+/** GET with the site header; one soft retry on network / 5xx, and one short wait-and-retry on a 429 burst guard. */
124+export async function siteGet(path, { signal } = {}) {
128 let attempt = 0 125 let attempt = 0
129 for (;;) { 126 for (;;) {
130 try { 127 try {
131− return await api(path, { apiKey, signal }) 128+ return await api(path, { signal, headers: SITE_HEADERS })
132 } catch (e) { 129 } catch (e) {
133 if (e?.name === 'AbortError') throw e 130 if (e?.name === 'AbortError') throw e
134− // one soft retry on network / 5xx, never on 4xx
135 if (attempt < 1 && (!e.status || e.status >= 500)) { attempt++; await sleep(600, signal); continue } 131 if (attempt < 1 && (!e.status || e.status >= 500)) { attempt++; await sleep(600, signal); continue }
132+ if (attempt < 1 && e.status === 429 && (e.rate?.retryAfter ?? 99) <= 5) { attempt++; await sleep((e.rate.retryAfter || 1) * 1000, signal); continue }
136 throw toBarsError(e) 133 throw toBarsError(e)
137 } 134 }
138 } 135 }
@@ -140,25 +137,26 @@ async function get(path, { apiKey, signal }) {
140 137
141 /** 138 /**
142 * Load bars ending at `end` (exclusive, ms; undefined = latest), going backwards. 139 * Load bars ending at `end` (exclusive, ms; undefined = latest), going backwards.
143− * @returns {Promise<{ bars: Bar[], complete: boolean, rate: object, meta?: object }>} complete = the API returned fewer 140+ * @returns {Promise<{ bars: Bar[], complete: boolean, meta?: object }>} complete = the API returned fewer
144 * rows than requested, i.e. we reached the start of the history. 141 * rows than requested, i.e. we reached the start of the history.
145 */ 142 */
146−export async function loadBars({ asset, ticker, timeframe = '1day', adjustment, end, limit, apiKey, signal, firstDate }) { 143+export async function loadBars({ asset, ticker, timeframe = '1day', adjustment, end, limit, signal, firstDate }) {
147 if (!ticker) throw new BarsError('not_found', 'No symbol') 144 if (!ticker) throw new BarsError('not_found', 'No symbol')
148− if (asset === CONTRACT_ASSET) return loadContractBars({ symbol: ticker, timeframe, end, limit, apiKey, signal, firstDate }) 145+ const lim = limit || INITIAL_BARS[timeframe] || 10_000
149− const q = new URLSearchParams({ timeframe, order: 'desc', limit: String(limit) }) 146+ if (asset === CONTRACT_ASSET) return loadContractBars({ symbol: ticker, timeframe, end, limit: lim, signal, firstDate })
147+ const q = new URLSearchParams({ timeframe, order: 'desc', limit: String(lim) })
150 if (adjustment) q.set('adjustment', adjustment) 148 if (adjustment) q.set('adjustment', adjustment)
151 if (end != null) q.set('end', formatStamp(end - (timeframe === '1day' ? TF_MS['1day'] : 1000), timeframe)) 149 if (end != null) q.set('end', formatStamp(end - (timeframe === '1day' ? TF_MS['1day'] : 1000), timeframe))
152− const { data, rate } = await get(`/v1/bars/${encodeURIComponent(asset)}/${encodeURIComponent(ticker)}?${q}`, { apiKey, signal }) 150+ const { data } = await siteGet(`/v1/bars/${encodeURIComponent(asset)}/${encodeURIComponent(ticker)}?${q}`, { signal })
153 const rows = Array.isArray(data?.data) ? data.data : Array.isArray(data) ? data : [] 151 const rows = Array.isArray(data?.data) ? data.data : Array.isArray(data) ? data : []
154 const bars = normalizeBars(rows.map(toBar)) 152 const bars = normalizeBars(rows.map(toBar))
155− return { bars, complete: rows.length < limit, rate } 153+ return { bars, complete: rows.length < lim }
156 } 154 }
157 155
158 // Wall-clock density of the contract endpoint: intraday futures trade ~23 h/day, 5 days/week. 156 // Wall-clock density of the contract endpoint: intraday futures trade ~23 h/day, 5 days/week.
159 const CONTRACT_DENSITY = { '1min': 1.7, '5min': 1.7, '30min': 1.7, '1hour': 1.7, '1day': 1.5 } 157 const CONTRACT_DENSITY = { '1min': 1.7, '5min': 1.7, '30min': 1.7, '1hour': 1.7, '1day': 1.5 }
160 158
161−async function loadContractBars({ symbol, timeframe, end, limit, apiKey, signal, firstDate }) { 159+async function loadContractBars({ symbol, timeframe, end, limit, signal, firstDate }) {
162 const interval = { '1min': '1m', '5min': '5m', '30min': '30m', '1hour': '1h', '1day': '1d' }[timeframe] || '1d' 160 const interval = { '1min': '1m', '5min': '5m', '30min': '30m', '1hour': '1h', '1day': '1d' }[timeframe] || '1d'
163 const to = end ?? Date.now() + TF_MS['1day'] 161 const to = end ?? Date.now() + TF_MS['1day']
164 const firstT = firstDate ? parseStamp(firstDate) : null 162 const firstT = firstDate ? parseStamp(firstDate) : null
@@ -167,12 +165,12 @@ async function loadContractBars({ symbol, timeframe, end, limit, apiKey, signal,
167 for (let attempt = 0; attempt < 4; attempt++) { 165 for (let attempt = 0; attempt < 4; attempt++) {
168 const from = Math.max(firstT ?? -Infinity, to - span) 166 const from = Math.max(firstT ?? -Infinity, to - span)
169 const q = new URLSearchParams({ interval, limit: String(limit), from: formatStamp(from, timeframe, { iso: true }), to: formatStamp(to, timeframe, { iso: true }) }) 167 const q = new URLSearchParams({ interval, limit: String(limit), from: formatStamp(from, timeframe, { iso: true }), to: formatStamp(to, timeframe, { iso: true }) })
170− const { data, rate } = await get(`/v1/futures/contract/${encodeURIComponent(symbol)}/bars?${q}`, { apiKey, signal }) 168+ const { data } = await siteGet(`/v1/futures/contract/${encodeURIComponent(symbol)}/bars?${q}`, { signal })
171 const rows = Array.isArray(data?.data) ? data.data : [] 169 const rows = Array.isArray(data?.data) ? data.data : []
172 const bars = normalizeBars(rows.map(toBar)) 170 const bars = normalizeBars(rows.map(toBar))
173 const atStart = firstT != null && from <= firstT 171 const atStart = firstT != null && from <= firstT
174 const more = !!data?.meta?.next_cursor 172 const more = !!data?.meta?.next_cursor
175− best = { bars, complete: !more && (atStart || rows.length < limit), rate, meta: data?.meta, atStart } 173+ best = { bars, complete: !more && (atStart || rows.length < limit), meta: data?.meta, atStart }
176 if (more) { span *= 0.5; continue } // window too wide: rows are the OLDEST of it 174 if (more) { span *= 0.5; continue } // window too wide: rows are the OLDEST of it
177 if (rows.length < limit * 0.6 && !atStart && attempt < 3) { span *= 3; continue } // too narrow: widen 175 if (rows.length < limit * 0.6 && !atStart && attempt < 3) { span *= 3; continue } // too narrow: widen
178 break 176 break
@@ -180,11 +178,30 @@ async function loadContractBars({ symbol, timeframe, end, limit, apiKey, signal,
180 return best 178 return best
181 } 179 }
182 180
183−/** Human error message for a BarsError. */ 181+/**
182+ * Backward pagination helper: loads the page before `before` (ms) for `q`, merges it into the cache and returns
183+ * only the bars strictly older than `before`. `complete` = start of history reached.
184+ */
185+export async function loadOlderBars(q, before, { signal, firstDate } = {}) {
186+ const res = await loadBars({ ...q, end: before, limit: INITIAL_BARS[q.timeframe] || 10_000, signal, firstDate })
187+ const older = res.bars.filter(b => b.t < before)
188+ putCached(q, older, { startOfHistory: res.complete })
189+ return { bars: older, complete: res.complete }
190+}
191+
192+/** Human error message for a BarsError (never a quota message: the site has none). */
184 export function barsErrorMessage(e, symbol) { 193 export function barsErrorMessage(e, symbol) {
185 if (!e) return '' 194 if (!e) return ''
186 if (e.kind === 'not_found') return `${symbol || 'This symbol'} is not in the dataset for this timeframe.` 195 if (e.kind === 'not_found') return `${symbol || 'This symbol'} is not in the dataset for this timeframe.`
187− if (e.kind === 'rate_limit') return 'The keyless quota is 30 requests per hour per IP. Sign in for 120 requests per minute.' 196+ if (e.kind === 'rate_limit') return 'The API is busy right now — the chart you have stays on screen, retry in a moment.'
188 if (e.kind === 'network') return 'Network error — please check your connection.' 197 if (e.kind === 'network') return 'Network error — please check your connection.'
198+ if (e.status >= 500) return 'The API returned a server error — retry in a moment.'
189 return e.message || 'Something went wrong.' 199 return e.message || 'Something went wrong.'
190 } 200 }
201+
202+/** CSV of a bar array (fallback when the engine has no `toCSV`). */
203+export function barsToCsv(bars, timeframe, tz = 'ET') {
204+ const head = ['datetime', 'open', 'high', 'low', 'close', 'volume']
205+ const lines = bars.map(b => [formatStamp(b.t, timeframe), b.o, b.h, b.l, b.c, b.v ?? ''].join(','))
206+ return `# HF Market Data — ${tz === 'UTC' ? 'timestamps UTC' : 'timestamps US/Eastern wall-clock'}\n${head.join(',')}\n${lines.join('\n')}\n`
207+}
modified hfmarketdata/web/src/charts/data/state.js +101 −35
@@ -1,4 +1,6 @@
1−// URL state ↔ page state for /charts (`?s=AAPL&asset=stock&tf=1day&type=candles&ind=sma:20,ema:50,rsi:14&cmp=MSFT&scale=log&vol=1&adj=adj_split`) 1+// URL state ↔ page state for /charts.
2+// ?s=AAPL&asset=stock&tf=1day&type=candles&ind=sma:20,ema:50,rsi:14&cmp=MSFT&scale=log&vol=1&adj=adj_split
3+// &layout=2h&s2=MSFT&tf2=1hour&ind2=rsi:14 ← multi-chart layouts: chart k ≥ 2 uses the suffix k
2 // plus localStorage persistence (last symbol, preferences, drawings per asset:ticker:tf). 4 // plus localStorage persistence (last symbol, preferences, drawings per asset:ticker:tf).
3 import { TIMEFRAMES } from './bars.js' 5 import { TIMEFRAMES } from './bars.js'
4 import { INDICATORS, indicatorFromSpec, indicatorToSpec } from '../../pages/charts/indicators.js' 6 import { INDICATORS, indicatorFromSpec, indicatorToSpec } from '../../pages/charts/indicators.js'
@@ -8,14 +10,30 @@ export const SERIES_TYPES = [
8 ['baseline', 'Baseline'], ['heikin', 'Heikin-Ashi'], ['columns', 'Columns'], ['hlc', 'HLC bars'], 10 ['baseline', 'Baseline'], ['heikin', 'Heikin-Ashi'], ['columns', 'Columns'], ['hlc', 'HLC bars'],
9 ] 11 ]
10 export const SCALES = ['linear', 'log', 'percent'] 12 export const SCALES = ['linear', 'log', 'percent']
13+/** Layouts: id → { label, count, css grid class }. */
14+export const LAYOUTS = {
15+ '1': { label: 'Single', count: 1 },
16+ '2h': { label: '2 side by side', count: 2 },
17+ '2v': { label: '2 stacked', count: 2 },
18+ '4': { label: '4 grid', count: 4 },
19+}
11 20
12−export const DEFAULT_STATE = { asset: 'stock', ticker: 'AAPL', label: 'AAPL', tf: '1day', type: 'candles', indicators: [], compares: [], scale: 'linear', volume: true, adjustment: '' } 21+export const DEFAULT_CHART = { asset: 'stock', ticker: 'AAPL', label: 'AAPL', tf: '1day', type: 'candles', indicators: [], compares: [], scale: 'linear', volume: true, adjustment: '' }
22+/** Kept for callers that still think in terms of a single chart. */
23+export const DEFAULT_STATE = DEFAULT_CHART
13 24
14 const PREFS_KEY = 'hfmd.charts.prefs' 25 const PREFS_KEY = 'hfmd.charts.prefs'
15 const LAST_KEY = 'hfmd.charts.last' 26 const LAST_KEY = 'hfmd.charts.last'
16 const DRAW_PREFIX = 'hfmd.charts.drawings.' 27 const DRAW_PREFIX = 'hfmd.charts.drawings.'
28+const FAV_KEY = 'hfmd.charts.favorites'
29+const TOOLS_KEY = 'hfmd.charts.lastTools'
17 30
18−export const DEFAULT_PREFS = { colorblind: false, magnet: false, watermark: true, autoScale: true, showDrawingBar: true, reducedMotion: false } 31+export const DEFAULT_PREFS = {
32+ colorblind: false, magnet: false, watermark: true, autoScale: true, showDrawingBar: true, reducedMotion: false,
33+ drawLock: false, // stay in drawing mode after a drawing is created
34+ syncSymbol: false, syncCrosshair: true, syncTime: false, // multi-chart layouts
35+ grid: true, announce: true, // announce = aria-live description of the visible range after pan/zoom
36+}
19 37
20 export function readPrefs() { try { return { ...DEFAULT_PREFS, ...(JSON.parse(localStorage.getItem(PREFS_KEY) || '{}')) } } catch { return { ...DEFAULT_PREFS } } } 38 export function readPrefs() { try { return { ...DEFAULT_PREFS, ...(JSON.parse(localStorage.getItem(PREFS_KEY) || '{}')) } } catch { return { ...DEFAULT_PREFS } } }
21 export function writePrefs(p) { try { localStorage.setItem(PREFS_KEY, JSON.stringify(p)) } catch { /* ignore */ } } 39 export function writePrefs(p) { try { localStorage.setItem(PREFS_KEY, JSON.stringify(p)) } catch { /* ignore */ } }
@@ -32,58 +50,106 @@ export function writeDrawings(s, list) {
32 } catch { /* ignore */ } 50 } catch { /* ignore */ }
33 } 51 }
34 52
35−/** Parse the URL search string into a partial state (unknown values are dropped). */ 53+/** Favourite indicators (ids). */
36−export function parseSearch(search) { 54+export function readFavorites() { try { const v = JSON.parse(localStorage.getItem(FAV_KEY) || '[]'); return Array.isArray(v) ? v : [] } catch { return [] } }
37− const p = new URLSearchParams(search) 55+export function writeFavorites(list) { try { localStorage.setItem(FAV_KEY, JSON.stringify(list)) } catch { /* ignore */ } }
56+
57+/** Last tool used per drawing group (group id → tool id). */
58+export function readLastTools() { try { return JSON.parse(localStorage.getItem(TOOLS_KEY) || '{}') || {} } catch { return {} } }
59+export function writeLastTools(map) { try { localStorage.setItem(TOOLS_KEY, JSON.stringify(map)) } catch { /* ignore */ } }
60+
61+// ---- one chart ↔ URL ---------------------------------------------------------------------------------------------
62+
63+/** Parse the params of chart `k` (0-based; k ≥ 1 reads the `s2`, `tf2`… suffixed keys). */
64+function parseChart(p, k) {
65+ const sfx = k ? String(k + 1) : ''
66+ const g = key => p.get(key + sfx)
38 const out = {} 67 const out = {}
39− const s = (p.get('s') || '').trim().toUpperCase() 68+ const s = (g('s') || '').trim().toUpperCase()
40 if (s) { out.ticker = s; out.label = s } 69 if (s) { out.ticker = s; out.label = s }
41− const asset = p.get('asset') 70+ const asset = g('asset')
42 if (asset) out.asset = asset 71 if (asset) out.asset = asset
43− const tf = p.get('tf') 72+ const tf = g('tf')
44 if (TIMEFRAMES.includes(tf)) out.tf = tf 73 if (TIMEFRAMES.includes(tf)) out.tf = tf
45− const type = p.get('type') 74+ const type = g('type')
46 if (SERIES_TYPES.some(([id]) => id === type)) out.type = type 75 if (SERIES_TYPES.some(([id]) => id === type)) out.type = type
47− const ind = p.get('ind') 76+ const ind = g('ind')
48 if (ind != null) out.indicators = ind.split(',').map(x => indicatorFromSpec(x.trim())).filter(Boolean) 77 if (ind != null) out.indicators = ind.split(',').map(x => indicatorFromSpec(x.trim())).filter(Boolean)
49− const cmp = p.get('cmp') 78+ const cmp = g('cmp')
50 if (cmp != null) out.compares = cmp.split(',').map(x => x.trim().toUpperCase()).filter(Boolean).map(sym => { const [ticker, a] = sym.split('@'); return { ticker, asset: a || undefined } }) 79 if (cmp != null) out.compares = cmp.split(',').map(x => x.trim().toUpperCase()).filter(Boolean).map(sym => { const [ticker, a] = sym.split('@'); return { ticker, asset: a || undefined } })
51− const scale = p.get('scale') 80+ const scale = g('scale')
52 if (SCALES.includes(scale)) out.scale = scale 81 if (SCALES.includes(scale)) out.scale = scale
53− const vol = p.get('vol') 82+ const vol = g('vol')
54 if (vol === '0' || vol === '1') out.volume = vol === '1' 83 if (vol === '0' || vol === '1') out.volume = vol === '1'
55− const adj = p.get('adj') 84+ const adj = g('adj')
56 if (adj) out.adjustment = adj 85 if (adj) out.adjustment = adj
57 return out 86 return out
58 } 87 }
59 88
60−/** Serialize the state into a canonical search string (only non-default values). */ 89+function chartParams(p, cs, k) {
61−export function toSearch(state) { 90+ const sfx = k ? String(k + 1) : ''
91+ const set = (key, v) => p.set(key + sfx, v)
92+ set('s', cs.ticker)
93+ if (cs.asset && cs.asset !== 'stock') set('asset', cs.asset)
94+ if (cs.tf !== '1day') set('tf', cs.tf)
95+ if (cs.type !== 'candles') set('type', cs.type)
96+ if (cs.indicators?.length) set('ind', cs.indicators.map(indicatorToSpec).join(','))
97+ if (cs.compares?.length) set('cmp', cs.compares.map(c => (c.asset && c.asset !== 'stock' ? `${c.ticker}@${c.asset}` : c.ticker)).join(','))
98+ if (cs.scale && cs.scale !== 'linear') set('scale', cs.scale)
99+ if (cs.volume === false) set('vol', '0')
100+ if (cs.adjustment) set('adj', cs.adjustment)
101+}
102+
103+/** Parse the URL search string into { layout?, charts: [partial chart state…] } (unknown values are dropped). */
104+export function parseSearch(search) {
105+ const p = new URLSearchParams(search)
106+ const layout = LAYOUTS[p.get('layout')] ? p.get('layout') : undefined
107+ const count = layout ? LAYOUTS[layout].count : 1
108+ const charts = []
109+ for (let k = 0; k < Math.max(count, 1); k++) charts.push(parseChart(p, k))
110+ const out = { charts }
111+ if (layout) out.layout = layout
112+ return out
113+}
114+
115+/** Serialize the page into a canonical search string (only non-default values). */
116+export function toSearch(page, charts) {
62 const p = new URLSearchParams() 117 const p = new URLSearchParams()
63− p.set('s', state.ticker) 118+ const layout = page?.layout || '1'
64− if (state.asset && state.asset !== 'stock') p.set('asset', state.asset) 119+ const n = LAYOUTS[layout]?.count || 1
65− if (state.tf !== '1day') p.set('tf', state.tf) 120+ const list = Array.isArray(charts) ? charts : [page] // single-chart callers pass the chart state itself
66− if (state.type !== 'candles') p.set('type', state.type) 121+ for (let k = 0; k < Math.min(n, list.length); k++) chartParams(p, list[k], k)
67− if (state.indicators?.length) p.set('ind', state.indicators.map(indicatorToSpec).join(',')) 122+ if (layout !== '1') p.set('layout', layout)
68− if (state.compares?.length) p.set('cmp', state.compares.map(c => (c.asset && c.asset !== 'stock' ? `${c.ticker}@${c.asset}` : c.ticker)).join(','))
69− if (state.scale && state.scale !== 'linear') p.set('scale', state.scale)
70− if (state.volume === false) p.set('vol', '0')
71− if (state.adjustment) p.set('adj', state.adjustment)
72 return `?${p.toString().replace(/%3A/g, ':').replace(/%2C/g, ',').replace(/%40/g, '@')}` 123 return `?${p.toString().replace(/%3A/g, ':').replace(/%2C/g, ',').replace(/%40/g, '@')}`
73 } 124 }
74 125
75−/** Initial state: URL → last visited symbol → AAPL 1D. */ 126+const stampIds = (cs, tag) => {
127+ const stamp = Date.now().toString(36)
128+ const indicators = (cs.indicators || []).map((i, k) => ({ ...i, id: `ind-${tag}${k}-${stamp}`, colorIndex: k }))
129+ const compares = (cs.compares || []).map((c, k) => ({ ...c, id: `cmp-${tag}${k}-${stamp}`, asset: c.asset || 'stock', colorIndex: indicators.length + k, loading: true }))
130+ return { ...cs, indicators, compares }
131+}
132+
133+/** Initial page state: URL → last visited symbol → AAPL 1D. Returns { page: { layout, active }, charts }. */
76 export function initialState(search) { 134 export function initialState(search) {
77 const fromUrl = parseSearch(search) 135 const fromUrl = parseSearch(search)
78− const base = { ...DEFAULT_STATE } 136+ const layout = fromUrl.layout || '1'
79− if (!fromUrl.ticker) { 137+ const n = LAYOUTS[layout].count
80− const last = readLast() 138+ const charts = []
81− if (last?.ticker) Object.assign(base, { asset: last.asset || 'stock', ticker: last.ticker, label: last.label || last.ticker, tf: TIMEFRAMES.includes(last.tf) ? last.tf : '1day', type: last.type || 'candles' }) 139+ for (let k = 0; k < n; k++) {
140+ const u = fromUrl.charts[k] || {}
141+ const base = { ...DEFAULT_CHART }
142+ if (k === 0 && !u.ticker) {
143+ const last = readLast()
144+ if (last?.ticker) Object.assign(base, { asset: last.asset || 'stock', ticker: last.ticker, label: last.label || last.ticker, tf: TIMEFRAMES.includes(last.tf) ? last.tf : '1day', type: last.type || 'candles' })
145+ } else if (k > 0 && !u.ticker) {
146+ // secondary charts default to the first chart's symbol
147+ const first = charts[0]
148+ Object.assign(base, { asset: first.asset, ticker: first.ticker, label: first.label, adjustment: first.adjustment })
149+ }
150+ charts.push(stampIds({ ...base, ...u }, `u${k}`))
82 } 151 }
83− const stamp = Date.now().toString(36) 152+ return { page: { layout, active: 0 }, charts }
84− const indicators = (fromUrl.indicators || []).map((i, k) => ({ ...i, id: `ind-u${k}-${stamp}`, colorIndex: k }))
85− const compares = (fromUrl.compares || []).map((c, k) => ({ ...c, id: `cmp-u${k}-${stamp}`, asset: c.asset || 'stock', colorIndex: indicators.length + k, loading: true }))
86− return { ...base, ...fromUrl, indicators, compares }
87 } 153 }
88 154
89 export { INDICATORS } 155 export { INDICATORS }
modified hfmarketdata/web/src/charts/data/symbols.js +19 −16
@@ -4,7 +4,10 @@
4 // GET /v1/futures/{root}/contracts → { data: [{ symbol, expiration_date, first_data_date, status, tick_size, … }] }. 4 // GET /v1/futures/{root}/contracts → { data: [{ symbol, expiration_date, first_data_date, status, tick_size, … }] }.
5 // Lists are cached in memory and in localStorage for 24 h, loaded lazily per asset. 5 // Lists are cached in memory and in localStorage for 24 h, loaded lazily per asset.
6 import { api } from '../../app/api.js' 6 import { api } from '../../app/api.js'
7−import { CONTRACT_ASSET } from './bars.js' 7+import { CONTRACT_ASSET, SITE_HEADERS } from './bars.js'
8+
9+// every list call is the site's own request (principal `site`, no quota) — see bars.js
10+const get = (path, { signal } = {}) => api(path, { signal, headers: SITE_HEADERS })
8 11
9 export const ASSETS = [ 12 export const ASSETS = [
10 { id: 'stock', label: 'Stocks', short: 'Stock' }, 13 { id: 'stock', label: 'Stocks', short: 'Stock' },
@@ -36,7 +39,7 @@ const readLS = k => { try { const raw = localStorage.getItem(k); if (!raw) retur
36 const writeLS = (k, list) => { try { localStorage.setItem(k, JSON.stringify({ at: Date.now(), list })) } catch { /* quota / private mode */ } } 39 const writeLS = (k, list) => { try { localStorage.setItem(k, JSON.stringify({ at: Date.now(), list })) } catch { /* quota / private mode */ } }
37 40
38 /** @returns {Promise<Array<{ asset, ticker, label, name? }>>} */ 41 /** @returns {Promise<Array<{ asset, ticker, label, name? }>>} */
39−export function loadTickers(asset, { apiKey, signal } = {}) { 42+export function loadTickers(asset, { signal } = {}) {
40 if (mem.has(asset)) return mem.get(asset) 43 if (mem.has(asset)) return mem.get(asset)
41 const cached = readLS(LS_PREFIX + asset) 44 const cached = readLS(LS_PREFIX + asset)
42 if (cached) { const p = Promise.resolve(cached); mem.set(asset, p); return p } 45 if (cached) { const p = Promise.resolve(cached); mem.set(asset, p); return p }
@@ -44,17 +47,17 @@ export function loadTickers(asset, { apiKey, signal } = {}) {
44 let list 47 let list
45 if (asset === 'futures') { 48 if (asset === 'futures') {
46 try { 49 try {
47− const { data } = await api('/v1/futures/roots', { apiKey, signal }) 50+ const { data } = await get('/v1/futures/roots', { signal })
48 const rows = data?.data || [] 51 const rows = data?.data || []
49 rows.forEach(r => specs.set(r.root, r)) 52 rows.forEach(r => specs.set(r.root, r))
50 list = rows.map(r => ({ asset, ticker: r.root, label: r.root, name: r.name || undefined })) 53 list = rows.map(r => ({ asset, ticker: r.root, label: r.root, name: r.name || undefined }))
51 } catch (e) { 54 } catch (e) {
52 if (e?.name === 'AbortError') throw e 55 if (e?.name === 'AbortError') throw e
53− const { data } = await api('/v1/futures/tickers?adjustment=contin_adj_ratio&limit=10000', { apiKey, signal }) 56+ const { data } = await get('/v1/futures/tickers?adjustment=contin_adj_ratio&limit=10000', { signal })
54 list = (data?.tickers || []).map(s => ({ asset, ticker: s, label: s })) 57 list = (data?.tickers || []).map(s => ({ asset, ticker: s, label: s }))
55 } 58 }
56 } else { 59 } else {
57− const { data } = await api(`/v1/${asset}/tickers?limit=20000`, { apiKey, signal }) 60+ const { data } = await get(`/v1/${asset}/tickers?limit=20000`, { signal })
58 list = (data?.tickers || []).map(s => ({ asset, ticker: s, label: s })) 61 list = (data?.tickers || []).map(s => ({ asset, ticker: s, label: s }))
59 } 62 }
60 writeLS(LS_PREFIX + asset, list) 63 writeLS(LS_PREFIX + asset, list)
@@ -75,11 +78,11 @@ const CONTRACT_RE = /^([A-Z0-9]{1,4}?)([FGHJKMNQUVXZ])(\d{2}|\d{4})$/
75 export const parseContract = sym => { const m = CONTRACT_RE.exec(sym || ''); return m ? { root: m[1], month: m[2], year: m[3] } : null } 78 export const parseContract = sym => { const m = CONTRACT_RE.exec(sym || ''); return m ? { root: m[1], month: m[2], year: m[3] } : null }
76 79
77 /** Individual contracts of a root → items { asset: 'contract', ticker: 'ESZ24', label, name, firstDate, lastDate, tickSize, status }. */ 80 /** Individual contracts of a root → items { asset: 'contract', ticker: 'ESZ24', label, name, firstDate, lastDate, tickSize, status }. */
78−export function loadContracts(root, { apiKey, signal } = {}) { 81+export function loadContracts(root, { signal } = {}) {
79 if (contractsMem.has(root)) return contractsMem.get(root) 82 if (contractsMem.has(root)) return contractsMem.get(root)
80 const cached = readLS(`${LS_PREFIX}contracts.${root}`) 83 const cached = readLS(`${LS_PREFIX}contracts.${root}`)
81 if (cached) { const p = Promise.resolve(cached); contractsMem.set(root, p); return p } 84 if (cached) { const p = Promise.resolve(cached); contractsMem.set(root, p); return p }
82− const p = api(`/v1/futures/${encodeURIComponent(root)}/contracts?sort=-expiration_date&limit=400`, { apiKey, signal }).then(({ data }) => { 85+ const p = get(`/v1/futures/${encodeURIComponent(root)}/contracts?sort=-expiration_date&limit=400`, { signal }).then(({ data }) => {
83 const list = (data?.data || []).map(c => ({ asset: CONTRACT_ASSET, ticker: c.symbol, label: c.symbol, name: `${c.root} · exp. ${c.expiration_date || '?'}${c.status === 'active' ? ' · active' : ''}`, firstDate: c.first_data_date, lastDate: c.last_data_date, tickSize: c.tick_size, status: c.status })) 86 const list = (data?.data || []).map(c => ({ asset: CONTRACT_ASSET, ticker: c.symbol, label: c.symbol, name: `${c.root} · exp. ${c.expiration_date || '?'}${c.status === 'active' ? ' · active' : ''}`, firstDate: c.first_data_date, lastDate: c.last_data_date, tickSize: c.tick_size, status: c.status }))
84 writeLS(`${LS_PREFIX}contracts.${root}`, list) 87 writeLS(`${LS_PREFIX}contracts.${root}`, list)
85 return list 88 return list
@@ -114,17 +117,17 @@ function rank(items, q, perGroup) {
114 * Fuzzy search (prefix first, then substring) across every asset list that is already loaded or loads quickly. 117 * Fuzzy search (prefix first, then substring) across every asset list that is already loaded or loads quickly.
115 * @returns {Promise<Array<{ asset, items }>>} groups ordered by ASSETS; empty groups omitted. 118 * @returns {Promise<Array<{ asset, items }>>} groups ordered by ASSETS; empty groups omitted.
116 */ 119 */
117−export async function searchSymbols(query, { apiKey, signal, perGroup = 6 } = {}) { 120+export async function searchSymbols(query, { signal, perGroup = 6 } = {}) {
118 const q = (query || '').trim().toUpperCase() 121 const q = (query || '').trim().toUpperCase()
119 if (!q) return [] 122 if (!q) return []
120− const results = await Promise.all(LIST_ASSETS.map(asset => loadTickers(asset, { apiKey, signal }).then(list => ({ asset, items: rank(list, q, perGroup) })).catch(e => { if (e?.name === 'AbortError') throw e; return { asset, items: [] } }))) 123+ const results = await Promise.all(LIST_ASSETS.map(asset => loadTickers(asset, { signal }).then(list => ({ asset, items: rank(list, q, perGroup) })).catch(e => { if (e?.name === 'AbortError') throw e; return { asset, items: [] } })))
121 // individual contracts: "ESZ24", "ESZ" or "ES " → contracts of the root 124 // individual contracts: "ESZ24", "ESZ" or "ES " → contracts of the root
122 const c = parseContract(q) || (/^[A-Z0-9]{1,4}[FGHJKMNQUVXZ]?$/.test(q) && q.length >= 2 ? { root: q.replace(/[FGHJKMNQUVXZ]$/, ''), month: q.length > 1 && /[FGHJKMNQUVXZ]$/.test(q) ? q.slice(-1) : '' } : null) 125 const c = parseContract(q) || (/^[A-Z0-9]{1,4}[FGHJKMNQUVXZ]?$/.test(q) && q.length >= 2 ? { root: q.replace(/[FGHJKMNQUVXZ]$/, ''), month: q.length > 1 && /[FGHJKMNQUVXZ]$/.test(q) ? q.slice(-1) : '' } : null)
123 if (c) { 126 if (c) {
124 try { 127 try {
125− const roots = await loadTickers('futures', { apiKey, signal }) 128+ const roots = await loadTickers('futures', { signal })
126 if (roots.some(r => r.ticker === c.root)) { 129 if (roots.some(r => r.ticker === c.root)) {
127− const contracts = await loadContracts(c.root, { apiKey, signal }) 130+ const contracts = await loadContracts(c.root, { signal })
128 const items = rank(contracts, q, perGroup) 131 const items = rank(contracts, q, perGroup)
129 if (items.length) results.push({ asset: CONTRACT_ASSET, items }) 132 if (items.length) results.push({ asset: CONTRACT_ASSET, items })
130 } 133 }
@@ -135,23 +138,23 @@ export async function searchSymbols(query, { apiKey, signal, perGroup = 6 } = {}
135 } 138 }
136 139
137 /** Best guess for a typed symbol: exact match in the lists (stock > etf > index > futures > crypto > fx), else a contract, else a stock. */ 140 /** Best guess for a typed symbol: exact match in the lists (stock > etf > index > futures > crypto > fx), else a contract, else a stock. */
138−export async function resolveSymbol(text, { asset, apiKey, signal } = {}) { 141+export async function resolveSymbol(text, { asset, signal } = {}) {
139 const q = (text || '').trim().toUpperCase() 142 const q = (text || '').trim().toUpperCase()
140 if (!q) return null 143 if (!q) return null
141 if (asset && asset !== CONTRACT_ASSET) { 144 if (asset && asset !== CONTRACT_ASSET) {
142− const list = await loadTickers(asset, { apiKey, signal }).catch(() => []) 145+ const list = await loadTickers(asset, { signal }).catch(() => [])
143 const hit = list.find(it => it.ticker === q) 146 const hit = list.find(it => it.ticker === q)
144 if (hit) return hit 147 if (hit) return hit
145 } 148 }
146 if (asset === CONTRACT_ASSET && parseContract(q)) { 149 if (asset === CONTRACT_ASSET && parseContract(q)) {
147− const list = await loadContracts(parseContract(q).root, { apiKey, signal }).catch(() => []) 150+ const list = await loadContracts(parseContract(q).root, { signal }).catch(() => [])
148 return list.find(it => it.ticker === q) || { asset: CONTRACT_ASSET, ticker: q, label: q } 151 return list.find(it => it.ticker === q) || { asset: CONTRACT_ASSET, ticker: q, label: q }
149 } 152 }
150 for (const a of LIST_ASSETS) { 153 for (const a of LIST_ASSETS) {
151− const list = await loadTickers(a, { apiKey, signal }).catch(() => []) 154+ const list = await loadTickers(a, { signal }).catch(() => [])
152 const hit = list.find(it => it.ticker === q) 155 const hit = list.find(it => it.ticker === q)
153 if (hit) return hit 156 if (hit) return hit
154 } 157 }
155− if (parseContract(q)) return resolveSymbol(q, { asset: CONTRACT_ASSET, apiKey, signal }) 158+ if (parseContract(q)) return resolveSymbol(q, { asset: CONTRACT_ASSET, signal })
156 return { asset: asset || 'stock', ticker: q, label: q } 159 return { asset: asset || 'stock', ticker: q, label: q }
157 } 160 }
added hfmarketdata/web/src/charts/data/templates.js +54 −0
@@ -0,0 +1,54 @@
1+// Chart templates: a named set of indicators (+ per-plot styles), series type, scale and volume, saved in
2+// localStorage, importable / exportable as JSON. One template can be flagged "default": it is applied to a chart
3+// that opens without `ind`/`type` in the URL and to every chart added to a multi-chart layout.
4+const KEY = 'hfmd.charts.templates'
5+export const TEMPLATE_VERSION = 1
6+
7+export function readTemplates() {
8+ try { const v = JSON.parse(localStorage.getItem(KEY) || '[]'); return Array.isArray(v) ? v.filter(t => t && t.name) : [] } catch { return [] }
9+}
10+export function writeTemplates(list) { try { localStorage.setItem(KEY, JSON.stringify(list)) } catch { /* ignore */ } }
11+
12+/** Snapshot of a chart state as a template (symbol-independent). */
13+export function templateFrom(cs, name) {
14+ return {
15+ version: TEMPLATE_VERSION, name, savedAt: new Date().toISOString(),
16+ type: cs.type, scale: cs.scale, volume: cs.volume,
17+ indicators: (cs.indicators || []).map(i => ({ type: i.type, params: { ...(i.params || {}) }, pane: i.pane, plots: i.plots ? JSON.parse(JSON.stringify(i.plots)) : undefined, hidden: !!i.hidden, visibility: i.visibility })),
18+ }
19+}
20+
21+export function saveTemplate(tpl) {
22+ const list = readTemplates().filter(t => t.name !== tpl.name)
23+ list.push(tpl)
24+ writeTemplates(list)
25+ return list
26+}
27+export function deleteTemplate(name) { const list = readTemplates().filter(t => t.name !== name); writeTemplates(list); return list }
28+export function setDefaultTemplate(name) {
29+ const list = readTemplates().map(t => ({ ...t, isDefault: name != null && t.name === name }))
30+ writeTemplates(list)
31+ return list
32+}
33+export const defaultTemplate = () => readTemplates().find(t => t.isDefault) || null
34+
35+/** Validate an imported JSON document (one template or an array). Throws on garbage. */
36+export function parseTemplateJSON(text) {
37+ const doc = JSON.parse(text)
38+ const list = Array.isArray(doc) ? doc : [doc]
39+ const out = []
40+ for (const t of list) {
41+ if (!t || typeof t !== 'object' || typeof t.name !== 'string' || !t.name.trim()) throw new Error('Each template needs a "name".')
42+ if (t.indicators != null && !Array.isArray(t.indicators)) throw new Error(`"${t.name}": indicators must be an array.`)
43+ out.push({
44+ version: TEMPLATE_VERSION, name: t.name.trim().slice(0, 60), savedAt: t.savedAt || new Date().toISOString(),
45+ type: typeof t.type === 'string' ? t.type : undefined, scale: typeof t.scale === 'string' ? t.scale : undefined,
46+ volume: typeof t.volume === 'boolean' ? t.volume : undefined,
47+ indicators: (t.indicators || []).filter(i => i && typeof i.type === 'string').map(i => ({ type: i.type, params: i.params && typeof i.params === 'object' ? i.params : {}, pane: i.pane, plots: i.plots && typeof i.plots === 'object' ? i.plots : undefined, hidden: !!i.hidden, visibility: i.visibility })),
48+ isDefault: !!t.isDefault,
49+ })
50+ }
51+ return out
52+}
53+
54+export const templatesJSON = list => JSON.stringify(list, null, 2)
modified hfmarketdata/web/src/pages/charts/StatusBar.jsx +9 −14
@@ -1,27 +1,22 @@
1−// Bottom status strip: bars loaded, covered range, "loading older…" / "start of history", quota left (from the 1+// Bottom status strip: bars loaded, covered range, "loading older…" / "start of history" / a retry chip when a page
2−// X-RateLimit-* headers of the last response) with a sign-in CTA for keyless visitors, and the session time zone. 2+// of history failed, and the session time zone. No quota information: the site's own requests have none.
3 import React from 'react' 3 import React from 'react'
4−import { Link } from 'react-router-dom'
5−import { useCountdown, fmtDuration } from '../../components/States.jsx'
6 import { formatStampLabel } from '../../charts/data/session.js' 4 import { formatStampLabel } from '../../charts/data/session.js'
7 5
8−export default function StatusBar({ status, state, tz, authenticated, compact = false }) { 6+export default function StatusBar({ status, cs, tz, compact = false, onRetryOlder, layoutLabel }) {
9− const { count, firstT, lastT, loadingOlder, startOfHistory, rate, phase } = status 7+ const { count, firstT, lastT, loadingOlder, startOfHistory, phase, olderError } = status
10− const left = useCountdown(rate?.reset || null) 8+ const range = firstT != null && lastT != null ? `${formatStampLabel(firstT, cs.tf, '')} → ${formatStampLabel(lastT, cs.tf, '')}` : ''
11− const range = firstT != null && lastT != null ? `${formatStampLabel(firstT, state.tf, '')} → ${formatStampLabel(lastT, state.tf, '')}` : ''
12 return ( 9 return (
13 <div className="ch-status" role="status" aria-label="Chart status" data-testid="ch-status"> 10 <div className="ch-status" role="status" aria-label="Chart status" data-testid="ch-status">
14 <span className="ch-status-item mono" data-testid="ch-status-count">{count ? `${count.toLocaleString('en-US')} bars` : phase === 'loading' ? 'Loading…' : '—'}</span> 11 <span className="ch-status-item mono" data-testid="ch-status-count">{count ? `${count.toLocaleString('en-US')} bars` : phase === 'loading' ? 'Loading…' : '—'}</span>
15 {range && !compact && <span className="ch-status-item ch-status-range mono" title="Loaded range (wall-clock stamps)">{range}</span>} 12 {range && !compact && <span className="ch-status-item ch-status-range mono" title="Loaded range (wall-clock stamps)">{range}</span>}
16 {loadingOlder && <span className="ch-status-item ch-status-busy" data-testid="ch-status-older"><span className="ch-spin" aria-hidden="true" /> loading older…</span>} 13 {loadingOlder && <span className="ch-status-item ch-status-busy" data-testid="ch-status-older"><span className="ch-spin" aria-hidden="true" /> loading older…</span>}
17 {startOfHistory && !loadingOlder && <span className="ch-status-item muted" data-testid="ch-status-start">start of history</span>} 14 {startOfHistory && !loadingOlder && <span className="ch-status-item muted" data-testid="ch-status-start">start of history</span>}
18− <span className="ch-status-spacer" /> 15+ {olderError && !loadingOlder && !startOfHistory && (
19− {rate?.limitRequests != null && ( 16+ <button type="button" className="ch-status-item ch-status-retry" onClick={onRetryOlder} data-testid="ch-status-retry">older history unavailable — retry</button>
20− <span className="ch-status-item ch-status-quota" title={rate.reset ? `Window resets in ${fmtDuration(left)}` : undefined} data-testid="ch-status-quota">
21− <span className="mono">{rate.remainingRequests ?? '?'}/{rate.limitRequests}</span> req left
22− {!authenticated && <Link to="/signin?next=/charts" className="ch-status-cta">Sign in for 120 req/min</Link>}
23− </span>
24 )} 17 )}
18+ <span className="ch-status-spacer" />
19+ {layoutLabel && !compact && <span className="ch-status-item muted" data-testid="ch-status-layout">{layoutLabel}</span>}
25 <span className="ch-status-item ch-status-tz" title={tz === 'ET' ? 'Timestamps are US/Eastern wall-clock (exchange time)' : 'Timestamps are UTC'}>{tz}</span> 20 <span className="ch-status-item ch-status-tz" title={tz === 'ET' ? 'Timestamps are US/Eastern wall-clock (exchange time)' : 'Timestamps are UTC'}>{tz}</span>
26 </div> 21 </div>
27 ) 22 )
modified hfmarketdata/web/src/pages/charts/SymbolSearch.jsx +4 −4
@@ -4,7 +4,7 @@ import React, { useEffect, useId, useImperativeHandle, useRef, useState, forward
4 import { SearchIcon } from '../../components/Icons.jsx' 4 import { SearchIcon } from '../../components/Icons.jsx'
5 import { ASSETS, assetLabel, getRecents, resolveSymbol, searchSymbols } from '../../charts/data/symbols.js' 5 import { ASSETS, assetLabel, getRecents, resolveSymbol, searchSymbols } from '../../charts/data/symbols.js'
6 6
7−const SymbolSearch = forwardRef(function SymbolSearch({ current, onPick, apiKey, compact = false, placeholder = 'Symbol', autoFocus = false, id: idProp }, ref) { 7+const SymbolSearch = forwardRef(function SymbolSearch({ current, onPick, compact = false, placeholder = 'Symbol', autoFocus = false, id: idProp }, ref) {
8 const [text, setText] = useState('') 8 const [text, setText] = useState('')
9 const [open, setOpen] = useState(false) 9 const [open, setOpen] = useState(false)
10 const [groups, setGroups] = useState([]) 10 const [groups, setGroups] = useState([])
@@ -30,12 +30,12 @@ const SymbolSearch = forwardRef(function SymbolSearch({ current, onPick, apiKey,
30 abortRef.current = ctrl 30 abortRef.current = ctrl
31 setBusy(true) 31 setBusy(true)
32 try { 32 try {
33− const res = await searchSymbols(q, { apiKey, signal: ctrl.signal }) 33+ const res = await searchSymbols(q, { signal: ctrl.signal })
34 if (!ctrl.signal.aborted) { setGroups(res); setActive(0) } 34 if (!ctrl.signal.aborted) { setGroups(res); setActive(0) }
35 } catch { /* aborted or offline */ } finally { if (!ctrl.signal.aborted) setBusy(false) } 35 } catch { /* aborted or offline */ } finally { if (!ctrl.signal.aborted) setBusy(false) }
36 }, 120) 36 }, 120)
37 return () => clearTimeout(t) 37 return () => clearTimeout(t)
38− }, [text, open, apiKey]) 38+ }, [text, open])
39 39
40 useEffect(() => { 40 useEffect(() => {
41 if (!open) return undefined 41 if (!open) return undefined
@@ -51,7 +51,7 @@ const SymbolSearch = forwardRef(function SymbolSearch({ current, onPick, apiKey,
51 if (!q) return 51 if (!q) return
52 if (flat[active] && flat[active].ticker.startsWith(q) && flat.length === 1) return pick(flat[active]) 52 if (flat[active] && flat[active].ticker.startsWith(q) && flat.length === 1) return pick(flat[active])
53 setBusy(true) 53 setBusy(true)
54− try { const r = await resolveSymbol(q, { apiKey }); if (r) pick(r) } finally { setBusy(false) } 54+ try { const r = await resolveSymbol(q); if (r) pick(r) } finally { setBusy(false) }
55 } 55 }
56 const onKey = e => { 56 const onKey = e => {
57 if (e.key === 'ArrowDown') { e.preventDefault(); setOpen(true); setActive(a => (flat.length ? (a + 1) % flat.length : 0)) } 57 if (e.key === 'ArrowDown') { e.preventDefault(); setOpen(true); setActive(a => (flat.length ? (a + 1) % flat.length : 0)) }
58 58
Menu

Theme