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

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

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