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: couche données /charts (symboles multi-actifs + contrats, barres paginées avec cache, sessions/décimales, état d'URL et persistance)

Simon-Pierre Boucher committed 18 days ago (Sep 7, 2026) parent ad2461e

6 changed files +547 −0

modified hfmarketdata/web/.gitignore +3 −0
@@ -5,3 +5,6 @@ playwright-report/
5 5 blob-report/
6 6 .playwright/
7 7 e2e/.*.tmp.mjs
8 +
9 +# src/charts/data is source code, not a data directory (root .gitignore ignores data/)
10 +!src/charts/data/
added hfmarketdata/web/src/charts/data/bars.js +190 −0
@@ -0,0 +1,190 @@
1 +// Bars loader for the /charts page: fetches OHLCV from the API, converts rows to engine `Bar`s (t = Date.UTC of the
2 +// naive wall-clock stamp, see the engine contract), paginates backwards, caches per (asset, ticker, tf, adjustment)
3 +// with range merging, and raises typed errors (BarsError.kind = 'rate_limit' | 'not_found' | 'network' | 'http').
4 +//
5 +// Endpoints (from /openapi.json):
6 +// * legacy GET /v1/bars/{asset}/{ticker}?timeframe&adjustment&start&end&order=desc&limit → { count, data: [rows] }
7 +// rows: { ticker, datetime ("2024-06-03" | "2024-06-03 09:30:00", US/Eastern naive), open, high, low, close, volume, open_interest? }
8 +// * contract GET /v1/futures/contract/{symbol}/bars?interval&from&to&limit → { data: [rows], meta: { next_cursor, timezone: 'UTC' } }
9 +// rows: { symbol, datetime ("2024-12-19T14:30:00Z" intraday UTC | "2024-12-19" daily), … } — no `order` parameter,
10 +// 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'
12 +
13 +export const TIMEFRAMES = ['1min', '5min', '30min', '1hour', '1day']
14 +export const TF_LABEL = { '1min': '1m', '5min': '5m', '30min': '30m', '1hour': '1h', '1day': '1D' }
15 +export const TF_MS = { '1min': 60_000, '5min': 300_000, '30min': 1_800_000, '1hour': 3_600_000, '1day': 86_400_000 }
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 }
18 +export const CONTRACT_ASSET = 'contract'
19 +export const KEYLESS_MAX_ROWS = TIERS.find(t => t.id === 'keyless')?.maxRows || 5000
20 +
21 +const pad2 = n => String(n).padStart(2, '0')
22 +
23 +/** "2024-06-03" | "2024-06-03 09:30:00" | "2024-06-03T09:30:00Z" → ms such that the UTC getters show the wall-clock stamp. */
24 +export function parseStamp(s) {
25 + if (typeof s === 'number') return s
26 + if (!s) return NaN
27 + const m = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?/.exec(s)
28 + if (!m) return NaN
29 + return Date.UTC(+m[1], +m[2] - 1, +m[3], +(m[4] || 0), +(m[5] || 0), +(m[6] || 0))
30 +}
31 +
32 +/** ms → API bound: a date for daily bars, a naive datetime otherwise. */
33 +export function formatStamp(t, timeframe, { iso = false } = {}) {
34 + const d = new Date(t)
35 + const date = `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`
36 + if (timeframe === '1day') return date
37 + const time = `${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}:${pad2(d.getUTCSeconds())}`
38 + return iso ? `${date}T${time}Z` : `${date} ${time}`
39 +}
40 +
41 +export function toBar(row) {
42 + const t = parseStamp(row.datetime ?? row.t ?? row.time)
43 + const bar = { t, o: +row.open, h: +row.high, l: +row.low, c: +row.close }
44 + if (row.volume != null) bar.v = +row.volume
45 + if (row.open_interest != null) bar.oi = +row.open_interest
46 + return bar
47 +}
48 +
49 +/** Sort ascending by t and drop duplicates (last one wins). */
50 +export function normalizeBars(list) {
51 + const out = list.filter(b => Number.isFinite(b.t) && Number.isFinite(b.c)).sort((a, b) => a.t - b.t)
52 + const res = []
53 + for (const b of out) { if (res.length && res[res.length - 1].t === b.t) res[res.length - 1] = b; else res.push(b) }
54 + return res
55 +}
56 +
57 +/** Merge two ascending, deduplicated arrays (incoming wins on equal t). */
58 +export function mergeBars(existing, incoming) {
59 + if (!existing.length) return incoming.slice()
60 + if (!incoming.length) return existing.slice()
61 + const out = []
62 + let i = 0, j = 0
63 + while (i < existing.length || j < incoming.length) {
64 + const a = existing[i], b = incoming[j]
65 + if (b === undefined || (a !== undefined && a.t < b.t)) { out.push(a); i++ }
66 + else if (a === undefined || b.t < a.t) { out.push(b); j++ }
67 + else { out.push(b); i++; j++ }
68 + }
69 + return out
70 +}
71 +
72 +export class BarsError extends Error {
73 + constructor(kind, message, extra = {}) {
74 + super(message)
75 + this.name = 'BarsError'
76 + this.kind = kind
77 + Object.assign(this, extra)
78 + }
79 +}
80 +
81 +export function toBarsError(e) {
82 + if (e instanceof BarsError) return e
83 + if (e?.name === 'AbortError') return e
84 + const rate = e?.rate || {}
85 + if (e?.status === 429) {
86 + 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 })
89 + }
90 + if (e?.status === 404) return new BarsError('not_found', e.message || 'Unknown symbol', { status: 404, code: e.code, rate })
91 + if (!e?.status || e.code === 'NETWORK') return new BarsError('network', e?.message || 'Network error', { status: 0, code: 'NETWORK' })
92 + return new BarsError('http', e.message || `HTTP ${e.status}`, { status: e.status, code: e.code, rate })
93 +}
94 +
95 +// ---- cache ------------------------------------------------------------------------------------------------------
96 +const cache = new Map()
97 +export const cacheKey = ({ asset, ticker, timeframe, adjustment }) => `${asset}|${ticker}|${timeframe}|${adjustment || ''}`
98 +
99 +/** Cached series: { bars, startOfHistory, rate } or undefined. */
100 +export function getCached(q) { return cache.get(cacheKey(q)) }
101 +export function putCached(q, bars, extra = {}) {
102 + const key = cacheKey(q)
103 + const prev = cache.get(key) || { bars: [], startOfHistory: false }
104 + const entry = { ...prev, ...extra, bars: mergeBars(prev.bars, bars) }
105 + cache.set(key, entry)
106 + return entry
107 +}
108 +export function clearBarsCache() { cache.clear() }
109 +
110 +// ---- limits -------------------------------------------------------------------------------------------------------
111 +let limitsPromise = null
112 +/** Row cap per request for the current principal (GET /v1/limits is free). Falls back to the keyless cap. */
113 +export function loadMaxRows({ apiKey } = {}) {
114 + if (!limitsPromise) {
115 + limitsPromise = api('/v1/limits', { apiKey }).then(({ data }) => {
116 + const p = data?.data?.principal || data?.principal
117 + return { maxRows: p?.max_rows_per_request || KEYLESS_MAX_ROWS, tier: p?.tier || 'keyless', requests: p?.requests, rows: p?.rows }
118 + }).catch(() => ({ maxRows: KEYLESS_MAX_ROWS, tier: 'keyless' }))
119 + }
120 + return limitsPromise
121 +}
122 +export function resetLimits() { limitsPromise = null }
123 +
124 +// ---- fetch --------------------------------------------------------------------------------------------------------
125 +const sleep = (ms, signal) => new Promise((res, rej) => { const t = setTimeout(res, ms); signal?.addEventListener('abort', () => { clearTimeout(t); rej(Object.assign(new Error('aborted'), { name: 'AbortError' })) }, { once: true }) })
126 +
127 +async function get(path, { apiKey, signal }) {
128 + let attempt = 0
129 + for (;;) {
130 + try {
131 + return await api(path, { apiKey, signal })
132 + } catch (e) {
133 + if (e?.name === 'AbortError') throw e
134 + // one soft retry on network / 5xx, never on 4xx
135 + if (attempt < 1 && (!e.status || e.status >= 500)) { attempt++; await sleep(600, signal); continue }
136 + throw toBarsError(e)
137 + }
138 + }
139 +}
140 +
141 +/**
142 + * 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
144 + * rows than requested, i.e. we reached the start of the history.
145 + */
146 +export async function loadBars({ asset, ticker, timeframe = '1day', adjustment, end, limit, apiKey, signal, firstDate }) {
147 + 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) })
150 + if (adjustment) q.set('adjustment', adjustment)
151 + 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 })
153 + const rows = Array.isArray(data?.data) ? data.data : Array.isArray(data) ? data : []
154 + const bars = normalizeBars(rows.map(toBar))
155 + return { bars, complete: rows.length < limit, rate }
156 +}
157 +
158 +// Wall-clock density of the contract endpoint: intraday futures trade ~23 h/day, 5 days/week.
159 +const CONTRACT_DENSITY = { '1min': 1.7, '5min': 1.7, '30min': 1.7, '1hour': 1.7, '1day': 1.5 }
160 +
161 +async function loadContractBars({ symbol, timeframe, end, limit, apiKey, signal, firstDate }) {
162 + const interval = { '1min': '1m', '5min': '5m', '30min': '30m', '1hour': '1h', '1day': '1d' }[timeframe] || '1d'
163 + const to = end ?? Date.now() + TF_MS['1day']
164 + const firstT = firstDate ? parseStamp(firstDate) : null
165 + let span = limit * TF_MS[timeframe] * CONTRACT_DENSITY[timeframe]
166 + let best = null
167 + for (let attempt = 0; attempt < 4; attempt++) {
168 + const from = Math.max(firstT ?? -Infinity, to - span)
169 + const q = new URLSearchParams({ interval, limit: String(limit), from: formatStamp(from, timeframe, { iso: true }), to: formatStamp(to, timeframe, { iso: true }) })
170 + const { data, rate } = await get(`/v1/futures/contract/${encodeURIComponent(symbol)}/bars?${q}`, { apiKey, signal })
171 + const rows = Array.isArray(data?.data) ? data.data : []
172 + const bars = normalizeBars(rows.map(toBar))
173 + const atStart = firstT != null && from <= firstT
174 + const more = !!data?.meta?.next_cursor
175 + best = { bars, complete: !more && (atStart || rows.length < limit), rate, meta: data?.meta, atStart }
176 + if (more) { span *= 0.5; continue } // window too wide: rows are the OLDEST of it
177 + if (rows.length < limit * 0.6 && !atStart && attempt < 3) { span *= 3; continue } // too narrow: widen
178 + break
179 + }
180 + return best
181 +}
182 +
183 +/** Human error message for a BarsError. */
184 +export function barsErrorMessage(e, symbol) {
185 + if (!e) return ''
186 + 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.'
188 + if (e.kind === 'network') return 'Network error — please check your connection.'
189 + return e.message || 'Something went wrong.'
190 +}
added hfmarketdata/web/src/charts/data/session.js +55 −0
@@ -0,0 +1,55 @@
1 +// Session / formatting metadata per asset: time-zone label, decimals and minimum price move.
2 +// Time conventions (docs/time-zones): every v1 bars endpoint (stock, etf, crypto, index, fx, continuous futures)
3 +// stamps intraday bars in US/Eastern wall-clock time → 'ET'; the v2 contract endpoint returns UTC ISO 8601 → 'UTC'.
4 +import { CONTRACT_ASSET } from './bars.js'
5 +
6 +export function timezoneLabel(asset) {
7 + return asset === CONTRACT_ASSET ? 'UTC' : 'ET'
8 +}
9 +
10 +const decimalsOf = x => { const s = String(x); const i = s.indexOf('.'); return i < 0 ? 0 : s.length - i - 1 }
11 +
12 +/**
13 + * Decimals to display. fx: 5 (3 for JPY-like quotes above 20); crypto: by price level; futures: from the tick size
14 + * when the spec is known; equities/indices: 2 (more for sub-dollar prices).
15 + * @returns {{ decimals: number, minMove?: number }}
16 + */
17 +export function priceFormatFor(asset, bars, spec) {
18 + const last = bars?.length ? bars[bars.length - 1].c : null
19 + if (spec?.tick_size || spec?.tickSize) {
20 + const tick = spec.tick_size || spec.tickSize
21 + return { decimals: Math.min(8, decimalsOf(tick)), minMove: tick }
22 + }
23 + if (asset === 'fx') return { decimals: last != null && last > 20 ? 3 : 5 }
24 + if (asset === 'crypto') {
25 + if (last == null) return { decimals: 'auto' }
26 + return { decimals: last >= 1000 ? 2 : last >= 1 ? 4 : last >= 0.01 ? 6 : 8 }
27 + }
28 + if (asset === 'futures' || asset === CONTRACT_ASSET) return { decimals: 'auto' }
29 + if (last != null && last < 1) return { decimals: 4 }
30 + return { decimals: 2 }
31 +}
32 +
33 +/** Number formatter (en-US) with fixed decimals; 'auto' → up to 4 significant decimals. */
34 +export function formatPrice(v, decimals = 2) {
35 + if (v == null || !Number.isFinite(v)) return '—'
36 + if (decimals === 'auto') return v.toLocaleString('en-US', { maximumFractionDigits: Math.abs(v) >= 100 ? 2 : 4 })
37 + return v.toLocaleString('en-US', { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
38 +}
39 +
40 +export function formatVolume(v) {
41 + if (v == null || !Number.isFinite(v)) return '—'
42 + if (v >= 1e9) return `${(v / 1e9).toFixed(2)}B`
43 + if (v >= 1e6) return `${(v / 1e6).toFixed(2)}M`
44 + if (v >= 1e3) return `${(v / 1e3).toFixed(1)}K`
45 + return String(Math.round(v))
46 +}
47 +
48 +const pad2 = n => String(n).padStart(2, '0')
49 +/** Wall-clock stamp for the legend / table (UTC getters = the stamp as published). */
50 +export function formatStampLabel(t, timeframe, tz) {
51 + const d = new Date(t)
52 + const date = `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`
53 + if (timeframe === '1day') return date
54 + return `${date} ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}${tz ? ` ${tz}` : ''}`
55 +}
added hfmarketdata/web/src/charts/data/state.js +86 −0
@@ -0,0 +1,86 @@
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`)
2 +// plus localStorage persistence (last symbol, preferences, drawings per asset:ticker:tf).
3 +import { TIMEFRAMES } from './bars.js'
4 +import { INDICATORS, indicatorFromSpec, indicatorToSpec } from '../../pages/charts/indicators.js'
5 +
6 +export const SERIES_TYPES = [
7 + ['candles', 'Candles'], ['hollow', 'Hollow candles'], ['ohlc', 'OHLC bars'], ['line', 'Line'], ['area', 'Area'],
8 + ['baseline', 'Baseline'], ['heikin', 'Heikin-Ashi'], ['columns', 'Columns'], ['hlc', 'HLC bars'],
9 +]
10 +export const SCALES = ['linear', 'log', 'percent']
11 +
12 +export const DEFAULT_STATE = { asset: 'stock', ticker: 'AAPL', label: 'AAPL', tf: '1day', type: 'candles', indicators: [], compares: [], scale: 'linear', volume: true, adjustment: '' }
13 +
14 +const PREFS_KEY = 'hfmd.charts.prefs'
15 +const LAST_KEY = 'hfmd.charts.last'
16 +const DRAW_PREFIX = 'hfmd.charts.drawings.'
17 +
18 +export const DEFAULT_PREFS = { colorblind: false, magnet: false, watermark: true, autoScale: true, showDrawingBar: true, reducedMotion: false }
19 +
20 +export function readPrefs() { try { return { ...DEFAULT_PREFS, ...(JSON.parse(localStorage.getItem(PREFS_KEY) || '{}')) } } catch { return { ...DEFAULT_PREFS } } }
21 +export function writePrefs(p) { try { localStorage.setItem(PREFS_KEY, JSON.stringify(p)) } catch { /* ignore */ } }
22 +
23 +export function readLast() { try { return JSON.parse(localStorage.getItem(LAST_KEY) || 'null') } catch { return null } }
24 +export function writeLast(s) { try { localStorage.setItem(LAST_KEY, JSON.stringify({ asset: s.asset, ticker: s.ticker, label: s.label, tf: s.tf, type: s.type })) } catch { /* ignore */ } }
25 +
26 +export const drawingsKey = s => `${s.asset}:${s.ticker}:${s.tf}`
27 +export function readDrawings(s) { try { return JSON.parse(localStorage.getItem(DRAW_PREFIX + drawingsKey(s)) || '[]') } catch { return [] } }
28 +export function writeDrawings(s, list) {
29 + try {
30 + if (!list?.length) localStorage.removeItem(DRAW_PREFIX + drawingsKey(s))
31 + else localStorage.setItem(DRAW_PREFIX + drawingsKey(s), JSON.stringify(list))
32 + } catch { /* ignore */ }
33 +}
34 +
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)
38 + const out = {}
39 + const s = (p.get('s') || '').trim().toUpperCase()
40 + if (s) { out.ticker = s; out.label = s }
41 + const asset = p.get('asset')
42 + if (asset) out.asset = asset
43 + const tf = p.get('tf')
44 + if (TIMEFRAMES.includes(tf)) out.tf = tf
45 + const type = p.get('type')
46 + if (SERIES_TYPES.some(([id]) => id === type)) out.type = type
47 + const ind = p.get('ind')
48 + if (ind != null) out.indicators = ind.split(',').map(x => indicatorFromSpec(x.trim())).filter(Boolean)
49 + const cmp = p.get('cmp')
50 + if (cmp != null) out.compares = cmp.split(',').map(x => x.trim().toUpperCase()).filter(Boolean).map(sym => { const [ticker, a] = sym.split('@'); return { ticker, asset: a || undefined } })
51 + const scale = p.get('scale')
52 + if (SCALES.includes(scale)) out.scale = scale
53 + const vol = p.get('vol')
54 + if (vol === '0' || vol === '1') out.volume = vol === '1'
55 + const adj = p.get('adj')
56 + if (adj) out.adjustment = adj
57 + return out
58 +}
59 +
60 +/** Serialize the state into a canonical search string (only non-default values). */
61 +export function toSearch(state) {
62 + 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)
72 + return `?${p.toString().replace(/%3A/g, ':').replace(/%2C/g, ',').replace(/%40/g, '@')}`
73 +}
74 +
75 +/** Initial state: URL → last visited symbol → AAPL 1D. */
76 +export function initialState(search) {
77 + 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' })
82 + }
83 + return { ...base, ...fromUrl, indicators: (fromUrl.indicators || []).map(i => ({ ...i, colorIndex: undefined })) }
84 +}
85 +
86 +export { INDICATORS }
added hfmarketdata/web/src/charts/data/symbols.js +157 −0
@@ -0,0 +1,157 @@
1 +// Symbol resolution and search for the /charts page.
2 +// Sources (see /openapi.json): GET /v1/{asset}/tickers → { tickers: [..] } for stock|etf|crypto|index|fx (+ futures
3 +// continuous roots via ?adjustment=contin_adj_ratio), GET /v1/futures/roots → { data: [{ root, name, tick_size, … }] },
4 +// GET /v1/futures/{root}/contracts → { data: [{ symbol, expiration_date, first_data_date, status, tick_size, … }] }.
5 +// Lists are cached in memory and in localStorage for 24 h, loaded lazily per asset.
6 +import { api } from '../../app/api.js'
7 +import { CONTRACT_ASSET } from './bars.js'
8 +
9 +export const ASSETS = [
10 + { id: 'stock', label: 'Stocks', short: 'Stock' },
11 + { id: 'etf', label: 'ETFs', short: 'ETF' },
12 + { id: 'index', label: 'Indices', short: 'Index' },
13 + { id: 'futures', label: 'Futures (continuous)', short: 'Futures' },
14 + { id: CONTRACT_ASSET, label: 'Futures contracts', short: 'Contract' },
15 + { id: 'crypto', label: 'Crypto', short: 'Crypto' },
16 + { id: 'fx', label: 'FX', short: 'FX' },
17 +]
18 +export const assetLabel = id => ASSETS.find(a => a.id === id)?.short || id
19 +export const LIST_ASSETS = ['stock', 'etf', 'index', 'futures', 'crypto', 'fx']
20 +
21 +/** Default adjustment per asset (server default for equities is adj_splitdiv; continuous futures ratio-adjusted). */
22 +export const ADJUSTMENTS = {
23 + stock: [['adj_splitdiv', 'Split + dividend adjusted'], ['adj_split', 'Split adjusted'], ['UNADJUSTED', 'Unadjusted']],
24 + etf: [['adj_splitdiv', 'Split + dividend adjusted'], ['adj_split', 'Split adjusted'], ['UNADJUSTED', 'Unadjusted']],
25 + futures: [['contin_adj_ratio', 'Ratio adjusted (continuous)'], ['contin_adj_absolute', 'Back adjusted (continuous)'], ['contin_UNadj', 'Unadjusted (continuous)']],
26 +}
27 +export const defaultAdjustment = asset => ADJUSTMENTS[asset]?.[0]?.[0] || ''
28 +
29 +const TTL = 24 * 3600 * 1000
30 +const LS_PREFIX = 'hfmd.charts.list.'
31 +const mem = new Map() // asset → Promise<Item[]>
32 +const specs = new Map() // futures root → spec row
33 +const contractsMem = new Map() // root → Promise<Item[]>
34 +
35 +const readLS = k => { try { const raw = localStorage.getItem(k); if (!raw) return null; const v = JSON.parse(raw); return v && Date.now() - v.at < TTL ? v.list : null } catch { return null } }
36 +const writeLS = (k, list) => { try { localStorage.setItem(k, JSON.stringify({ at: Date.now(), list })) } catch { /* quota / private mode */ } }
37 +
38 +/** @returns {Promise<Array<{ asset, ticker, label, name? }>>} */
39 +export function loadTickers(asset, { apiKey, signal } = {}) {
40 + if (mem.has(asset)) return mem.get(asset)
41 + const cached = readLS(LS_PREFIX + asset)
42 + if (cached) { const p = Promise.resolve(cached); mem.set(asset, p); return p }
43 + const p = (async () => {
44 + let list
45 + if (asset === 'futures') {
46 + try {
47 + const { data } = await api('/v1/futures/roots', { apiKey, signal })
48 + const rows = data?.data || []
49 + rows.forEach(r => specs.set(r.root, r))
50 + list = rows.map(r => ({ asset, ticker: r.root, label: r.root, name: r.name || undefined }))
51 + } catch (e) {
52 + if (e?.name === 'AbortError') throw e
53 + const { data } = await api('/v1/futures/tickers?adjustment=contin_adj_ratio&limit=10000', { apiKey, signal })
54 + list = (data?.tickers || []).map(s => ({ asset, ticker: s, label: s }))
55 + }
56 + } else {
57 + const { data } = await api(`/v1/${asset}/tickers?limit=20000`, { apiKey, signal })
58 + list = (data?.tickers || []).map(s => ({ asset, ticker: s, label: s }))
59 + }
60 + writeLS(LS_PREFIX + asset, list)
61 + return list
62 + })()
63 + p.catch(() => mem.delete(asset))
64 + mem.set(asset, p)
65 + return p
66 +}
67 +
68 +/** Futures root specification (tick size, name, rth window) when the roots list has been loaded. */
69 +export async function loadRootSpec(root, opts) {
70 + if (!specs.has(root)) { try { await loadTickers('futures', opts) } catch { /* offline */ } }
71 + return specs.get(root) || null
72 +}
73 +
74 +const CONTRACT_RE = /^([A-Z0-9]{1,4}?)([FGHJKMNQUVXZ])(\d{2}|\d{4})$/
75 +export const parseContract = sym => { const m = CONTRACT_RE.exec(sym || ''); return m ? { root: m[1], month: m[2], year: m[3] } : null }
76 +
77 +/** Individual contracts of a root → items { asset: 'contract', ticker: 'ESZ24', label, name, firstDate, lastDate, tickSize, status }. */
78 +export function loadContracts(root, { apiKey, signal } = {}) {
79 + if (contractsMem.has(root)) return contractsMem.get(root)
80 + const cached = readLS(`${LS_PREFIX}contracts.${root}`)
81 + 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 }) => {
83 + const list = (data?.data || []).map(c => ({ asset: CONTRACT_ASSET, ticker: c.symbol, label: c.symbol, name: `${c.root} · exp. ${c.expiration_date || '?'}${c.status === 'active' ? ' · active' : ''}`, firstDate: c.first_data_date, lastDate: c.last_data_date, tickSize: c.tick_size, status: c.status }))
84 + writeLS(`${LS_PREFIX}contracts.${root}`, list)
85 + return list
86 + })
87 + p.catch(() => contractsMem.delete(root))
88 + contractsMem.set(root, p)
89 + return p
90 +}
91 +
92 +// ---- recents ---------------------------------------------------------------------------------------------------
93 +const RECENT_KEY = 'hfmd.charts.recent'
94 +export function getRecents() { try { return JSON.parse(localStorage.getItem(RECENT_KEY) || '[]') } catch { return [] } }
95 +export function pushRecent(item) {
96 + const list = [item, ...getRecents().filter(r => !(r.asset === item.asset && r.ticker === item.ticker))].slice(0, 8)
97 + try { localStorage.setItem(RECENT_KEY, JSON.stringify(list)) } catch { /* ignore */ }
98 + return list
99 +}
100 +
101 +// ---- search -----------------------------------------------------------------------------------------------------
102 +function rank(items, q, perGroup) {
103 + const prefix = [], sub = []
104 + for (const it of items) {
105 + const t = it.ticker.toUpperCase()
106 + if (t.startsWith(q)) prefix.push(it)
107 + else if (t.includes(q) || (it.name && it.name.toUpperCase().includes(q))) sub.push(it)
108 + if (prefix.length >= perGroup) break
109 + }
110 + return prefix.concat(sub).slice(0, perGroup)
111 +}
112 +
113 +/**
114 + * Fuzzy search (prefix first, then substring) across every asset list that is already loaded or loads quickly.
115 + * @returns {Promise<Array<{ asset, items }>>} groups ordered by ASSETS; empty groups omitted.
116 + */
117 +export async function searchSymbols(query, { apiKey, signal, perGroup = 6 } = {}) {
118 + const q = (query || '').trim().toUpperCase()
119 + 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: [] } })))
121 + // individual contracts: "ESZ24", "ESZ" or "ES " → contracts of the root
122 + const c = parseContract(q) || (/^[A-Z0-9]{1,4}[FGHJKMNQUVXZ]?$/.test(q) && q.length >= 2 ? { root: q.replace(/[FGHJKMNQUVXZ]$/, ''), month: q.length > 1 && /[FGHJKMNQUVXZ]$/.test(q) ? q.slice(-1) : '' } : null)
123 + if (c) {
124 + try {
125 + const roots = await loadTickers('futures', { apiKey, signal })
126 + if (roots.some(r => r.ticker === c.root)) {
127 + const contracts = await loadContracts(c.root, { apiKey, signal })
128 + const items = rank(contracts, q, perGroup)
129 + if (items.length) results.push({ asset: CONTRACT_ASSET, items })
130 + }
131 + } catch (e) { if (e?.name === 'AbortError') throw e }
132 + }
133 + const order = ASSETS.map(a => a.id)
134 + return results.filter(g => g.items.length).sort((a, b) => order.indexOf(a.asset) - order.indexOf(b.asset))
135 +}
136 +
137 +/** 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 } = {}) {
139 + const q = (text || '').trim().toUpperCase()
140 + if (!q) return null
141 + if (asset && asset !== CONTRACT_ASSET) {
142 + const list = await loadTickers(asset, { apiKey, signal }).catch(() => [])
143 + const hit = list.find(it => it.ticker === q)
144 + if (hit) return hit
145 + }
146 + if (asset === CONTRACT_ASSET && parseContract(q)) {
147 + const list = await loadContracts(parseContract(q).root, { apiKey, signal }).catch(() => [])
148 + return list.find(it => it.ticker === q) || { asset: CONTRACT_ASSET, ticker: q, label: q }
149 + }
150 + for (const a of LIST_ASSETS) {
151 + const list = await loadTickers(a, { apiKey, signal }).catch(() => [])
152 + const hit = list.find(it => it.ticker === q)
153 + if (hit) return hit
154 + }
155 + if (parseContract(q)) return resolveSymbol(q, { asset: CONTRACT_ASSET, apiKey, signal })
156 + return { asset: asset || 'stock', ticker: q, label: q }
157 +}
added hfmarketdata/web/src/pages/charts/indicators.js +56 −0
@@ -0,0 +1,56 @@
1 +// Indicator catalog for the /charts page: id, label, default pane, parameters (ordered — the URL form is
2 +// `type:p1:p2…`) and the output keys shown in the legend (matching the engine's `values` / crosshair keys).
3 +export const INDICATORS = [
4 + { type: 'sma', label: 'Moving average (SMA)', pane: 'main', params: [['length', 20, 1, 500]], outputs: ['value'] },
5 + { type: 'ema', label: 'Exponential MA (EMA)', pane: 'main', params: [['length', 20, 1, 500]], outputs: ['value'] },
6 + { type: 'wma', label: 'Weighted MA (WMA)', pane: 'main', params: [['length', 20, 1, 500]], outputs: ['value'] },
7 + { type: 'vwap', label: 'VWAP', pane: 'main', params: [], outputs: ['value'] },
8 + { type: 'bollinger', label: 'Bollinger Bands', pane: 'main', params: [['length', 20, 2, 500], ['mult', 2, 0.1, 10]], outputs: ['upper', 'middle', 'lower'] },
9 + { type: 'keltner', label: 'Keltner Channels', pane: 'main', params: [['length', 20, 2, 500], ['mult', 2, 0.1, 10]], outputs: ['upper', 'middle', 'lower'] },
10 + { type: 'donchian', label: 'Donchian Channels', pane: 'main', params: [['length', 20, 2, 500]], outputs: ['upper', 'middle', 'lower'] },
11 + { type: 'supertrend', label: 'Supertrend', pane: 'main', params: [['length', 10, 1, 200], ['mult', 3, 0.1, 10]], outputs: ['value'] },
12 + { type: 'ichimoku', label: 'Ichimoku Cloud', pane: 'main', params: [['conversion', 9, 1, 200], ['base', 26, 1, 400], ['span', 52, 1, 800]], outputs: ['conversion', 'base', 'spanA', 'spanB'] },
13 + { type: 'rsi', label: 'RSI', pane: 'new', params: [['length', 14, 2, 500]], outputs: ['value'] },
14 + { type: 'macd', label: 'MACD', pane: 'new', params: [['fast', 12, 1, 200], ['slow', 26, 2, 400], ['signal', 9, 1, 200]], outputs: ['macd', 'signal', 'histogram'] },
15 + { type: 'stoch', label: 'Stochastic', pane: 'new', params: [['k', 14, 1, 200], ['d', 3, 1, 100], ['smooth', 3, 1, 100]], outputs: ['k', 'd'] },
16 + { type: 'atr', label: 'ATR', pane: 'new', params: [['length', 14, 1, 500]], outputs: ['value'] },
17 + { type: 'obv', label: 'On-balance volume', pane: 'new', params: [], outputs: ['value'] },
18 + { type: 'adx', label: 'ADX', pane: 'new', params: [['length', 14, 1, 500]], outputs: ['adx', 'plusDI', 'minusDI'] },
19 + { type: 'cci', label: 'CCI', pane: 'new', params: [['length', 20, 1, 500]], outputs: ['value'] },
20 + { type: 'mfi', label: 'Money flow index', pane: 'new', params: [['length', 14, 1, 500]], outputs: ['value'] },
21 + { type: 'volume-ma', label: 'Volume MA', pane: 'new', params: [['length', 20, 1, 500]], outputs: ['value'] },
22 +]
23 +
24 +export const indicatorDef = type => INDICATORS.find(i => i.type === type) || null
25 +
26 +export function defaultParams(def) {
27 + return Object.fromEntries(def.params.map(([k, v]) => [k, v]))
28 +}
29 +
30 +/** `sma:20` | `macd:12:26:9` | `vwap` → { type, params, pane } (null when unknown). */
31 +export function indicatorFromSpec(spec) {
32 + const [type, ...vals] = spec.split(':')
33 + const def = indicatorDef(type)
34 + if (!def) return null
35 + const params = defaultParams(def)
36 + def.params.forEach(([k, , min, max], i) => {
37 + const n = Number(vals[i])
38 + if (vals[i] != null && Number.isFinite(n)) params[k] = Math.min(max, Math.max(min, n))
39 + })
40 + return { type, params, pane: def.pane }
41 +}
42 +
43 +export function indicatorToSpec(ind) {
44 + const def = indicatorDef(ind.type)
45 + if (!def) return ind.type
46 + const vals = def.params.map(([k]) => ind.params?.[k]).filter(v => v != null)
47 + return [ind.type, ...vals].join(':')
48 +}
49 +
50 +/** Short label for the legend: "SMA 20", "MACD 12 26 9". */
51 +export function indicatorShortLabel(ind) {
52 + const def = indicatorDef(ind.type)
53 + const name = ind.type === 'volume-ma' ? 'Vol MA' : ind.type.toUpperCase()
54 + const vals = def ? def.params.map(([k]) => ind.params?.[k]).filter(v => v != null) : []
55 + return vals.length ? `${name} ${vals.join(' ')}` : name
56 +}
57