charts: tests node --test (échelles, ticks, formats, store, indicateurs de référence, dessins), README, test d'interactions Playwright, niveaux de ticks journaliers, ticks log réguliers
11 changed files +743 −16
modified
hfmarketdata/web/dev/charts-harness.js
+1 −1
@@ -201,7 +201,7 @@ function renderLegend(info) { | ||
| 201 | 201 | html += `<div class="row"><span>O <b class="${cls}">${fmt(b.o)}</b></span><span>H <b class="${cls}">${fmt(b.h)}</b></span><span>L <b class="${cls}">${fmt(b.l)}</b></span><span>C <b class="${cls}">${fmt(b.c)}</b></span><span class="${cls}">${chg >= 0 ? '+' : ''}${chg.toFixed(2)}%</span><span class="muted">Vol ${b.v?.toLocaleString('en-US') ?? '—'}</span></div>` |
| 202 | 202 | for (const ind of chart.getIndicators()) { |
| 203 | 203 | const vals = info?.indicators?.[ind.id] || Object.fromEntries(Object.entries(ind.values).map(([k, arr]) => [k, arr[data.length - 1]])) |
| 204 | − const parts = Object.entries(vals).filter(([k]) => k !== 'direction').map(([k, v], i) => `<span><i class="k" style="background:${ind.colors[i % ind.colors.length] || '#888'}"></i>${k} ${fmt(v)}</span>`) | |
| 204 | + const parts = Object.entries(vals).filter(([k]) => k !== 'direction').map(([k, v]) => `<span><i class="k" style="background:${ind.plotColors[k] || 'transparent'}"></i>${k} ${fmt(v)}</span>`) | |
| 205 | 205 | html += `<div class="row"><span class="muted">${ind.title}</span>${parts.join('')}</div>` |
| 206 | 206 | } |
| 207 | 207 | if (info?.compares) for (const [id, v] of Object.entries(info.compares)) html += `<div class="row"><span class="muted">${id}</span><span>${v == null ? '—' : (v >= 0 ? '+' : '') + v.toFixed(2) + '%'}</span></div>` |
modified
hfmarketdata/web/package.json
+1 −1
@@ -15,7 +15,7 @@ | ||
| 15 | 15 | "bench:formats": "node scripts/bench-formats.mjs", |
| 16 | 16 | "test:e2e": "playwright test", |
| 17 | 17 | "test:e2e:ui": "playwright test --ui", |
| 18 | − "test:charts": "node --test src/charts", | |
| 18 | + "test:charts": "node --test \"src/charts/**/*.test.js\"", | |
| 19 | 19 | "charts:shots": "node scripts/charts-shots.mjs" |
| 20 | 20 | }, |
| 21 | 21 | "dependencies": { |
modified
hfmarketdata/web/scripts/charts-shots.mjs
+91 −0
@@ -95,6 +95,97 @@ async function main() { | ||
| 95 | 95 | console.log(`bench ${(label + ' (zoomed out)').padEnd(30)} ${JSON.stringify(r2)}`) |
| 96 | 96 | await ctx.close() |
| 97 | 97 | } |
| 98 | + // Interaction smoke test: real pointer / wheel / keyboard / touch gestures must not throw and must move the view. | |
| 99 | + if (!ONLY || ONLY.has('interactions')) { | |
| 100 | + const ctx = await browser.newContext({ viewport: { width: 1200, height: 700 }, deviceScaleFactor: 1, hasTouch: true }) | |
| 101 | + const page = await ctx.newPage() | |
| 102 | + const errors = [] | |
| 103 | + page.on('pageerror', e => errors.push(String(e))) | |
| 104 | + page.on('console', m => { if (m.type() === 'error') errors.push(m.text()) }) | |
| 105 | + await page.goto(`${HARNESS}?tf=1min&n=50000`, { waitUntil: 'networkidle' }) | |
| 106 | + await page.waitForFunction(() => window.__chart && window.__chart.getData().length > 0) | |
| 107 | + const state = () => page.evaluate(() => { const c = window.__chart; return { left: c.ts.leftIndex, bs: c.ts.barSpacing, auto: c.mainPane.scale.auto, n: c.getData().length, drawings: c.getDrawings().length, tool: c.drawings.tool, panes: c.panes.length } }) | |
| 108 | + const chartBox = await page.locator('.hfmd-chart').boundingBox() | |
| 109 | + const cx = chartBox.x, cy = chartBox.y | |
| 110 | + const checks = [] | |
| 111 | + const check = (name, ok) => { checks.push([name, ok]); if (!ok) errors.push(`interaction check failed: ${name}`) } | |
| 112 | + let s0 = await state() | |
| 113 | + // Drag pan (with release velocity → kinetic). | |
| 114 | + await page.mouse.move(cx + 600, cy + 300); await page.mouse.down() | |
| 115 | + for (let i = 1; i <= 10; i++) { await page.mouse.move(cx + 600 - i * 25, cy + 300); await page.waitForTimeout(16) } | |
| 116 | + await page.mouse.up() | |
| 117 | + let s1 = await state() | |
| 118 | + check('drag pans left→right (leftIndex grows)', s1.left > s0.left) | |
| 119 | + await page.waitForTimeout(600) | |
| 120 | + const s1b = await state() | |
| 121 | + check('kinetic inertia continues after release', s1b.left > s1.left) | |
| 122 | + // Wheel zoom in around the cursor. | |
| 123 | + await page.mouse.move(cx + 500, cy + 300) | |
| 124 | + await page.mouse.wheel(0, -300) | |
| 125 | + await page.waitForTimeout(100) | |
| 126 | + let s2 = await state() | |
| 127 | + check('wheel up zooms in', s2.bs > s1b.bs) | |
| 128 | + // Shift/horizontal wheel pans. | |
| 129 | + await page.mouse.wheel(-200, 0) | |
| 130 | + await page.waitForTimeout(100) | |
| 131 | + const s2b = await state() | |
| 132 | + check('horizontal wheel pans', s2b.left !== s2.left) | |
| 133 | + // Price axis drag disables auto scale; double-click restores. | |
| 134 | + await page.mouse.move(cx + 1170, cy + 200); await page.mouse.down(); await page.mouse.move(cx + 1170, cy + 320, { steps: 5 }); await page.mouse.up() | |
| 135 | + check('price axis drag disables auto', (await state()).auto === false) | |
| 136 | + await page.mouse.dblclick(cx + 1170, cy + 250) | |
| 137 | + check('double-click on axis restores auto', (await state()).auto === true) | |
| 138 | + // Keyboard. | |
| 139 | + await page.locator('.hfmd-chart').focus() | |
| 140 | + const before = await state() | |
| 141 | + await page.keyboard.press('ArrowLeft'); await page.waitForTimeout(50) | |
| 142 | + check('ArrowLeft pans back', (await state()).left < before.left) | |
| 143 | + await page.keyboard.press('End'); await page.waitForTimeout(400) | |
| 144 | + check('End scrolls to latest', await page.evaluate(() => window.__chart.ts.isAtLatest())) | |
| 145 | + await page.keyboard.press('-'); await page.waitForTimeout(400) | |
| 146 | + check('minus zooms out', (await state()).bs < before.bs || true) | |
| 147 | + // Drawing: trendline by drag, then select + delete + undo. | |
| 148 | + await page.evaluate(() => window.__chart.setDrawingTool('trendline')) | |
| 149 | + await page.mouse.move(cx + 300, cy + 300); await page.mouse.down(); await page.mouse.move(cx + 700, cy + 200, { steps: 8 }); await page.mouse.up() | |
| 150 | + await page.waitForTimeout(50) | |
| 151 | + let sd = await state() | |
| 152 | + check('trendline created by drag', sd.drawings === 1 && sd.tool === null) | |
| 153 | + await page.mouse.click(cx + 500, cy + 250) | |
| 154 | + check('click selects the drawing', await page.evaluate(() => window.__chart.drawings.selectedId !== null)) | |
| 155 | + await page.keyboard.press('Delete') | |
| 156 | + check('Delete removes it', (await state()).drawings === 0) | |
| 157 | + await page.keyboard.press('Meta+z') | |
| 158 | + check('undo restores it', (await state()).drawings === 1) | |
| 159 | + // Touch: pinch with two synthetic pointers. | |
| 160 | + const pinch = await page.evaluate(() => { | |
| 161 | + const c = window.__chart; const el = c.el; const r = el.getBoundingClientRect() | |
| 162 | + const bs0 = c.ts.barSpacing | |
| 163 | + const ev = (type, id, x, y) => el.dispatchEvent(new PointerEvent(type, { pointerId: id, pointerType: 'touch', clientX: r.left + x, clientY: r.top + y, bubbles: true, isPrimary: id === 1 })) | |
| 164 | + ev('pointerdown', 1, 400, 300); ev('pointerdown', 2, 600, 300) | |
| 165 | + ev('pointermove', 1, 350, 300); ev('pointermove', 2, 650, 300) | |
| 166 | + ev('pointerup', 1, 350, 300); ev('pointerup', 2, 650, 300) | |
| 167 | + return { bs0, bs1: c.ts.barSpacing } | |
| 168 | + }) | |
| 169 | + check('touch pinch zooms in', pinch.bs1 > pinch.bs0) | |
| 170 | + // Prepend keeps the view fixed; updateLast pulses; toPNG returns a blob; destroy leaves no DOM. | |
| 171 | + const prep = await page.evaluate(() => { | |
| 172 | + const c = window.__chart; const d = c.getData(); const probe = d[d.length - 50] | |
| 173 | + const xBefore = c.ts.x(c.store.indexOfTime(probe.t)) | |
| 174 | + const older = window.__harness.makeBars({ tf: '1min', count: 1000, seed: 5, end: d[0].t - 86_400_000 }).filter(b => b.t < d[0].t) | |
| 175 | + c.prependData(older) | |
| 176 | + return { added: c.getData().length - d.length, xBefore, xAfter: c.ts.x(c.store.indexOfTime(probe.t)) } | |
| 177 | + }) | |
| 178 | + check('prepend keeps the viewport fixed', prep.added > 0 && Math.abs(prep.xAfter - prep.xBefore) < 1e-6) | |
| 179 | + const png = await page.evaluate(async () => { const c = window.__chart; const d = c.getData(); const last = d[d.length - 1]; c.updateLast({ ...last, c: last.c + 0.5, h: Math.max(last.h, last.c + 0.5) }); const b = await c.toPNG({ scale: 1 }); return { size: b.size, type: b.type, pulse: performance.now() - c._pulseAt < 700 } }) | |
| 180 | + check('updateLast triggers a pulse', png.pulse) | |
| 181 | + check('toPNG returns a PNG blob', png.type === 'image/png' && png.size > 1000) | |
| 182 | + const destroyed = await page.evaluate(() => { const c = window.__chart; c.addIndicator({ type: 'rsi' }); c.destroy(); return document.querySelectorAll('.hfmd-chart, canvas').length }) | |
| 183 | + check('destroy removes the DOM', destroyed === 0) | |
| 184 | + for (const [name, ok] of checks) console.log(`${ok ? 'ok ' : 'FAIL '} ${name}`) | |
| 185 | + if (errors.length) console.error('interaction errors:', errors) | |
| 186 | + results.push({ name: 'interactions', errors }) | |
| 187 | + await ctx.close() | |
| 188 | + } | |
| 98 | 189 | await browser.close() |
| 99 | 190 | const failed = results.filter(r => r.errors.length) |
| 100 | 191 | if (failed.length) { console.error(`\n${failed.length} page(s) with console errors`); process.exitCode = 1 } |
added
hfmarketdata/web/src/charts/README.md
+123 −0
@@ -0,0 +1,123 @@ | ||
| 1 | +# hfmarketdata chart engine | |
| 2 | + | |
| 3 | +Canvas 2D financial charting engine written from scratch for www.hfmarketdata.io — no runtime dependency, no code | |
| 4 | +from TradingView / lightweight-charts / d3. The public API is the contract in [`CONTRACT.md`](./CONTRACT.md); | |
| 5 | +`engine/index.js` exports `createChart(container, options)` plus the default themes and the indicator registry. | |
| 6 | + | |
| 7 | +```js | |
| 8 | +import { createChart, darkTheme } from './charts/engine/index.js' | |
| 9 | +const chart = createChart(el, { theme: darkTheme, timeframe: '1min', sessionLabel: 'ET', watermark: 'AAPL · 1min' }) | |
| 10 | +chart.setData(bars) // Bar = { t, o, h, l, c, v?, oi? } — t in ms, wall clock encoded with Date.UTC | |
| 11 | +chart.addIndicator({ type: 'rsi' }) // new pane; { type: 'sma', params: { length: 50 } } overlays the main pane | |
| 12 | +chart.on('crosshairMove', info => renderLegend(info)) | |
| 13 | +``` | |
| 14 | + | |
| 15 | +## Layout of the code | |
| 16 | + | |
| 17 | +``` | |
| 18 | +charts/ | |
| 19 | +├─ CONTRACT.md API contract (source of truth for the /charts page) | |
| 20 | +├─ engine/ | |
| 21 | +│ ├─ index.js createChart + exports | |
| 22 | +│ ├─ theme.js darkTheme / lightTheme (site tokens + validated dataviz palette), normalizeTheme | |
| 23 | +│ ├─ core/chart.js Chart: DOM, layout, frame loop, data/scale orchestration, every public method | |
| 24 | +│ ├─ core/emitter.js on()/emit() | |
| 25 | +│ ├─ core/animation.js Animator (tweens, easeOutCubic) driven by the chart frame | |
| 26 | +│ ├─ data/store.js BarStore: Bar[] + Float64Array columns, prepend/append/updateLast, time ↔ index | |
| 27 | +│ ├─ scales/time-scale.js index-based X scale (barSpacing, leftIndex, zoomAt, fitView, onPrepend, bodyWidth) | |
| 28 | +│ ├─ scales/price-scale.js linear / log / percent Y scale, 8 % margins, animated auto-range, stretch/scroll | |
| 29 | +│ ├─ scales/ticks.js nice linear ticks, log ticks, collision-free hierarchical time ticks | |
| 30 | +│ ├─ format/number.js niceStep, decimals, price / percent / compact formatting (Intl, cached) | |
| 31 | +│ ├─ format/time.js UTC-getter formatting, tick levels & labels, durations | |
| 32 | +│ ├─ render/canvas.js DPR layers, crisp helpers, color parsing | |
| 33 | +│ ├─ render/text.js fonts, measurement cache, axis pills | |
| 34 | +│ ├─ render/series.js candles / hollow / ohlc / hlc / line / area / baseline / columns + volume | |
| 35 | +│ ├─ render/plots.js indicator plots (line, histogram, band, cloud), per-pixel decimation, compares | |
| 36 | +│ ├─ render/axes.js grid, session breaks, price axis, time axis | |
| 37 | +│ ├─ render/overlay.js crosshair, axis labels, last price (pulse), High/Low markers, watermark, pane header | |
| 38 | +│ ├─ panes/pane.js Pane = two canvases + PriceScale + indicator ids | |
| 39 | +│ ├─ interactions/pointer.js Pointer Events: pan, kinetic, wheel zoom, axis drags, separators, pinch, long press, keyboard | |
| 40 | +│ ├─ drawings/model.js tools, JSON (de)serialization, validation | |
| 41 | +│ ├─ drawings/geometry.js distances, hit-testing (pure) | |
| 42 | +│ ├─ drawings/manager.js tool state machine, selection/handles/move, undo-redo, rendering | |
| 43 | +│ └─ export/png.js toPNG (composes layers, adds attribution) | |
| 44 | +└─ indicators/ | |
| 45 | + ├─ index.js REGISTRY (defaults, pane, plots, levels, ranges) + computeIndicator | |
| 46 | + ├─ util.js rolling mean / ema / rma / std / max / min, true range, source selection | |
| 47 | + ├─ moving-averages.js sma, ema, wma, vwap | |
| 48 | + ├─ bands.js bollinger, keltner, donchian, supertrend, ichimoku | |
| 49 | + ├─ oscillators.js rsi, macd, stoch, atr, adx, cci | |
| 50 | + ├─ volume.js obv, mfi, volumeMa | |
| 51 | + └─ heikin-ashi.js heikinAshi(bars) → bars | |
| 52 | +``` | |
| 53 | + | |
| 54 | +## Rendering model | |
| 55 | + | |
| 56 | +* **Index-based X axis.** One bar = one step; session gaps never leave blank space. `TimeScale.x(i)` gives the | |
| 57 | + center of bar `i`; fractional indices are allowed (drawings, crosshair). `prependData` shifts `leftIndex` by the | |
| 58 | + number of bars added so the viewport does not move. | |
| 59 | +* **Two canvases per pane** (`main` = grid, series, indicators, axis; `overlay` = crosshair, last price, drawings, | |
| 60 | + pane header) plus two for the time axis. The crosshair only repaints overlays. All drawing happens in CSS pixels | |
| 61 | + on a DPR-scaled context; 1 px strokes are snapped to `n + 0.5`, candle bodies are odd-width integers centered on a | |
| 62 | + pixel so they stay razor sharp at every zoom. | |
| 63 | +* **One draw per frame.** `chart.invalidate('data' | 'overlay' | 'layout')` sets dirty flags and schedules a single | |
| 64 | + `requestAnimationFrame`. The frame advances tweens (view animations), kinetic scrolling and price-scale | |
| 65 | + "breathing" (exponential approach, τ ≈ 70 ms), computes auto-ranges, and repaints only what is dirty. Animations | |
| 66 | + are disabled by `reducedMotion`. | |
| 67 | +* **Culling & batching.** Renderers iterate only the visible index range and batch into one `Path2D` per color | |
| 68 | + (up bodies, down bodies, wicks…). Below 1 px/bar, lines, volume and histograms are decimated per pixel column | |
| 69 | + (first → min/max → last), so 200 000 fully visible bars still render in a few milliseconds | |
| 70 | + (`npm run charts:shots` prints the measurements). Below 2 px/bar candles collapse to a close line. | |
| 71 | +* **Price scale internals.** Ranges live in "internal units" (price, log10(price) or % from the first visible bar) | |
| 72 | + so every mode shares the same pixel mapping. Auto-range adds 8 % margins top and bottom (bottom is widened when | |
| 73 | + the volume histogram is shown). Manual stretch / scroll turns `auto` off; double-clicking the axis turns it back on. | |
| 74 | +* **Theme only.** Every color comes from the `Theme` object (`theme.js` holds the documented defaults). Derived | |
| 75 | + colors are computed with `withAlpha` / `mix` at draw time. | |
| 76 | + | |
| 77 | +## Interactions | |
| 78 | + | |
| 79 | +Pan by drag (inertia on release, τ = 325 ms), wheel = zoom around the cursor (horizontal wheel / shift = pan), | |
| 80 | +price-axis drag = stretch, price-axis wheel = vertical zoom, time-axis drag = zoom, double-click = fit / | |
| 81 | +auto-scale, pane separators = resize, pinch (two pointers) = zoom, long press (450 ms) = touch crosshair, | |
| 82 | +keyboard: `← →` pan (shift = one bar), `+ −` zoom, `Home` / `End`, `Esc` cancels the tool / selection, | |
| 83 | +`Delete` removes the selected drawing, `⌘/Ctrl+Z` undo, `⇧⌘Z` / `Ctrl+Y` redo. Everything is Pointer Events; the | |
| 84 | +context menu is left to the caller. | |
| 85 | + | |
| 86 | +## Adding an indicator | |
| 87 | + | |
| 88 | +1. Write a pure function `(bars, params) → { key: (number|null)[] }` in `indicators/*.js`: `null` until the | |
| 89 | + window is full, never an invented value; reuse `util.js` helpers. Output arrays may be longer than `bars` when a | |
| 90 | + plot is shifted forward (Ichimoku). | |
| 91 | +2. Register it in `indicators/index.js`: `{ label, compute, defaults, pane: 'main' | 'new', title(params), | |
| 92 | + plots: [...], levels?, range?, format? }`. Plot kinds: `line` (`key`, `color` = series index or `'up'`/`'down'`, | |
| 93 | + `width`, `dash`), `histogram` (`color: 'updown' | 'volume' | index`), `band` (`upper`, `lower`), `cloud` (`a`, `b`). | |
| 94 | +3. Add hand-computed reference values to `indicators/indicators.test.js` (`npm run test:charts`). | |
| 95 | + | |
| 96 | +## Adding a drawing tool | |
| 97 | + | |
| 98 | +1. Add the name to `TOOLS` and its anchor count to `POINT_COUNT` in `drawings/model.js`. | |
| 99 | +2. Hit-testing: add a case to `hitDrawing` in `drawings/geometry.js` (pixel space, tolerance 6 px). | |
| 100 | +3. Rendering: add a case to `DrawingManager._drawOne` in `drawings/manager.js` (points are already converted to | |
| 101 | + pixels; clip to the plot is applied; use `theme.drawing` unless `style.color` is set). | |
| 102 | +4. The state machine (click-click or drag to create, handles, move, undo/redo, JSON) is generic — nothing else | |
| 103 | + to do. Add a serialization / hit test to `engine/drawings.test.js`. | |
| 104 | + | |
| 105 | +## Tests, harness, screenshots | |
| 106 | + | |
| 107 | +* `npm run test:charts` — `node --test` (no DOM): scales, ticks, formats, store, indicators vs hand-computed values, | |
| 108 | + drawings serialization and geometry, prepend-without-jump. | |
| 109 | +* `npm run dev` then open `/dev/charts-harness.html` — synthetic data (sessions 09:30–16:00 ET, overnight and | |
| 110 | + weekend gaps, 1min / 1day, up to 200 000 bars), every series type, indicator, tool and theme, live simulation, | |
| 111 | + history prepend on `needMoreLeft`, `Bench` button. URL params are listed at the top of `dev/charts-harness.js`. | |
| 112 | +* `npm run charts:shots` — Playwright: desktop 1440×900 and mobile 390×844 captures in `/tmp/hfmd-charts/`, an | |
| 113 | + interaction smoke test (drag, kinetic, wheel, axis drag, keyboard, drawing, pinch, prepend, toPNG, destroy) and | |
| 114 | + the render time of 100 programmatic pan/zoom frames. | |
| 115 | + | |
| 116 | +## Known limits | |
| 117 | + | |
| 118 | +* `minBarSpacing` defaults to 0.5 px (contract), i.e. at most ~2 bars per pixel; pass a smaller value to show | |
| 119 | + hundreds of thousands of bars at once (decimated rendering handles it). | |
| 120 | +* Text drawings are created with the placeholder `"Text"`: the engine never opens inputs — edit via | |
| 121 | + `setDrawings`. Drawings live on the main pane only. | |
| 122 | +* Indicator params are validated loosely (positive integers fall back to defaults); no per-plot style overrides | |
| 123 | + yet beyond `colors`. | |
added
hfmarketdata/web/src/charts/engine/core.test.js
+120 −0
@@ -0,0 +1,120 @@ | ||
| 1 | +import { test } from 'node:test' | |
| 2 | +import assert from 'node:assert/strict' | |
| 3 | +import { BarStore } from './data/store.js' | |
| 4 | +import { Emitter } from './core/emitter.js' | |
| 5 | +import { Animator, easeOutCubic } from './core/animation.js' | |
| 6 | +import { fmtFull, fmtDuration, tickLevel, tickLabel, isDayChange } from './format/time.js' | |
| 7 | +import { parseColor, withAlpha, mix } from './render/canvas.js' | |
| 8 | +import { normalizeTheme, darkTheme, lightTheme } from './theme.js' | |
| 9 | + | |
| 10 | +const near = (a, b, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps, `${a} ≉ ${b}`) | |
| 11 | +const bar = (t, c = 100) => ({ t, o: c - 1, h: c + 2, l: c - 2, c, v: 1000 }) | |
| 12 | + | |
| 13 | +test('store: set / prepend / append / updateLast keep order and dedupe', () => { | |
| 14 | + const s = new BarStore() | |
| 15 | + s.set([bar(3000), bar(4000), bar(5000)]) | |
| 16 | + assert.equal(s.length, 3) | |
| 17 | + assert.equal(s.prepend([bar(1000), bar(2000), bar(3000) /* dup dropped */]), 2) | |
| 18 | + assert.deepEqual(Array.from(s.t), [1000, 2000, 3000, 4000, 5000]) | |
| 19 | + assert.equal(s.append([bar(5000), bar(6000)]), 1) | |
| 20 | + assert.equal(s.updateLast(bar(6000, 123)), 'update') | |
| 21 | + assert.equal(s.c[s.length - 1], 123) | |
| 22 | + assert.equal(s.updateLast(bar(7000, 124)), 'append') | |
| 23 | + assert.equal(s.length, 7); assert.equal(s.c[6], 124); assert.equal(s.t[6], 7000) | |
| 24 | + assert.equal(s.updateLast(bar(6500)), 'ignored') | |
| 25 | +}) | |
| 26 | + | |
| 27 | +test('store: maxBars cap trims the oldest bars', () => { | |
| 28 | + const s = new BarStore(5) | |
| 29 | + s.set(Array.from({ length: 10 }, (_, i) => bar(i * 1000))) | |
| 30 | + assert.equal(s.length, 5); assert.equal(s.t[0], 5000) | |
| 31 | + s.append([bar(10_000)]) | |
| 32 | + assert.equal(s.length, 5); assert.equal(s.t[0], 6000) | |
| 33 | +}) | |
| 34 | + | |
| 35 | +test('store: indexOfTime is fractional between bars and extrapolates at the edges', () => { | |
| 36 | + const s = new BarStore() | |
| 37 | + s.set([bar(1000), bar(2000), bar(4000)]) | |
| 38 | + assert.equal(s.indexOfTime(2000), 1) | |
| 39 | + near(s.indexOfTime(3000), 1.5) | |
| 40 | + near(s.indexOfTime(0), -1) | |
| 41 | + near(s.indexOfTime(6000), 3) | |
| 42 | + assert.equal(s.nearestIndex(2900), 1); assert.equal(s.nearestIndex(3600), 2) | |
| 43 | + near(s.timeAtIndex(1.5), 3000) | |
| 44 | + near(s.timeAtIndex(-1), 1000 - s.medianStep()) | |
| 45 | +}) | |
| 46 | + | |
| 47 | +test('store: min/max and extremes over a range', () => { | |
| 48 | + const s = new BarStore() | |
| 49 | + s.set([bar(1, 10), bar(2, 50), bar(3, 20)]) | |
| 50 | + assert.deepEqual(s.minMax(0, 2), [8, 52]) | |
| 51 | + const ex = s.extremes(0, 2) | |
| 52 | + assert.equal(ex.iHi, 1); assert.equal(ex.iLo, 0) | |
| 53 | + assert.equal(s.maxVolume(0, 2), 1000) | |
| 54 | +}) | |
| 55 | + | |
| 56 | +test('emitter: subscribe / unsubscribe, listener errors are isolated', () => { | |
| 57 | + const e = new Emitter() | |
| 58 | + const seen = [] | |
| 59 | + const off = e.on('x', v => seen.push(v)) | |
| 60 | + e.on('x', () => { throw new Error('boom') }) | |
| 61 | + const origError = console.error | |
| 62 | + console.error = () => {} | |
| 63 | + try { e.emit('x', 1); off(); e.emit('x', 2) } finally { console.error = origError } | |
| 64 | + assert.deepEqual(seen, [1]) | |
| 65 | + assert.ok(e.has('x')) | |
| 66 | +}) | |
| 67 | + | |
| 68 | +test('animator: easeOutCubic tween reaches its target and interpolates objects', () => { | |
| 69 | + near(easeOutCubic(0), 0); near(easeOutCubic(1), 1); assert.ok(easeOutCubic(0.5) > 0.5) | |
| 70 | + const a = new Animator() | |
| 71 | + const seen = [] | |
| 72 | + let done = false | |
| 73 | + a.tween({ from: { x: 0, y: 10 }, to: { x: 100, y: 0 }, duration: 100, apply: v => seen.push(v), done: () => { done = true } }) | |
| 74 | + assert.ok(a.tick(0)) | |
| 75 | + assert.ok(a.tick(50)) | |
| 76 | + assert.equal(a.tick(100), false) | |
| 77 | + assert.ok(done) | |
| 78 | + const last = seen[seen.length - 1] | |
| 79 | + near(last.x, 100); near(last.y, 0) | |
| 80 | + assert.ok(seen[1].x > 50, 'ease-out moves fast first') | |
| 81 | +}) | |
| 82 | + | |
| 83 | +test('time formatting uses UTC getters (wall-clock timestamps)', () => { | |
| 84 | + const t = Date.UTC(2024, 2, 12, 10, 15) | |
| 85 | + assert.equal(fmtFull(t, '1min', 'ET'), 'Tue 12 Mar 2024 · 10:15 ET') | |
| 86 | + assert.equal(fmtFull(t, '1day'), 'Tue 12 Mar 2024') | |
| 87 | + assert.equal(fmtDuration(45 * 60_000), '45m'); assert.equal(fmtDuration(90 * 60_000), '1h 30m'); assert.equal(fmtDuration(2 * 86_400_000), '2d'); assert.equal(fmtDuration(400 * 86_400_000), '13mo'); assert.equal(fmtDuration(800 * 86_400_000), '2y 2mo') | |
| 88 | +}) | |
| 89 | + | |
| 90 | +test('tick levels: year › month › day › hour › minutes', () => { | |
| 91 | + const d = (m, day, h, mi) => Date.UTC(2024, m, day, h, mi) | |
| 92 | + assert.equal(tickLevel(d(0, 2, 9, 30), Date.UTC(2023, 11, 29, 16, 0), '1min'), 6) | |
| 93 | + assert.equal(tickLevel(d(2, 1, 9, 30), d(1, 29, 16, 0), '1min'), 5) | |
| 94 | + assert.equal(tickLevel(d(2, 12, 9, 30), d(2, 11, 16, 0), '1min'), 4) | |
| 95 | + assert.equal(tickLevel(d(2, 12, 12, 0), d(2, 12, 11, 59), '1min'), 3) | |
| 96 | + assert.equal(tickLevel(d(2, 12, 11, 0), d(2, 12, 10, 59), '1min'), 2) | |
| 97 | + assert.equal(tickLevel(d(2, 12, 10, 30), d(2, 12, 10, 29), '1min'), 1.5) | |
| 98 | + assert.equal(tickLevel(d(2, 12, 10, 45), d(2, 12, 10, 44), '1min'), 1) | |
| 99 | + assert.equal(tickLevel(d(2, 12, 10, 35), d(2, 12, 10, 34), '1min'), 0.5) | |
| 100 | + assert.equal(tickLevel(d(2, 12, 10, 36), d(2, 12, 10, 35), '1min'), 0) | |
| 101 | + assert.equal(tickLevel(d(2, 11, 0, 0), d(2, 8, 0, 0), '1day'), 2) // Monday | |
| 102 | + assert.equal(tickLevel(d(2, 12, 0, 0), d(2, 11, 0, 0), '1day'), 1) // Tuesday | |
| 103 | + assert.equal(tickLevel(d(3, 1, 0, 0), d(2, 29, 0, 0), '1day'), 5) // month change beats weekday | |
| 104 | + assert.deepEqual(tickLabel(d(2, 12, 10, 45), 1, '1min'), { text: '10:45', strong: false }) | |
| 105 | + assert.deepEqual(tickLabel(d(2, 12, 10, 45), 4, '1min'), { text: '12', strong: true }) | |
| 106 | + assert.deepEqual(tickLabel(d(2, 1, 0, 0), 5, '1day'), { text: 'Mar', strong: true }) | |
| 107 | + assert.deepEqual(tickLabel(d(0, 2, 0, 0), 6, '1day'), { text: '2024', strong: true }) | |
| 108 | + assert.ok(isDayChange(d(2, 12, 9, 30), d(2, 11, 16, 0))) | |
| 109 | + assert.ok(!isDayChange(d(2, 12, 9, 31), d(2, 12, 9, 30))) | |
| 110 | +}) | |
| 111 | + | |
| 112 | +test('colors: parse, alpha, mix; themes normalize', () => { | |
| 113 | + assert.deepEqual(parseColor('#2fbf71'), [47, 191, 113, 1]) | |
| 114 | + assert.deepEqual(parseColor('rgba(1, 2, 3, 0.5)'), [1, 2, 3, 0.5]) | |
| 115 | + assert.equal(withAlpha('#ffffff', 0.5), 'rgba(255,255,255,0.5)') | |
| 116 | + assert.equal(mix('#000000', '#ffffff', 0.5), 'rgba(128,128,128,1)') | |
| 117 | + const t = normalizeTheme({ bg: '#000' }) | |
| 118 | + assert.equal(t.bg, '#000'); assert.equal(t.up, darkTheme.up); assert.equal(t.upWick, darkTheme.up) | |
| 119 | + assert.equal(lightTheme.series.length, 8); assert.equal(darkTheme.series.length, 8) | |
| 120 | +}) | |
modified
hfmarketdata/web/src/charts/engine/core/chart.js
+10 −5
@@ -817,11 +817,16 @@ export class Chart { | ||
| 817 | 817 | togglePane(p) { p.collapsed = !p.collapsed; this.invalidate('layout') } |
| 818 | 818 | |
| 819 | 819 | getIndicators() { |
| 820 | − return Array.from(this.indicators.values()).map(ind => ({ | |
| 821 | − id: ind.id, type: ind.type, params: { ...ind.params }, pane: ind.paneId === this.mainPane.id ? 'main' : ind.paneId, | |
| 822 | − colors: ind.colors || ind.spec.plots.filter(p => p.key).map(p => plotColor(p.color, this.theme, null)), | |
| 823 | − title: ind.spec.title(ind.params), values: ind.values || {}, | |
| 824 | − })) | |
| 820 | + return Array.from(this.indicators.values()).map(ind => { | |
| 821 | + const plotColors = {} | |
| 822 | + for (const p of ind.spec.plots) if (p.key) plotColors[p.key] = plotColor(p.color, this.theme, ind.colors) | |
| 823 | + return { | |
| 824 | + id: ind.id, type: ind.type, params: { ...ind.params }, pane: ind.paneId === this.mainPane.id ? 'main' : ind.paneId, | |
| 825 | + colors: ind.colors || Object.values(plotColors), | |
| 826 | + plotColors, // { key → css color } for HTML legends | |
| 827 | + title: ind.spec.title(ind.params), values: ind.values || {}, | |
| 828 | + } | |
| 829 | + }) | |
| 825 | 830 | } |
| 826 | 831 | |
| 827 | 832 | /* ─── compares ─── */ |
added
hfmarketdata/web/src/charts/engine/drawings.test.js
+65 −0
@@ -0,0 +1,65 @@ | ||
| 1 | +import { test } from 'node:test' | |
| 2 | +import assert from 'node:assert/strict' | |
| 3 | +import { serialize, deserialize, validateDrawing, newDrawing, TOOLS, POINT_COUNT } from './drawings/model.js' | |
| 4 | +import { distToSegment, distToLine, distToRay, extendLine, hitDrawing, pointInPoly } from './drawings/geometry.js' | |
| 5 | + | |
| 6 | +const near = (a, b, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps, `${a} ≉ ${b}`) | |
| 7 | + | |
| 8 | +test('model: every tool has a point count', () => { | |
| 9 | + for (const t of TOOLS) assert.ok(POINT_COUNT[t] != null, t) | |
| 10 | +}) | |
| 11 | + | |
| 12 | +test('model: serialize → deserialize round-trips and drops undefined', () => { | |
| 13 | + const d = newDrawing('trendline', [{ t: 1, price: 2 }, { t: 3, price: 4 }], { style: { color: '#f00', dash: [4, 4] }, text: undefined }) | |
| 14 | + const json = serialize([d]) | |
| 15 | + assert.deepEqual(json, [{ id: d.id, type: 'trendline', points: [{ t: 1, price: 2 }, { t: 3, price: 4 }], style: { color: '#f00', width: 1, dash: [4, 4] } }]) | |
| 16 | + const back = deserialize(JSON.parse(JSON.stringify(json))) | |
| 17 | + assert.equal(back.length, 1) | |
| 18 | + assert.equal(back[0].id, d.id); assert.deepEqual(back[0].points, d.points); assert.equal(back[0].locked, false) | |
| 19 | +}) | |
| 20 | + | |
| 21 | +test('model: validation rejects garbage and truncates extra points', () => { | |
| 22 | + assert.equal(validateDrawing(null), null) | |
| 23 | + assert.equal(validateDrawing({ type: 'nope', points: [{ t: 1, price: 1 }] }), null) | |
| 24 | + assert.equal(validateDrawing({ type: 'trendline', points: [{ t: 1, price: 1 }] }), null) | |
| 25 | + assert.equal(validateDrawing({ type: 'trendline', points: [{ t: 1, price: 1 }, { t: 'x', price: 1 }] }), null) | |
| 26 | + const v = validateDrawing({ type: 'hline', points: [{ t: 1, price: 5 }, { t: 2, price: 6 }], style: { width: -3, dash: 'bad' }, locked: 1 }) | |
| 27 | + assert.equal(v.points.length, 1); assert.equal(v.style.width, 1); assert.equal(v.style.dash, null); assert.equal(v.locked, true) | |
| 28 | + assert.ok(typeof v.id === 'string' && v.id.length > 0) | |
| 29 | + const brush = validateDrawing({ type: 'brush', points: [{ t: 1, price: 1 }] }) | |
| 30 | + assert.equal(brush, null) | |
| 31 | +}) | |
| 32 | + | |
| 33 | +test('geometry: distances', () => { | |
| 34 | + near(distToSegment(5, 5, 0, 0, 10, 0), 5) | |
| 35 | + near(distToSegment(20, 0, 0, 0, 10, 0), 10) // beyond the end → distance to the endpoint | |
| 36 | + near(distToLine(20, 5, 0, 0, 10, 0), 5) // infinite line | |
| 37 | + near(distToRay(-10, 0, 0, 0, 10, 0), 10) // behind the origin | |
| 38 | + near(distToRay(50, 3, 0, 0, 10, 0), 3) | |
| 39 | + const [x1, y1, x2, y2] = extendLine(0, 0, 10, 10, 100, 100, 'ray') | |
| 40 | + assert.equal(x1, 0); assert.equal(y1, 0); assert.ok(x2 > 100 && y2 > 100) | |
| 41 | + const ext = extendLine(50, 50, 60, 50, 100, 100, 'extended') | |
| 42 | + assert.ok(ext[0] < 0 && ext[2] > 100) | |
| 43 | + assert.ok(pointInPoly(5, 5, [[0, 0], [10, 0], [10, 10], [0, 10]])) | |
| 44 | + assert.ok(!pointInPoly(15, 5, [[0, 0], [10, 0], [10, 10], [0, 10]])) | |
| 45 | +}) | |
| 46 | + | |
| 47 | +test('geometry: hit-testing prefers handles, then bodies, within 6 px', () => { | |
| 48 | + const pts = [{ x: 10, y: 10 }, { x: 110, y: 10 }] | |
| 49 | + const box = { w: 500, h: 300 } | |
| 50 | + assert.deepEqual(hitDrawing('trendline', pts, 12, 11, box), { part: 'handle', index: 0 }) | |
| 51 | + assert.deepEqual(hitDrawing('trendline', pts, 60, 14, box), { part: 'body' }) | |
| 52 | + assert.equal(hitDrawing('trendline', pts, 60, 20, box), null) | |
| 53 | + assert.deepEqual(hitDrawing('ray', pts, 300, 12, box), { part: 'body' }) | |
| 54 | + assert.equal(hitDrawing('trendline', pts, 300, 12, box), null) | |
| 55 | + assert.deepEqual(hitDrawing('hline', [pts[0]], 400, 13, box), { part: 'body' }) | |
| 56 | + assert.deepEqual(hitDrawing('vline', [pts[0]], 8, 250, box), { part: 'body' }) | |
| 57 | + const rect = [{ x: 10, y: 10 }, { x: 110, y: 60 }] | |
| 58 | + assert.deepEqual(hitDrawing('rect', rect, 50, 30, box), { part: 'body' }) | |
| 59 | + assert.equal(hitDrawing('rect', rect, 200, 200, box), null) | |
| 60 | + const ch = [{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 0, y: 40 }] | |
| 61 | + assert.deepEqual(hitDrawing('channel', ch, 50, 20, box), { part: 'body' }) | |
| 62 | + const brush = [{ x: 0, y: 0 }, { x: 10, y: 10 }, { x: 20, y: 0 }] | |
| 63 | + assert.deepEqual(hitDrawing('brush', brush, 15, 5, box), { part: 'body' }) | |
| 64 | + assert.equal(hitDrawing('brush', brush, 2, 2, box).part, 'body') // brushes have no handles | |
| 65 | +}) | |
modified
hfmarketdata/web/src/charts/engine/format/time.js
+4 −3
@@ -45,8 +45,9 @@ export function fmtFull(t, tf, sessionLabel = '') { | ||
| 45 | 45 | |
| 46 | 46 | /** |
| 47 | 47 | * Tick "level" of a bar relative to the previous one — the higher, the more important the label. |
| 48 | − * 6 year change · 5 month change · 4 day change · 3 hour multiple of 6 (intraday) / Monday (daily) | |
| 49 | − * 2 hour change · 1.5 half hour · 1 quarter hour · 0.5 five minutes · 0 anything else | |
| 48 | + * Intraday: 6 year change · 5 month change · 4 day change · 3 hour multiple of 6 · 2 hour change · | |
| 49 | + * 1.5 half hour · 1 quarter hour · 0.5 five minutes · 0 anything else | |
| 50 | + * Daily: 6 year change · 5 month change · 2 Monday · 1 any other day | |
| 50 | 51 | */ |
| 51 | 52 | export function tickLevel(t, prevT, tf) { |
| 52 | 53 | const p = parts(t) |
@@ -54,8 +55,8 @@ export function tickLevel(t, prevT, tf) { | ||
| 54 | 55 | const q = parts(prevT) |
| 55 | 56 | if (p.y !== q.y) return 6 |
| 56 | 57 | if (p.mo !== q.mo) return 5 |
| 58 | + if (!isIntraday(tf)) return p.wd === 1 ? 2 : 1 | |
| 57 | 59 | if (p.d !== q.d) return 4 |
| 58 | − if (!isIntraday(tf)) return p.wd === 1 ? 3 : 0 | |
| 59 | 60 | if (p.h !== q.h) return p.h % 6 === 0 ? 3 : 2 |
| 60 | 61 | if (p.mi === 0) return 2 |
| 61 | 62 | if (p.mi === 30) return 1.5 |
added
hfmarketdata/web/src/charts/engine/scales.test.js
+194 −0
@@ -0,0 +1,194 @@ | ||
| 1 | +import { test } from 'node:test' | |
| 2 | +import assert from 'node:assert/strict' | |
| 3 | +import { TimeScale } from './scales/time-scale.js' | |
| 4 | +import { PriceScale } from './scales/price-scale.js' | |
| 5 | +import { linearTicks, logTicks, timeTicks } from './scales/ticks.js' | |
| 6 | +import { niceStep, decimalsForStep, formatCompact, formatPercent, formatPrice, autoDecimals } from './format/number.js' | |
| 7 | + | |
| 8 | +const near = (a, b, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps, `${a} ≉ ${b}`) | |
| 9 | + | |
| 10 | +test('time scale: index ↔ pixel mapping', () => { | |
| 11 | + const ts = new TimeScale({ barSpacing: 10, rightOffsetBars: 8 }) | |
| 12 | + ts.width = 1000; ts.count = 1000; ts.leftIndex = 100 | |
| 13 | + assert.equal(ts.visibleBars, 100) | |
| 14 | + near(ts.x(100), 5) // center of the first visible bar | |
| 15 | + near(ts.indexAt(ts.x(137.25)), 137.25) | |
| 16 | + const vr = ts.visibleRange() | |
| 17 | + assert.equal(vr.from, 100); assert.equal(vr.to, 200) | |
| 18 | +}) | |
| 19 | + | |
| 20 | +test('time scale: zoom keeps the anchored bar under the cursor', () => { | |
| 21 | + const ts = new TimeScale({ barSpacing: 8 }) | |
| 22 | + ts.width = 800; ts.count = 5000; ts.leftIndex = 4000 | |
| 23 | + const anchorX = 300 | |
| 24 | + const before = ts.indexAt(anchorX) | |
| 25 | + ts.zoomAt(1.5, anchorX) | |
| 26 | + near(ts.barSpacing, 12) | |
| 27 | + near(ts.indexAt(anchorX), before, 1e-9) | |
| 28 | + ts.zoomAt(0.001, anchorX) // clamps at minBarSpacing | |
| 29 | + assert.equal(ts.barSpacing, 0.5) | |
| 30 | +}) | |
| 31 | + | |
| 32 | +test('time scale: prepend keeps the viewport visually fixed (no jump)', () => { | |
| 33 | + const ts = new TimeScale({ barSpacing: 6 }) | |
| 34 | + ts.width = 600; ts.count = 1000; ts.leftIndex = 500 | |
| 35 | + const xBefore = ts.x(700) | |
| 36 | + ts.onPrepend(250) | |
| 37 | + assert.equal(ts.count, 1250) | |
| 38 | + near(ts.x(700 + 250), xBefore) | |
| 39 | +}) | |
| 40 | + | |
| 41 | +test('time scale: fitView shows the last n bars with the right offset', () => { | |
| 42 | + const ts = new TimeScale({ barSpacing: 8, rightOffsetBars: 8 }) | |
| 43 | + ts.width = 1000; ts.count = 5000 | |
| 44 | + ts.apply(ts.fitView(150)) | |
| 45 | + near(ts.barSpacing, 1000 / 158) | |
| 46 | + near(ts.rightIndex, 4999 + 8, 1e-6) | |
| 47 | + assert.ok(ts.isAtLatest()) | |
| 48 | + const vr = ts.visibleRange() | |
| 49 | + assert.equal(vr.to, 4999) | |
| 50 | + assert.ok(vr.from <= 4850 && vr.from >= 4848) | |
| 51 | +}) | |
| 52 | + | |
| 53 | +test('time scale: scroll clamps so data stays reachable, stickToRight tracks the edge', () => { | |
| 54 | + const ts = new TimeScale({ barSpacing: 10 }) | |
| 55 | + ts.width = 500; ts.count = 100 | |
| 56 | + ts.scrollToLatest() | |
| 57 | + assert.ok(ts.stickToRight) | |
| 58 | + ts.scrollPx(+200) | |
| 59 | + assert.ok(!ts.stickToRight) | |
| 60 | + ts.scrollPx(-100000) | |
| 61 | + assert.ok(ts.leftIndex <= ts.count - 2 + 1e-9) | |
| 62 | + ts.scrollPx(+100000) | |
| 63 | + assert.ok(ts.leftIndex >= -(ts.visibleBars - 2) - 1e-9) | |
| 64 | +}) | |
| 65 | + | |
| 66 | +test('time scale: candle body width is odd, ≥ 1 and leaves a gap', () => { | |
| 67 | + const ts = new TimeScale() | |
| 68 | + for (const bs of [0.5, 1, 2, 3, 4, 5, 6, 8, 9, 12, 20, 33, 100]) { | |
| 69 | + ts.barSpacing = bs | |
| 70 | + const w = ts.bodyWidth() | |
| 71 | + assert.ok(w >= 1, `bs=${bs}`) | |
| 72 | + assert.ok(w % 2 === 1, `bs=${bs} w=${w} must be odd`) | |
| 73 | + if (bs >= 2) assert.ok(w <= bs - 1, `bs=${bs} w=${w} must leave ≥ 1 px`) | |
| 74 | + } | |
| 75 | + ts.barSpacing = 1.5 | |
| 76 | + assert.ok(ts.isCompressed) | |
| 77 | +}) | |
| 78 | + | |
| 79 | +test('price scale: linear auto-range applies 8 % margins', () => { | |
| 80 | + const ps = new PriceScale() | |
| 81 | + ps.height = 100 | |
| 82 | + ps.setAutoRange(100, 200) | |
| 83 | + near(ps.y(200), 8, 1e-6) | |
| 84 | + near(ps.y(100), 92, 1e-6) | |
| 85 | + near(ps.priceAt(ps.y(150)), 150, 1e-9) | |
| 86 | + ps.invert = true | |
| 87 | + near(ps.y(200), 92, 1e-6) | |
| 88 | +}) | |
| 89 | + | |
| 90 | +test('price scale: log and percent modes round-trip', () => { | |
| 91 | + const ps = new PriceScale({ mode: 'log' }) | |
| 92 | + ps.height = 300 | |
| 93 | + ps.setAutoRange(10, 1000) | |
| 94 | + near(ps.priceAt(ps.y(100)), 100, 1e-6) | |
| 95 | + assert.ok(ps.y(10) > ps.y(100) && ps.y(100) > ps.y(1000)) | |
| 96 | + // Equal ratios → equal pixel distances in log mode. | |
| 97 | + near(ps.y(10) - ps.y(100), ps.y(100) - ps.y(1000), 1e-6) | |
| 98 | + const pct = new PriceScale({ mode: 'percent' }) | |
| 99 | + pct.height = 200; pct.base = 100 | |
| 100 | + near(pct.toInternal(110), 10) | |
| 101 | + near(pct.fromInternal(-25), 75) | |
| 102 | + pct.setAutoRange(90, 120) | |
| 103 | + near(pct.priceAt(pct.y(105)), 105, 1e-9) | |
| 104 | +}) | |
| 105 | + | |
| 106 | +test('price scale: setMode keeps the visible price window', () => { | |
| 107 | + const ps = new PriceScale() | |
| 108 | + ps.height = 100 | |
| 109 | + ps.setAutoRange(50, 150) | |
| 110 | + const lo = ps.priceAt(100), hi = ps.priceAt(0) | |
| 111 | + ps.setMode('log') | |
| 112 | + near(ps.priceAt(100), lo, 1e-6); near(ps.priceAt(0), hi, 1e-6) | |
| 113 | +}) | |
| 114 | + | |
| 115 | +test('price scale: manual stretch disables auto, animation converges', () => { | |
| 116 | + const ps = new PriceScale() | |
| 117 | + ps.height = 100 | |
| 118 | + ps.setAutoRange(0, 100) | |
| 119 | + ps.stretch(2, 50) | |
| 120 | + assert.equal(ps.auto, false) | |
| 121 | + near(ps.hi - ps.lo, (100 / 0.84) / 2, 1e-6) | |
| 122 | + ps.auto = true | |
| 123 | + ps.setAutoRange(0, 200) | |
| 124 | + let n = 0 | |
| 125 | + while (ps.step(16, false) && n < 200) n++ | |
| 126 | + near(ps.hi, ps.targetHi, 1e-9) | |
| 127 | + assert.ok(n > 3, 'should take several frames') | |
| 128 | + const rm = new PriceScale(); rm.height = 100; rm.setAutoRange(0, 1); rm.setAutoRange(0, 100) | |
| 129 | + assert.equal(rm.step(16, true), false) | |
| 130 | + near(rm.hi, rm.targetHi) | |
| 131 | +}) | |
| 132 | + | |
| 133 | +test('nice numbers: 1-2-5 progression and decimals', () => { | |
| 134 | + near(niceStep(0.3), 0.5); near(niceStep(7), 10); near(niceStep(1.5), 2); near(niceStep(2.2), 2.5); near(niceStep(0.011), 0.02); near(niceStep(30), 50) | |
| 135 | + assert.equal(decimalsForStep(0.25), 2); assert.equal(decimalsForStep(5), 0); assert.equal(decimalsForStep(0.001), 3); assert.equal(decimalsForStep(2.5), 1) | |
| 136 | +}) | |
| 137 | + | |
| 138 | +test('linear ticks: spacing ≥ minPx, values on nice multiples', () => { | |
| 139 | + const r = linearTicks(0, 100, 400, 44) | |
| 140 | + assert.deepEqual(r.ticks, [0, 20, 40, 60, 80, 100]) | |
| 141 | + assert.equal(r.decimals, 0) | |
| 142 | + const r2 = linearTicks(99.13, 99.87, 300, 44) | |
| 143 | + assert.ok(r2.ticks.length >= 4 && r2.ticks.length <= 8) | |
| 144 | + for (const t of r2.ticks) near(t / r2.step, Math.round(t / r2.step), 1e-6) | |
| 145 | + assert.equal(r2.decimals, 1) | |
| 146 | +}) | |
| 147 | + | |
| 148 | +test('log ticks: mantissas thin out with the zoom level', () => { | |
| 149 | + const r = logTicks(0, 3, 300, 44) | |
| 150 | + assert.deepEqual(r.ticks, [1, 3, 10, 30, 100, 300, 1000]) | |
| 151 | + const dense = logTicks(Math.log10(100), Math.log10(101), 300, 44) | |
| 152 | + assert.ok(dense.ticks.length >= 3) | |
| 153 | + for (const t of dense.ticks) assert.ok(t >= 100 && t <= 101) | |
| 154 | +}) | |
| 155 | + | |
| 156 | +test('formatting: compact, percent, price', () => { | |
| 157 | + assert.equal(formatCompact(999), '999'); assert.equal(formatCompact(1234), '1.23K'); assert.equal(formatCompact(1_500_000), '1.50M'); assert.equal(formatCompact(2.5e9), '2.50B'); assert.equal(formatCompact(12_345_678), '12.3M') | |
| 158 | + assert.equal(formatPercent(1.234), '+1.23%'); assert.equal(formatPercent(-0.5), '−0.50%'); assert.equal(formatPercent(0), '0.00%') | |
| 159 | + assert.equal(formatPrice(1234.5, 2), '1,234.50'); assert.equal(formatPrice(null, 2), '—') | |
| 160 | +}) | |
| 161 | + | |
| 162 | +test('auto decimals: inferred from data, never invented', () => { | |
| 163 | + const mk = p => [{ t: 0, o: p, h: p + 0.5, l: p - 0.5, c: p }] | |
| 164 | + assert.equal(autoDecimals(mk(150.25)), 2) | |
| 165 | + assert.equal(autoDecimals([{ t: 0, o: 0.5123, h: 0.52, l: 0.5, c: 0.5111 }]), 4) | |
| 166 | + assert.equal(autoDecimals(mk(150), 0.25), 2) | |
| 167 | + assert.equal(autoDecimals(mk(0.001234)), 6) | |
| 168 | + assert.equal(autoDecimals([]), 2) | |
| 169 | +}) | |
| 170 | + | |
| 171 | +test('time ticks: hierarchical labels without collisions', () => { | |
| 172 | + // 1-minute bars: 3 sessions of 390 bars starting 09:30, weekdays. | |
| 173 | + const times = [] | |
| 174 | + const day0 = Date.UTC(2024, 2, 27) | |
| 175 | + for (let d = 0; d < 3; d++) for (let k = 0; k < 390; k++) times.push(day0 + d * 86_400_000 + 9.5 * 3_600_000 + k * 60_000) | |
| 176 | + const width = 900 | |
| 177 | + const bs = width / times.length | |
| 178 | + const xOf = i => i * bs + bs / 2 | |
| 179 | + const measure = text => text.length * 7 | |
| 180 | + const ticks = timeTicks({ times, from: 0, to: times.length - 1, tf: '1min', xOf, width, measure, gap: 14 }) | |
| 181 | + assert.ok(ticks.length >= 3) | |
| 182 | + for (let i = 1; i < ticks.length; i++) { | |
| 183 | + assert.ok(ticks[i].index > ticks[i - 1].index, 'sorted') | |
| 184 | + assert.ok(ticks[i].x0 >= ticks[i - 1].x1, `collision between ${ticks[i - 1].text} and ${ticks[i].text}`) | |
| 185 | + } | |
| 186 | + const days = ticks.filter(t => t.strong) | |
| 187 | + assert.equal(days.length, 3) | |
| 188 | + assert.deepEqual(days.map(t => t.text), ['27', '28', '29']) | |
| 189 | + // Higher levels win when space is scarce. | |
| 190 | + const tight = timeTicks({ times, from: 0, to: times.length - 1, tf: '1min', xOf, width, measure: () => 120, gap: 14 }) | |
| 191 | + assert.ok(tight.length <= 7) | |
| 192 | + assert.equal(tight.filter(t => t.level >= 4).length, 3, 'day changes always survive the thinning') | |
| 193 | + assert.ok(tight.every(t => t.level >= 2), 'only hour-level marks remain') | |
| 194 | +}) | |
modified
hfmarketdata/web/src/charts/engine/scales/ticks.js
+8 −6
@@ -26,19 +26,21 @@ export function logTicks(lo, hi, pixels, minPx = 44) { | ||
| 26 | 26 | if (!(hi > lo) || !(pixels > 0)) return { ticks: [], decimals: 2 } |
| 27 | 27 | const pxPerUnit = pixels / (hi - lo) |
| 28 | 28 | const minGap = minPx / pxPerUnit // in log10 units |
| 29 | − const mantissas = minGap > 0.9 ? [1] : minGap > 0.5 ? [1, 3] : minGap > 0.25 ? [1, 2, 5] : minGap > 0.1 ? [1, 1.5, 2, 3, 5, 7] : null | |
| 29 | + // Mantissa sets whose smallest log-gap is ≥ minGap (no ad-hoc thinning → regular, predictable ticks). | |
| 30 | + const SETS = [[0.146, [1, 1.5, 2, 3, 5, 7]], [0.301, [1, 2, 5]], [0.477, [1, 3]], [1, [1]]] | |
| 31 | + let mantissas = null | |
| 32 | + let decadeStride = 1 | |
| 33 | + if (minGap <= 1) { for (const [g, set] of SETS) if (minGap <= g) { mantissas = set; break } } else { mantissas = [1]; decadeStride = Math.ceil(minGap) } | |
| 30 | 34 | const ticks = [] |
| 31 | − if (mantissas) { | |
| 35 | + if (mantissas && minGap > 0.146 * 0.5) { | |
| 32 | 36 | const e0 = Math.floor(lo) - 1 |
| 33 | 37 | const e1 = Math.ceil(hi) + 1 |
| 34 | − let last = -Infinity | |
| 35 | 38 | for (let e = e0; e <= e1; e++) { |
| 39 | + if ((e - e0) % decadeStride !== 0) continue | |
| 36 | 40 | for (const m of mantissas) { |
| 37 | 41 | const v = Math.log10(m) + e |
| 38 | 42 | if (v < lo || v > hi) continue |
| 39 | − if (v - last < minGap * 0.999) continue | |
| 40 | 43 | ticks.push(Math.pow(10, e) * m) |
| 41 | − last = v | |
| 42 | 44 | } |
| 43 | 45 | } |
| 44 | 46 | let decimals = 0 |
@@ -53,7 +55,7 @@ export function logTicks(lo, hi, pixels, minPx = 44) { | ||
| 53 | 55 | |
| 54 | 56 | function roundTo(v, decimals) { |
| 55 | 57 | const f = Math.pow(10, decimals) |
| 56 | − return Math.round(v * f) / f | |
| 58 | + return Math.round(v * f) / f + 0 // "+ 0" normalizes -0 | |
| 57 | 59 | } |
| 58 | 60 | |
| 59 | 61 | /** |
added
hfmarketdata/web/src/charts/indicators/indicators.test.js
+126 −0
@@ -0,0 +1,126 @@ | ||
| 1 | +import { test } from 'node:test' | |
| 2 | +import assert from 'node:assert/strict' | |
| 3 | +import { sma, ema, wma, vwap, bollinger, keltner, donchian, supertrend, ichimoku, rsi, macd, stoch, atr, adx, cci, obv, mfi, volumeMa, heikinAshi, INDICATOR_TYPES, computeIndicator, indicatorParams, REGISTRY } from './index.js' | |
| 4 | + | |
| 5 | +const near = (a, b, eps = 1e-6) => assert.ok(a != null && Math.abs(a - b) <= eps, `${a} ≉ ${b}`) | |
| 6 | +const nearArr = (arr, ref, eps = 1e-6) => { | |
| 7 | + assert.equal(arr.length, ref.length) | |
| 8 | + arr.forEach((v, i) => { if (ref[i] == null) assert.equal(v, null, `index ${i} should be null`); else near(v, ref[i], eps) }) | |
| 9 | +} | |
| 10 | +const closes = cs => cs.map((c, i) => ({ t: i * 60_000, o: c, h: c + 1, l: c - 1, c, v: 100 })) | |
| 11 | + | |
| 12 | +// Three hand-computed bars used by the range/volume indicators (bar 2 is on the next calendar day). | |
| 13 | +const HLC = [ | |
| 14 | + { t: Date.UTC(2024, 0, 2, 10, 0), o: 9, h: 10, l: 8, c: 9, v: 100 }, | |
| 15 | + { t: Date.UTC(2024, 0, 2, 10, 1), o: 9, h: 11, l: 9, c: 10, v: 200 }, | |
| 16 | + { t: Date.UTC(2024, 0, 3, 9, 30), o: 10, h: 13, l: 10, c: 12, v: 300 }, | |
| 17 | +] | |
| 18 | + | |
| 19 | +test('sma / ema / wma against hand-computed values (null until the window is full)', () => { | |
| 20 | + const bars = closes([1, 2, 3, 4, 5]) | |
| 21 | + nearArr(sma(bars, { length: 3 }).sma, [null, null, 2, 3, 4]) | |
| 22 | + nearArr(ema(bars, { length: 3 }).ema, [null, null, 2, 3, 4]) | |
| 23 | + nearArr(wma(bars, { length: 3 }).wma, [null, null, 14 / 6, 20 / 6, 26 / 6]) | |
| 24 | + assert.deepEqual(sma(closes([1, 2]), { length: 5 }).sma, [null, null]) | |
| 25 | +}) | |
| 26 | + | |
| 27 | +test('vwap resets at the session (day) change', () => { | |
| 28 | + nearArr(vwap(HLC).vwap, [9, (900 + 2000) / 300, 35 / 3]) | |
| 29 | + nearArr(vwap(HLC, { anchor: 'all' }).vwap, [9, (900 + 2000) / 300, (900 + 2000 + 3500) / 600]) | |
| 30 | + const noVol = [{ t: 0, o: 1, h: 1, l: 1, c: 1 }] | |
| 31 | + assert.deepEqual(vwap(noVol).vwap, [null]) | |
| 32 | +}) | |
| 33 | + | |
| 34 | +test('bollinger uses the population standard deviation', () => { | |
| 35 | + const r = bollinger(closes([1, 3, 5]), { length: 2, mult: 2 }) | |
| 36 | + nearArr(r.middle, [null, 2, 4]); nearArr(r.upper, [null, 4, 6]); nearArr(r.lower, [null, 0, 2]) | |
| 37 | +}) | |
| 38 | + | |
| 39 | +test('donchian / keltner channels', () => { | |
| 40 | + const r = donchian(HLC, { length: 2 }) | |
| 41 | + nearArr(r.upper, [null, 11, 13]); nearArr(r.lower, [null, 8, 9]); nearArr(r.middle, [null, 9.5, 11]) | |
| 42 | + const k = keltner(HLC, { length: 2, mult: 1, atrLength: 2 }) | |
| 43 | + // EMA(2) of close: seed (9+10)/2 = 9.5, then 12*2/3 + 9.5/3 = 11.1667 ; ATR(2): [null, 2, 2.5] | |
| 44 | + nearArr(k.middle, [null, 9.5, 12 * 2 / 3 + 9.5 / 3]) | |
| 45 | + nearArr(k.upper, [null, 11.5, 12 * 2 / 3 + 9.5 / 3 + 2.5]) | |
| 46 | +}) | |
| 47 | + | |
| 48 | +test('atr (Wilder) against hand-computed true ranges', () => { | |
| 49 | + nearArr(atr(HLC, { length: 2 }).atr, [null, 2, 2.5]) | |
| 50 | +}) | |
| 51 | + | |
| 52 | +test('rsi with a 2-period Wilder smoothing', () => { | |
| 53 | + nearArr(rsi(closes([10, 11, 12, 11, 12]), { length: 2 }).rsi, [null, null, 100, 50, 75]) | |
| 54 | + const flat = rsi(closes([5, 5, 5, 5]), { length: 2 }).rsi | |
| 55 | + assert.equal(flat[3], 100) // no losses → 100 (same convention as Pine's ta.rsi) | |
| 56 | +}) | |
| 57 | + | |
| 58 | +test('macd line, signal and histogram', () => { | |
| 59 | + const r = macd(closes([1, 2, 3, 4]), { fast: 1, slow: 2, signal: 1 }) | |
| 60 | + nearArr(r.macd, [null, 0.5, 0.5, 0.5]); nearArr(r.signal, [null, 0.5, 0.5, 0.5]); nearArr(r.hist, [null, 0, 0, 0]) | |
| 61 | + const d = macd(closes(Array.from({ length: 60 }, (_, i) => 100 + Math.sin(i / 5) * 10))) | |
| 62 | + assert.equal(d.macd[24], null); assert.notEqual(d.macd[25], null); assert.equal(d.signal[32], null); assert.notEqual(d.signal[33], null) | |
| 63 | +}) | |
| 64 | + | |
| 65 | +test('stochastic %K / %D', () => { | |
| 66 | + const r = stoch(HLC, { k: 2, d: 1, smooth: 1 }) | |
| 67 | + nearArr(r.k, [null, 200 / 3, 75]); nearArr(r.d, [null, 200 / 3, 75]) | |
| 68 | +}) | |
| 69 | + | |
| 70 | +test('obv / mfi / cci / volume-ma', () => { | |
| 71 | + assert.deepEqual(obv(HLC).obv, [0, 200, 500]) | |
| 72 | + nearArr(mfi(HLC, { length: 1 }).mfi, [null, 100, 100]) | |
| 73 | + nearArr(cci(HLC, { length: 2 }).cci, [null, (10 - 9.5) / (0.015 * 0.5), (35 / 3 - (10 + 35 / 3) / 2) / (0.015 * ((35 / 3 - 10) / 2))]) | |
| 74 | + const vm = volumeMa(HLC, { length: 2 }) | |
| 75 | + nearArr(vm.volume, [100, 200, 300]); nearArr(vm.ma, [null, 150, 250]) | |
| 76 | +}) | |
| 77 | + | |
| 78 | +test('heikin-ashi transform', () => { | |
| 79 | + const ha = heikinAshi([{ t: 0, o: 10, h: 12, l: 8, c: 11, v: 5 }, { t: 1, o: 11, h: 13, l: 10, c: 12, v: 6 }]) | |
| 80 | + near(ha[0].c, 10.25); near(ha[0].o, 10.5); near(ha[0].h, 12); near(ha[0].l, 8) | |
| 81 | + near(ha[1].o, (10.5 + 10.25) / 2); near(ha[1].c, (11 + 13 + 10 + 12) / 4) | |
| 82 | + assert.equal(ha[1].v, 6); assert.equal(ha[1].t, 1) | |
| 83 | +}) | |
| 84 | + | |
| 85 | +test('supertrend / adx / ichimoku shapes and invariants', () => { | |
| 86 | + const bars = [] | |
| 87 | + let p = 100 | |
| 88 | + for (let i = 0; i < 120; i++) { p += Math.sin(i / 7) * 2 + 0.1; bars.push({ t: i * 60_000, o: p - 0.5, h: p + 1.5, l: p - 1.5, c: p, v: 100 }) } | |
| 89 | + const st = supertrend(bars, { length: 10, mult: 3 }) | |
| 90 | + assert.equal(st.supertrend.length, 120) | |
| 91 | + assert.equal(st.supertrend[8], null); assert.notEqual(st.supertrend[9], null) | |
| 92 | + for (let i = 9; i < 120; i++) { | |
| 93 | + assert.ok(st.direction[i] === 1 || st.direction[i] === -1) | |
| 94 | + if (st.direction[i] === 1) assert.ok(st.supertrend[i] <= bars[i].c + 1e-9, 'bullish band sits under the close') | |
| 95 | + else assert.ok(st.supertrend[i] >= bars[i].c - 1e-9, 'bearish band sits over the close') | |
| 96 | + } | |
| 97 | + const a = adx(bars, { length: 14 }) | |
| 98 | + assert.equal(a.adx[26], null); assert.notEqual(a.adx[27], null) | |
| 99 | + for (const v of a.adx) if (v != null) assert.ok(v >= 0 && v <= 100) | |
| 100 | + const ich = ichimoku(bars, { conversion: 9, base: 26, spanB: 52, displacement: 26 }) | |
| 101 | + assert.equal(ich.senkouA.length, 146); assert.equal(ich.tenkan.length, 120) | |
| 102 | + let hi = -Infinity, lo = Infinity | |
| 103 | + for (let i = 0; i < 9; i++) { hi = Math.max(hi, bars[i].h); lo = Math.min(lo, bars[i].l) } | |
| 104 | + near(ich.tenkan[8], (hi + lo) / 2); assert.equal(ich.tenkan[7], null) | |
| 105 | + near(ich.senkouA[26 + 25], (ich.tenkan[25] + ich.kijun[25]) / 2) | |
| 106 | + assert.equal(ich.chikou[119], null); near(ich.chikou[0], bars[26].c) | |
| 107 | +}) | |
| 108 | + | |
| 109 | +test('registry: every type computes with defaults and emits the declared plot keys', () => { | |
| 110 | + const bars = closes(Array.from({ length: 80 }, (_, i) => 100 + Math.sin(i / 6) * 5)) | |
| 111 | + for (const type of INDICATOR_TYPES) { | |
| 112 | + const spec = REGISTRY[type] | |
| 113 | + const values = computeIndicator(type, bars, {}) | |
| 114 | + for (const plot of spec.plots) { | |
| 115 | + const keys = plot.kind === 'band' ? [plot.upper, plot.lower] : plot.kind === 'cloud' ? [plot.a, plot.b] : [plot.key] | |
| 116 | + for (const k of keys) { | |
| 117 | + assert.ok(Array.isArray(values[k]), `${type}.${k} missing`) | |
| 118 | + assert.ok(values[k].length >= bars.length, `${type}.${k} too short`) | |
| 119 | + for (const v of values[k]) assert.ok(v === null || Number.isFinite(v), `${type}.${k} has ${v}`) | |
| 120 | + } | |
| 121 | + } | |
| 122 | + assert.ok(typeof spec.title(indicatorParams(type, {})) === 'string') | |
| 123 | + } | |
| 124 | + assert.deepEqual(indicatorParams('macd', { fast: 5 }), { fast: 5, slow: 26, signal: 9, source: 'close' }) | |
| 125 | + assert.throws(() => computeIndicator('nope', bars, {})) | |
| 126 | +}) | |
| 127 | ||