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