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>
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 | |