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: stub temporaire du moteur de charts (contrat createChart) pour développer la page /charts en parallèle

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

1 changed file +311 −0

added hfmarketdata/web/src/charts/engine/index.js +311 −0
@@ -0,0 +1,311 @@
1 +// TEMPORARY STUB — replaced by feat/engine at merge
2 +//
3 +// Minimal Canvas 2D implementation of the chart engine contract (/tmp/hfmd-charts-contract.md) so that the
4 +// /charts page can be built and tested in parallel with the real engine. It draws candles (or a close line), a
5 +// volume histogram, a right price axis and a bottom time axis, emits `visibleRangeChange` / `crosshairMove` /
6 +// `toolChange` / `drawingsChange`, supports wheel zoom + drag pan, and stores indicators / compares / drawings
7 +// without rendering them. Nothing here is meant to survive the merge.
8 +
9 +const TF_STEP = { '1min': 60_000, '5min': 300_000, '30min': 1_800_000, '1hour': 3_600_000, '1day': 86_400_000 }
10 +const AXIS_W = 64
11 +const AXIS_H = 24
12 +const pad2 = n => String(n).padStart(2, '0')
13 +
14 +function fmtTime(t, tf) {
15 + const d = new Date(t)
16 + const date = `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`
17 + return tf === '1day' ? date : `${date} ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}`
18 +}
19 +
20 +function niceStep(range, target) {
21 + const raw = range / Math.max(1, target)
22 + const p = Math.pow(10, Math.floor(Math.log10(raw)))
23 + for (const m of [1, 2, 5, 10]) if (m * p >= raw) return m * p
24 + return 10 * p
25 +}
26 +
27 +export function createChart(container, options = {}) {
28 + const opts = { rightOffsetBars: 8, barSpacing: 8, minBarSpacing: 0.5, timeframe: '1day', locale: 'en-US', ...options }
29 + let theme = opts.theme || {}
30 + let bars = []
31 + let seriesType = 'candles'
32 + let volumeVisible = true
33 + let scaleMode = 'linear'
34 + let crosshairMode = 'normal'
35 + let tool = null
36 + let fromIndex = 0, toIndex = 0 // visible index range (fractional allowed)
37 + const listeners = new Map()
38 + const indicators = new Map()
39 + const compares = new Map()
40 + let drawings = []
41 + const undoStack = [], redoStack = []
42 + let destroyed = false
43 + let raf = 0
44 + let hover = null
45 +
46 + container.style.position = container.style.position || 'relative'
47 + const main = document.createElement('canvas')
48 + const overlay = document.createElement('canvas')
49 + for (const c of [main, overlay]) { c.style.position = 'absolute'; c.style.inset = '0'; c.style.width = '100%'; c.style.height = '100%'; container.appendChild(c) }
50 + overlay.style.touchAction = 'none'
51 + overlay.setAttribute('data-hfmd-chart-canvas', '')
52 + const ctx = main.getContext('2d')
53 + const octx = overlay.getContext('2d')
54 +
55 + const emit = (ev, payload) => { for (const fn of listeners.get(ev) || []) fn(payload) }
56 + const w = () => container.clientWidth || 300
57 + const h = () => container.clientHeight || 200
58 + const plotW = () => Math.max(10, w() - AXIS_W)
59 + const plotH = () => Math.max(10, h() - AXIS_H)
60 + const barSpacing = () => plotW() / Math.max(1, toIndex - fromIndex)
61 + const xOf = i => (i - fromIndex + 0.5) * barSpacing()
62 +
63 + function visibleSlice() {
64 + const a = Math.max(0, Math.floor(fromIndex)), b = Math.min(bars.length - 1, Math.ceil(toIndex))
65 + return [a, b]
66 + }
67 + function priceRange() {
68 + const [a, b] = visibleSlice()
69 + let lo = Infinity, hi = -Infinity
70 + for (let i = a; i <= b; i++) { const bar = bars[i]; if (!bar) continue; if (bar.l < lo) lo = bar.l; if (bar.h > hi) hi = bar.h }
71 + if (!isFinite(lo)) { lo = 0; hi = 1 }
72 + if (hi === lo) { hi += 1; lo -= 1 }
73 + const padY = (hi - lo) * 0.08
74 + return [lo - padY, hi + padY]
75 + }
76 + const yOf = (p, lo, hi) => {
77 + const ph = plotH() * (volumeVisible ? 0.8 : 1)
78 + if (scaleMode === 'log' && lo > 0) return ph - ((Math.log(p) - Math.log(lo)) / (Math.log(hi) - Math.log(lo))) * ph
79 + return ph - ((p - lo) / (hi - lo)) * ph
80 + }
81 +
82 + function emitRange() {
83 + const barsLeft = Math.max(0, Math.floor(fromIndex))
84 + emit('visibleRangeChange', { fromIndex: Math.floor(fromIndex), toIndex: Math.ceil(toIndex), barsLeftOfViewport: barsLeft, needMoreLeft: bars.length > 0 && barsLeft < 200 })
85 + }
86 +
87 + function schedule() { if (destroyed || raf) return; raf = requestAnimationFrame(() => { raf = 0; draw() }) }
88 +
89 + function draw() {
90 + const dpr = window.devicePixelRatio || 1
91 + const W = w(), H = h()
92 + for (const c of [main, overlay]) { if (c.width !== Math.round(W * dpr) || c.height !== Math.round(H * dpr)) { c.width = Math.round(W * dpr); c.height = Math.round(H * dpr) } }
93 + ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
94 + ctx.fillStyle = theme.bg || '#0a0c10'
95 + ctx.fillRect(0, 0, W, H)
96 + if (!bars.length) { drawOverlay(); return }
97 + const [lo, hi] = priceRange()
98 + const pw = plotW(), ph = plotH()
99 + // grid + price axis
100 + ctx.font = `11px ${theme.mono || 'monospace'}`
101 + ctx.textBaseline = 'middle'
102 + const step = niceStep(hi - lo, Math.max(3, Math.floor(ph / 60)))
103 + ctx.strokeStyle = theme.grid || '#232833'
104 + ctx.fillStyle = theme.axisText || '#8f98a8'
105 + ctx.lineWidth = 1
106 + for (let p = Math.ceil(lo / step) * step; p <= hi; p += step) {
107 + const y = Math.round(yOf(p, lo, hi)) + 0.5
108 + ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(pw, y); ctx.stroke()
109 + ctx.fillText(p.toLocaleString(opts.locale, { maximumFractionDigits: 2 }), pw + 6, y)
110 + }
111 + // time axis labels
112 + const [a, b] = visibleSlice()
113 + const every = Math.max(1, Math.ceil(90 / barSpacing()))
114 + ctx.textAlign = 'center'
115 + for (let i = a; i <= b; i += every) {
116 + const x = xOf(i)
117 + ctx.fillText(fmtTime(bars[i].t, opts.timeframe).slice(opts.timeframe === '1day' ? 0 : 5), x, ph + AXIS_H / 2)
118 + }
119 + ctx.textAlign = 'left'
120 + ctx.strokeStyle = theme.axisLine || '#313847'
121 + ctx.beginPath(); ctx.moveTo(pw + 0.5, 0); ctx.lineTo(pw + 0.5, ph); ctx.moveTo(0, ph + 0.5); ctx.lineTo(pw, ph + 0.5); ctx.stroke()
122 + // volume
123 + if (volumeVisible) {
124 + let vmax = 0
125 + for (let i = a; i <= b; i++) vmax = Math.max(vmax, bars[i].v || 0)
126 + const vh = ph * 0.2
127 + for (let i = a; i <= b; i++) {
128 + const bar = bars[i]
129 + const vhh = vmax ? ((bar.v || 0) / vmax) * vh : 0
130 + ctx.fillStyle = bar.c >= bar.o ? (theme.volumeUp || 'rgba(47,191,113,.35)') : (theme.volumeDown || 'rgba(229,72,77,.35)')
131 + ctx.fillRect(xOf(i) - Math.max(0.5, barSpacing() * 0.35), ph - vhh, Math.max(1, barSpacing() * 0.7), vhh)
132 + }
133 + }
134 + // series
135 + const lineLike = seriesType === 'line' || seriesType === 'area' || seriesType === 'baseline'
136 + if (lineLike || barSpacing() < 2) {
137 + ctx.strokeStyle = theme.accent || '#34d399'
138 + ctx.lineWidth = 1.5
139 + ctx.beginPath()
140 + for (let i = a; i <= b; i++) { const y = yOf(bars[i].c, lo, hi); i === a ? ctx.moveTo(xOf(i), y) : ctx.lineTo(xOf(i), y) }
141 + ctx.stroke()
142 + } else {
143 + const bw = Math.max(1, Math.floor(barSpacing() * 0.7))
144 + for (let i = a; i <= b; i++) {
145 + const bar = bars[i]
146 + const up = bar.c >= bar.o
147 + const color = up ? (theme.up || '#2fbf71') : (theme.down || '#e5484d')
148 + const x = Math.round(xOf(i)) + 0.5
149 + ctx.strokeStyle = color; ctx.fillStyle = color
150 + ctx.beginPath(); ctx.moveTo(x, yOf(bar.h, lo, hi)); ctx.lineTo(x, yOf(bar.l, lo, hi)); ctx.stroke()
151 + const y1 = yOf(Math.max(bar.o, bar.c), lo, hi), y2 = yOf(Math.min(bar.o, bar.c), lo, hi)
152 + if (seriesType === 'hollow' && up) ctx.strokeRect(x - bw / 2, y1, bw, Math.max(1, y2 - y1))
153 + else ctx.fillRect(x - bw / 2, y1, bw, Math.max(1, y2 - y1))
154 + }
155 + }
156 + // last price
157 + const last = bars[bars.length - 1]
158 + const ly = Math.round(yOf(last.c, lo, hi)) + 0.5
159 + ctx.setLineDash([3, 3]); ctx.strokeStyle = last.c >= last.o ? (theme.lastPriceUp || theme.up) : (theme.lastPriceDown || theme.down)
160 + ctx.beginPath(); ctx.moveTo(0, ly); ctx.lineTo(pw, ly); ctx.stroke(); ctx.setLineDash([])
161 + ctx.fillStyle = ctx.strokeStyle; ctx.fillRect(pw + 1, ly - 8, AXIS_W - 2, 16)
162 + ctx.fillStyle = theme.crosshairLabelText || '#fff'; ctx.fillText(last.c.toFixed(2), pw + 6, ly)
163 + if (opts.watermark) { ctx.fillStyle = theme.textMuted || '#5a6373'; ctx.globalAlpha = 0.35; ctx.font = `600 22px ${theme.font || 'sans-serif'}`; ctx.textAlign = 'center'; ctx.fillText(opts.watermark, pw / 2, ph / 2); ctx.globalAlpha = 1; ctx.textAlign = 'left' }
164 + drawOverlay()
165 + }
166 +
167 + function drawOverlay() {
168 + const dpr = window.devicePixelRatio || 1
169 + octx.setTransform(dpr, 0, 0, dpr, 0, 0)
170 + octx.clearRect(0, 0, w(), h())
171 + if (!hover || crosshairMode === 'hidden') return
172 + octx.strokeStyle = theme.crosshair || '#8f98a8'
173 + octx.setLineDash([2, 3])
174 + octx.beginPath(); octx.moveTo(hover.x + 0.5, 0); octx.lineTo(hover.x + 0.5, plotH()); octx.moveTo(0, hover.y + 0.5); octx.lineTo(plotW(), hover.y + 0.5); octx.stroke()
175 + octx.setLineDash([])
176 + }
177 +
178 + function indexAt(x) { return Math.floor(fromIndex + x / barSpacing()) }
179 + function priceAt(y) { const [lo, hi] = priceRange(); const ph = plotH() * (volumeVisible ? 0.8 : 1); return hi - (y / ph) * (hi - lo) }
180 +
181 + // ---- interaction ------------------------------------------------------------------------------
182 + let drag = null
183 + const onDown = e => { overlay.setPointerCapture?.(e.pointerId); drag = { x: e.offsetX, from: fromIndex, to: toIndex } }
184 + const onMove = e => {
185 + if (drag) {
186 + const di = (drag.x - e.offsetX) / barSpacing()
187 + const span = drag.to - drag.from
188 + fromIndex = Math.max(-span * 0.5, Math.min(bars.length - 1, drag.from + di))
189 + toIndex = fromIndex + span
190 + emitRange(); schedule()
191 + }
192 + hover = { x: e.offsetX, y: e.offsetY }
193 + const i = indexAt(e.offsetX)
194 + const bar = bars[i]
195 + emit('crosshairMove', bar ? { index: i, bar, x: e.offsetX, y: e.offsetY, price: priceAt(e.offsetY), pane: 'main', indicators: {}, compares: {} } : null)
196 + schedule()
197 + }
198 + const onUp = e => {
199 + if (drag && Math.abs(drag.x - e.offsetX) < 3) {
200 + const i = indexAt(e.offsetX)
201 + if (bars[i]) {
202 + const info = { index: i, bar: bars[i], price: priceAt(e.offsetY), pane: 'main' }
203 + emit('click', info)
204 + if (tool) { pushDrawing({ id: `d${Date.now().toString(36)}`, type: tool, points: [{ t: bars[i].t, price: info.price }], style: { color: theme.drawing } }) }
205 + }
206 + }
207 + drag = null
208 + }
209 + const onLeave = () => { hover = null; emit('crosshairMove', null); schedule() }
210 + const onWheel = e => { e.preventDefault(); zoom(e.deltaY < 0 ? 1.15 : 1 / 1.15, e.offsetX) }
211 + const onDblClick = () => fitContent()
212 + overlay.addEventListener('pointerdown', onDown)
213 + overlay.addEventListener('pointermove', onMove)
214 + overlay.addEventListener('pointerup', onUp)
215 + overlay.addEventListener('pointerleave', onLeave)
216 + overlay.addEventListener('wheel', onWheel, { passive: false })
217 + overlay.addEventListener('dblclick', onDblClick)
218 + const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => schedule()) : null
219 + ro?.observe(container)
220 +
221 + function pushDrawing(d) { undoStack.push(drawings); redoStack.length = 0; drawings = [...drawings, d]; emit('drawingsChange', drawings) }
222 +
223 + function fitContent() {
224 + const n = bars.length
225 + const show = Math.min(n, 150)
226 + fromIndex = n - show
227 + toIndex = n + (opts.rightOffsetBars || 8)
228 + emitRange(); schedule()
229 + }
230 + function zoom(factor, anchorX) {
231 + const span = toIndex - fromIndex
232 + const newSpan = Math.max(10, Math.min(bars.length * 2 + 20, span / factor))
233 + const ax = anchorX == null ? plotW() / 2 : anchorX
234 + const anchorIdx = fromIndex + (ax / plotW()) * span
235 + const ratio = (anchorIdx - fromIndex) / span
236 + fromIndex = anchorIdx - ratio * newSpan
237 + toIndex = fromIndex + newSpan
238 + emitRange(); schedule()
239 + }
240 +
241 + const chart = {
242 + setData(list) {
243 + const first = bars.length === 0
244 + bars = list.slice()
245 + if (first || !bars.length) fitContent(); else { emitRange(); schedule() }
246 + },
247 + prependData(older) {
248 + if (!older.length) return
249 + const shift = older.length
250 + bars = older.concat(bars)
251 + fromIndex += shift; toIndex += shift // viewport stays visually fixed
252 + emitRange(); schedule()
253 + },
254 + appendData(newer) { const stick = toIndex >= bars.length; bars = bars.concat(newer); if (stick) { const span = toIndex - fromIndex; toIndex = bars.length + (opts.rightOffsetBars || 8); fromIndex = toIndex - span } emitRange(); schedule() },
255 + updateLast(bar) { if (bars.length && bars[bars.length - 1].t === bar.t) bars[bars.length - 1] = bar; else bars.push(bar); schedule() },
256 + getData() { return bars },
257 + setTimeframe(tf) { opts.timeframe = tf; schedule() },
258 + setSeriesType(t) { seriesType = t; schedule() },
259 + setPriceScale(o) { if (o.mode) scaleMode = o.mode; emit('priceScaleChange', { mode: scaleMode, auto: o.auto !== false }); schedule() },
260 + setVolume(v) { volumeVisible = !!v; schedule() },
261 + setTheme(t) { theme = t; schedule() },
262 + setCrosshair(o) { if (o.mode) crosshairMode = o.mode; schedule() },
263 + setOptions(partial) { Object.assign(opts, partial); if (partial.theme) theme = partial.theme; schedule() },
264 + addIndicator(spec) {
265 + const id = spec.id || `${spec.type}-${Math.random().toString(36).slice(2, 8)}`
266 + indicators.set(id, { id, type: spec.type, params: { ...(spec.params || {}) }, pane: spec.pane || 'main', colors: spec.colors || [], values: {} })
267 + return id
268 + },
269 + updateIndicator(id, params) { const ind = indicators.get(id); if (ind) ind.params = { ...ind.params, ...params } },
270 + removeIndicator(id) { indicators.delete(id) },
271 + getIndicators() { return [...indicators.values()] },
272 + addCompare(id, label, list, color) { compares.set(id, { id, label, bars: list, color }); scaleMode = 'percent'; schedule() },
273 + removeCompare(id) { compares.delete(id); if (!compares.size && scaleMode === 'percent') scaleMode = 'linear'; schedule() },
274 + setDrawingTool(t) { tool = t; emit('toolChange', t) },
275 + getDrawings() { return drawings },
276 + setDrawings(list) { drawings = (list || []).slice(); schedule() },
277 + clearDrawings() { if (drawings.length) { undoStack.push(drawings); drawings = []; emit('drawingsChange', drawings) } },
278 + deleteSelectedDrawing() { if (drawings.length) { undoStack.push(drawings); drawings = drawings.slice(0, -1); emit('drawingsChange', drawings) } },
279 + undo() { if (undoStack.length) { redoStack.push(drawings); drawings = undoStack.pop(); emit('drawingsChange', drawings) } },
280 + redo() { if (redoStack.length) { undoStack.push(drawings); drawings = redoStack.pop(); emit('drawingsChange', drawings) } },
281 + setVisibleRange(r) {
282 + if ('fromIndex' in r) { fromIndex = r.fromIndex; toIndex = r.toIndex }
283 + else { const f = bars.findIndex(b => b.t >= r.fromT); let t = bars.findIndex(b => b.t > r.toT); if (t < 0) t = bars.length; fromIndex = Math.max(0, f); toIndex = t }
284 + emitRange(); schedule()
285 + },
286 + getVisibleRange() { const [a, b] = visibleSlice(); return { fromIndex: a, toIndex: b, fromT: bars[a]?.t ?? null, toT: bars[b]?.t ?? null } },
287 + fitContent,
288 + scrollToLatest() { const span = toIndex - fromIndex; toIndex = bars.length + (opts.rightOffsetBars || 8); fromIndex = toIndex - span; emitRange(); schedule() },
289 + zoom,
290 + resetView: fitContent,
291 + resize() { schedule() },
292 + toPNG() { return new Promise(resolve => { draw(); main.toBlob(b => resolve(b), 'image/png') }) },
293 + destroy() {
294 + destroyed = true
295 + if (raf) cancelAnimationFrame(raf)
296 + ro?.disconnect()
297 + overlay.removeEventListener('pointerdown', onDown); overlay.removeEventListener('pointermove', onMove); overlay.removeEventListener('pointerup', onUp)
298 + overlay.removeEventListener('pointerleave', onLeave); overlay.removeEventListener('wheel', onWheel); overlay.removeEventListener('dblclick', onDblClick)
299 + main.remove(); overlay.remove(); listeners.clear()
300 + },
301 + on(ev, fn) {
302 + if (!listeners.has(ev)) listeners.set(ev, new Set())
303 + listeners.get(ev).add(fn)
304 + return () => listeners.get(ev)?.delete(fn)
305 + },
306 + }
307 + schedule()
308 + return chart
309 +}
310 +
311 +export default createChart
312