SPB Git forge

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)

127commits 1branches 0releases
24.7 MBsize
maindefault branch
11 days agolast push
JavaScript 53.7% Python 38.3% CSS 4.6% TypeScript 3.1%

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 18 days ago (Sep 7, 2026) parent 00cc7af

7 changed files +256 −120

modified hfmarketdata/web/e2e/fixtures/charts.js +17 −16
@@ -1,9 +1,7 @@
1 1 // Synthetic OHLCV fixtures + route handler for the /charts E2E spec. Mirrors the real shapes:
2 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 } ·
4 −// GET /v1/limits → { data: { tiers, principal } }.
5 −import { RATE } from '../mocks.js'
6 −
3 +// envelope { count, data }, `order=desc`, `end` inclusive, `limit` ≤ 50 000) · GET /v1/futures/roots → { data, meta }.
4 +// The site's own requests carry `X-HFMD-Client: charts` and get NO X-RateLimit headers (principal `site`).
7 5 const pad2 = n => String(n).padStart(2, '0')
8 6 const stamp = (ms, daily) => {
9 7 const d = new Date(ms)
@@ -37,37 +35,40 @@ export function makeSeries(ticker, timeframe, n, seed = 7) {
37 35
38 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 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 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);
47 − * `state.rateLimit` = true → every bars call answers 429 with Retry-After.
44 + * Route handler factory. `state.calls` collects every bars request (with the `client` header); `state.total` =
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 48 export function chartsHandler(state = {}) {
50 49 const series = new Map()
51 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 51 state.calls = state.calls || []
53 − return (url) => {
52 + state.listCalls = state.listCalls || []
53 + return (url, _route, req) => {
54 54 const p = url.pathname
55 − if (p === '/v1/limits') return json(LIMITS)
56 − if (p === '/v1/futures/roots') return json({ data: ROOTS, meta: { count: ROOTS.length } })
55 + const client = req ? req.headers()['x-hfmd-client'] || null : null
56 + if (p === '/v1/futures/roots') { state.listCalls.push({ path: p, client }); return json({ data: ROOTS, meta: { count: ROOTS.length } }) }
57 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 59 m = /^\/v1\/bars\/(\w+)\/([A-Z0-9.]+)$/.exec(p)
60 60 if (m) {
61 61 const [, asset, ticker] = m
62 62 const tf = url.searchParams.get('timeframe') || '1day'
63 − state.calls.push({ asset, ticker, tf, search: url.search })
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' })
63 + state.calls.push({ asset, ticker, tf, search: url.search, client })
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 66 if (!(TICKERS[asset] || []).includes(ticker)) return err('TICKER_NOT_FOUND', `Ticker '${ticker}' not found in ${asset}/${tf}`, 404)
66 67 let rows = seriesFor(ticker, tf)
67 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 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 72 if (url.searchParams.get('order') === 'desc') rows = rows.slice().reverse()
72 73 rows = rows.slice(0, limit)
73 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 2 // naive wall-clock stamp, see the engine contract), paginates backwards, caches per (asset, ticker, tf, adjustment)
3 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 10 // Endpoints (from /openapi.json):
6 11 // * legacy GET /v1/bars/{asset}/{ticker}?timeframe&adjustment&start&end&order=desc&limit → { count, data: [rows] }
7 12 // rows: { ticker, datetime ("2024-06-03" | "2024-06-03 09:30:00", US/Eastern naive), open, high, low, close, volume, open_interest? }
8 13 // * contract GET /v1/futures/contract/{symbol}/bars?interval&from&to&limit → { data: [rows], meta: { next_cursor, timezone: 'UTC' } }
9 14 // rows: { symbol, datetime ("2024-12-19T14:30:00Z" intraday UTC | "2024-12-19" daily), … } — no `order` parameter,
10 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 18 export const TIMEFRAMES = ['1min', '5min', '30min', '1hour', '1day']
14 19 export const TF_LABEL = { '1min': '1m', '5min': '5m', '30min': '30m', '1hour': '1h', '1day': '1D' }
15 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). */
17 −export const INITIAL_BARS = { '1day': 1500, '1hour': 1500, '30min': 2000, '5min': 3000, '1min': 3000 }
21 +/** Bars requested per call, per timeframe: daily = the whole history in one or two calls, intraday 10–20k bars. */
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 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 31 const pad2 = n => String(n).padStart(2, '0')
22 32
@@ -84,8 +94,8 @@ export function toBarsError(e) {
84 94 const rate = e?.rate || {}
85 95 if (e?.status === 429) {
86 96 const now = Math.floor(Date.now() / 1000)
87 − const retryUntil = rate.retryAfter ? now + rate.retryAfter : rate.reset || now + 60
88 − return new BarsError('rate_limit', e.message || 'Rate limited', { status: 429, code: e.code, retryUntil, rate })
97 + const retryUntil = rate.retryAfter ? now + rate.retryAfter : now + 15
98 + return new BarsError('rate_limit', e.message || 'The API is busy', { status: 429, code: e.code, retryUntil, rate })
89 99 }
90 100 if (e?.status === 404) return new BarsError('not_found', e.message || 'Unknown symbol', { status: 404, code: e.code, rate })
91 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 106 const cache = new Map()
97 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 110 export function getCached(q) { return cache.get(cacheKey(q)) }
101 111 export function putCached(q, bars, extra = {}) {
102 112 const key = cacheKey(q)
@@ -107,32 +117,19 @@ export function putCached(q, bars, extra = {}) {
107 117 }
108 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 120 // ---- fetch --------------------------------------------------------------------------------------------------------
125 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 125 let attempt = 0
129 126 for (;;) {
130 127 try {
131 − return await api(path, { apiKey, signal })
128 + return await api(path, { signal, headers: SITE_HEADERS })
132 129 } catch (e) {
133 130 if (e?.name === 'AbortError') throw e
134 − // one soft retry on network / 5xx, never on 4xx
135 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 133 throw toBarsError(e)
137 134 }
138 135 }
@@ -140,25 +137,26 @@ async function get(path, { apiKey, signal }) {
140 137
141 138 /**
142 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 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 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 })
149 − const q = new URLSearchParams({ timeframe, order: 'desc', limit: String(limit) })
145 + const lim = limit || INITIAL_BARS[timeframe] || 10_000
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 148 if (adjustment) q.set('adjustment', adjustment)
151 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 151 const rows = Array.isArray(data?.data) ? data.data : Array.isArray(data) ? data : []
154 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 156 // Wall-clock density of the contract endpoint: intraday futures trade ~23 h/day, 5 days/week.
159 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 160 const interval = { '1min': '1m', '5min': '5m', '30min': '30m', '1hour': '1h', '1day': '1d' }[timeframe] || '1d'
163 161 const to = end ?? Date.now() + TF_MS['1day']
164 162 const firstT = firstDate ? parseStamp(firstDate) : null
@@ -167,12 +165,12 @@ async function loadContractBars({ symbol, timeframe, end, limit, apiKey, signal,
167 165 for (let attempt = 0; attempt < 4; attempt++) {
168 166 const from = Math.max(firstT ?? -Infinity, to - span)
169 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 169 const rows = Array.isArray(data?.data) ? data.data : []
172 170 const bars = normalizeBars(rows.map(toBar))
173 171 const atStart = firstT != null && from <= firstT
174 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 174 if (more) { span *= 0.5; continue } // window too wide: rows are the OLDEST of it
177 175 if (rows.length < limit * 0.6 && !atStart && attempt < 3) { span *= 3; continue } // too narrow: widen
178 176 break
@@ -180,11 +178,30 @@ async function loadContractBars({ symbol, timeframe, end, limit, apiKey, signal,
180 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 193 export function barsErrorMessage(e, symbol) {
185 194 if (!e) return ''
186 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 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 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 4 // plus localStorage persistence (last symbol, preferences, drawings per asset:ticker:tf).
3 5 import { TIMEFRAMES } from './bars.js'
4 6 import { INDICATORS, indicatorFromSpec, indicatorToSpec } from '../../pages/charts/indicators.js'
@@ -8,14 +10,30 @@ export const SERIES_TYPES = [
8 10 ['baseline', 'Baseline'], ['heikin', 'Heikin-Ashi'], ['columns', 'Columns'], ['hlc', 'HLC bars'],
9 11 ]
10 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 25 const PREFS_KEY = 'hfmd.charts.prefs'
15 26 const LAST_KEY = 'hfmd.charts.last'
16 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 38 export function readPrefs() { try { return { ...DEFAULT_PREFS, ...(JSON.parse(localStorage.getItem(PREFS_KEY) || '{}')) } } catch { return { ...DEFAULT_PREFS } } }
21 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 50 } catch { /* ignore */ }
33 51 }
34 52
35 −/** Parse the URL search string into a partial state (unknown values are dropped). */
36 −export function parseSearch(search) {
37 − const p = new URLSearchParams(search)
53 +/** Favourite indicators (ids). */
54 +export function readFavorites() { try { const v = JSON.parse(localStorage.getItem(FAV_KEY) || '[]'); return Array.isArray(v) ? v : [] } catch { return [] } }
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 67 const out = {}
39 − const s = (p.get('s') || '').trim().toUpperCase()
68 + const s = (g('s') || '').trim().toUpperCase()
40 69 if (s) { out.ticker = s; out.label = s }
41 − const asset = p.get('asset')
70 + const asset = g('asset')
42 71 if (asset) out.asset = asset
43 − const tf = p.get('tf')
72 + const tf = g('tf')
44 73 if (TIMEFRAMES.includes(tf)) out.tf = tf
45 − const type = p.get('type')
74 + const type = g('type')
46 75 if (SERIES_TYPES.some(([id]) => id === type)) out.type = type
47 − const ind = p.get('ind')
76 + const ind = g('ind')
48 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 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 81 if (SCALES.includes(scale)) out.scale = scale
53 − const vol = p.get('vol')
82 + const vol = g('vol')
54 83 if (vol === '0' || vol === '1') out.volume = vol === '1'
55 − const adj = p.get('adj')
84 + const adj = g('adj')
56 85 if (adj) out.adjustment = adj
57 86 return out
58 87 }
59 88
60 −/** Serialize the state into a canonical search string (only non-default values). */
61 −export function toSearch(state) {
89 +function chartParams(p, cs, k) {
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 117 const p = new URLSearchParams()
63 − p.set('s', state.ticker)
64 − if (state.asset && state.asset !== 'stock') p.set('asset', state.asset)
65 − if (state.tf !== '1day') p.set('tf', state.tf)
66 − if (state.type !== 'candles') p.set('type', state.type)
67 − if (state.indicators?.length) p.set('ind', state.indicators.map(indicatorToSpec).join(','))
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)
118 + const layout = page?.layout || '1'
119 + const n = LAYOUTS[layout]?.count || 1
120 + const list = Array.isArray(charts) ? charts : [page] // single-chart callers pass the chart state itself
121 + for (let k = 0; k < Math.min(n, list.length); k++) chartParams(p, list[k], k)
122 + if (layout !== '1') p.set('layout', layout)
72 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 134 export function initialState(search) {
77 135 const fromUrl = parseSearch(search)
78 − const base = { ...DEFAULT_STATE }
79 − if (!fromUrl.ticker) {
80 − const last = readLast()
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' })
136 + const layout = fromUrl.layout || '1'
137 + const n = LAYOUTS[layout].count
138 + const charts = []
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)
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 }
152 + return { page: { layout, active: 0 }, charts }
87 153 }
88 154
89 155 export { INDICATORS }
modified hfmarketdata/web/src/charts/data/symbols.js +19 −16
@@ -4,7 +4,10 @@
4 4 // GET /v1/futures/{root}/contracts → { data: [{ symbol, expiration_date, first_data_date, status, tick_size, … }] }.
5 5 // Lists are cached in memory and in localStorage for 24 h, loaded lazily per asset.
6 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 12 export const ASSETS = [
10 13 { id: 'stock', label: 'Stocks', short: 'Stock' },
@@ -36,7 +39,7 @@ const readLS = k => { try { const raw = localStorage.getItem(k); if (!raw) retur
36 39 const writeLS = (k, list) => { try { localStorage.setItem(k, JSON.stringify({ at: Date.now(), list })) } catch { /* quota / private mode */ } }
37 40
38 41 /** @returns {Promise<Array<{ asset, ticker, label, name? }>>} */
39 −export function loadTickers(asset, { apiKey, signal } = {}) {
42 +export function loadTickers(asset, { signal } = {}) {
40 43 if (mem.has(asset)) return mem.get(asset)
41 44 const cached = readLS(LS_PREFIX + asset)
42 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 47 let list
45 48 if (asset === 'futures') {
46 49 try {
47 − const { data } = await api('/v1/futures/roots', { apiKey, signal })
50 + const { data } = await get('/v1/futures/roots', { signal })
48 51 const rows = data?.data || []
49 52 rows.forEach(r => specs.set(r.root, r))
50 53 list = rows.map(r => ({ asset, ticker: r.root, label: r.root, name: r.name || undefined }))
51 54 } catch (e) {
52 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 57 list = (data?.tickers || []).map(s => ({ asset, ticker: s, label: s }))
55 58 }
56 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 61 list = (data?.tickers || []).map(s => ({ asset, ticker: s, label: s }))
59 62 }
60 63 writeLS(LS_PREFIX + asset, list)
@@ -75,11 +78,11 @@ const CONTRACT_RE = /^([A-Z0-9]{1,4}?)([FGHJKMNQUVXZ])(\d{2}|\d{4})$/
75 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 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 82 if (contractsMem.has(root)) return contractsMem.get(root)
80 83 const cached = readLS(`${LS_PREFIX}contracts.${root}`)
81 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 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 87 writeLS(`${LS_PREFIX}contracts.${root}`, list)
85 88 return list
@@ -114,17 +117,17 @@ function rank(items, q, perGroup) {
114 117 * Fuzzy search (prefix first, then substring) across every asset list that is already loaded or loads quickly.
115 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 121 const q = (query || '').trim().toUpperCase()
119 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 124 // individual contracts: "ESZ24", "ESZ" or "ES " → contracts of the root
122 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 126 if (c) {
124 127 try {
125 − const roots = await loadTickers('futures', { apiKey, signal })
128 + const roots = await loadTickers('futures', { signal })
126 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 131 const items = rank(contracts, q, perGroup)
129 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 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 142 const q = (text || '').trim().toUpperCase()
140 143 if (!q) return null
141 144 if (asset && asset !== CONTRACT_ASSET) {
142 − const list = await loadTickers(asset, { apiKey, signal }).catch(() => [])
145 + const list = await loadTickers(asset, { signal }).catch(() => [])
143 146 const hit = list.find(it => it.ticker === q)
144 147 if (hit) return hit
145 148 }
146 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 151 return list.find(it => it.ticker === q) || { asset: CONTRACT_ASSET, ticker: q, label: q }
149 152 }
150 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 155 const hit = list.find(it => it.ticker === q)
153 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 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
2 −// X-RateLimit-* headers of the last response) with a sign-in CTA for keyless visitors, and the session time zone.
1 +// Bottom status strip: bars loaded, covered range, "loading older…" / "start of history" / a retry chip when a page
2 +// of history failed, and the session time zone. No quota information: the site's own requests have none.
3 3 import React from 'react'
4 −import { Link } from 'react-router-dom'
5 −import { useCountdown, fmtDuration } from '../../components/States.jsx'
6 4 import { formatStampLabel } from '../../charts/data/session.js'
7 5
8 −export default function StatusBar({ status, state, tz, authenticated, compact = false }) {
9 − const { count, firstT, lastT, loadingOlder, startOfHistory, rate, phase } = status
10 − const left = useCountdown(rate?.reset || null)
11 − const range = firstT != null && lastT != null ? `${formatStampLabel(firstT, state.tf, '')} → ${formatStampLabel(lastT, state.tf, '')}` : ''
6 +export default function StatusBar({ status, cs, tz, compact = false, onRetryOlder, layoutLabel }) {
7 + const { count, firstT, lastT, loadingOlder, startOfHistory, phase, olderError } = status
8 + const range = firstT != null && lastT != null ? `${formatStampLabel(firstT, cs.tf, '')} → ${formatStampLabel(lastT, cs.tf, '')}` : ''
12 9 return (
13 10 <div className="ch-status" role="status" aria-label="Chart status" data-testid="ch-status">
14 11 <span className="ch-status-item mono" data-testid="ch-status-count">{count ? `${count.toLocaleString('en-US')} bars` : phase === 'loading' ? 'Loading…' : '—'}</span>
15 12 {range && !compact && <span className="ch-status-item ch-status-range mono" title="Loaded range (wall-clock stamps)">{range}</span>}
16 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 14 {startOfHistory && !loadingOlder && <span className="ch-status-item muted" data-testid="ch-status-start">start of history</span>}
18 − <span className="ch-status-spacer" />
19 − {rate?.limitRequests != null && (
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>
15 + {olderError && !loadingOlder && !startOfHistory && (
16 + <button type="button" className="ch-status-item ch-status-retry" onClick={onRetryOlder} data-testid="ch-status-retry">older history unavailable — retry</button>
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 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 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 4 import { SearchIcon } from '../../components/Icons.jsx'
5 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 8 const [text, setText] = useState('')
9 9 const [open, setOpen] = useState(false)
10 10 const [groups, setGroups] = useState([])
@@ -30,12 +30,12 @@ const SymbolSearch = forwardRef(function SymbolSearch({ current, onPick, apiKey,
30 30 abortRef.current = ctrl
31 31 setBusy(true)
32 32 try {
33 − const res = await searchSymbols(q, { apiKey, signal: ctrl.signal })
33 + const res = await searchSymbols(q, { signal: ctrl.signal })
34 34 if (!ctrl.signal.aborted) { setGroups(res); setActive(0) }
35 35 } catch { /* aborted or offline */ } finally { if (!ctrl.signal.aborted) setBusy(false) }
36 36 }, 120)
37 37 return () => clearTimeout(t)
38 − }, [text, open, apiKey])
38 + }, [text, open])
39 39
40 40 useEffect(() => {
41 41 if (!open) return undefined
@@ -51,7 +51,7 @@ const SymbolSearch = forwardRef(function SymbolSearch({ current, onPick, apiKey,
51 51 if (!q) return
52 52 if (flat[active] && flat[active].ticker.startsWith(q) && flat.length === 1) return pick(flat[active])
53 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 56 const onKey = e => {
57 57 if (e.key === 'ArrowDown') { e.preventDefault(); setOpen(true); setActive(a => (flat.length ? (a + 1) % flat.length : 0)) }
58 58