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%

charts: fonctions v2 — marqueurs de série, lignes de prix, ombrage de sessions, bar replay (setReplay/stepReplay), setRightOffset/setBarSpacing/setPriceScaleWidth/setTimeAxisVisible/setGrid/setBackground, toPNG legend, toCSV, describeVisible, émetteur multi-arguments, démo features du harnais

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 18 days ago (Sep 7, 2026) parent ff5d73f

3 changed files +203 −12

modified hfmarketdata/web/dev/charts-harness.js +26 −0
@@ -333,6 +333,32 @@ if (q.get('drawings') === '3') {
333 333 ])
334 334 }
335 335 if (q.get('live') === '1') liveBtn.click()
336 +if (q.get('features') === '1') {
337 + // v2 chart features showcase: markers, price lines, session shading, gradient background, replay, grid, offsets.
338 + const data = chart.getData(); const n = data.length; const at = i => data[Math.max(0, Math.min(n - 1, i))]
339 + chart.setMarkers([
340 + { t: at(n - 60).t, position: 'below', shape: 'arrowUp', text: 'Buy' },
341 + { t: at(n - 35).t, position: 'above', shape: 'arrowDown', text: 'Sell' },
342 + { t: at(n - 20).t, position: 'above', shape: 'circle', color: '#c98500', text: 'News' },
343 + { t: at(n - 90).t, position: 'below', shape: 'square', color: '#9085e9' },
344 + ])
345 + chart.addPriceLine({ price: at(n - 1).c * 1.006, color: '#e5484d', title: 'Resistance', style: 'dashed' })
346 + chart.addPriceLine({ price: at(n - 1).c * 0.994, color: '#2fbf71', title: 'Support', style: 'dotted', width: 2 })
347 + chart.setSessions({ regular: [10, 15.5], shade: true })
348 + chart.setBackground({ gradient: ['#0d1017', '#141a26'] })
349 + chart.setGrid({ v: false, h: true })
350 + chart.setRightOffset(14)
351 + chart.setReplay({ index: n - 30 })
352 + chart.on('replayChange', r => status(`replay ${r.index + 1}/${r.total} · ${chart.describeVisible()}`))
353 + status(chart.describeVisible())
354 +}
355 +g = group()
356 +btn(g, '⏮', () => chart.setReplay({ index: Math.max(0, chart.getData().length - 200) }), 'desktop-only')
357 +btn(g, '◂', () => chart.stepReplay(-1), 'desktop-only'); btn(g, '▸', () => chart.stepReplay(1), 'desktop-only')
358 +btn(g, '⏭', () => chart.setReplay(null), 'desktop-only')
359 +btn(g, 'CSV', () => { const blob = new Blob([chart.toCSV(true)], { type: 'text/csv' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'visible.csv'; a.click() }, 'desktop-only')
360 +btn(g, 'Describe', () => status(chart.describeVisible()), 'desktop-only')
361 +btn(g, 'PNG+legend', async () => { const blob = await chart.toPNG({ scale: 2, legend: true, watermark: `${state.symbol} · ${state.tf}` }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'chart-legend.png'; a.click() }, 'desktop-only')
336 362
337 363 /* ───────────── benchmark ───────────── */
338 364
modified hfmarketdata/web/src/charts/engine/core/chart.js +153 −10
@@ -10,7 +10,7 @@ import { PriceScale } from '../scales/price-scale.js'
10 10 import { createLayer, crisp, withAlpha, hair, lw, snap } from '../render/canvas.js'
11 11 import { font, measure, FONT_SIZE } from '../render/text.js'
12 12 import { drawSeries, drawVolume } from '../render/series.js'
13 −import { drawGrid, drawSessionBreaks, drawPriceAxis, drawTimeAxis, measureAxisWidth, TIME_AXIS_H, MIN_AXIS_W } from '../render/axes.js'
13 +import { drawGrid, drawSessionBreaks, drawSessionShade, drawPriceAxis, drawTimeAxis, measureAxisWidth, TIME_AXIS_H, MIN_AXIS_W } from '../render/axes.js'
14 14 import { drawPlots, valuePath, plotColor } from '../render/plots.js'
15 15 import { drawCrosshair, drawAxisLabel, drawTimeLabel, drawLastPrice, drawHiLoMarkers, drawWatermark, drawPaneHeader } from '../render/overlay.js'
16 16 import { normalizeTheme, darkTheme } from '../theme.js'
@@ -54,6 +54,7 @@ export class Chart {
54 54 this.emitter = new Emitter()
55 55 this.animator = new Animator()
56 56 this.store = new BarStore(this.opts.maxBars)
57 + this.fullStore = this.store // v2 bar replay: `store` is a truncated view while replaying
57 58 this.renderStore = this.store
58 59 this._haVersion = -1
59 60 this.ts = new TimeScale({ barSpacing: this.opts.barSpacing, minBarSpacing: this.opts.minBarSpacing, rightOffsetBars: this.opts.rightOffsetBars })
@@ -75,6 +76,8 @@ export class Chart {
75 76 this.priceLines = new Map() // v2: custom horizontal price lines
76 77 this.replayIndex = null // v2: bar replay — bars after this index are hidden
77 78 this.sessions = null // v2: { regular: [startHour, endHour], shade: true }
79 + this.background = null // v2: color | { gradient: [c0, c1, …] } (null = theme.bg)
80 + this.fixedAxisWidth = null // v2: setPriceScaleWidth(n)
78 81 this.width = 0; this.height = 0; this.dpr = 1
79 82 this.dirty = { layout: true, data: true, overlay: true }
80 83 this._raf = 0
@@ -377,7 +380,8 @@ export class Chart {
377 380 if (need > w) w = need
378 381 }
379 382 w = Math.ceil(w / 4) * 4
380 − if (w > this.axisWidth || w < this.axisWidth - 12) {
383 + if (this.fixedAxisWidth != null) w = this.fixedAxisWidth
384 + if (w > this.axisWidth || w < this.axisWidth - 12 || w !== this.axisWidth && this.fixedAxisWidth != null) {
381 385 this.axisWidth = w
382 386 this.ts.width = this.plotWidth
383 387 }
@@ -411,7 +415,7 @@ export class Chart {
411 415 const ctx = layer.ctx
412 416 layer.clear()
413 417 const W = this.plotWidth, H = p.height, theme = this.theme
414 − ctx.fillStyle = theme.bg
418 + ctx.fillStyle = this._bgStyle(ctx, p)
415 419 ctx.fillRect(0, 0, this.width, H)
416 420 if (p.collapsed) {
417 421 this._drawPriceAxisFor(p, [])
@@ -424,7 +428,7 @@ export class Chart {
424 428 ctx.beginPath(); ctx.rect(0, 0, W, H); ctx.clip()
425 429 drawGrid(ctx, { width: W, height: H, priceTicks: ticks, timeTicks: this._timeTicks || [], theme, showV: this.gridOpts.v, showH: this.gridOpts.h })
426 430 if (p.kind === 'main') {
427 − if (isIntraday(this.opts.timeframe)) this._drawSessionBreaks(ctx, vr, H)
431 + if (isIntraday(this.opts.timeframe)) { this._drawSessionShade(ctx, vr, H); this._drawSessionBreaks(ctx, vr, H) }
428 432 drawWatermark(ctx, { text: this.opts.watermark, width: W, height: H, theme })
429 433 const g = { ctx, store: this.renderStore, from: vr.from, to: vr.to, ts: this.ts, ps: p.scale, theme, width: W, height: H, type: this.seriesType, baseline: this._baselineValue(vr) }
430 434 if (this.volumeVisible) drawVolume(g, this.opts.volumeFraction)
@@ -453,6 +457,40 @@ export class Chart {
453 457 drawPriceAxis(ctx, { x0: 0, width: this.leftAxisWidth, height: p.height, ticks, theme: this.theme, side: 'left' })
454 458 }
455 459
460 + /** Background: a color or a vertical gradient spanning the whole chart (panes get their slice). */
461 + _bgStyle(ctx, p) {
462 + const bg = this.background
463 + if (!bg) return this.theme.bg
464 + if (typeof bg === 'string') return bg
465 + if (Array.isArray(bg.gradient) && bg.gradient.length >= 2) {
466 + const total = Math.max(1, this.height - this.timeAxisH)
467 + const g = ctx.createLinearGradient(0, -p.top, 0, total - p.top)
468 + bg.gradient.forEach((c, i) => g.addColorStop(i / (bg.gradient.length - 1), c))
469 + return g
470 + }
471 + return this.theme.bg
472 + }
473 +
474 + /** v2 sessions: shade bars outside the regular session (intraday). */
475 + _drawSessionShade(ctx, vr, H) {
476 + const s = this.sessions
477 + if (!s || !s.shade || !Array.isArray(s.regular) || vr.to < vr.from) return
478 + const [h0, h1] = s.regular
479 + const T = this.store.t
480 + const spans = []
481 + let open = null
482 + const bs = this.ts.barSpacing
483 + for (let i = vr.from; i <= vr.to; i++) {
484 + const d = new Date(T[i]); const h = d.getUTCHours() + d.getUTCMinutes() / 60
485 + const ext = h < h0 || h >= h1
486 + if (ext && open === null) open = this.ts.x(i) - bs / 2
487 + if (!ext && open !== null) { spans.push({ x0: open, x1: this.ts.x(i) - bs / 2 }); open = null }
488 + }
489 + if (open !== null) spans.push({ x0: open, x1: this.ts.x(vr.to) + bs / 2 })
490 + if (spans.length > 400) return
491 + drawSessionShade(ctx, { spans, height: H, theme: this.theme })
492 + }
493 +
456 494 _drawPaneBorder(p) {
457 495 const ctx = p.main.ctx
458 496 if (p !== this.panes[0]) {
@@ -803,7 +841,9 @@ export class Chart {
803 841 /* ═══════════════════════════ PUBLIC API ═══════════════════════════ */
804 842
805 843 setData(bars) {
806 − this.store.set(bars || [])
844 + this.fullStore.set(bars || [])
845 + this.replayIndex = null
846 + this.store = this.fullStore
807 847 this.ts.count = this.store.length
808 848 this.ts.width = this.plotWidth
809 849 this._refreshDecimals()
@@ -817,13 +857,14 @@ export class Chart {
817 857 }
818 858
819 859 prependData(older) {
820 − const n = this.store.prepend(older || [])
821 − if (n) { this.ts.onPrepend(n); this._refreshDecimals(); this.drawings.onDataChange(); this.invalidate('data') }
860 + const n = this.fullStore.prepend(older || [])
861 + if (n) { if (this.replayIndex != null) { this.replayIndex += n; this._applyReplay() } this.ts.onPrepend(n); this._refreshDecimals(); this.drawings.onDataChange(); this.invalidate('data') }
822 862 }
823 863
824 864 appendData(newer) {
825 − const n = this.store.append(newer || [])
865 + const n = this.fullStore.append(newer || [])
826 866 if (!n) return
867 + if (this.replayIndex != null) { this._applyReplay(); this.invalidate('data'); return }
827 868 this.ts.count = this.store.length
828 869 if (this.ts.stickToRight) this.ts.scrollToLatest()
829 870 this._lastClose = this.store.c[this.store.length - 1]
@@ -831,15 +872,51 @@ export class Chart {
831 872 }
832 873
833 874 updateLast(bar) {
834 − const r = this.store.updateLast(bar)
875 + const r = this.fullStore.updateLast(bar)
835 876 if (r === 'ignored') return
877 + if (this.replayIndex != null) { this._applyReplay(); this.invalidate('data'); return }
836 878 this.ts.count = this.store.length
837 879 if (r === 'append' && this.ts.stickToRight) this.ts.scrollToLatest()
838 880 if (bar.c !== this._lastClose) { this._pulseAt = performance.now(); this._lastClose = bar.c }
839 881 this.invalidate('data')
840 882 }
841 883
842 − getData() { return this.store.bars.slice() }
884 + /** Full data set (replay does not hide anything here). */
885 + getData() { return this.fullStore.bars.slice() }
886 +
887 + /* ─── v2: bar replay ─── */
888 +
889 + /** Hide every bar after `index` (rendering AND indicators see only bars 0…index). `null` / `{ index: null }` exits replay. */
890 + setReplay(o) {
891 + const index = o == null ? null : typeof o === 'number' ? o : o.index
892 + const n = this.fullStore.length
893 + if (index == null || !n) this.replayIndex = null
894 + else this.replayIndex = Math.max(0, Math.min(n - 1, Math.round(index)))
895 + this._applyReplay()
896 + this.ts.count = this.store.length
897 + if (this.replayIndex != null) this.ts.stickToRight = false
898 + this.invalidate('data')
899 + this.emitter.emit('replayChange', this.getReplay())
900 + }
901 +
902 + stepReplay(n = 1) {
903 + if (this.replayIndex == null) { this.setReplay({ index: Math.max(0, this.fullStore.length - 1 - Math.abs(n)) }); return }
904 + this.setReplay({ index: this.replayIndex + n })
905 + }
906 +
907 + getReplay() {
908 + const i = this.replayIndex
909 + return { active: i != null, index: i, total: this.fullStore.length, bar: i != null ? this.fullStore.bars[i] : null }
910 + }
911 +
912 + _applyReplay() {
913 + if (this.replayIndex == null) { this.store = this.fullStore; return }
914 + const cut = new BarStore(this.opts.maxBars)
915 + cut.set(this.fullStore.bars.slice(0, this.replayIndex + 1))
916 + this.store = cut
917 + this._indicatorVersion = -1 // force indicators to recompute on the truncated data
918 + this._haVersion = -1
919 + }
843 920
844 921 setTimeframe(tf) { this.opts.timeframe = tf; this.invalidate('data') }
845 922
@@ -1145,6 +1222,72 @@ export class Chart {
1145 1222 return exportPNG(this, o || {})
1146 1223 }
1147 1224
1225 + /* ─── v2: markers & price lines ─── */
1226 +
1227 + /** Series markers: [{ t, position: 'above' | 'below', shape?: 'arrowUp' | 'arrowDown' | 'circle' | 'square', color?, text?, size? }]. */
1228 + setMarkers(list) { this.markers = Array.isArray(list) ? list.filter(m => m && Number.isFinite(m.t)).map(m => ({ ...m })) : []; this.invalidate('overlay') }
1229 + getMarkers() { return this.markers.map(m => ({ ...m })) }
1230 +
1231 + /** Custom horizontal price line on the main pane. Returns its id. */
1232 + addPriceLine({ id, price, color, title, style = 'solid', width = 1, axisLabel = true } = {}) {
1233 + if (!Number.isFinite(price)) throw new Error('addPriceLine: price required')
1234 + const finalId = id || `pl-${++idSeq}`
1235 + this.priceLines.set(finalId, { id: finalId, price, color: color || null, title: title || '', style, width, axisLabel })
1236 + this.invalidate('overlay')
1237 + return finalId
1238 + }
1239 + updatePriceLine(id, patch) { const pl = this.priceLines.get(id); if (!pl) return; Object.assign(pl, patch || {}); this.invalidate('overlay') }
1240 + removePriceLine(id) { if (this.priceLines.delete(id)) this.invalidate('overlay') }
1241 + getPriceLines() { return Array.from(this.priceLines.values()).map(p => ({ ...p })) }
1242 +
1243 + /* ─── v2: sessions & layout ─── */
1244 +
1245 + /** { regular: [startHour, endHour] (decimal wall-clock hours, e.g. [9.5, 16]), shade: boolean }. null disables. */
1246 + setSessions(o) { this.sessions = o && Array.isArray(o.regular) ? { regular: [Number(o.regular[0]), Number(o.regular[1])], shade: o.shade !== false } : null; this.invalidate('data') }
1247 + setRightOffset(bars) { this.ts.rightOffsetBars = Math.max(0, Number(bars) || 0); this.opts.rightOffsetBars = this.ts.rightOffsetBars; if (this.ts.stickToRight) this.ts.scrollToLatest(); this.invalidate('data') }
1248 + setBarSpacing(px) { const bs = Math.max(this.ts.minBarSpacing, Math.min(this.ts.maxBarSpacing, Number(px) || this.ts.barSpacing)); this._animateView({ barSpacing: bs, leftIndex: this.ts.rightIndex - (this.ts.width - bs / 2) / bs }, true) }
1249 + setPriceScaleWidth(w) { this.fixedAxisWidth = w === 'auto' || w == null ? null : Math.max(24, Math.round(Number(w))); this.invalidate('data') }
1250 + setTimeAxisVisible(v) { this.timeAxisVisible = v !== false; this.invalidate('layout') }
1251 + setGrid({ v, h } = {}) { if (v != null) this.gridOpts.v = !!v; if (h != null) this.gridOpts.h = !!h; this.invalidate('data') }
1252 + /** A CSS color, or { gradient: ['#top', '#bottom'] }. null restores the theme background. */
1253 + setBackground(bg) { this.background = bg || null; this.el.style.background = typeof bg === 'string' ? bg : bg && Array.isArray(bg.gradient) ? `linear-gradient(${bg.gradient.join(',')})` : this.theme.bg; this.invalidate('data') }
1254 +
1255 + /* ─── v2: export & accessibility ─── */
1256 +
1257 + /** CSV of the bars (visible range only when `visibleOnly`) with one column per indicator series. */
1258 + toCSV(visibleOnly = false) {
1259 + const vr = this.ts.visibleRange()
1260 + const from = visibleOnly ? Math.max(0, vr.from) : 0, to = visibleOnly ? Math.min(this.store.length - 1, vr.to) : this.store.length - 1
1261 + const cols = ['time', 'open', 'high', 'low', 'close', 'volume']
1262 + const series = []
1263 + for (const ind of this.indicators.values()) if (ind.values) for (const key of Object.keys(ind.values)) if (Array.isArray(ind.values[key])) { const name = `${ind.spec.title(ind.params)} ${key}`.replace(/,/g, ' '); cols.push(name); series.push(ind.values[key]) }
1264 + const iso = t => { const d = new Date(t); return isIntraday(this.opts.timeframe) ? d.toISOString().slice(0, 19).replace('T', ' ') : d.toISOString().slice(0, 10) }
1265 + const lines = [cols.join(',')]
1266 + const B = this.store.bars
1267 + for (let i = from; i <= to; i++) {
1268 + const b = B[i]
1269 + const row = [iso(b.t), b.o, b.h, b.l, b.c, b.v == null ? '' : b.v]
1270 + for (const arr of series) { const v = arr[i]; row.push(v == null || Number.isNaN(v) ? '' : v) }
1271 + lines.push(row.join(','))
1272 + }
1273 + return lines.join('\n')
1274 + }
1275 +
1276 + /** One-sentence description of the visible range (for an aria-live region). */
1277 + describeVisible() {
1278 + const vr = this.ts.visibleRange()
1279 + const n = this.store.length
1280 + if (!n || vr.to < vr.from) return 'Empty chart.'
1281 + const from = Math.max(0, vr.from), to = Math.min(n - 1, vr.to)
1282 + const ex = this.store.extremes(from, to)
1283 + const first = this.store.bars[from], last = this.store.bars[to]
1284 + const chg = first.o ? ((last.c - first.o) / first.o) * 100 : 0
1285 + const tf = this.opts.timeframe
1286 + const label = this.opts.watermark ? `${this.opts.watermark.split('·')[0].trim()}, ` : ''
1287 + const f = v => this._formatMain(v)
1288 + return `${label}${to - from + 1} ${tf} bars from ${fmtFull(first.t, tf, this.opts.sessionLabel)} to ${fmtFull(last.t, tf, this.opts.sessionLabel)}. Low ${f(ex.lo)}, high ${f(ex.hi)}. Last ${f(last.c)}, ${formatPercent(chg, 2, this.opts.locale)} over the range.${this.replayIndex != null ? ` Replay at bar ${this.replayIndex + 1} of ${this.fullStore.length}.` : ''}`
1289 + }
1290 +
1148 1291 on(event, fn) { return this.emitter.on(event, fn) }
1149 1292
1150 1293 destroy() {
modified hfmarketdata/web/src/charts/engine/export/png.js +24 −2
@@ -2,12 +2,13 @@
2 2
3 3 import { font } from '../render/text.js'
4 4 import { withAlpha } from '../render/canvas.js'
5 +import { formatCompact } from '../format/number.js'
5 6
6 7 /**
7 8 * @param chart internal chart (uses chart.panes, chart.timeAxis, chart.width/height, chart.theme)
8 − * @param o { scale?: number, watermark?: string, background?: string }
9 + * @param o { scale?: number, watermark?: string, background?: string, legend?: boolean }
9 10 */
10 −export async function exportPNG(chart, { scale = 2, watermark, background } = {}) {
11 +export async function exportPNG(chart, { scale = 2, watermark, background, legend = false } = {}) {
11 12 const w = chart.width, h = chart.height
12 13 const out = document.createElement('canvas')
13 14 out.width = Math.round(w * scale); out.height = Math.round(h * scale)
@@ -22,6 +23,27 @@ export async function exportPNG(chart, { scale = 2, watermark, background } = {}
22 23 for (const p of chart.panes) { blit(p.main, 0, p.top); blit(p.overlay, 0, p.top) }
23 24 blit(chart.timeAxis.main, 0, chart.timeAxis.top)
24 25 const theme = chart.theme
26 + // v2: optional legend (last bar OHLC + indicator titles / last values) drawn top-left of the main pane.
27 + if (legend) {
28 + const st = chart.store
29 + if (st.length) {
30 + const i = st.length - 1
31 + const f = v => chart._formatMain(v)
32 + const lines = [`${watermark ? watermark + ' ' : ''}O ${f(st.o[i])} H ${f(st.h[i])} L ${f(st.l[i])} C ${f(st.c[i])}${Number.isFinite(st.v[i]) ? ` Vol ${formatCompact(st.v[i], chart.opts.locale)}` : ''}`]
33 + for (const ind of chart.indicators.values()) {
34 + if (!ind.values) continue
35 + const parts = []
36 + for (const key of ind.spec.legendKeys || Object.keys(ind.values)) { const arr = ind.values[key]; if (!Array.isArray(arr)) continue; const v = arr[Math.min(i, arr.length - 1)]; if (v != null && !Number.isNaN(v)) parts.push(`${key} ${chart._formatPane(chart.panes.find(p => p.id === ind.paneId) || chart.mainPane, v)}`) }
37 + lines.push(`${ind.spec.title(ind.params)}${parts.length ? ' ' + parts.join(' ') : ''}`)
38 + }
39 + ctx.font = font(theme, { mono: true, size: 11, weight: 500 })
40 + ctx.textBaseline = 'top'; ctx.textAlign = 'left'
41 + const tw = Math.max(...lines.map(l => ctx.measureText(l).width))
42 + const x0 = chart.plotX0 + 8, y0 = chart.mainPane.top + (watermark ? 28 : 8)
43 + ctx.fillStyle = withAlpha(theme.bg, 0.78); ctx.fillRect(x0 - 6, y0 - 4, tw + 12, lines.length * 16 + 8)
44 + lines.forEach((l, k) => { ctx.fillStyle = k === 0 ? theme.text : theme.textMuted; ctx.fillText(l, x0, y0 + k * 16) })
45 + }
46 + }
25 47 if (watermark) {
26 48 ctx.font = font(theme, { size: 13, weight: 600 })
27 49 ctx.fillStyle = withAlpha(theme.text, 0.55)
28 50