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
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 | |