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: décimation par colonne de pixels (ligne, volume, histogrammes) pour 200k barres visibles, dessins non clippés pour les étiquettes d'axe, fonds des libellés Fib, données synthétiques en marche log

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

6 changed files +109 −48

modified hfmarketdata/web/dev/charts-harness.js +21 −19
@@ -18,25 +18,26 @@ function gauss(rnd) { let u = 0, v = 0; while (u === 0) u = rnd(); while (v ===
18 18 * Random-walk bars. 1min: sessions 09:30–16:00 ET (390 bars/day), weekdays only, overnight / weekend gaps with a
19 19 * jump at the open. 1day: weekdays only. Timestamps are wall-clock encoded with Date.UTC (contract).
20 20 */
21 −export function makeBars({ tf = '1min', count = 50_000, seed = 42, start = Date.UTC(2023, 0, 2), price = 187.5, vol = 0.00035 } = {}) {
21 +export function makeBars({ tf = '1min', count = 50_000, seed = 42, end = Date.UTC(2024, 11, 31), price = 187.5, vol = 0.00035 } = {}) {
22 22 const rnd = mulberry32(seed)
23 23 const bars = []
24 − let p = price
25 − let sigma = vol
26 − let t = start
27 24 const dayMs = 86_400_000
28 25 const isWeekend = ts => { const d = new Date(ts).getUTCDay(); return d === 0 || d === 6 }
26 + // Log-price walk (drift cancels the volatility decay) so long histories stay realistic.
27 + let logP = Math.log(price)
29 28 if (tf === '1day') {
30 − let d = Math.floor(t / dayMs) * dayMs
29 + let sigma = 0.010
30 + const tradingDays = count
31 + let d = Math.floor(end / dayMs) * dayMs - Math.ceil(tradingDays * 7 / 5 + 10) * dayMs
31 32 while (bars.length < count) {
32 33 if (!isWeekend(d)) {
33 − sigma = Math.max(0.006, Math.min(0.05, sigma * 0.97 + 0.015 * 0.03 + Math.abs(gauss(rnd)) * 0.002))
34 − const o = p * (1 + gauss(rnd) * sigma * 0.4)
35 − let hi = o, lo = o, c = o
36 − for (let k = 0; k < 8; k++) { c = c * (1 + gauss(rnd) * sigma * 0.35); hi = Math.max(hi, c); lo = Math.min(lo, c) }
34 + sigma = Math.max(0.005, Math.min(0.025, sigma * 0.97 + 0.010 * 0.03 + Math.abs(gauss(rnd)) * 0.001))
35 + const o = Math.exp(logP + gauss(rnd) * sigma * 0.3)
36 + let c = o, hi = o, lo = o
37 + for (let k = 0; k < 8; k++) { c = c * Math.exp(gauss(rnd) * sigma * 0.35 + 0.000012); hi = Math.max(hi, c); lo = Math.min(lo, c) }
37 38 const v = Math.round((4e6 + 3e6 * Math.abs(gauss(rnd))) * (1 + 6 * Math.abs(c - o) / o))
38 39 bars.push({ t: d, o: r2(o), h: r2(hi), l: r2(lo), c: r2(c), v })
39 − p = c
40 + logP = Math.log(c)
40 41 }
41 42 d += dayMs
42 43 }
@@ -44,24 +45,24 @@ export function makeBars({ tf = '1min', count = 50_000, seed = 42, start = Date.
44 45 }
45 46 const stepMs = { '1min': 60_000, '5min': 300_000, '30min': 1_800_000, '1hour': 3_600_000 }[tf] || 60_000
46 47 const perDay = Math.floor((6.5 * 3_600_000) / stepMs)
47 − let day = Math.floor(t / dayMs) * dayMs
48 + const days = Math.ceil(count / perDay)
49 + let day = Math.floor(end / dayMs) * dayMs - Math.ceil(days * 7 / 5 + 4) * dayMs
50 + let sigma = vol
48 51 while (bars.length < count) {
49 52 if (isWeekend(day)) { day += dayMs; continue }
50 − // Overnight gap.
51 − p = p * (1 + gauss(rnd) * 0.006)
53 + logP += gauss(rnd) * 0.006 // overnight gap
52 54 sigma = Math.max(0.00015, Math.min(0.002, sigma * 0.9 + vol * 0.1 + Math.abs(gauss(rnd)) * 0.00005))
53 55 for (let k = 0; k < perDay && bars.length < count; k++) {
54 56 const ts = day + 9.5 * 3_600_000 + k * stepMs
55 − // U-shaped intraday activity.
56 57 const u = k / perDay
57 − const act = 0.6 + 1.6 * (Math.pow(u - 0.5, 2) * 4)
58 + const act = 0.6 + 1.6 * (Math.pow(u - 0.5, 2) * 4) // U-shaped intraday activity
58 59 const s = sigma * act
59 − const o = p
60 − let hi = o, lo = o, c = o
61 − for (let m = 0; m < 4; m++) { c = c * (1 + gauss(rnd) * s * 0.5); hi = Math.max(hi, c); lo = Math.min(lo, c) }
60 + const o = Math.exp(logP)
61 + let c = o, hi = o, lo = o
62 + for (let m = 0; m < 4; m++) { c = c * Math.exp(gauss(rnd) * s * 0.5 + s * s * 0.125); hi = Math.max(hi, c); lo = Math.min(lo, c) }
62 63 const v = Math.round((12_000 + 20_000 * act) * (0.5 + Math.abs(gauss(rnd))))
63 64 bars.push({ t: ts, o: r2(o), h: r2(hi), l: r2(lo), c: r2(c), v })
64 − p = c
65 + logP = Math.log(c)
65 66 }
66 67 day += dayMs
67 68 }
@@ -91,6 +92,7 @@ const chart = createChart(container, {
91 92 sessionLabel: 'ET',
92 93 watermark: q.get('watermark') ?? `${state.symbol} · ${state.tf === '1day' ? '1D' : state.tf} · HF Market Data`,
93 94 reducedMotion: q.get('reduced') === '1',
95 + minBarSpacing: q.get('min') ? Number(q.get('min')) : undefined,
94 96 })
95 97 window.__chart = chart
96 98
modified hfmarketdata/web/scripts/charts-shots.mjs +2 −2
@@ -30,7 +30,7 @@ const SHOTS = [
30 30 { name: 'desktop-hollow-drawings', q: 'tf=1min&n=50000&type=hollow&drawings=1&crosshair=magnet' },
31 31 { name: 'desktop-ohlc-supertrend-light', q: 'tf=1min&n=50000&type=ohlc&ind=supertrend,vwap,stoch&theme=light' },
32 32 { name: 'desktop-baseline-heikin', q: 'tf=1day&n=10000&type=heikin&ind=ema,sma' },
33 − { name: 'desktop-200k-zoomed-out', q: 'tf=1min&n=200000', zoomOut: 40 },
33 + { name: 'desktop-200k-zoomed-out', q: 'tf=1min&n=200000&min=0.005', zoomOut: 40 },
34 34 { name: 'mobile-1min-candles', q: 'tf=1min&n=50000&ind=rsi', mobile: true },
35 35 { name: 'mobile-1day-light', q: 'tf=1day&n=10000&theme=light&type=area', mobile: true },
36 36 ]
@@ -83,7 +83,7 @@ async function main() {
83 83 await ctx.close()
84 84 }
85 85 // Benchmark: 100 programmatic pan/zoom frames on 200 000 bars and on 50 000 bars.
86 − for (const [label, qs] of [['200k 1min', 'tf=1min&n=200000'], ['50k 1min + 3 indicators', 'tf=1min&n=50000&ind=bollinger,rsi,macd']]) {
86 + for (const [label, qs] of [['200k 1min', 'tf=1min&n=200000&min=0.005'], ['50k 1min + 3 indicators', 'tf=1min&n=50000&ind=bollinger,rsi,macd']]) {
87 87 const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2 })
88 88 const page = await ctx.newPage()
89 89 await page.goto(`${HARNESS}?${qs}&reduced=1`, { waitUntil: 'networkidle' })
modified hfmarketdata/web/src/charts/engine/core/chart.js +1 −2
@@ -512,9 +512,8 @@ export class Chart {
512 512 ctx.save(); ctx.beginPath(); ctx.rect(0, 0, this.width, H); ctx.clip()
513 513 drawLastPrice(ctx, { y, text: this._formatMain(c), plotWidth: W, axisWidth: this.axisWidth, height: H, up: c >= o, theme, pulse })
514 514 ctx.restore()
515 − ctx.save(); ctx.beginPath(); ctx.rect(0, 0, W, H); ctx.clip()
515 + // Drawings clip themselves to the plot; their axis labels (hline price) may use the axis strip.
516 516 this.drawings.draw(ctx, { width: W, height: H, ts: this.ts, ps: p.scale, store: this.store, theme, pointer: hovered ? ptr : null })
517 − ctx.restore()
518 517 }
519 518 // Crosshair.
520 519 if (ptr && this.crosshairOpts.mode !== 'hidden' && ptr.region === 'plot') {
modified hfmarketdata/web/src/charts/engine/drawings/manager.js +8 −1
@@ -278,6 +278,7 @@ export class DrawingManager {
278 278 const color = (d.style && d.style.color) || theme.drawing
279 279 const lw = (d.style && d.style.width) || 1
280 280 ctx.save()
281 + ctx.beginPath(); ctx.rect(0, 0, W, H); ctx.clip()
281 282 ctx.strokeStyle = color
282 283 ctx.fillStyle = color
283 284 ctx.lineWidth = hovered && !selected ? lw + 1 : lw
@@ -300,6 +301,8 @@ export class DrawingManager {
300 301 case 'hline': {
301 302 ctx.setLineDash(d.style && d.style.dash ? d.style.dash : [])
302 303 line(ctx, 0, crisp(a.y), W, crisp(a.y))
304 + ctx.restore(); ctx.save() // axis label lives outside the plot clip
305 + ctx.globalAlpha = preview ? 0.85 : 1
303 306 pill(ctx, this.chart._formatMain(d.points[0].price), W + 4, a.y, { bg: color, color: inkFor(color), fontStr: font(theme, { mono: true, size: 11 }), h: 18, padX: 4, clampTo: { x0: W + 2, x1: W + this.chart.axisWidth - 1, y0: 0, y1: H } })
304 307 break
305 308 }
@@ -384,9 +387,13 @@ export class DrawingManager {
384 387 ctx.strokeStyle = withAlpha(c, 0.9); ctx.lineWidth = 1
385 388 line(ctx, l, crisp(y), r, crisp(y))
386 389 const label = `${lv.toFixed(3).replace(/0+$/, '').replace(/\.$/, '')} ${this.chart._formatMain(price)}`
390 + const lx = Math.min(r + 6, W - 120)
391 + const lw2 = measure(ctx, label, f)
392 + ctx.fillStyle = withAlpha(theme.bg, 0.8)
393 + ctx.fillRect(lx - 3, y - 7, lw2 + 6, 14)
387 394 ctx.fillStyle = c
388 395 ctx.textAlign = 'left'
389 − ctx.fillText(label, Math.min(r + 6, W - 120), y)
396 + ctx.fillText(label, lx, y)
390 397 prevY = y
391 398 })
392 399 ctx.strokeStyle = withAlpha(color, 0.5); ctx.setLineDash([3, 3])
modified hfmarketdata/web/src/charts/engine/render/plots.js +56 −11
@@ -12,16 +12,45 @@ export function plotColor(spec, theme, colors) {
12 12
13 13 /** Path of non-null values over [from, to]; y via `yOf(value)`; x via time scale. Gaps break the path. */
14 14 export function valuePath(values, from, to, ts, yOf) {
15 + const n = values.length
16 + return seriesPath(i => values[i], Math.max(0, from), Math.min(to, n - 1), ts, yOf)
17 +}
18 +
19 +/**
20 + * Polyline through `get(i)` for i in [from, to]. Below 1 px per bar the points are aggregated per pixel column
21 + * (first → min/max in order → last) so a 200 000-bar line costs one segment group per pixel, not per bar, while
22 + * keeping every spike visible. null/NaN values break the path.
23 + */
24 +export function seriesPath(get, from, to, ts, yOf) {
15 25 const path = new Path2D()
16 26 let pen = false
17 − const n = values.length
18 − const end = Math.min(to, n - 1)
19 − for (let i = Math.max(0, from); i <= end; i++) {
20 − const v = values[i]
21 − if (v == null || Number.isNaN(v)) { pen = false; continue }
22 − const x = ts.x(i), y = yOf(v)
23 − if (!pen) { path.moveTo(x, y); pen = true } else path.lineTo(x, y)
27 + if (ts.barSpacing >= 1) {
28 + for (let i = from; i <= to; i++) {
29 + const v = get(i)
30 + if (v == null || Number.isNaN(v)) { pen = false; continue }
31 + const x = ts.x(i), y = yOf(v)
32 + if (!pen) { path.moveTo(x, y); pen = true } else path.lineTo(x, y)
33 + }
34 + return path
24 35 }
36 + let col = null, first = 0, last = 0, min = 0, max = 0, minI = 0, maxI = 0
37 + const flush = () => {
38 + if (col === null) return
39 + const x = col + 0.5
40 + if (!pen) { path.moveTo(x, yOf(first)); pen = true } else path.lineTo(x, yOf(first))
41 + if (minI < maxI) { path.lineTo(x, yOf(min)); path.lineTo(x, yOf(max)) } else { path.lineTo(x, yOf(max)); path.lineTo(x, yOf(min)) }
42 + path.lineTo(x, yOf(last))
43 + col = null
44 + }
45 + for (let i = from; i <= to; i++) {
46 + const v = get(i)
47 + if (v == null || Number.isNaN(v)) { flush(); pen = false; continue }
48 + const x = Math.floor(ts.x(i))
49 + if (x !== col) { flush(); col = x; first = v; min = v; max = v; minI = i; maxI = i }
50 + else { if (v < min) { min = v; minI = i } if (v > max) { max = v; maxI = i } }
51 + last = v
52 + }
53 + flush()
25 54 return path
26 55 }
27 56
@@ -124,10 +153,7 @@ function drawHistogram(ctx, v, from, to, ts, yOf, height, p, theme, colors, stor
124 153 const pos = new Path2D(), neg = new Path2D()
125 154 const zero = p.color === 'volume' ? height : Math.round(yOf(0))
126 155 const end = Math.min(to, v.length - 1)
127 − for (let i = Math.max(0, from); i <= end; i++) {
128 − const val = v[i]
129 − if (val == null || Number.isNaN(val)) continue
130 − const xc = Math.floor(ts.x(i)) + 0.5
156 + const add = (i, val, xc) => {
131 157 const y = Math.round(yOf(val))
132 158 let bucket
133 159 if (p.color === 'volume') bucket = store && store.c[i] < store.o[i] ? neg : pos
@@ -136,6 +162,25 @@ function drawHistogram(ctx, v, from, to, ts, yOf, height, p, theme, colors, stor
136 162 const top = Math.min(y, zero), h = Math.max(1, Math.abs(zero - y))
137 163 bucket.rect(xc - half - 0.5, top, w, h)
138 164 }
165 + if (ts.barSpacing < 1) {
166 + // Per pixel column: keep the largest magnitude.
167 + let col = null, bestI = -1, bestAbs = -1
168 + const flush = () => { if (bestI >= 0) add(bestI, v[bestI], col + 0.5) }
169 + for (let i = Math.max(0, from); i <= end; i++) {
170 + const val = v[i]
171 + if (val == null || Number.isNaN(val)) continue
172 + const x = Math.floor(ts.x(i))
173 + if (x !== col) { flush(); col = x; bestI = -1; bestAbs = -1 }
174 + if (Math.abs(val) > bestAbs) { bestAbs = Math.abs(val); bestI = i }
175 + }
176 + flush()
177 + } else {
178 + for (let i = Math.max(0, from); i <= end; i++) {
179 + const val = v[i]
180 + if (val == null || Number.isNaN(val)) continue
181 + add(i, val, Math.floor(ts.x(i)) + 0.5)
182 + }
183 + }
139 184 if (p.color === 'volume') {
140 185 ctx.fillStyle = theme.volumeUp; ctx.fill(pos)
141 186 ctx.fillStyle = theme.volumeDown; ctx.fill(neg)
modified hfmarketdata/web/src/charts/engine/render/series.js +21 −13
@@ -2,6 +2,7 @@
2 2 // (no per-bar objects). Coordinates are CSS pixels; the context is already scaled for the DPR.
3 3
4 4 import { withAlpha } from './canvas.js'
5 +import { seriesPath } from './plots.js'
5 6
6 7 /** Pixel-center x for bar `i` (odd body widths center on a pixel). */
7 8 const centerX = (ts, i) => Math.floor(ts.x(i)) + 0.5
@@ -83,13 +84,7 @@ function drawBars(g, withOpen) {
83 84 function closePath(g) {
84 85 const { store, from, to, ts, ps } = g
85 86 const C = store.c
86 − const path = new Path2D()
87 − let first = true
88 − for (let i = from; i <= to; i++) {
89 − const x = ts.x(i), y = ps.y(C[i])
90 − if (first) { path.moveTo(x, y); first = false } else path.lineTo(x, y)
91 − }
92 − return path
87 + return seriesPath(i => C[i], from, to, ts, v => ps.y(v))
93 88 }
94 89
95 90 function drawLine(g, color, width) {
@@ -163,12 +158,25 @@ export function drawVolume(g, fraction = 0.2) {
163 158 const w = ts.isCompressed ? 1 : Math.max(1, ts.bodyWidth())
164 159 const half = (w - 1) / 2
165 160 const up = new Path2D(), down = new Path2D()
166 − for (let i = from; i <= to; i++) {
167 − const v = V[i]
168 − if (!(v > 0)) continue
169 − const h = Math.max(1, Math.round((v / max) * areaH))
170 − const xc = centerX(ts, i)
171 − ;(C[i] >= O[i] ? up : down).rect(xc - half - 0.5, height - h, w, h)
161 + if (ts.barSpacing < 1) {
162 + // One bar per pixel column: tallest volume of the column, colored by that bar's direction.
163 + let col = null, best = 0, bestUp = true
164 + const flush = () => { if (col !== null && best > 0) { const h = Math.max(1, Math.round((best / max) * areaH)); (bestUp ? up : down).rect(col, height - h, 1, h) } }
165 + for (let i = from; i <= to; i++) {
166 + const x = Math.floor(ts.x(i))
167 + if (x !== col) { flush(); col = x; best = 0 }
168 + const v = V[i]
169 + if (v > best) { best = v; bestUp = C[i] >= O[i] }
170 + }
171 + flush()
172 + } else {
173 + for (let i = from; i <= to; i++) {
174 + const v = V[i]
175 + if (!(v > 0)) continue
176 + const h = Math.max(1, Math.round((v / max) * areaH))
177 + const xc = centerX(ts, i)
178 + ;(C[i] >= O[i] ? up : down).rect(xc - half - 0.5, height - h, w, h)
179 + }
172 180 }
173 181 ctx.fillStyle = theme.volumeUp; ctx.fill(up)
174 182 ctx.fillStyle = theme.volumeDown; ctx.fill(down)
175 183