SPB Git forge

spb/hfmarketdata

Public

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

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

web: charts — valeurs de légende depuis les typed arrays du moteur, fitContent adapté à la largeur (mobile ~45 bougies), e2e tolère l'id de panneau réel, README section Charts

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

3 changed files +30 −3

modified hfmarketdata/web/README.md +18 −0
@@ -173,6 +173,24 @@ Every reference page's **Try it** button and the MDX `<TryIt>` component produce
173 173 present (`json|csv|parquet`); `api_key` is never included.
174 174 - A deep link with `ep` **auto-runs once** when the form validates (one request). `/playground` alone never calls the API.
175 175
176 +## Charts (`/charts`)
177 +
178 +TradingView-class charting built **from scratch** — no lightweight-charts, no third-party engine.
179 +
180 +- `src/charts/engine/` — canvas 2D engine, zero dependencies (see `src/charts/CONTRACT.md` for the public API and
181 + `src/charts/README.md` for the architecture: index-based time scale, layered canvases, panes, 9 series types,
182 + drawings with undo/redo, decimation for 200k+ bars). Unit tests: `npm run test:charts` (`node --test`).
183 + Visual harness: `dev/charts-harness.html` (`npm run charts:shots` takes the reference screenshots).
184 +- `src/charts/indicators/` — pure indicator math (SMA/EMA/WMA/VWAP, Bollinger/Keltner/Donchian, Supertrend,
185 + Ichimoku, RSI/MACD/Stoch/ATR/OBV/ADX/CCI/MFI, volume MA, Heikin-Ashi). `null` until the window is full.
186 +- `src/charts/data/` — API layer: symbol resolution (all assets, futures roots and contracts), bar loading with
187 + backward pagination (`order=desc` + `end`), merged range cache, quota-aware limits, URL/localStorage state.
188 +- `src/pages/charts/` — the page: toolbar, symbol combobox, indicators/compare/adjustment menus, drawing bar,
189 + HTML legend fed by `crosshairMove` (aria-live), bars table + CSV, bottom sheet on mobile. Deep link:
190 + `/charts?s=AAPL&asset=stock&tf=1day&type=candles&ind=sma:20,ema:50,rsi:14&cmp=MSFT&scale=log&vol=1`.
191 +- A signed-in browser gets the account tier on data calls (session cookie → `user:<id>` principal), so no key
192 + needs to be pasted; keyless visitors see the 429 countdown with a sign-in CTA.
193 +
176 194 ## Prerender (LCP)
177 195
178 196 `npm run build` runs `vite build` (with `build.manifest`) then `scripts/prerender.mjs`, which writes static shells for
modified hfmarketdata/web/e2e/charts.spec.js +1 −1
@@ -54,7 +54,7 @@ test.describe('charts', () => {
54 54 await expect(page.getByTestId('ch-legend-ind').nth(1)).toContainText('RSI 14')
55 55 await expect(page.getByTestId('ch-type-menu')).toContainText('Line')
56 56 await expect(page.getByTestId('ch-scale-menu')).toContainText('Log')
57 − await expect.poll(() => page.evaluate(() => window.__hfmdChart.getIndicators().map(i => `${i.type}:${i.params.length}:${i.pane}`))).toEqual(['sma:20:main', 'rsi:14:new'])
57 + await expect.poll(() => page.evaluate(() => window.__hfmdChart.getIndicators().map(i => `${i.type}:${i.params.length}:${i.pane === 'main' ? 'main' : 'new'}`))).toEqual(['sma:20:main', 'rsi:14:new'])
58 58 await expect(page).toHaveTitle(/MSFT 1h · Charts/)
59 59 })
60 60
modified hfmarketdata/web/src/pages/charts/ChartsPage.jsx +11 −2
@@ -85,7 +85,9 @@ export default function ChartsPage() {
85 85 const el = containerRef.current
86 86 if (!el) return undefined
87 87 const s = stateRef.current
88 − const chart = createChart(el, { theme: buildTheme({ colorblind: prefs.colorblind }), timeframe: s.tf, sessionLabel: timezoneLabel(s.asset), watermark: prefs.watermark ? `${s.label} · ${TF_LABEL[s.tf]} · HF Market Data` : undefined, reducedMotion: prefs.reducedMotion || window.matchMedia('(prefers-reduced-motion: reduce)').matches })
88 + // bars shown by fitContent(): ~9 px per bar so phones start on ~45 readable candles, desktops on ~150
89 + const fitBarsFor = width => Math.max(40, Math.min(200, Math.round(width / 9)))
90 + const chart = createChart(el, { fitBars: fitBarsFor(el.clientWidth || window.innerWidth), theme: buildTheme({ colorblind: prefs.colorblind }), timeframe: s.tf, sessionLabel: timezoneLabel(s.asset), watermark: prefs.watermark ? `${s.label} · ${TF_LABEL[s.tf]} · HF Market Data` : undefined, reducedMotion: prefs.reducedMotion || window.matchMedia('(prefers-reduced-motion: reduce)').matches })
89 91 chartRef.current = chart
90 92 if (isDebug()) window.__hfmdChart = chart
91 93 const offs = [
@@ -106,9 +108,12 @@ export default function ChartsPage() {
106 108 ]
107 109 const onTheme = () => chart.setTheme(buildTheme({ colorblind: readPrefs().colorblind }))
108 110 window.addEventListener('hfmd:theme', onTheme)
111 + const onResize = () => chart.setOptions({ fitBars: fitBarsFor(el.clientWidth || window.innerWidth) })
112 + window.addEventListener('resize', onResize)
109 113 return () => {
110 114 offs.forEach(off => off())
111 115 window.removeEventListener('hfmd:theme', onTheme)
116 + window.removeEventListener('resize', onResize)
112 117 cancelAnimationFrame(hoverRaf.current)
113 118 abortRef.current?.abort()
114 119 chart.destroy()
@@ -126,7 +131,11 @@ export default function ChartsPage() {
126 131 const vals = {}
127 132 for (const ind of chart.getIndicators()) {
128 133 vals[ind.id] = {}
129 − for (const [k, arr] of Object.entries(ind.values || {})) vals[ind.id][k] = Array.isArray(arr) ? arr[arr.length - 1] : null
134 + for (const [k, arr] of Object.entries(ind.values || {})) {
135 + // the engine stores plots as typed arrays (Float64Array): not Array.isArray, NaN = no value yet
136 + const v = arr && typeof arr.length === 'number' && arr.length ? arr[arr.length - 1] : null
137 + vals[ind.id][k] = v == null || Number.isNaN(v) ? null : v
138 + }
130 139 }
131 140 setIndValues(vals)
132 141 }, [])
133 142