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 v2 intégration — setCrosshairPosition (sync multi-charts), sélection via selectDrawing, e2e alignés sur le moteur v2 (replay masqué, lignes de prix, description) ; tiroir mobile en portail body avec fond opaque de repli

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

6 changed files +51 −20

modified hfmarketdata/web/e2e/charts.spec.js +23 −14
@@ -262,7 +262,7 @@ test.describe('charts', () => {
262 262 await bar.getByTestId('ch-group-more-lines').click()
263 263 const fly = page.getByTestId('ch-flyout-lines')
264 264 await expect(fly).toBeVisible()
265 − await expect(fly.locator('[data-tool]')).toHaveCount(6) // engine v1: trendline, ray, extended, hline, vline, channel
265 + expect(await fly.locator('[data-tool]').count()).toBeGreaterThanOrEqual(6) // v1: 6 line tools, v2: 11
266 266 await fly.locator('[data-tool="ray"]').click()
267 267 await expect(fly).toHaveCount(0)
268 268 await expect(bar.getByTestId('ch-group-lines')).toHaveAttribute('data-tool', 'ray')
@@ -286,21 +286,29 @@ test.describe('charts', () => {
286 286 })
287 287
288 288 test('drawing properties bar appears on selection (colour, width, style, text, lock, duplicate, delete)', async ({ page }) => {
289 − test.fixme(true, 'moteur v2 requis : événement drawingSelect + getDrawingStyle/setDrawingStyle')
290 289 await setup(page)
291 290 await page.goto('/charts?debug=1')
292 − await expect(page.getByTestId('ch-props')).toBeVisible()
291 + await expect(page.getByTestId('ch-status-count')).toContainText(/bars/)
292 + await page.evaluate(() => { const c = window.__hfmdChart; const b = c.getData(); const d = { id: 'e2e-h1', type: 'hline', points: [{ t: b[b.length - 20].t, price: b[b.length - 20].c }] }; c.setDrawings([d]); c.selectDrawing(['e2e-h1']) })
293 + const props = page.getByTestId('ch-props')
294 + await expect(props).toBeVisible()
295 + await props.getByRole('button', { name: /delete/i }).click()
296 + await expect.poll(() => page.evaluate(() => window.__hfmdChart.getDrawings().length)).toBe(0)
293 297 })
294 298
295 299 test('inline text edit on the textEdit event', async ({ page }) => {
296 − test.fixme(true, 'moteur v2 requis : événement textEdit + setDrawingText')
297 300 await setup(page)
298 301 await page.goto('/charts?debug=1')
299 − await expect(page.getByTestId('ch-textedit')).toBeVisible()
302 + await expect(page.getByTestId('ch-status-count')).toContainText(/bars/)
303 + await page.evaluate(() => { const c = window.__hfmdChart; const b = c.getData(); c.setDrawings([{ id: 'e2e-t1', type: 'text', text: 'Old', points: [{ t: b[b.length - 30].t, price: b[b.length - 30].c }] }]); c.emitter.emit('textEdit', { id: 'e2e-t1', text: 'Old', x: 200, y: 120 }) })
304 + const box = page.getByTestId('ch-textedit')
305 + await expect(box).toBeVisible()
306 + await box.getByLabel('Drawing text').fill('Breakout')
307 + await box.getByLabel('Drawing text').press('Enter')
308 + await expect.poll(() => page.evaluate(() => window.__hfmdChart.getDrawings()[0].text)).toBe('Breakout')
300 309 })
301 310
302 311 test('patterns / Fibonacci-Gann / positions flyouts list the v2 tools', async ({ page }) => {
303 − test.fixme(true, 'moteur v2 requis : DRAWING_TOOLS étendu (elliott-impulse, xabcd, long-position, gann-fan…)')
304 312 await setup(page)
305 313 await page.goto('/charts')
306 314 await expect(page.getByTestId('ch-group-patterns')).toBeVisible()
@@ -314,26 +322,27 @@ test.describe('charts', () => {
314 322 const bar = page.getByTestId('ch-replay')
315 323 await expect(bar).toBeVisible()
316 324 await expect(page.getByTestId('ch-legend-replay')).toContainText('Replay')
317 − const shown = await page.evaluate(() => window.__hfmdChart.getData().length)
325 + const shownExpr = () => { const c = window.__hfmdChart; const r = typeof c.getReplay === 'function' ? c.getReplay() : null; return r && r.active ? r.index + 1 : c.getData().length }
326 + const shown = await page.evaluate(shownExpr)
318 327 expect(shown).toBeLessThan(4200)
319 328 await expect(bar.getByTestId('ch-replay-pos')).toContainText(`${shown}/4200`)
320 329 const sma = await page.evaluate(() => { const v = window.__hfmdChart.getIndicators()[0].values.sma; return v[v.length - 1] })
321 330 await bar.getByTestId('ch-replay-fwd').click()
322 331 await expect(bar.getByTestId('ch-replay-pos')).toContainText(`${shown + 1}/4200`)
323 − await expect.poll(() => page.evaluate(() => window.__hfmdChart.getData().length)).toBe(shown + 1)
332 + await expect.poll(() => page.evaluate(shownExpr)).toBe(shown + 1)
324 333 await expect.poll(() => page.evaluate(() => { const v = window.__hfmdChart.getIndicators()[0].values.sma; return v[v.length - 1] })).not.toBe(sma) // indicators follow the replay
325 334 await bar.getByTestId('ch-replay-speed').selectOption('10')
326 335 await bar.getByTestId('ch-replay-play').click()
327 336 await expect(bar.getByTestId('ch-replay-play')).toHaveAttribute('aria-pressed', 'true')
328 − await expect.poll(() => page.evaluate(() => window.__hfmdChart.getData().length), { timeout: 5000 }).toBeGreaterThan(shown + 3)
337 + await expect.poll(() => page.evaluate(shownExpr), { timeout: 5000 }).toBeGreaterThan(shown + 3)
329 338 await bar.getByTestId('ch-replay-play').click()
330 339 await expect(bar.getByTestId('ch-replay-play')).toHaveAttribute('aria-pressed', 'false')
331 340 await bar.getByTestId('ch-replay-slider').fill('100')
332 − await expect.poll(() => page.evaluate(() => window.__hfmdChart.getData().length)).toBe(101)
341 + await expect.poll(() => page.evaluate(shownExpr)).toBe(101)
333 342 await page.screenshot({ path: '/tmp/hfmd-shots/charts-replay.png' })
334 343 await bar.getByTestId('ch-replay-exit').click()
335 344 await expect(bar).toHaveCount(0)
336 − await expect.poll(() => page.evaluate(() => window.__hfmdChart.getData().length)).toBe(4200)
345 + await expect.poll(() => page.evaluate(shownExpr)).toBe(4200)
337 346 })
338 347
339 348 test('layouts: 2 side by side, per-chart symbol, active chart drives the toolbar, URL layout= / s2=, Alt+1 back', async ({ page }) => {
@@ -417,8 +426,8 @@ test.describe('charts', () => {
417 426 await expect.poll(() => page.evaluate(() => window.__hfmdChart.getDrawings().length)).toBe(1)
418 427 await page.mouse.click(box.x + box.width / 2 + 5, box.y + box.height / 2 + 5, { button: 'right' })
419 428 await page.getByTestId('ch-ctx-alert').click()
420 − await expect.poll(() => page.evaluate(() => window.__hfmdChart.getDrawings().map(d => d.type))).toEqual(['hline', 'hline'])
421 − expect(await page.evaluate(() => window.__hfmdChart.getDrawings()[1].text)).toMatch(/^Alert /)
429 + await expect.poll(() => page.evaluate(() => { const c = window.__hfmdChart; return typeof c.getPriceLines === 'function' ? c.getPriceLines().map(l => l.title || l.text || 'Alert') : c.getDrawings().slice(1).map(d => d.text) })).toHaveLength(1)
430 + expect(await page.evaluate(() => { const c = window.__hfmdChart; const l = typeof c.getPriceLines === 'function' ? c.getPriceLines()[0] : c.getDrawings()[1]; return l.title || l.text })).toMatch(/^Alert /)
422 431 await expect(page.getByTestId('ch-draw-clear')).toBeEnabled()
423 432 await page.mouse.click(box.x + box.width / 2 + 5, box.y + box.height / 2 + 5, { button: 'right' })
424 433 await expect(page.getByTestId('ch-ctx-reset')).toBeVisible()
@@ -437,7 +446,7 @@ test.describe('charts', () => {
437 446 await setup(page)
438 447 await page.goto('/charts?s=MSFT&tf=1hour')
439 448 await expect(page.getByTestId('ch-status-count')).toContainText(/bars/)
440 − await expect(page.getByTestId('ch-legend-describe')).toContainText(/MSFT 1h: \d+ bars from/)
449 + await expect(page.getByTestId('ch-legend-describe')).toContainText(/MSFT.*\d+ (1h|1hour) bars from/)
441 450 const blobSize = await page.evaluate(async () => { const b = await window.__hfmdChart?.toPNG?.(); return b ? b.size : -1 })
442 451 expect(blobSize).toBe(-1) // not exposed without ?debug=1
443 452 await page.getByTestId('ch-table-btn').click()
modified hfmarketdata/web/src/app/Layout.jsx +4 −2
@@ -1,5 +1,6 @@
1 1 // App shell: top bar (nav · search · theme · session), mobile drawer, footer. Owner: web-core.
2 2 // Header is 56 px; every nav target is ≥ 36 px on desktop and ≥ 44 px on touch viewports (theme.css --tap).
3 +import { createPortal } from 'react-dom'
3 4 import React, { Suspense, lazy, useCallback, useEffect, useState } from 'react'
4 5 import { Link, NavLink, useLocation } from 'react-router-dom'
5 6 import { useAuth } from './auth.jsx'
@@ -90,7 +91,7 @@ export default function Layout({ children }) {
90 91 </div>
91 92 </header>
92 93
93 − {drawer && (
94 + {drawer && createPortal(
94 95 <>
95 96 <div className="drawer-backdrop" onClick={() => setDrawer(false)} />
96 97 <nav className="drawer" id="mobile-drawer" aria-label="Mobile">
@@ -105,7 +106,8 @@ export default function Layout({ children }) {
105 106 {user ? <Link to="/dashboard" className="btn">Dashboard</Link> : <><Link to="/signin" className="btn">Sign in</Link><Link to="/signup" className="btn btn-primary">Create free account</Link></>}
106 107 </div>
107 108 </nav>
108 − </>
109 + </>,
110 + document.body
109 111 )}
110 112
111 113 <div id="main" style={{ display: 'contents' }}>{children}</div>
modified hfmarketdata/web/src/app/theme.css +3 −2
@@ -217,9 +217,10 @@ input[type="checkbox"], input[type="radio"] { min-height: 0; width: 18px; height
217 217 .error-state.is-429 svg { color: var(--warn); }
218 218 .error-state .countdown { font-family: var(--mono); font-variant-numeric: tabular-nums; font-size: var(--fs-6); font-weight: 600; color: var(--fg); }
219 219
220 −/* ---- mobile drawer ------------------------------------------------------------------------- */
220 +/* ---- mobile drawer (portaled to <body>: never inside the blurred topbar; solid fallback colour for old WebKit) ---- */
221 +:root[data-theme="light"] .drawer { background-color: #f6f7f9; background-color: var(--bg-1); }
221 222 .drawer-backdrop { position: fixed; inset: 0; z-index: var(--z-drawer); background: rgba(0, 0, 0, .62); }
222 −.drawer { position: fixed; top: 0; right: 0; bottom: 0; z-index: calc(var(--z-drawer) + 1); width: min(320px, 86vw); background: var(--bg-1); isolation: isolate; overflow-y: auto; -webkit-overflow-scrolling: touch; border-left: 1px solid var(--line); padding: 12px 16px calc(16px + env(safe-area-inset-bottom)); display: flex; flex-direction: column; gap: 4px; box-shadow: var(--shadow-2); }
223 +.drawer { position: fixed; top: 0; right: 0; bottom: 0; z-index: calc(var(--z-drawer) + 1); width: min(320px, 86vw); background-color: #10131a; background-color: var(--bg-1); opacity: 1; -webkit-backdrop-filter: none; backdrop-filter: none; transform: translateZ(0); overflow-y: auto; -webkit-overflow-scrolling: touch; border-left: 1px solid var(--line); padding: 12px 16px calc(16px + env(safe-area-inset-bottom)); display: flex; flex-direction: column; gap: 4px; box-shadow: var(--shadow-2); }
223 224 .drawer-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; min-height: var(--tap-mobile); }
224 225 .drawer a.navlink { display: flex; align-items: center; min-height: var(--tap-mobile); padding: 0 12px; border-radius: var(--r-md); color: var(--fg-1); font-weight: 500; font-size: var(--fs-3); }
225 226 .drawer a.navlink.active, .drawer a.navlink:hover { background: var(--bg-2); color: var(--fg); text-decoration: none; }
modified hfmarketdata/web/src/charts/CONTRACT.md +2 −0
@@ -244,3 +244,5 @@ describeVisible(): string // phrase pour
244 244 ```
245 245 Exports supplémentaires de `engine/index.js` : `listIndicators, INDICATOR_CATEGORIES, INDICATOR_SOURCES, DRAWING_TOOLS_V1, DRAWING_TOOLS_V2,
246 246 DRAWING_TOOL_LABELS, DRAWING_POINT_COUNT`. `Emitter.emit` transmet désormais tous les arguments (les écouteurs v1 à un argument ne changent pas).
247 +
248 +- `setCrosshairPosition({t, price} | null)` — programmatic crosshair for multi-chart sync (draw only, no `crosshairMove` emitted; a real pointer move replaces it).
modified hfmarketdata/web/src/charts/engine/core/chart.js +17 −0
@@ -792,6 +792,23 @@ export class Chart {
792 792
793 793 /* ───────────────────────── pointer / crosshair state ───────────────────────── */
794 794
795 + /** Programmatic crosshair (multi-chart sync): {t, price} places the crosshair on the bar at `t` (nearest) and at
796 + * `price` (main pane; pane centre when price is null); null clears it. Draws only — never emits crosshairMove,
797 + * so two synced charts cannot feed each other back. A real pointer move replaces it. */
798 + setCrosshairPosition(pos) {
799 + if (!pos || pos.t == null || !this.store.length) {
800 + if (this.pointer && this.pointer.external) { this.pointer = null; this.invalidate('overlay') }
801 + return
802 + }
803 + const i = Math.max(0, Math.min(this.store.length - 1, Math.round(this.store.indexOfTime(pos.t))))
804 + const x = this.ts.x(i)
805 + if (x < 0 || x > this.plotWidth) { if (this.pointer && this.pointer.external) { this.pointer = null; this.invalidate('overlay') } return }
806 + const p = this.mainPane
807 + const y = pos.price != null && Number.isFinite(pos.price) ? p.scale.y(pos.price) : (p.height || 0) / 2
808 + this.pointer = { x, y, pane: p, region: 'plot', external: true }
809 + this.invalidate('overlay')
810 + }
811 +
795 812 setPointer(pos) {
796 813 const prev = this.pointer
797 814 this.pointer = pos
modified hfmarketdata/web/src/pages/charts/ChartView.jsx +2 −2
@@ -458,9 +458,9 @@ const ChartView = forwardRef(function ChartView({ cs, onChange, prefs, theme, ac
458 458 setDrawingStyle: (id, style) => { const c = chartRef.current; if (!c) return; if (has(c, 'setDrawingStyle')) { try { c.setDrawingStyle(id, style); return } catch { /* fall back */ } } c.setDrawings(c.getDrawings().map(d => (d.id === id ? { ...d, style: { ...(d.style || {}), ...style } } : d))) },
459 459 setDrawingText: (id, text) => { const c = chartRef.current; if (!c) return; if (has(c, 'setDrawingText')) { try { c.setDrawingText(id, text); return } catch { /* fall back */ } } c.setDrawings(c.getDrawings().map(d => (d.id === id ? { ...d, text } : d))) },
460 460 setDrawingLocked: (id, locked) => { const c = chartRef.current; if (!c) return; c.setDrawings(c.getDrawings().map(d => (d.id === id ? { ...d, locked } : d))) },
461 − duplicateDrawing: id => { const c = chartRef.current; if (!c) return null; const d = c.getDrawings().find(x => x.id === id); if (!d) return null; const shift = 5 * TF_MS[csRef.current.tf]; const copy = { ...d, id: `${d.id}-c${Date.now().toString(36)}`, points: d.points.map(p => ({ ...p, t: p.t + shift })), locked: false }; c.setDrawings([...c.getDrawings(), copy]); if (has(c, 'drawingSelect')) { try { c.drawingSelect(copy.id) } catch { /* ignore */ } } return copy.id },
461 + duplicateDrawing: id => { const c = chartRef.current; if (!c) return null; const d = c.getDrawings().find(x => x.id === id); if (!d) return null; const shift = 5 * TF_MS[csRef.current.tf]; const copy = { ...d, id: `${d.id}-c${Date.now().toString(36)}`, points: d.points.map(p => ({ ...p, t: p.t + shift })), locked: false }; c.setDrawings([...c.getDrawings(), copy]); if (has(c, 'selectDrawing')) { try { c.selectDrawing([copy.id]) } catch { /* ignore */ } } else if (has(c, 'drawingSelect')) { try { c.drawingSelect(copy.id) } catch { /* ignore */ } } return copy.id },
462 462 deleteDrawing: id => { const c = chartRef.current; if (!c) return; c.setDrawings(c.getDrawings().filter(d => d.id !== id)); onSelection?.(index, null) },
463 − selectDrawing: id => { const c = chartRef.current; if (has(c, 'drawingSelect')) { try { c.drawingSelect(id) } catch { /* ignore */ } } },
463 + selectDrawing: id => { const c = chartRef.current; if (has(c, 'selectDrawing')) { try { c.selectDrawing(id == null ? [] : [id]) } catch { /* ignore */ } } else if (has(c, 'drawingSelect')) { try { c.drawingSelect(id) } catch { /* ignore */ } } },
464 464 /** Sibling synchronisation. */
465 465 syncCrosshair: info => { const c = chartRef.current; if (!c) return; if (has(c, 'setCrosshairPosition')) { try { c.setCrosshairPosition(info ? { t: info.t, price: info.price } : null) } catch { /* ignore */ } } },
466 466 syncRange: r => { const c = chartRef.current; if (!c || !r || r.fromT == null || r.toT == null) return; syncingRange.current = true; try { c.setVisibleRange({ fromT: r.fromT, toT: r.toT }, false) } finally { setTimeout(() => { syncingRange.current = false }, 0) } },
467 467