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: +41 indicateurs (HMA/DEMA/TEMA/KAMA/ALMA/LSMA+canal/enveloppe/SAR/ZigZag/pivots/AVWAP/bandes VWAP/profil de volume/auto-Fib, 22 oscillateurs, 7 volume), registre typé (inputs/plots/levels/zones/precision/category) + listIndicators(), plots step/connectGaps/circles/hlevels/profile/delta, indicateurs dynamiques (plage visible), échelle propre gauche/cachée, panneau partagé, setIndicatorStyle, tests de référence (61)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 18 days ago (Sep 7, 2026) parent 62735b4

15 changed files +1,625 −149

modified hfmarketdata/web/dev/charts-harness.js +27 −3
@@ -4,7 +4,7 @@
4 4 // URL params: ?tf=1min|1day&n=50000&theme=dark|light&type=candles&ind=rsi,macd&mode=log|percent&compare=1
5 5 // &drawings=1&volume=0&watermark=…&reduced=1
6 6
7 −import { createChart, darkTheme, lightTheme, SERIES_TYPES, INDICATOR_TYPES, DRAWING_TOOLS } from '../src/charts/engine/index.js'
7 +import { createChart, darkTheme, lightTheme, SERIES_TYPES, INDICATOR_TYPES, INDICATOR_CATEGORIES, listIndicators, DRAWING_TOOLS } from '../src/charts/engine/index.js'
8 8
9 9 /* ───────────── synthetic data ───────────── */
10 10
@@ -136,7 +136,20 @@ btn(g, 'Invert', () => chart.setPriceScale({ invert: !chart.getPriceScale().inve
136 136 sel(g, ['normal', 'magnet', 'hidden'], state.crosshair, v => chart.setCrosshair({ mode: v }))
137 137
138 138 g = group()
139 −const indSel = sel(g, ['+ indicator', ...INDICATOR_TYPES], '+ indicator', v => { if (v !== '+ indicator') { chart.addIndicator({ type: v }); indSel.value = '+ indicator'; updateIndicatorList() } })
139 +// Indicator library grouped by category (from listIndicators()).
140 +const indSel = document.createElement('select')
141 +{
142 + const first = document.createElement('option'); first.value = '+ indicator'; first.textContent = `+ indicator (${INDICATOR_TYPES.length})`; indSel.appendChild(first)
143 + const lib = listIndicators()
144 + for (const cat of INDICATOR_CATEGORIES) {
145 + const og = document.createElement('optgroup'); og.label = cat
146 + for (const it of lib.filter(x => x.category === cat)) { const op = document.createElement('option'); op.value = it.id; op.textContent = `${it.name}${it.pane === 'main' ? '' : ' ▾'}`; og.appendChild(op) }
147 + indSel.appendChild(og)
148 + }
149 + indSel.value = '+ indicator'
150 + indSel.addEventListener('change', () => { if (indSel.value !== '+ indicator') { chart.addIndicator({ type: indSel.value }); indSel.value = '+ indicator'; updateIndicatorList() } })
151 + g.appendChild(indSel)
152 +}
140 153 btn(g, 'Clear ind.', () => { for (const i of chart.getIndicators()) chart.removeIndicator(i.id); updateIndicatorList() })
141 154 const cmpBtn = btn(g, 'Compare', () => {
142 155 if (chart._cmp) { chart.removeCompare('MSFT'); chart.removeCompare('SPY'); chart._cmp = false }
@@ -216,7 +229,18 @@ status(`${bars.length.toLocaleString()} bars · ${state.tf}`)
216 229
217 230 /* ───────────── URL-driven setup for screenshots ───────────── */
218 231
219 −for (const t of (q.get('ind') || '').split(',').filter(Boolean)) chart.addIndicator({ type: t })
232 +// ?ind=sma,rsi · ?ind=sma:50 (length) · ?ind=obv@left (own left scale) · ?ind=rsi,stoch@pane (share the previous pane)
233 +let lastPaneId = null
234 +for (const spec of (q.get('ind') || '').split(',').filter(Boolean)) {
235 + const [typeAndLen, flag] = spec.split('@')
236 + const [type, length] = typeAndLen.split(':')
237 + const opts = { type, params: length ? { length: Number(length) } : undefined }
238 + if (flag === 'left' || flag === 'hidden') opts.scale = flag
239 + if (flag === 'pane' && lastPaneId) opts.pane = lastPaneId
240 + const id = chart.addIndicator(opts)
241 + const info = chart.getIndicators().find(i => i.id === id)
242 + if (info && info.pane !== 'main') lastPaneId = info.pane
243 +}
220 244 if (q.get('compare') === '1') cmpBtn.click()
221 245 if (q.get('drawings') === '1') {
222 246 const data = chart.getData()
modified hfmarketdata/web/scripts/charts-shots.mjs +5 −0
@@ -31,6 +31,11 @@ const SHOTS = [
31 31 { name: 'desktop-ohlc-supertrend-light', q: 'tf=1min&n=50000&type=ohlc&ind=supertrend,vwap,stoch&theme=light' },
32 32 { name: 'desktop-baseline-heikin', q: 'tf=1day&n=10000&type=heikin&ind=ema,sma' },
33 33 { name: 'desktop-200k-zoomed-out', q: 'tf=1min&n=200000&min=0.005', zoomOut: 40 },
34 + { name: 'desktop-v2-overlays', q: 'tf=1min&n=50000&ind=pivots,psar,zigzag,volume-profile,auto-fib' },
35 + { name: 'desktop-v2-oscillators', q: 'tf=1day&n=10000&ind=hma,lsma,stochrsi,ao,elder-ray' },
36 + { name: 'desktop-v2-left-scale', q: 'tf=1day&n=10000&ind=obv@left,rsi,stoch@pane,cmf' },
37 + { name: 'desktop-v2-drawings', q: 'tf=1min&n=50000&drawings=2' },
38 + { name: 'desktop-v2-features', q: 'tf=1min&n=50000&features=1' },
34 39 { name: 'mobile-1min-candles', q: 'tf=1min&n=50000&ind=rsi', mobile: true },
35 40 { name: 'mobile-1day-light', q: 'tf=1day&n=10000&theme=light&type=area', mobile: true },
36 41 ]
modified hfmarketdata/web/src/charts/engine/core/chart.js +232 −42
@@ -6,7 +6,8 @@ import { BarStore } from '../data/store.js'
6 6 import { TimeScale } from '../scales/time-scale.js'
7 7 import { timeTicks as buildTimeTicks } from '../scales/ticks.js'
8 8 import { Pane, COLLAPSED_H, MIN_PANE_H } from '../panes/pane.js'
9 −import { createLayer, crisp, withAlpha, hair, lw } from '../render/canvas.js'
9 +import { PriceScale } from '../scales/price-scale.js'
10 +import { createLayer, crisp, withAlpha, hair, lw, snap } from '../render/canvas.js'
10 11 import { font, measure, FONT_SIZE } from '../render/text.js'
11 12 import { drawSeries, drawVolume } from '../render/series.js'
12 13 import { drawGrid, drawSessionBreaks, drawPriceAxis, drawTimeAxis, measureAxisWidth, TIME_AXIS_H, MIN_AXIS_W } from '../render/axes.js'
@@ -15,7 +16,7 @@ import { drawCrosshair, drawAxisLabel, drawTimeLabel, drawLastPrice, drawHiLoMar
15 16 import { normalizeTheme, darkTheme } from '../theme.js'
16 17 import { autoDecimals, formatPrice, formatCompact, formatPercent, clamp } from '../format/number.js'
17 18 import { fmtFull, isIntraday, isDayChange } from '../format/time.js'
18 −import { indicatorSpec, indicatorParams, computeIndicator } from '../../indicators/index.js'
19 +import { indicatorSpec, indicatorParams, computeIndicator, plotKeys } from '../../indicators/index.js'
19 20 import { heikinAshi } from '../../indicators/heikin-ashi.js'
20 21 import { DrawingManager } from '../drawings/manager.js'
21 22 import { attachInteractions } from '../interactions/pointer.js'
@@ -41,6 +42,7 @@ const DEFAULTS = {
41 42 }
42 43
43 44 const PULSE_MS = 650
45 +const LABEL_PAD = 18
44 46 const SERIES_TYPES = new Set(['candles', 'hollow', 'ohlc', 'line', 'area', 'baseline', 'heikin', 'columns', 'hlc'])
45 47 let idSeq = 0
46 48
@@ -64,6 +66,15 @@ export class Chart {
64 66 this._modeBeforeCompare = null
65 67 this.decimals = 2
66 68 this.axisWidth = MIN_AXIS_W
69 + this.leftAxisWidth = 0 // > 0 when an overlay indicator uses scale: 'left'
70 + this._hasDynamic = false
71 + this._dynamicKey = null
72 + this.gridOpts = { v: true, h: true }
73 + this.timeAxisVisible = true
74 + this.markers = [] // v2: series markers [{ t, position, shape, color, text, size }]
75 + this.priceLines = new Map() // v2: custom horizontal price lines
76 + this.replayIndex = null // v2: bar replay — bars after this index are hidden
77 + this.sessions = null // v2: { regular: [startHour, endHour], shade: true }
67 78 this.width = 0; this.height = 0; this.dpr = 1
68 79 this.dirty = { layout: true, data: true, overlay: true }
69 80 this._raf = 0
@@ -116,8 +127,10 @@ export class Chart {
116 127 this.invalidate('data')
117 128 }
118 129
130 + get timeAxisH() { return this.timeAxisVisible ? TIME_AXIS_H : 0 }
131 +
119 132 _layout() {
120 − const total = this.height - TIME_AXIS_H
133 + const total = this.height - this.timeAxisH
121 134 let free = total
122 135 let weight = 0
123 136 for (const p of this.panes) { if (p.collapsed) free -= COLLAPSED_H; else weight += p.weight }
@@ -140,29 +153,35 @@ export class Chart {
140 153 this.timeAxis.top = total
141 154 this.timeAxis.el.style.top = total + 'px'
142 155 this.timeAxis.el.style.width = this.width + 'px'
143 − this.timeAxis.el.style.height = TIME_AXIS_H + 'px'
144 − this.timeAxis.main.resize(this.width, TIME_AXIS_H, this.dpr)
145 − this.timeAxis.overlay.resize(this.width, TIME_AXIS_H, this.dpr)
156 + this.timeAxis.el.style.height = this.timeAxisH + 'px'
157 + this.timeAxis.height = this.timeAxisH
158 + this.timeAxis.main.resize(this.width, Math.max(1, this.timeAxisH), this.dpr)
159 + this.timeAxis.overlay.resize(this.width, Math.max(1, this.timeAxisH), this.dpr)
146 160 this.dirty.layout = false
147 161 }
148 162
149 − get plotWidth() { return Math.max(10, this.width - this.axisWidth) }
163 + /** Left edge of the plot area (0 unless a left price axis is shown). */
164 + get plotX0() { return this.leftAxisWidth }
165 + get plotWidth() { return Math.max(10, this.width - this.axisWidth - this.leftAxisWidth) }
150 166
151 167 /** Which part of the chart is under (x, y) in chart-local pixels. */
152 168 hitRegion(x, y) {
153 169 if (x < 0 || y < 0 || x > this.width || y > this.height) return { region: 'outside' }
154 − if (y >= this.timeAxis.top) return { region: x >= this.plotWidth ? 'corner' : 'timeAxis', x, y: y - this.timeAxis.top }
170 + const x0 = this.plotX0
171 + const px = x - x0 // plot-relative x (what the time scale and drawings work with)
172 + if (y >= this.timeAxis.top) return { region: px >= this.plotWidth || px < 0 ? 'corner' : 'timeAxis', x: px, y: y - this.timeAxis.top }
155 173 for (let i = 0; i < this.panes.length; i++) {
156 174 const p = this.panes[i]
157 175 if (y >= p.top && y < p.top + p.height) {
158 176 const ly = y - p.top
159 − if (i > 0 && ly <= 4) return { region: 'separator', pane: p, index: i, x, y: ly }
160 − if (i < this.panes.length - 1 && ly >= p.height - 4) return { region: 'separator', pane: this.panes[i + 1], index: i + 1, x, y: ly }
177 + if (i > 0 && ly <= 4) return { region: 'separator', pane: p, index: i, x: px, y: ly }
178 + if (i < this.panes.length - 1 && ly >= p.height - 4) return { region: 'separator', pane: this.panes[i + 1], index: i + 1, x: px, y: ly }
161 179 if (p.kind !== 'main' && p.headerBoxes.length) {
162 − for (const b of p.headerBoxes) if (x >= b.x && x <= b.x + b.w && ly >= b.y && ly <= b.y + b.h) return { region: 'paneButton', pane: p, button: b.id, x, y: ly }
180 + for (const b of p.headerBoxes) if (x >= b.x && x <= b.x + b.w && ly >= b.y && ly <= b.y + b.h) return { region: 'paneButton', pane: p, button: b.id, x: px, y: ly }
163 181 }
164 − if (x >= this.plotWidth) return { region: 'priceAxis', pane: p, x, y: ly }
165 − return { region: 'plot', pane: p, x, y: ly }
182 + if (px < 0) return { region: 'leftAxis', pane: p, x: px, y: ly }
183 + if (px >= this.plotWidth) return { region: 'priceAxis', pane: p, x: px, y: ly }
184 + return { region: 'plot', pane: p, x: px, y: ly }
166 185 }
167 186 }
168 187 return { region: 'outside' }
@@ -190,6 +209,7 @@ export class Chart {
190 209 this.ts.width = this.plotWidth
191 210 this.ts.count = this.store.length
192 211 const vr = this.ts.visibleRange()
212 + this._syncDynamic(vr)
193 213 this._updateBases(vr)
194 214 // Scales: auto ranges (snap while dragging so the chart never lags the hand).
195 215 for (const p of this.panes) {
@@ -198,6 +218,12 @@ export class Chart {
198 218 const moving = p.scale.step(this.dragging ? 1e9 : dt, this.opts.reducedMotion || this.dragging)
199 219 if (moving) animating = true
200 220 }
221 + for (const ind of this.indicators.values()) {
222 + if (!ind.ownScale) continue
223 + ind.ownScale.height = this.mainPane.height
224 + if (ind.ownScale.auto) this._autoRangeOwn(ind, vr)
225 + if (ind.ownScale.step(this.dragging ? 1e9 : dt, this.opts.reducedMotion || this.dragging)) animating = true
226 + }
201 227 const needData = this.dirty.data || animating
202 228 if (needData) this._timeTicks = this._computeTimeTicks(vr)
203 229 if (needData) this._updateAxisWidth()
@@ -280,12 +306,11 @@ export class Chart {
280 306 }
281 307 for (const id of p.indicators) {
282 308 const ind = this.indicators.get(id)
283 − if (!ind || !ind.values) continue
309 + if (!ind || !ind.values || ind.scale === 'left' || ind.scale === 'hidden') continue
284 310 for (const plot of ind.spec.plots) {
285 − const keys = plot.kind === 'band' ? [plot.upper, plot.lower] : plot.kind === 'cloud' ? [plot.a, plot.b] : [plot.key]
286 − for (const key of keys) {
311 + for (const key of plotKeys(plot)) {
287 312 const arr = ind.values[key]
288 − if (!arr) continue
313 + if (!Array.isArray(arr)) continue
289 314 const end = Math.min(vr.to, arr.length - 1)
290 315 for (let i = vr.from; i <= end; i++) {
291 316 const v = arr[i]
@@ -302,6 +327,19 @@ export class Chart {
302 327 else ps.setAutoRange(lo, hi)
303 328 }
304 329
330 + /** Auto-range of an overlay indicator that owns its scale (scale 'left' | 'hidden'). */
331 + _autoRangeOwn(ind, vr) {
332 + if (!ind.values || vr.to < vr.from) return
333 + let lo = Infinity, hi = -Infinity
334 + for (const plot of ind.spec.plots) for (const key of plotKeys(plot)) {
335 + const arr = ind.values[key]
336 + if (!Array.isArray(arr)) continue
337 + const end = Math.min(vr.to, arr.length - 1)
338 + for (let i = vr.from; i <= end; i++) { const v = arr[i]; if (v == null || Number.isNaN(v)) continue; if (v < lo) lo = v; if (v > hi) hi = v }
339 + }
340 + if (Number.isFinite(lo) && Number.isFinite(hi)) ind.ownScale.setAutoRange(lo, hi)
341 + }
342 +
305 343 _baselineValue(vr) {
306 344 if (this.opts.baselineValue != null) return this.opts.baselineValue
307 345 const rs = this.renderStore
@@ -382,8 +420,9 @@ export class Chart {
382 420 }
383 421 const ticks = p._ticks || []
384 422 ctx.save()
423 + ctx.translate(this.plotX0, 0)
385 424 ctx.beginPath(); ctx.rect(0, 0, W, H); ctx.clip()
386 − drawGrid(ctx, { width: W, height: H, priceTicks: ticks, timeTicks: this._timeTicks || [], theme })
425 + drawGrid(ctx, { width: W, height: H, priceTicks: ticks, timeTicks: this._timeTicks || [], theme, showV: this.gridOpts.v, showH: this.gridOpts.h })
387 426 if (p.kind === 'main') {
388 427 if (isIntraday(this.opts.timeframe)) this._drawSessionBreaks(ctx, vr, H)
389 428 drawWatermark(ctx, { text: this.opts.watermark, width: W, height: H, theme })
@@ -399,9 +438,21 @@ export class Chart {
399 438 }
400 439 ctx.restore()
401 440 this._drawPriceAxisFor(p, ticks)
441 + if (this.leftAxisWidth) this._drawLeftAxisFor(p)
402 442 this._drawPaneBorder(p)
403 443 }
404 444
445 + /** Left price axis: ticks of the first visible overlay indicator with scale 'left' (main pane only). */
446 + _drawLeftAxisFor(p) {
447 + const ctx = p.main.ctx
448 + let ticks = []
449 + if (p.kind === 'main') {
450 + const ind = Array.from(this.indicators.values()).find(i => i.scale === 'left' && !i.hidden && i.ownScale)
451 + if (ind) ticks = ind.ownScale.ticks(p.height, ind.spec.precision ?? 2, this.opts.locale, 44, ind.spec.format === 'compact' ? v => formatCompact(v, this.opts.locale) : null).ticks
452 + }
453 + drawPriceAxis(ctx, { x0: 0, width: this.leftAxisWidth, height: p.height, ticks, theme: this.theme, side: 'left' })
454 + }
455 +
405 456 _drawPaneBorder(p) {
406 457 const ctx = p.main.ctx
407 458 if (p !== this.panes[0]) {
@@ -412,7 +463,7 @@ export class Chart {
412 463 }
413 464
414 465 _drawPriceAxisFor(p, ticks) {
415 − drawPriceAxis(p.main.ctx, { x0: this.plotWidth, width: this.axisWidth, height: p.height, ticks, theme: this.theme })
466 + drawPriceAxis(p.main.ctx, { x0: this.plotX0 + this.plotWidth, width: this.axisWidth, height: p.height, ticks, theme: this.theme })
416 467 }
417 468
418 469 _drawSessionBreaks(ctx, vr, H) {
@@ -428,6 +479,22 @@ export class Chart {
428 479 _drawLevels(ctx, p, W) {
429 480 const ind = this.indicators.get(p.indicators[0])
430 481 if (!ind || !ind.spec.levels) return
482 + // Zones (e.g. RSI 30–70): a faint hatched band between the two levels.
483 + if (ind.spec.zones) {
484 + for (const [a, b] of ind.spec.zones) {
485 + const y0 = p.scale.y(Math.max(a, b)), y1 = p.scale.y(Math.min(a, b))
486 + if (!(y1 > y0)) continue
487 + ctx.save()
488 + ctx.beginPath(); ctx.rect(0, y0, W, y1 - y0); ctx.clip()
489 + ctx.fillStyle = withAlpha(this.theme.axisText, 0.035); ctx.fillRect(0, y0, W, y1 - y0)
490 + ctx.strokeStyle = withAlpha(this.theme.axisText, 0.07); ctx.lineWidth = hair()
491 + ctx.beginPath()
492 + const step = 8
493 + for (let x = -(y1 - y0); x < W; x += step) { ctx.moveTo(x, y1); ctx.lineTo(x + (y1 - y0), y0) }
494 + ctx.stroke()
495 + ctx.restore()
496 + }
497 + }
431 498 ctx.strokeStyle = this.theme.gridStrong
432 499 ctx.lineWidth = hair()
433 500 ctx.setLineDash([4, 4])
@@ -437,17 +504,41 @@ export class Chart {
437 504 ctx.setLineDash([])
438 505 }
439 506
507 + /** Plots of an indicator with the instance's style overrides (lineWidth, per-key colors). */
508 + _plotsOf(ind) {
509 + if (!ind.lineWidth && !ind.styles) return ind.spec.plots
510 + if (ind._plotsCache && ind._plotsKey === `${ind.lineWidth}|${JSON.stringify(ind.styles || null)}`) return ind._plotsCache
511 + const plots = ind.spec.plots.map(p => {
512 + const o = { ...p }
513 + if (ind.lineWidth && (p.kind === 'line' || !p.kind)) o.width = ind.lineWidth
514 + const st = ind.styles && p.key && ind.styles[p.key]
515 + if (st) { if (st.color) o.color = st.color; if (st.width) o.width = st.width; if (st.style) o.style = st.style; if (st.dash) o.dash = st.dash; if (st.visible === false) o.hidden = true }
516 + return o
517 + }).filter(p => !p.hidden)
518 + ind._plotsCache = plots; ind._plotsKey = `${ind.lineWidth}|${JSON.stringify(ind.styles || null)}`
519 + return plots
520 + }
521 +
440 522 _drawIndicators(ctx, p, vr, W, H) {
441 − const ps = p.scale
442 523 // Indicator arrays may extend past the data (Ichimoku forward cloud): draw up to the right edge.
443 524 const to = Math.max(vr.to, Math.ceil(this.ts.indexAt(W)))
444 525 for (const id of p.indicators) {
445 526 const ind = this.indicators.get(id)
446 − if (!ind || !ind.values) continue
447 − drawPlots({ ctx, plots: ind.spec.plots, values: ind.values, colors: ind.colors, from: vr.from, to, ts: this.ts, yOf: v => ps.y(v), theme: this.theme, height: H, store: this.store })
527 + if (!ind || !ind.values || ind.hidden) continue
528 + const ps = ind.ownScale || p.scale
529 + drawPlots({ ctx, plots: this._plotsOf(ind), values: ind.values, colors: ind.colors, from: vr.from, to, ts: this.ts, yOf: v => ps.y(v), theme: this.theme, height: H, width: W, store: this.store, format: v => this._formatPane(p, v) })
448 530 }
449 531 }
450 532
533 + /** Recompute visible-range ("dynamic") indicators when the range they depend on changed. */
534 + _syncDynamic(vr) {
535 + if (!this._hasDynamic) return
536 + const key = `${vr.from}|${vr.to}|${this.store.version}`
537 + if (key === this._dynamicKey) return
538 + this._dynamicKey = key
539 + for (const ind of this.indicators.values()) if (ind.spec.dynamic) this._computeIndicator(ind, vr)
540 + }
541 +
451 542 _drawCompares(ctx, p, vr, W, H) {
452 543 if (!this.compares.size || vr.to < vr.from) return
453 544 const ps = p.scale
@@ -486,7 +577,8 @@ export class Chart {
486 577 _drawTimeAxis() {
487 578 const layer = this.timeAxis.main
488 579 layer.clear()
489 − drawTimeAxis(layer.ctx, { width: this.width, plotWidth: this.plotWidth, height: TIME_AXIS_H, ticks: this._timeTicks || [], theme: this.theme })
580 + if (!this.timeAxisVisible) return
581 + drawTimeAxis(layer.ctx, { width: this.width, plotWidth: this.plotWidth, plotX0: this.plotX0, height: TIME_AXIS_H, ticks: this._timeTicks || [], theme: this.theme })
490 582 }
491 583
492 584 /* ───────────────────────── drawing: overlay layer ───────────────────────── */
@@ -502,19 +594,22 @@ export class Chart {
502 594 }
503 595 const ptr = this.pointer
504 596 const hovered = ptr && ptr.pane === p
505 − // Main pane: last price, drawings.
597 + ctx.save(); ctx.translate(this.plotX0, 0)
598 + // Main pane: price lines, last price, markers, drawings.
506 599 if (p.kind === 'main' && this.store.length) {
507 − const n = this.store.length
508 − const c = this.store.c[n - 1], o = this.store.o[n - 1]
600 + const n = this.renderStore.length
601 + const c = this.renderStore.c[n - 1], o = this.renderStore.o[n - 1]
509 602 const y = p.scale.y(c)
510 603 const age = now - this._pulseAt
511 604 const pulse = age < PULSE_MS ? 1 - age / PULSE_MS : 0
512 − ctx.save(); ctx.beginPath(); ctx.rect(0, 0, this.width, H); ctx.clip()
605 + ctx.save(); ctx.beginPath(); ctx.rect(0, 0, W + this.axisWidth, H); ctx.clip()
606 + this._drawPriceLines(ctx, p, W, H)
513 607 drawLastPrice(ctx, { y, text: this._formatMain(c), plotWidth: W, axisWidth: this.axisWidth, height: H, up: c >= o, theme, pulse })
514 608 ctx.restore()
515 − // Drawings clip themselves to the plot; their axis labels (hline price) may use the axis strip.
516 − this.drawings.draw(ctx, { width: W, height: H, ts: this.ts, ps: p.scale, store: this.store, theme, pointer: hovered ? ptr : null })
609 + if (this.markers.length) { ctx.save(); ctx.beginPath(); ctx.rect(0, 0, W, H); ctx.clip(); this._drawMarkers(ctx, p, vr, W, H); ctx.restore() }
517 610 }
611 + // Drawings (any pane): they clip themselves to the plot; axis labels (hline price) may use the axis strip.
612 + this.drawings.draw(ctx, { width: W, height: H, ts: this.ts, ps: p.scale, store: this.store, theme, pointer: hovered ? ptr : null, pane: p })
518 613 // Crosshair.
519 614 if (ptr && this.crosshairOpts.mode !== 'hidden' && ptr.region === 'plot') {
520 615 const x = this._crosshairX()
@@ -531,9 +626,56 @@ export class Chart {
531 626 if (p.kind !== 'main') {
532 627 this._drawIndicatorLastValues(ctx, p, W, H)
533 628 p.headerBoxes = drawPaneHeader(ctx, { title: p.title, plotWidth: W, theme, hover: this._headerHover(p), collapsed: false })
629 + for (const b of p.headerBoxes) b.x += this.plotX0
630 + }
631 + ctx.restore()
632 + }
633 +
634 + /** v2: custom horizontal price lines (main pane) with an axis label. */
635 + _drawPriceLines(ctx, p, W, H) {
636 + if (!this.priceLines.size) return
637 + for (const pl of this.priceLines.values()) {
638 + const y = p.scale.y(pl.price)
639 + if (y < -LABEL_PAD || y > H + LABEL_PAD) continue
640 + const color = pl.color || this.theme.series[0]
641 + ctx.strokeStyle = color; ctx.lineWidth = lw(pl.width || 1)
642 + ctx.setLineDash(pl.style === 'dashed' ? [4, 3] : pl.style === 'dotted' ? [1, 3] : [])
643 + const yy = crisp(y)
644 + ctx.beginPath(); ctx.moveTo(0, yy); ctx.lineTo(W, yy); ctx.stroke(); ctx.setLineDash([])
645 + if (pl.title) {
646 + ctx.font = font(this.theme, { size: 10, weight: 500 }); ctx.textBaseline = 'alphabetic'; ctx.textAlign = 'left'
647 + ctx.fillStyle = color; ctx.fillText(pl.title, 6, y - 4)
648 + }
649 + if (pl.axisLabel !== false) pill(ctx, this._formatMain(pl.price), W + 4, y, { bg: color, fontStr: font(this.theme, { mono: true, size: FONT_SIZE }), h: 18, padX: 4, clampTo: { x0: W + 2, x1: W + this.axisWidth - 1, y0: 0, y1: H } })
534 650 }
535 651 }
536 652
653 + /** v2: series markers (arrows / circles / squares with optional text) above or below bars. */
654 + _drawMarkers(ctx, p, vr, W, H) {
655 + const ps = p.scale, rs = this.renderStore
656 + const f = font(this.theme, { size: 10, weight: 600 })
657 + ctx.font = f; ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic'
658 + for (const m of this.markers) {
659 + const i = this.store.nearestIndex(m.t)
660 + if (i < vr.from - 1 || i > vr.to + 1 || i < 0 || (this.replayIndex != null && i > this.replayIndex)) continue
661 + const x = snap(this.ts.x(i))
662 + const size = m.size || Math.max(4, Math.min(8, this.ts.barSpacing * 0.5))
663 + const above = m.position !== 'below'
664 + const baseY = above ? ps.y(rs.h[i]) - 6 : ps.y(rs.l[i]) + 6
665 + const color = m.color || (above ? this.theme.down : this.theme.up)
666 + ctx.fillStyle = color
667 + const shape = m.shape || (above ? 'arrowDown' : 'arrowUp')
668 + ctx.beginPath()
669 + if (shape === 'arrowUp') { ctx.moveTo(x, baseY); ctx.lineTo(x - size, baseY + size * 1.5); ctx.lineTo(x + size, baseY + size * 1.5); ctx.closePath() }
670 + else if (shape === 'arrowDown') { ctx.moveTo(x, baseY); ctx.lineTo(x - size, baseY - size * 1.5); ctx.lineTo(x + size, baseY - size * 1.5); ctx.closePath() }
671 + else if (shape === 'square') { const cy = above ? baseY - size : baseY + size; ctx.rect(x - size * 0.8, cy - size * 0.8, size * 1.6, size * 1.6) }
672 + else { const cy = above ? baseY - size : baseY + size; ctx.arc(x, cy, size * 0.8, 0, Math.PI * 2) }
673 + ctx.fill()
674 + if (m.text) { const ty = above ? baseY - size * 1.9 - 3 : baseY + size * 1.9 + 11; ctx.fillStyle = m.textColor || this.theme.text; ctx.fillText(m.text, x, ty) }
675 + }
676 + ctx.textAlign = 'left'
677 + }
678 +
537 679 _headerHover(p) {
538 680 const ptr = this.pointer
539 681 if (!ptr || ptr.pane !== p) return null
@@ -546,10 +688,10 @@ export class Chart {
546 688 for (const id of p.indicators) {
547 689 const ind = this.indicators.get(id)
548 690 if (!ind || !ind.values) continue
549 − for (const plot of ind.spec.plots) {
691 + for (const plot of this._plotsOf(ind)) {
550 692 if (plot.kind !== 'line') continue
551 693 const arr = ind.values[plot.key]
552 − if (!arr) continue
694 + if (!Array.isArray(arr)) continue
553 695 let i = Math.min(n - 1, arr.length - 1)
554 696 while (i >= 0 && (arr[i] == null || Number.isNaN(arr[i]))) i--
555 697 if (i < 0) continue
@@ -569,7 +711,9 @@ export class Chart {
569 711 const idx = this._pointerIndex()
570 712 const t = this.store.timeAtIndex(idx)
571 713 if (t == null) return
714 + layer.ctx.save(); layer.ctx.translate(this.plotX0, 0)
572 715 drawTimeLabel(layer.ctx, { text: fmtFull(t, this.opts.timeframe, this.opts.sessionLabel), x: this._crosshairX(), plotWidth: this.plotWidth, height: TIME_AXIS_H, theme: this.theme })
716 + layer.ctx.restore()
573 717 }
574 718
575 719 /** Bar index under the pointer (rounded, may exceed the data in the right offset area). */
@@ -631,7 +775,11 @@ export class Chart {
631 775 const indicators = {}
632 776 for (const ind of this.indicators.values()) {
633 777 const vals = {}
634 − if (ind.values) for (const key of Object.keys(ind.values)) { const arr = ind.values[key]; vals[key] = index >= 0 && index < arr.length && arr[index] != null && !Number.isNaN(arr[index]) ? arr[index] : null }
778 + if (ind.values) for (const key of Object.keys(ind.values)) {
779 + const arr = ind.values[key]
780 + if (!Array.isArray(arr)) continue
781 + vals[key] = index >= 0 && index < arr.length && arr[index] != null && !Number.isNaN(arr[index]) ? arr[index] : null
782 + }
635 783 indicators[ind.id] = vals
636 784 }
637 785 const compares = {}
@@ -753,30 +901,55 @@ export class Chart {
753 901
754 902 /* ─── indicators ─── */
755 903
756 − addIndicator({ type, params, pane, colors, id } = {}) {
904 + /**
905 + * v2 additions: `lineWidth` (device px, applies to every line plot), `styles` ({ key → { color, width, style,
906 + * dash, visible } }), `scale` ('right' = pane scale (default) | 'left' = own scale drawn on a left axis |
907 + * 'hidden' = own scale, no axis) for overlays, `pane: <paneId>` to join an existing indicator pane.
908 + */
909 + addIndicator({ type, params, pane, colors, id, lineWidth, styles, scale } = {}) {
757 910 const spec = indicatorSpec(type)
758 911 const finalId = id || `${type}-${++idSeq}`
759 912 if (this.indicators.has(finalId)) this.removeIndicator(finalId)
760 − const where = pane || spec.pane
761 − const ind = { id: finalId, type, params: indicatorParams(type, params), spec, colors: colors || null, values: null, paneId: null }
913 + // An own scale ('left' | 'hidden') only makes sense as an overlay: it forces the main pane.
914 + const where = scale === 'left' || scale === 'hidden' ? 'main' : pane || spec.pane
915 + const ind = { id: finalId, type, params: indicatorParams(type, params), spec, colors: colors || null, values: null, paneId: null, lineWidth: lineWidth || null, styles: styles || null, scale: 'right', ownScale: null, hidden: false }
762 916 let target
763 917 if (where === 'main') target = this.mainPane
764 − else {
918 + else if (where === 'new' || !this.panes.some(p => p.id === where)) {
765 919 target = new Pane(this.el, { kind: 'indicator', weight: 0.28 })
766 920 this.panes.push(target)
767 921 this.dirty.layout = true
768 − }
922 + } else target = this.panes.find(p => p.id === where)
769 923 target.indicators.push(finalId)
770 924 ind.paneId = target.id
771 − if (target.kind !== 'main') { target.title = spec.title(ind.params); if (spec.range) target.scale.fixedRange = { ...spec.range } }
925 + if (target.kind !== 'main') { if (target.indicators.length === 1) { target.title = spec.title(ind.params); if (spec.range) target.scale.fixedRange = { ...spec.range } } else target.title = `${target.title} · ${spec.title(ind.params)}` }
926 + if (target.kind === 'main' && (scale === 'left' || scale === 'hidden')) this._setIndicatorScale(ind, scale)
772 927 this.indicators.set(finalId, ind)
773 − this._computeIndicator(ind)
928 + this._updateLeftAxis()
929 + this._hasDynamic = Array.from(this.indicators.values()).some(x => x.spec.dynamic)
930 + this._dynamicKey = null
931 + this._computeIndicator(ind, this.ts.visibleRange())
774 932 this.invalidate('layout')
775 933 return finalId
776 934 }
777 935
778 − _computeIndicator(ind) {
779 − ind.values = this.store.length ? computeIndicator(ind.type, this.store.bars, ind.params) : null
936 + _setIndicatorScale(ind, scale) {
937 + ind.scale = scale
938 + if (scale === 'left' || scale === 'hidden') { if (!ind.ownScale) ind.ownScale = new PriceScale(); ind.ownScale.height = this.mainPane.height }
939 + else ind.ownScale = null
940 + this._updateLeftAxis()
941 + }
942 +
943 + _updateLeftAxis() {
944 + const had = this.leftAxisWidth > 0
945 + const need = Array.from(this.indicators.values()).some(i => i.scale === 'left' && !i.hidden)
946 + this.leftAxisWidth = need ? MIN_AXIS_W : 0
947 + if (had !== need) { this.dirty.layout = true; this.ts.width = this.plotWidth; if (this.ts.stickToRight) this.ts.scrollToLatest() }
948 + }
949 +
950 + _computeIndicator(ind, vr) {
951 + if (!this.store.length) { ind.values = null; return }
952 + ind.values = ind.spec.dynamic ? computeIndicator(ind.type, this.store.bars, ind.params, vr || this.ts.visibleRange()) : computeIndicator(ind.type, this.store.bars, ind.params)
780 953 }
781 954
782 955 updateIndicator(id, params) {
@@ -784,11 +957,25 @@ export class Chart {
784 957 if (!ind) return
785 958 ind.params = indicatorParams(ind.type, { ...ind.params, ...(params || {}) })
786 959 const p = this.panes.find(p => p.id === ind.paneId)
787 − if (p && p.kind !== 'main') p.title = ind.spec.title(ind.params)
960 + if (p && p.kind !== 'main') p.title = p.indicators.map(x => this.indicators.get(x)).filter(Boolean).map(x => x.spec.title(x.params)).join(' · ')
788 961 this._computeIndicator(ind)
789 962 this.invalidate('data')
790 963 }
791 964
965 + /** v2: change the visual style of an indicator without recomputing: { lineWidth, styles, colors, scale, visible }. */
966 + setIndicatorStyle(id, { lineWidth, styles, colors, scale, visible } = {}) {
967 + const ind = this.indicators.get(id)
968 + if (!ind) return
969 + if (lineWidth !== undefined) ind.lineWidth = lineWidth || null
970 + if (styles !== undefined) ind.styles = styles ? { ...(ind.styles || {}), ...styles } : null
971 + if (colors !== undefined) ind.colors = colors || null
972 + if (visible !== undefined) ind.hidden = !visible
973 + if (scale !== undefined && ind.paneId === this.mainPane.id) this._setIndicatorScale(ind, scale)
974 + ind._plotsCache = null
975 + this._updateLeftAxis()
976 + this.invalidate('layout')
977 + }
978 +
792 979 removeIndicator(id) {
793 980 const ind = this.indicators.get(id)
794 981 if (!ind) return
@@ -797,7 +984,10 @@ export class Chart {
797 984 if (p) {
798 985 p.indicators = p.indicators.filter(x => x !== id)
799 986 if (p.kind !== 'main' && p.indicators.length === 0) this._removePane(p)
987 + else if (p.kind !== 'main') p.title = p.indicators.map(x => this.indicators.get(x)).filter(Boolean).map(x => x.spec.title(x.params)).join(' · ')
800 988 }
989 + this._hasDynamic = Array.from(this.indicators.values()).some(x => x.spec.dynamic)
990 + this._updateLeftAxis()
801 991 this.invalidate('layout')
802 992 }
803 993
modified hfmarketdata/web/src/charts/engine/drawings/manager.js +11 −4
@@ -129,7 +129,8 @@ export class DrawingManager {
129 129 return null
130 130 }
131 131
132 − cursorAt(x, y) {
132 + cursorAt(x, y, pane) {
133 + if (pane && pane.kind !== 'main') return null
133 134 if (this.drag) return this.drag.part === 'handle' ? 'grabbing' : 'move'
134 135 if (this.tool) return 'crosshair'
135 136 const h = this.hitTest(x, y)
@@ -139,7 +140,10 @@ export class DrawingManager {
139 140
140 141 /* ─── pointer state machine (coordinates relative to the main pane plot) ─── */
141 142
142 − pointerDown(x, y, ev) {
143 + onDoubleClick(x, y, pane) { return (!pane || pane.kind === 'main') && !!this.hitTest(x, y) }
144 +
145 + pointerDown(x, y, ev, pane) {
146 + if (pane && pane.kind !== 'main') return false
143 147 if (this.tool) {
144 148 const p = this.fromPixel(x, y)
145 149 if (!this.creating) {
@@ -167,7 +171,8 @@ export class DrawingManager {
167 171 return true
168 172 }
169 173
170 − pointerMove(x, y, ev) {
174 + pointerMove(x, y, ev, pane) {
175 + if (pane && pane.kind !== 'main') return false
171 176 if (this.creating) {
172 177 const p = this.fromPixel(x, y)
173 178 const cr = this.creating
@@ -208,7 +213,8 @@ export class DrawingManager {
208 213 return false
209 214 }
210 215
211 − pointerUp(x, y) {
216 + pointerUp(x, y, ev, pane) {
217 + if (pane && pane.kind !== 'main') return false
212 218 if (this.creating) {
213 219 const cr = this.creating
214 220 if (cr.type === 'brush') { cr.dragging = false; if (cr.points.length >= 2) this._commitCreating(); else this.creating = null; return true }
@@ -258,6 +264,7 @@ export class DrawingManager {
258 264
259 265 draw(ctx, g) {
260 266 const { theme } = g
267 + if (g.pane && g.pane.kind !== 'main') return
261 268 for (const d of this.list) this._drawOne(ctx, g, d, d.id === this.selectedId, d.id === this.hoverId)
262 269 if (this.creating) {
263 270 const cr = this.creating
modified hfmarketdata/web/src/charts/engine/index.js +1 −1
@@ -4,7 +4,7 @@ import { Chart } from './core/chart.js'
4 4
5 5 export { darkTheme, lightTheme, normalizeTheme } from './theme.js'
6 6 export { TOOLS as DRAWING_TOOLS } from './drawings/model.js'
7 −export { INDICATOR_TYPES, REGISTRY as INDICATORS, computeIndicator, indicatorParams } from '../indicators/index.js'
7 +export { INDICATOR_TYPES, REGISTRY as INDICATORS, computeIndicator, indicatorParams, listIndicators, CATEGORIES as INDICATOR_CATEGORIES, SOURCES as INDICATOR_SOURCES } from '../indicators/index.js'
8 8
9 9 export const SERIES_TYPES = ['candles', 'hollow', 'ohlc', 'line', 'area', 'baseline', 'heikin', 'columns', 'hlc']
10 10
modified hfmarketdata/web/src/charts/engine/interactions/pointer.js +33 −16
@@ -18,13 +18,13 @@ export function attachInteractions(chart) {
18 18
19 19 const hoverCursor = (hit, x, y) => {
20 20 switch (hit.region) {
21 − case 'priceAxis': return 'ns-resize'
21 + case 'priceAxis': case 'leftAxis': return 'ns-resize'
22 22 case 'timeAxis': return 'ew-resize'
23 23 case 'separator': return 'row-resize'
24 24 case 'paneButton': return 'pointer'
25 25 case 'plot': {
26 − if (hit.pane.kind === 'main') {
27 − const c = chart.drawings.cursorAt(x, hit.y)
26 + {
27 + const c = chart.drawings.cursorAt(x, hit.y, hit.pane)
28 28 if (c) return c
29 29 }
30 30 return chart.drawings.tool ? 'crosshair' : 'crosshair'
@@ -33,7 +33,8 @@ export function attachInteractions(chart) {
33 33 }
34 34 }
35 35
36 − const pointerInfo = (hit, p) => (hit.region === 'plot' ? { x: p.x, y: hit.y, pane: hit.pane, region: 'plot' } : { x: p.x, y: hit.y, pane: hit.pane || null, region: hit.region })
36 + // Pointer positions handed to the chart are PLOT-relative (hit.x), so a left axis never shifts the time scale.
37 + const pointerInfo = (hit, p) => (hit.region === 'plot' ? { x: hit.x, y: hit.y, pane: hit.pane, region: 'plot' } : { x: hit.x ?? p.x, y: hit.y, pane: hit.pane || null, region: hit.region })
37 38
38 39 function onPointerDown(ev) {
39 40 if (ev.button !== 0 && ev.pointerType === 'mouse') return
@@ -58,12 +59,15 @@ export function attachInteractions(chart) {
58 59 case 'priceAxis':
59 60 mode = 'priceAxis'; st = { pane: hit.pane, lastY: p.y }
60 61 return
62 + case 'leftAxis':
63 + mode = 'leftAxis'; st = { pane: hit.pane, lastY: p.y }
64 + return
61 65 case 'timeAxis':
62 66 mode = 'timeAxis'; st = { lastX: p.x }
63 67 return
64 68 case 'plot': {
65 69 chart.setPointer(pointerInfo(hit, p))
66 − if (hit.pane.kind === 'main' && chart.drawings.pointerDown(p.x, hit.y, ev)) { mode = 'drawing'; st = { pane: hit.pane }; return }
70 + if (chart.drawings.pointerDown(hit.x, hit.y, ev, hit.pane)) { mode = 'drawing'; st = { pane: hit.pane }; return }
67 71 mode = 'pan'
68 72 st = { lastX: p.x, lastT: performance.now(), vx: 0 }
69 73 chart.dragging = true
@@ -131,6 +135,13 @@ export function attachInteractions(chart) {
131 135 chart.invalidate('data')
132 136 return
133 137 }
138 + case 'leftAxis': {
139 + const dy = p.y - st.lastY
140 + st.lastY = p.y
141 + for (const ind of chart.indicators.values()) if (ind.scale === 'left' && ind.ownScale) ind.ownScale.stretch(Math.exp(-dy / 250), st.pane.height / 2)
142 + chart.invalidate('data')
143 + return
144 + }
134 145 case 'timeAxis': {
135 146 const dx = p.x - st.lastX
136 147 st.lastX = p.x
@@ -151,12 +162,11 @@ export function attachInteractions(chart) {
151 162 return
152 163 }
153 164 case 'drawing': {
154 − const hit = chart.hitRegion(p.x, p.y)
155 165 const y = p.y - st.pane.top
156 − chart.pointer = { x: p.x, y, pane: st.pane, region: 'plot' }
157 − chart.drawings.pointerMove(p.x, y, ev)
166 + const x = p.x - chart.plotX0
167 + chart.pointer = { x, y, pane: st.pane, region: 'plot' }
168 + chart.drawings.pointerMove(x, y, ev, st.pane)
158 169 chart.invalidate('overlay')
159 − void hit
160 170 return
161 171 }
162 172 case 'button':
@@ -166,8 +176,8 @@ export function attachInteractions(chart) {
166 176 const hit = chart.hitRegion(p.x, p.y)
167 177 if (hit.region === 'outside') { chart.setPointer(null); setCursor('default'); return }
168 178 chart.setPointer(pointerInfo(hit, p))
169 − if (hit.region === 'plot' && hit.pane.kind === 'main') chart.drawings.pointerMove(p.x, hit.y, ev)
170 − setCursor(hoverCursor(hit, p.x, hit.y))
179 + if (hit.region === 'plot') chart.drawings.pointerMove(hit.x, hit.y, ev, hit.pane)
180 + setCursor(hoverCursor(hit, hit.x, hit.y))
171 181 }
172 182 }
173 183 }
@@ -193,7 +203,7 @@ export function attachInteractions(chart) {
193 203 chart.setPointer(null)
194 204 break
195 205 case 'drawing':
196 − chart.drawings.pointerUp(p.x, p.y - st.pane.top, ev)
206 + chart.drawings.pointerUp(p.x - chart.plotX0, p.y - st.pane.top, ev, st.pane)
197 207 chart.invalidate('overlay')
198 208 break
199 209 case 'button': {
@@ -213,14 +223,15 @@ export function attachInteractions(chart) {
213 223 }
214 224 mode = null; st = null; downInfo = null
215 225 const hit = chart.hitRegion(p.x, p.y)
216 − setCursor(hit.region === 'outside' ? 'default' : hoverCursor(hit, p.x, hit.y))
226 + setCursor(hit.region === 'outside' ? 'default' : hoverCursor(hit, hit.x, hit.y))
217 227 }
218 228
219 229 function emitClick(p, hit) {
220 230 if (!hit || hit.region !== 'plot' || !chart.emitter.has('click')) return
221 231 const n = chart.store.length
222 − const raw = Math.round(chart.ts.indexAt(p.x))
232 + const raw = Math.round(chart.ts.indexAt(hit.x))
223 233 const index = n ? Math.max(0, Math.min(n - 1, raw)) : -1
234 + void p
224 235 chart.emitter.emit('click', { index, bar: index >= 0 ? chart.store.bars[index] : null, price: hit.pane.scale.priceAt(hit.y), pane: hit.pane.kind === 'main' ? 'main' : hit.pane.id })
225 236 }
226 237
@@ -238,6 +249,11 @@ export function attachInteractions(chart) {
238 249 const dy = ev.deltaY * scale, dx = ev.deltaX * scale
239 250 chart.kinetic = null
240 251 chart.animator.cancel('view')
252 + if (hit.region === 'leftAxis') {
253 + for (const ind of chart.indicators.values()) if (ind.scale === 'left' && ind.ownScale) ind.ownScale.stretch(Math.exp(-dy * 0.002), hit.y)
254 + chart.invalidate('data')
255 + return
256 + }
241 257 if (hit.region === 'priceAxis') {
242 258 hit.pane.scale.stretch(Math.exp(-dy * 0.002), hit.y)
243 259 chart.emitter.emit('priceScaleChange', chart.getPriceScale())
@@ -246,7 +262,7 @@ export function attachInteractions(chart) {
246 262 }
247 263 if (ev.ctrlKey || ev.metaKey || Math.abs(dy) >= Math.abs(dx)) {
248 264 const amount = Math.max(-60, Math.min(60, dy))
249 − chart.ts.zoomAt(Math.exp(-amount * 0.004), p.x)
265 + chart.ts.zoomAt(Math.exp(-amount * 0.004), hit.x)
250 266 } else {
251 267 chart.ts.scrollPx(-dx)
252 268 }
@@ -258,8 +274,9 @@ export function attachInteractions(chart) {
258 274 const p = local(ev)
259 275 const hit = chart.hitRegion(p.x, p.y)
260 276 if (hit.region === 'priceAxis') { hit.pane.scale.auto = true; hit.pane.scale._forceAnim = true; chart.emitter.emit('priceScaleChange', chart.getPriceScale()); chart.invalidate('data'); return }
277 + if (hit.region === 'leftAxis') { for (const ind of chart.indicators.values()) if (ind.ownScale) { ind.ownScale.auto = true; ind.ownScale._forceAnim = true } chart.invalidate('data'); return }
261 278 if (hit.region === 'plot' || hit.region === 'timeAxis') {
262 − if (hit.region === 'plot' && hit.pane.kind === 'main' && chart.drawings.hitTest(p.x, hit.y)) return
279 + if (hit.region === 'plot' && chart.drawings.onDoubleClick(hit.x, hit.y, hit.pane)) return
263 280 chart.fitContent(true)
264 281 }
265 282 }
modified hfmarketdata/web/src/charts/engine/render/plots.js +149 −7
@@ -57,9 +57,28 @@ export function seriesPath(get, from, to, ts, yOf) {
57 57 return path
58 58 }
59 59
60 +/** Path connecting the non-null points only (ZigZag): gaps are bridged with straight segments. */
61 +export function connectedPath(values, from, to, ts, yOf) {
62 + const path = new Path2D()
63 + const n = values.length
64 + // Include the last non-null point before `from` and the first after `to` so the legs entering the viewport are drawn.
65 + let a = Math.max(0, from)
66 + while (a > 0 && (values[a] == null || Number.isNaN(values[a]))) a--
67 + let b = Math.min(to, n - 1)
68 + while (b < n - 1 && (values[b] == null || Number.isNaN(values[b]))) b++
69 + let pen = false
70 + for (let i = a; i <= b; i++) {
71 + const v = values[i]
72 + if (v == null || Number.isNaN(v)) continue
73 + const x = ts.x(i), y = yOf(v)
74 + if (!pen) { path.moveTo(x, y); pen = true } else path.lineTo(x, y)
75 + }
76 + return path
77 +}
78 +
60 79 /**
61 80 * Draw the plots of one indicator instance.
62 − * @param g { ctx, plots, values, colors, from, to, ts, yOf, theme, height, store }
81 + * @param g { ctx, plots, values, colors, from, to, ts, yOf, theme, height, width, store, format }
63 82 */
64 83 export function drawPlots(g) {
65 84 const { ctx, plots, values, colors, from, to, ts, yOf, theme, height, store } = g
@@ -72,23 +91,145 @@ export function drawPlots(g) {
72 91 const a = values[p.a], b = values[p.b]
73 92 if (!a || !b) continue
74 93 fillBetween(ctx, a, b, from, to, ts, yOf, withAlpha(plotColor(p.colorA, theme, colors), p.alpha ?? 0.12), withAlpha(plotColor(p.colorB, theme, colors), p.alpha ?? 0.12))
75 − } else if (p.kind === 'histogram') {
94 + } else if (p.kind === 'histogram' || p.kind === 'columns') {
76 95 const v = values[p.key]
77 96 if (!v) continue
78 97 drawHistogram(ctx, v, from, to, ts, yOf, height, p, theme, colors, store)
98 + } else if (p.kind === 'circles') {
99 + const v = values[p.key]
100 + if (!v) continue
101 + drawCircles(ctx, v, from, to, ts, yOf, plotColor(p.color, theme, colors), p.radius ?? 1.5)
102 + } else if (p.kind === 'arrows') {
103 + const v = values[p.key]
104 + if (!v) continue
105 + drawArrows(ctx, v, from, to, ts, yOf, theme, g)
106 + } else if (p.kind === 'hlevels') {
107 + drawHLevels(ctx, values[p.key || 'levels'], g, plotColor(p.color, theme, colors))
108 + } else if (p.kind === 'profile') {
109 + drawProfile(ctx, values, g, plotColor(p.color, theme, colors))
79 110 } else {
80 111 const v = values[p.key]
81 112 if (!v) continue
82 113 ctx.strokeStyle = plotColor(p.color, theme, colors)
83 114 ctx.lineWidth = lw(p.width ?? (ts.isCompressed ? 1 : PLOT_LINE_DEVICE_PX))
84 − ctx.lineJoin = 'round'; ctx.lineCap = 'round'
85 − if (p.dash) ctx.setLineDash(p.dash)
86 − ctx.stroke(valuePath(v, from, to, ts, yOf))
87 − if (p.dash) ctx.setLineDash([])
115 + ctx.lineJoin = 'round'; ctx.lineCap = p.step ? 'butt' : 'round'
116 + const dash = p.dash || (p.style === 'dashed' ? [4, 3] : null)
117 + if (dash) ctx.setLineDash(dash)
118 + if (p.step) ctx.stroke(stepPath(v, from, to, ts, yOf))
119 + else if (p.connectGaps) ctx.stroke(connectedPath(v, from, to, ts, yOf))
120 + else ctx.stroke(valuePath(v, from, to, ts, yOf))
121 + if (dash) ctx.setLineDash([])
88 122 }
89 123 }
90 124 }
91 125
126 +/** Stepwise path: crisp horizontal segments, broken where the value changes (pivot levels and the like). */
127 +export function stepPath(values, from, to, ts, yOf) {
128 + const path = new Path2D()
129 + const n = values.length
130 + const start = Math.max(0, from), end = Math.min(to, n - 1)
131 + const half = ts.barSpacing / 2
132 + let cur = null, x0 = 0
133 + for (let i = start; i <= end + 1; i++) {
134 + const v = i <= end ? values[i] : null
135 + const same = v != null && cur != null && v === cur
136 + if (same) continue
137 + if (cur != null) { const y = (Math.round(yOf(cur) * DPR) + 0.5) / DPR; path.moveTo(x0, y); path.lineTo(ts.x(i - 1) + half, y) }
138 + cur = v == null || Number.isNaN(v) ? null : v
139 + x0 = ts.x(i) - half
140 + }
141 + return path
142 +}
143 +
144 +function drawCircles(ctx, v, from, to, ts, yOf, color, radius) {
145 + const path = new Path2D()
146 + const end = Math.min(to, v.length - 1)
147 + const r = Math.max(0.75, Math.min(radius, ts.barSpacing * 0.35))
148 + const stride = ts.barSpacing < 2 ? Math.ceil(2 / ts.barSpacing) : 1
149 + for (let i = Math.max(0, from); i <= end; i += stride) {
150 + const val = v[i]
151 + if (val == null || Number.isNaN(val)) continue
152 + const x = snap(ts.x(i)), y = snap(yOf(val))
153 + path.moveTo(x + r, y); path.arc(x, y, r, 0, Math.PI * 2)
154 + }
155 + ctx.fillStyle = color; ctx.fill(path)
156 +}
157 +
158 +/** ±1 markers: up arrows below the bar's low, down arrows above the high (needs g.store). */
159 +function drawArrows(ctx, v, from, to, ts, yOf, theme, g) {
160 + const store = g.store
161 + if (!store) return
162 + const up = new Path2D(), down = new Path2D()
163 + const end = Math.min(to, v.length - 1, store.length - 1)
164 + const s = Math.max(3, Math.min(6, ts.barSpacing * 0.45))
165 + for (let i = Math.max(0, from); i <= end; i++) {
166 + const val = v[i]
167 + if (val == null || val === 0 || Number.isNaN(val)) continue
168 + const x = snap(ts.x(i))
169 + if (val > 0) { const y = yOf(store.l[i]) + 4; up.moveTo(x, y); up.lineTo(x - s, y + s * 1.6); up.lineTo(x + s, y + s * 1.6); up.closePath() }
170 + else { const y = yOf(store.h[i]) - 4; down.moveTo(x, y); down.lineTo(x - s, y - s * 1.6); down.lineTo(x + s, y - s * 1.6); down.closePath() }
171 + }
172 + ctx.fillStyle = theme.up; ctx.fill(up)
173 + ctx.fillStyle = theme.down; ctx.fill(down)
174 +}
175 +
176 +/** Horizontal levels [{ ratio, price }] across the plot with a ratio + price label at the right (auto Fib). */
177 +function drawHLevels(ctx, levels, g, color) {
178 + if (!Array.isArray(levels) || !levels.length) return
179 + const { yOf, width, theme, format } = g
180 + const W = width
181 + ctx.font = `500 10px ${theme.mono}`
182 + ctx.textBaseline = 'alphabetic'; ctx.textAlign = 'right'
183 + let prevY = null
184 + levels.forEach((l, i) => {
185 + const y = yOf(l.price)
186 + const c = theme.series[i % theme.series.length]
187 + if (prevY != null) { ctx.fillStyle = withAlpha(c, 0.05); ctx.fillRect(0, Math.min(prevY, y), W, Math.abs(y - prevY)) }
188 + ctx.strokeStyle = withAlpha(color, 0.7); ctx.lineWidth = 1 / DPR
189 + const yy = (Math.round(y * DPR) + 0.5) / DPR
190 + ctx.beginPath(); ctx.moveTo(0, yy); ctx.lineTo(W, yy); ctx.stroke()
191 + const label = `${String(l.ratio).replace(/^0\./, '.')} ${format ? format(l.price) : l.price}`
192 + const tw = ctx.measureText(label).width
193 + ctx.fillStyle = withAlpha(theme.bg, 0.8); ctx.fillRect(W - tw - 10, y - 13, tw + 6, 12)
194 + ctx.fillStyle = theme.axisText; ctx.fillText(label, W - 7, y - 3)
195 + prevY = y
196 + })
197 + ctx.textAlign = 'left'
198 +}
199 +
200 +/** Volume profile: horizontal histogram anchored to the right edge of the plot (≤ 30 % of the width), POC / VAH / VAL. */
201 +function drawProfile(ctx, vp, g, color) {
202 + if (!vp || !vp.rows || !vp.rows.length || !(vp.maxVol > 0)) return
203 + const { yOf, width, theme, format } = g
204 + const maxW = Math.max(40, width * 0.28)
205 + const one = 1 / DPR
206 + const up = new Path2D(), down = new Path2D(), va = new Path2D()
207 + for (const r of vp.rows) {
208 + const y0 = snap(yOf(r.hi)), y1 = snap(yOf(r.lo))
209 + const h = Math.max(one, y1 - y0 - one)
210 + const wu = (r.up / vp.maxVol) * maxW, wd = (r.down / vp.maxVol) * maxW
211 + const inVA = r.lo >= vp.val - 1e-9 && r.hi <= vp.vah + 1e-9
212 + const x1 = width
213 + ;(inVA ? va : up).rect(x1 - wu - wd, y0, wu, h)
214 + ;(inVA ? va : down).rect(x1 - wd, y0, wd, h)
215 + if (inVA) { up.rect(x1 - wu - wd, y0, wu, h); down.rect(x1 - wd, y0, wd, h) }
216 + }
217 + ctx.fillStyle = withAlpha(theme.up, 0.28); ctx.fill(up)
218 + ctx.fillStyle = withAlpha(theme.down, 0.28); ctx.fill(down)
219 + ctx.fillStyle = withAlpha(color, 0.18); ctx.fill(va)
220 + // POC line + VAH / VAL dashed.
221 + const yPoc = (Math.round(yOf(vp.poc) * DPR) + 0.5) / DPR
222 + ctx.strokeStyle = withAlpha(color, 0.9); ctx.lineWidth = lw(1.5)
223 + ctx.beginPath(); ctx.moveTo(width - maxW, yPoc); ctx.lineTo(width, yPoc); ctx.stroke()
224 + ctx.setLineDash([3, 3]); ctx.lineWidth = one; ctx.strokeStyle = withAlpha(color, 0.6)
225 + for (const p of [vp.vah, vp.val]) { const y = (Math.round(yOf(p) * DPR) + 0.5) / DPR; ctx.beginPath(); ctx.moveTo(width - maxW, y); ctx.lineTo(width, y); ctx.stroke() }
226 + ctx.setLineDash([])
227 + ctx.font = `500 10px ${theme.mono}`; ctx.textAlign = 'right'; ctx.textBaseline = 'alphabetic'
228 + ctx.fillStyle = theme.axisText
229 + ctx.fillText(`POC ${format ? format(vp.poc) : vp.poc}`, width - 4, yPoc - 3)
230 + ctx.textAlign = 'left'
231 +}
232 +
92 233 /** Fill the region between two series. With `colorB`, segments where b > a use colorB (cloud). */
93 234 function fillBetween(ctx, a, b, from, to, ts, yOf, colorA, colorB) {
94 235 const n = Math.min(a.length, b.length)
@@ -161,6 +302,7 @@ function drawHistogram(ctx, v, from, to, ts, yOf, height, p, theme, colors, stor
161 302 let bucket
162 303 if (p.color === 'volume') bucket = store && store.c[i] < store.o[i] ? neg : pos
163 304 else if (p.color === 'updown') bucket = val >= 0 ? pos : neg
305 + else if (p.color === 'delta') { const pv = i > 0 ? v[i - 1] : null; bucket = pv == null || val >= pv ? pos : neg }
164 306 else bucket = pos
165 307 const top = Math.min(y, zero), h = Math.max(one, Math.abs(zero - y))
166 308 bucket.rect(xc - w / 2, top, w, h)
@@ -187,7 +329,7 @@ function drawHistogram(ctx, v, from, to, ts, yOf, height, p, theme, colors, stor
187 329 if (p.color === 'volume') {
188 330 ctx.fillStyle = theme.volumeUp; ctx.fill(pos)
189 331 ctx.fillStyle = theme.volumeDown; ctx.fill(neg)
190 − } else if (p.color === 'updown') {
332 + } else if (p.color === 'updown' || p.color === 'delta') {
191 333 ctx.fillStyle = withAlpha(theme.up, 0.65); ctx.fill(pos)
192 334 ctx.fillStyle = withAlpha(theme.down, 0.65); ctx.fill(neg)
193 335 } else {
modified hfmarketdata/web/src/charts/indicators/index.js +138 −53
@@ -1,78 +1,143 @@
1 −// Indicator registry: pure compute functions + rendering metadata used by the engine.
1 +// Indicator registry: pure compute functions + rendering metadata used by the engine and by the UI library.
2 2 //
3 −// Plot kinds: 'line' (key), 'histogram' (key; color 'updown' = sign of the value, 'volume' = bar direction),
4 −// 'band' (fill between `upper` and `lower` keys), 'cloud' (fill between `a` and `b`, colored by which is on top).
5 −// Colors: an index into theme.series, or a theme role ('up' | 'down').
3 +// Registry entry: { label, category, compute, defaults, inputs, pane, title(params), plots, levels?, range?,
4 +// format?, precision?, dynamic?, legendKeys? }
5 +// inputs: [{ name, type: 'int' | 'float' | 'source' | 'bool' | 'select' | 'time', min?, max?, step?, options? }]
6 +// — typed so the page can generate the settings form.
7 +// plots: { kind: 'line', key, color, width? (device px), dash?, style?: 'solid' | 'dashed', step?, connectGaps? }
8 +// { kind: 'histogram' | 'columns', key, color: 'updown' | 'volume' | 'delta' | index }
9 +// { kind: 'band', upper, lower, color, alpha } { kind: 'cloud', a, b, colorA, colorB, alpha }
10 +// { kind: 'circles', key, color, radius? } { kind: 'arrows', key, color } (key holds ±1 / null)
11 +// { kind: 'hlevels', key: 'levels' } (dynamic list of { ratio, price }) { kind: 'profile' }
12 +// colors: an index into theme.series, or a theme role ('up' | 'down').
13 +// levels: horizontal reference values in an indicator pane (e.g. RSI 30/70); `zones` = [[lo, hi]] hatched.
14 +// range: fixed { lo, hi } for bounded oscillators. precision: axis/legend decimals. dynamic: recomputed with
15 +// the visible range (compute(bars, params, { from, to })).
6 16
7 −import { sma, ema, wma, vwap } from './moving-averages.js'
17 +import { sma, ema, wma, hma, dema, tema, kama, alma, lsma, envelope, vwap, avwap } from './moving-averages.js'
8 18 import { bollinger, keltner, donchian, supertrend, ichimoku } from './bands.js'
9 19 import { rsi, macd, stoch, atr, adx, cci } from './oscillators.js'
10 −import { obv, mfi, volumeMa } from './volume.js'
20 +import { stochRsi, williamsR, roc, momentum, awesome, trix, ultimate, vortex, coppock, dpo, kst, tsi, rvi, cmo, aroon, choppiness, massIndex, elderRay, bollingerPctB, bollingerWidth, historicalVolatility, stddev } from './momentum.js'
21 +import { obv, mfi, volumeMa, adLine, cmf, chaikinOsc, forceIndex, eom, klinger, volumeOsc } from './volume.js'
22 +import { psar, zigzag, pivots, pivotLevels, volumeProfile, autoFib } from './overlays.js'
11 23 import { heikinAshi } from './heikin-ashi.js'
12 24
13 −export { sma, ema, wma, vwap, bollinger, keltner, donchian, supertrend, ichimoku, rsi, macd, stoch, atr, adx, cci, obv, mfi, volumeMa, heikinAshi }
25 +export {
26 + sma, ema, wma, hma, dema, tema, kama, alma, lsma, envelope, vwap, avwap, bollinger, keltner, donchian, supertrend, ichimoku,
27 + rsi, macd, stoch, atr, adx, cci, stochRsi, williamsR, roc, momentum, awesome, trix, ultimate, vortex, coppock, dpo, kst, tsi, rvi, cmo,
28 + aroon, choppiness, massIndex, elderRay, bollingerPctB, bollingerWidth, historicalVolatility, stddev,
29 + obv, mfi, volumeMa, adLine, cmf, chaikinOsc, forceIndex, eom, klinger, volumeOsc, psar, zigzag, pivots, pivotLevels, volumeProfile, autoFib, heikinAshi,
30 +}
31 +
32 +export const SOURCES = ['close', 'open', 'high', 'low', 'hl2', 'hlc3', 'ohlc4']
14 33
34 +/* ─── input builders ─── */
35 +const I = {
36 + int: (name, min = 1, max = 500, label) => ({ name, type: 'int', min, max, step: 1, label: label || name }),
37 + float: (name, min = 0, max = 100, step = 0.1, label) => ({ name, type: 'float', min, max, step, label: label || name }),
38 + source: (name = 'source') => ({ name, type: 'source', options: SOURCES, label: 'source' }),
39 + bool: (name, label) => ({ name, type: 'bool', label: label || name }),
40 + select: (name, options, label) => ({ name, type: 'select', options, label: label || name }),
41 + time: (name, label) => ({ name, type: 'time', label: label || name }),
42 +}
15 43 const len = p => `${p.length}`
44 +const line = (key, color, extra = {}) => ({ kind: 'line', key, color, ...extra })
45 +const dashed = (key, color) => line(key, color, { dash: [4, 3], style: 'dashed' })
46 +const channel = (color, alpha = 0.07) => [{ kind: 'band', upper: 'upper', lower: 'lower', color, alpha }, line('upper', color), dashed('middle', color), line('lower', color)]
16 47
17 48 export const REGISTRY = {
18 − sma: { label: 'SMA', compute: sma, defaults: { length: 20, source: 'close' }, pane: 'main', title: p => `SMA ${len(p)}`, plots: [{ key: 'sma', kind: 'line', color: 0 }] },
19 − ema: { label: 'EMA', compute: ema, defaults: { length: 20, source: 'close' }, pane: 'main', title: p => `EMA ${len(p)}`, plots: [{ key: 'ema', kind: 'line', color: 1 }] },
20 − wma: { label: 'WMA', compute: wma, defaults: { length: 20, source: 'close' }, pane: 'main', title: p => `WMA ${len(p)}`, plots: [{ key: 'wma', kind: 'line', color: 2 }] },
21 − vwap: { label: 'VWAP', compute: vwap, defaults: { anchor: 'session' }, pane: 'main', title: p => `VWAP ${p.anchor}`, plots: [{ key: 'vwap', kind: 'line', color: 3 }] },
22 − bollinger: {
23 − label: 'Bollinger Bands', compute: bollinger, defaults: { length: 20, mult: 2, source: 'close' }, pane: 'main',
24 − title: p => `BB ${p.length} ${p.mult}`,
25 − plots: [
26 − { kind: 'band', upper: 'upper', lower: 'lower', color: 0, alpha: 0.07 },
27 − { key: 'upper', kind: 'line', color: 0 }, { key: 'middle', kind: 'line', color: 0, dash: [4, 3] }, { key: 'lower', kind: 'line', color: 0 },
28 − ],
29 − },
30 − keltner: {
31 − label: 'Keltner Channels', compute: keltner, defaults: { length: 20, mult: 2, atrLength: 10 }, pane: 'main',
32 − title: p => `KC ${p.length} ${p.mult}`,
33 − plots: [
34 − { kind: 'band', upper: 'upper', lower: 'lower', color: 6, alpha: 0.07 },
35 − { key: 'upper', kind: 'line', color: 6 }, { key: 'middle', kind: 'line', color: 6, dash: [4, 3] }, { key: 'lower', kind: 'line', color: 6 },
36 − ],
37 − },
38 − donchian: {
39 − label: 'Donchian Channels', compute: donchian, defaults: { length: 20 }, pane: 'main', title: p => `DC ${len(p)}`,
40 − plots: [
41 − { kind: 'band', upper: 'upper', lower: 'lower', color: 3, alpha: 0.06 },
42 − { key: 'upper', kind: 'line', color: 3 }, { key: 'middle', kind: 'line', color: 3, dash: [4, 3] }, { key: 'lower', kind: 'line', color: 3 },
43 − ],
49 + /* ───── trend / moving averages (overlay) ───── */
50 + sma: { label: 'SMA', category: 'trend', compute: sma, defaults: { length: 20, source: 'close' }, inputs: [I.int('length'), I.source()], pane: 'main', title: p => `SMA ${len(p)}`, plots: [line('sma', 0)] },
51 + ema: { label: 'EMA', category: 'trend', compute: ema, defaults: { length: 20, source: 'close' }, inputs: [I.int('length'), I.source()], pane: 'main', title: p => `EMA ${len(p)}`, plots: [line('ema', 1)] },
52 + wma: { label: 'WMA', category: 'trend', compute: wma, defaults: { length: 20, source: 'close' }, inputs: [I.int('length'), I.source()], pane: 'main', title: p => `WMA ${len(p)}`, plots: [line('wma', 2)] },
53 + hma: { label: 'Hull MA', category: 'trend', compute: hma, defaults: { length: 16, source: 'close' }, inputs: [I.int('length'), I.source()], pane: 'main', title: p => `HMA ${len(p)}`, plots: [line('hma', 4)] },
54 + dema: { label: 'DEMA', category: 'trend', compute: dema, defaults: { length: 20, source: 'close' }, inputs: [I.int('length'), I.source()], pane: 'main', title: p => `DEMA ${len(p)}`, plots: [line('dema', 5)] },
55 + tema: { label: 'TEMA', category: 'trend', compute: tema, defaults: { length: 20, source: 'close' }, inputs: [I.int('length'), I.source()], pane: 'main', title: p => `TEMA ${len(p)}`, plots: [line('tema', 6)] },
56 + kama: { label: 'KAMA', category: 'trend', compute: kama, defaults: { length: 10, fast: 2, slow: 30, source: 'close' }, inputs: [I.int('length'), I.int('fast'), I.int('slow'), I.source()], pane: 'main', title: p => `KAMA ${p.length} ${p.fast} ${p.slow}`, plots: [line('kama', 3)] },
57 + alma: { label: 'ALMA', category: 'trend', compute: alma, defaults: { length: 9, offset: 0.85, sigma: 6, source: 'close' }, inputs: [I.int('length'), I.float('offset', 0, 1, 0.01), I.float('sigma', 0.1, 50, 0.1), I.source()], pane: 'main', title: p => `ALMA ${p.length} ${p.offset} ${p.sigma}`, plots: [line('alma', 7)] },
58 + lsma: {
59 + label: 'Linear Regression (LSMA + channel)', category: 'trend', compute: lsma, defaults: { length: 25, mult: 2, source: 'close' }, inputs: [I.int('length', 2), I.float('mult', 0, 10, 0.1), I.source()], pane: 'main',
60 + title: p => `LSMA ${p.length} ${p.mult}`, plots: [{ kind: 'band', upper: 'upper', lower: 'lower', color: 0, alpha: 0.05 }, line('lsma', 0, { width: 2 }), dashed('upper', 0), dashed('lower', 0)], legendKeys: ['lsma', 'upper', 'lower'],
44 61 },
62 + envelope: { label: 'MA Envelope', category: 'trend', compute: envelope, defaults: { length: 20, percent: 2.5, ma: 'sma', source: 'close' }, inputs: [I.int('length'), I.float('percent', 0.1, 50, 0.1), I.select('ma', ['sma', 'ema']), I.source()], pane: 'main', title: p => `ENV ${p.length} ${p.percent}%`, plots: channel(2, 0.05) },
63 + vwap: { label: 'VWAP', category: 'trend', compute: vwap, defaults: { anchor: 'session', bands: 0 }, inputs: [I.select('anchor', ['session', 'all']), I.float('bands', 0, 4, 0.5, 'σ bands (0 = off)')], pane: 'main', title: p => `VWAP ${p.anchor}${p.bands > 0 ? ` ±${p.bands}σ` : ''}`, plots: [{ kind: 'band', upper: 'upper', lower: 'lower', color: 3, alpha: 0.05 }, line('vwap', 3), dashed('upper', 3), dashed('lower', 3)], legendKeys: ['vwap'] },
64 + avwap: { label: 'Anchored VWAP', category: 'trend', compute: avwap, defaults: { anchor: null, bands: 1 }, inputs: [I.time('anchor'), I.float('bands', 0, 4, 0.5, 'σ bands (0 = off)')], pane: 'main', title: () => 'AVWAP', plots: [{ kind: 'band', upper: 'upper', lower: 'lower', color: 4, alpha: 0.05 }, line('avwap', 4, { width: 2 }), dashed('upper', 4), dashed('lower', 4)], legendKeys: ['avwap'] },
45 65 supertrend: {
46 − label: 'Supertrend', compute: supertrend, defaults: { length: 10, mult: 3 }, pane: 'main', title: p => `ST ${p.length} ${p.mult}`,
47 − plots: [{ key: 'up', kind: 'line', color: 'up', width: 1.5 }, { key: 'down', kind: 'line', color: 'down', width: 1.5 }],
48 − legendKeys: ['supertrend'],
66 + label: 'Supertrend', category: 'trend', compute: supertrend, defaults: { length: 10, mult: 3 }, inputs: [I.int('length'), I.float('mult', 0.1, 20, 0.1)], pane: 'main', title: p => `ST ${p.length} ${p.mult}`,
67 + plots: [line('up', 'up', { width: 2 }), line('down', 'down', { width: 2 })], legendKeys: ['supertrend'],
49 68 },
69 + psar: { label: 'Parabolic SAR', category: 'trend', compute: psar, defaults: { start: 0.02, increment: 0.02, max: 0.2 }, inputs: [I.float('start', 0.001, 1, 0.001), I.float('increment', 0.001, 1, 0.001), I.float('max', 0.01, 1, 0.01)], pane: 'main', title: p => `SAR ${p.start} ${p.increment} ${p.max}`, plots: [{ kind: 'circles', key: 'bull', color: 'up', radius: 1.5 }, { kind: 'circles', key: 'bear', color: 'down', radius: 1.5 }], legendKeys: ['sar'] },
50 70 ichimoku: {
51 − label: 'Ichimoku Cloud', compute: ichimoku, defaults: { conversion: 9, base: 26, spanB: 52, displacement: 26 }, pane: 'main',
71 + label: 'Ichimoku Cloud', category: 'trend', compute: ichimoku, defaults: { conversion: 9, base: 26, spanB: 52, displacement: 26 }, inputs: [I.int('conversion'), I.int('base'), I.int('spanB'), I.int('displacement', 0)], pane: 'main',
52 72 title: p => `Ichimoku ${p.conversion} ${p.base} ${p.spanB}`,
53 73 plots: [
54 74 { kind: 'cloud', a: 'senkouA', b: 'senkouB', colorA: 'up', colorB: 'down', alpha: 0.12 },
55 − { key: 'tenkan', kind: 'line', color: 0 }, { key: 'kijun', kind: 'line', color: 1 },
56 − { key: 'senkouA', kind: 'line', color: 'up', width: 0.75 }, { key: 'senkouB', kind: 'line', color: 'down', width: 0.75 },
57 − { key: 'chikou', kind: 'line', color: 4 },
75 + line('tenkan', 0), line('kijun', 1), line('senkouA', 'up', { width: 1 }), line('senkouB', 'down', { width: 1 }), line('chikou', 4),
58 76 ],
59 77 },
60 − rsi: { label: 'RSI', compute: rsi, defaults: { length: 14, source: 'close' }, pane: 'new', title: p => `RSI ${len(p)}`, range: { lo: 0, hi: 100 }, levels: [30, 70], plots: [{ key: 'rsi', kind: 'line', color: 6 }] },
78 + zigzag: { label: 'ZigZag', category: 'levels', compute: zigzag, defaults: { deviation: 5, mode: 'percent', atrLength: 14, atrMult: 3 }, inputs: [I.float('deviation', 0.1, 50, 0.1, 'deviation %'), I.select('mode', ['percent', 'atr']), I.int('atrLength'), I.float('atrMult', 0.1, 10, 0.1)], pane: 'main', title: p => `ZigZag ${p.mode === 'atr' ? `ATR ${p.atrLength}×${p.atrMult}` : `${p.deviation}%`}`, plots: [line('zigzag', 3, { width: 2, connectGaps: true })], legendKeys: [] },
79 + pivots: {
80 + label: 'Pivot Points', category: 'levels', compute: pivots, defaults: { type: 'classic', period: 'day' }, inputs: [I.select('type', ['classic', 'fibonacci', 'camarilla', 'woodie']), I.select('period', ['day', 'week', 'month'])], pane: 'main',
81 + title: p => `Pivots ${p.type} ${p.period}`,
82 + plots: [line('p', 3, { step: true, width: 1.5 }), line('r1', 'down', { step: true, width: 1 }), line('r2', 'down', { step: true, width: 1 }), line('r3', 'down', { step: true, width: 1 }), line('r4', 'down', { step: true, width: 1 }), line('s1', 'up', { step: true, width: 1 }), line('s2', 'up', { step: true, width: 1 }), line('s3', 'up', { step: true, width: 1 }), line('s4', 'up', { step: true, width: 1 })],
83 + },
84 + 'volume-profile': { label: 'Volume Profile (visible range)', category: 'volume', compute: volumeProfile, defaults: { rows: 24, valueArea: 70 }, inputs: [I.int('rows', 4, 200), I.float('valueArea', 10, 100, 1, 'value area %')], pane: 'main', dynamic: true, title: p => `VP ${p.rows}`, plots: [{ kind: 'profile', color: 0 }], legendKeys: [] },
85 + 'auto-fib': { label: 'Auto Fibonacci (visible range)', category: 'levels', compute: autoFib, defaults: { levels: [0, 0.236, 0.382, 0.5, 0.618, 0.786, 1] }, inputs: [], pane: 'main', dynamic: true, title: () => 'Auto Fib', plots: [{ kind: 'hlevels', key: 'levels', color: 3 }], legendKeys: [] },
86 +
87 + /* ───── volatility bands (overlay) ───── */
88 + bollinger: { label: 'Bollinger Bands', category: 'volatility', compute: bollinger, defaults: { length: 20, mult: 2, source: 'close' }, inputs: [I.int('length'), I.float('mult', 0.1, 10, 0.1), I.source()], pane: 'main', title: p => `BB ${p.length} ${p.mult}`, plots: channel(0) },
89 + keltner: { label: 'Keltner Channels', category: 'volatility', compute: keltner, defaults: { length: 20, mult: 2, atrLength: 10 }, inputs: [I.int('length'), I.float('mult', 0.1, 10, 0.1), I.int('atrLength')], pane: 'main', title: p => `KC ${p.length} ${p.mult}`, plots: channel(6) },
90 + donchian: { label: 'Donchian Channels', category: 'volatility', compute: donchian, defaults: { length: 20 }, inputs: [I.int('length')], pane: 'main', title: p => `DC ${len(p)}`, plots: channel(3, 0.06) },
91 +
92 + /* ───── momentum oscillators (pane) ───── */
93 + rsi: { label: 'RSI', category: 'momentum', compute: rsi, defaults: { length: 14, source: 'close' }, inputs: [I.int('length'), I.source()], pane: 'new', title: p => `RSI ${len(p)}`, range: { lo: 0, hi: 100 }, levels: [30, 70], zones: [[30, 70]], plots: [line('rsi', 6)], precision: 2 },
94 + stochrsi: { label: 'Stochastic RSI', category: 'momentum', compute: stochRsi, defaults: { rsiLength: 14, stochLength: 14, k: 3, d: 3, source: 'close' }, inputs: [I.int('rsiLength'), I.int('stochLength'), I.int('k'), I.int('d'), I.source()], pane: 'new', title: p => `StochRSI ${p.rsiLength} ${p.stochLength} ${p.k} ${p.d}`, range: { lo: 0, hi: 100 }, levels: [20, 80], zones: [[20, 80]], plots: [line('k', 0), line('d', 1)], precision: 2 },
61 95 macd: {
62 − label: 'MACD', compute: macd, defaults: { fast: 12, slow: 26, signal: 9, source: 'close' }, pane: 'new',
96 + label: 'MACD', category: 'momentum', compute: macd, defaults: { fast: 12, slow: 26, signal: 9, source: 'close' }, inputs: [I.int('fast'), I.int('slow'), I.int('signal'), I.source()], pane: 'new',
63 97 title: p => `MACD ${p.fast} ${p.slow} ${p.signal}`, levels: [0],
64 − plots: [{ key: 'hist', kind: 'histogram', color: 'updown' }, { key: 'macd', kind: 'line', color: 0 }, { key: 'signal', kind: 'line', color: 1 }],
98 + plots: [{ key: 'hist', kind: 'histogram', color: 'updown' }, line('macd', 0), line('signal', 1)],
65 99 },
66 − stoch: { label: 'Stochastic', compute: stoch, defaults: { k: 14, d: 3, smooth: 3 }, pane: 'new', title: p => `Stoch ${p.k} ${p.d} ${p.smooth}`, range: { lo: 0, hi: 100 }, levels: [20, 80], plots: [{ key: 'k', kind: 'line', color: 0 }, { key: 'd', kind: 'line', color: 1 }] },
67 − atr: { label: 'ATR', compute: atr, defaults: { length: 14 }, pane: 'new', title: p => `ATR ${len(p)}`, plots: [{ key: 'atr', kind: 'line', color: 1 }] },
68 − obv: { label: 'OBV', compute: obv, defaults: {}, pane: 'new', title: () => 'OBV', plots: [{ key: 'obv', kind: 'line', color: 0 }], format: 'compact' },
69 − adx: { label: 'ADX', compute: adx, defaults: { length: 14 }, pane: 'new', title: p => `ADX ${len(p)}`, levels: [25], plots: [{ key: 'adx', kind: 'line', color: 0, width: 1.5 }, { key: 'plusDI', kind: 'line', color: 'up' }, { key: 'minusDI', kind: 'line', color: 'down' }] },
70 − cci: { label: 'CCI', compute: cci, defaults: { length: 20 }, pane: 'new', title: p => `CCI ${len(p)}`, levels: [-100, 100], plots: [{ key: 'cci', kind: 'line', color: 4 }] },
71 − mfi: { label: 'MFI', compute: mfi, defaults: { length: 14 }, pane: 'new', title: p => `MFI ${len(p)}`, range: { lo: 0, hi: 100 }, levels: [20, 80], plots: [{ key: 'mfi', kind: 'line', color: 2 }] },
72 − 'volume-ma': { label: 'Volume', compute: volumeMa, defaults: { length: 20 }, pane: 'new', title: p => `Vol MA ${len(p)}`, plots: [{ key: 'volume', kind: 'histogram', color: 'volume' }, { key: 'ma', kind: 'line', color: 3 }], format: 'compact' },
100 + stoch: { label: 'Stochastic', category: 'momentum', compute: stoch, defaults: { k: 14, d: 3, smooth: 3 }, inputs: [I.int('k'), I.int('d'), I.int('smooth')], pane: 'new', title: p => `Stoch ${p.k} ${p.d} ${p.smooth}`, range: { lo: 0, hi: 100 }, levels: [20, 80], zones: [[20, 80]], plots: [line('k', 0), line('d', 1)], precision: 2 },
101 + williams: { label: 'Williams %R', category: 'momentum', compute: williamsR, defaults: { length: 14 }, inputs: [I.int('length')], pane: 'new', title: p => `%R ${len(p)}`, range: { lo: -100, hi: 0 }, levels: [-80, -20], zones: [[-80, -20]], plots: [line('r', 4)], precision: 2 },
102 + cci: { label: 'CCI', category: 'momentum', compute: cci, defaults: { length: 20 }, inputs: [I.int('length')], pane: 'new', title: p => `CCI ${len(p)}`, levels: [-100, 100], zones: [[-100, 100]], plots: [line('cci', 4)], precision: 2 },
103 + roc: { label: 'Rate of Change', category: 'momentum', compute: roc, defaults: { length: 9, source: 'close' }, inputs: [I.int('length'), I.source()], pane: 'new', title: p => `ROC ${len(p)}`, levels: [0], plots: [line('roc', 0)], precision: 2 },
104 + momentum: { label: 'Momentum', category: 'momentum', compute: momentum, defaults: { length: 10, source: 'close' }, inputs: [I.int('length'), I.source()], pane: 'new', title: p => `Mom ${len(p)}`, levels: [0], plots: [line('mom', 1)] },
105 + ao: { label: 'Awesome Oscillator', category: 'momentum', compute: awesome, defaults: { fast: 5, slow: 34 }, inputs: [I.int('fast'), I.int('slow')], pane: 'new', title: p => `AO ${p.fast} ${p.slow}`, levels: [0], plots: [{ kind: 'histogram', key: 'ao', color: 'delta' }] },
106 + trix: { label: 'TRIX', category: 'momentum', compute: trix, defaults: { length: 15, signal: 9, source: 'close' }, inputs: [I.int('length'), I.int('signal'), I.source()], pane: 'new', title: p => `TRIX ${p.length} ${p.signal}`, levels: [0], plots: [line('trix', 0), line('signal', 1)], precision: 4 },
107 + ultimate: { label: 'Ultimate Oscillator', category: 'momentum', compute: ultimate, defaults: { fast: 7, mid: 14, slow: 28 }, inputs: [I.int('fast'), I.int('mid'), I.int('slow')], pane: 'new', title: p => `UO ${p.fast} ${p.mid} ${p.slow}`, range: { lo: 0, hi: 100 }, levels: [30, 70], zones: [[30, 70]], plots: [line('uo', 2)], precision: 2 },
108 + vortex: { label: 'Vortex', category: 'trend', compute: vortex, defaults: { length: 14 }, inputs: [I.int('length')], pane: 'new', title: p => `VI ${len(p)}`, levels: [1], plots: [line('plus', 'up'), line('minus', 'down')], precision: 3 },
109 + coppock: { label: 'Coppock Curve', category: 'momentum', compute: coppock, defaults: { wma: 10, long: 14, short: 11, source: 'close' }, inputs: [I.int('wma'), I.int('long'), I.int('short'), I.source()], pane: 'new', title: p => `Coppock ${p.wma} ${p.long} ${p.short}`, levels: [0], plots: [{ kind: 'histogram', key: 'coppock', color: 'updown' }], precision: 2 },
110 + dpo: { label: 'Detrended Price', category: 'momentum', compute: dpo, defaults: { length: 20, source: 'close' }, inputs: [I.int('length'), I.source()], pane: 'new', title: p => `DPO ${len(p)}`, levels: [0], plots: [line('dpo', 5)] },
111 + kst: { label: 'Know Sure Thing', category: 'momentum', compute: kst, defaults: { roc1: 10, roc2: 15, roc3: 20, roc4: 30, sma1: 10, sma2: 10, sma3: 10, sma4: 15, signal: 9, source: 'close' }, inputs: [I.int('roc1'), I.int('roc2'), I.int('roc3'), I.int('roc4'), I.int('sma1'), I.int('sma2'), I.int('sma3'), I.int('sma4'), I.int('signal'), I.source()], pane: 'new', title: () => 'KST', levels: [0], plots: [line('kst', 0), line('signal', 1)], precision: 2 },
112 + tsi: { label: 'True Strength Index', category: 'momentum', compute: tsi, defaults: { long: 25, short: 13, signal: 13, source: 'close' }, inputs: [I.int('long'), I.int('short'), I.int('signal'), I.source()], pane: 'new', title: p => `TSI ${p.long} ${p.short} ${p.signal}`, levels: [0], plots: [line('tsi', 0), line('signal', 1)], precision: 2 },
113 + rvi: { label: 'Relative Vigor Index', category: 'momentum', compute: rvi, defaults: { length: 10 }, inputs: [I.int('length')], pane: 'new', title: p => `RVI ${len(p)}`, levels: [0], plots: [line('rvi', 0), line('signal', 1)], precision: 3 },
114 + cmo: { label: 'Chande Momentum', category: 'momentum', compute: cmo, defaults: { length: 9, source: 'close' }, inputs: [I.int('length'), I.source()], pane: 'new', title: p => `CMO ${len(p)}`, range: { lo: -100, hi: 100 }, levels: [-50, 50], zones: [[-50, 50]], plots: [line('cmo', 6)], precision: 2 },
115 + aroon: { label: 'Aroon', category: 'trend', compute: aroon, defaults: { length: 14 }, inputs: [I.int('length')], pane: 'new', title: p => `Aroon ${len(p)}`, range: { lo: 0, hi: 100 }, levels: [30, 70], plots: [line('up', 'up'), line('down', 'down')], precision: 1 },
116 + adx: { label: 'ADX', category: 'trend', compute: adx, defaults: { length: 14 }, inputs: [I.int('length')], pane: 'new', title: p => `ADX ${len(p)}`, levels: [25], plots: [line('adx', 0, { width: 2 }), line('plusDI', 'up'), line('minusDI', 'down')], precision: 2 },
117 + choppiness: { label: 'Choppiness Index', category: 'volatility', compute: choppiness, defaults: { length: 14 }, inputs: [I.int('length')], pane: 'new', title: p => `CHOP ${len(p)}`, range: { lo: 0, hi: 100 }, levels: [38.2, 61.8], zones: [[38.2, 61.8]], plots: [line('chop', 3)], precision: 2 },
118 + mass: { label: 'Mass Index', category: 'volatility', compute: massIndex, defaults: { ema: 9, sum: 25 }, inputs: [I.int('ema'), I.int('sum')], pane: 'new', title: p => `Mass ${p.ema} ${p.sum}`, levels: [26.5, 27], plots: [line('mass', 5)], precision: 2 },
119 + 'elder-ray': { label: 'Elder Ray', category: 'momentum', compute: elderRay, defaults: { length: 13 }, inputs: [I.int('length')], pane: 'new', title: p => `Elder Ray ${len(p)}`, levels: [0], plots: [{ kind: 'histogram', key: 'bull', color: 'up' }, { kind: 'histogram', key: 'bear', color: 'down' }] },
120 + atr: { label: 'ATR', category: 'volatility', compute: atr, defaults: { length: 14 }, inputs: [I.int('length')], pane: 'new', title: p => `ATR ${len(p)}`, plots: [line('atr', 1)] },
121 + 'bb-pctb': { label: 'Bollinger %B', category: 'volatility', compute: bollingerPctB, defaults: { length: 20, mult: 2, source: 'close' }, inputs: [I.int('length'), I.float('mult', 0.1, 10, 0.1), I.source()], pane: 'new', title: p => `%B ${p.length} ${p.mult}`, levels: [0, 1], zones: [[0, 1]], plots: [line('pctb', 0)], precision: 3 },
122 + 'bb-width': { label: 'Bollinger Bandwidth', category: 'volatility', compute: bollingerWidth, defaults: { length: 20, mult: 2, source: 'close' }, inputs: [I.int('length'), I.float('mult', 0.1, 10, 0.1), I.source()], pane: 'new', title: p => `BBW ${p.length} ${p.mult}`, plots: [line('bbw', 0)], precision: 2 },
123 + hv: { label: 'Historical Volatility', category: 'volatility', compute: historicalVolatility, defaults: { length: 10, annual: 252 }, inputs: [I.int('length'), I.int('annual', 1, 100000, 'periods per year')], pane: 'new', title: p => `HV ${len(p)}`, plots: [line('hv', 4)], precision: 2 },
124 + stddev: { label: 'Standard Deviation', category: 'volatility', compute: stddev, defaults: { length: 20, source: 'close' }, inputs: [I.int('length'), I.source()], pane: 'new', title: p => `StdDev ${len(p)}`, plots: [line('stddev', 2)] },
125 +
126 + /* ───── volume (pane) ───── */
127 + obv: { label: 'OBV', category: 'volume', compute: obv, defaults: {}, inputs: [], pane: 'new', title: () => 'OBV', plots: [line('obv', 0)], format: 'compact' },
128 + ad: { label: 'Accumulation / Distribution', category: 'volume', compute: adLine, defaults: {}, inputs: [], pane: 'new', title: () => 'A/D', plots: [line('ad', 2)], format: 'compact' },
129 + mfi: { label: 'MFI', category: 'volume', compute: mfi, defaults: { length: 14 }, inputs: [I.int('length')], pane: 'new', title: p => `MFI ${len(p)}`, range: { lo: 0, hi: 100 }, levels: [20, 80], zones: [[20, 80]], plots: [line('mfi', 2)], precision: 2 },
130 + cmf: { label: 'Chaikin Money Flow', category: 'volume', compute: cmf, defaults: { length: 20 }, inputs: [I.int('length')], pane: 'new', title: p => `CMF ${len(p)}`, levels: [0], plots: [line('cmf', 2)], precision: 3 },
131 + 'chaikin-osc': { label: 'Chaikin Oscillator', category: 'volume', compute: chaikinOsc, defaults: { fast: 3, slow: 10 }, inputs: [I.int('fast'), I.int('slow')], pane: 'new', title: p => `Chaikin ${p.fast} ${p.slow}`, levels: [0], plots: [line('osc', 2)], format: 'compact' },
132 + force: { label: 'Force Index', category: 'volume', compute: forceIndex, defaults: { length: 13 }, inputs: [I.int('length')], pane: 'new', title: p => `Force ${len(p)}`, levels: [0], plots: [line('force', 1)], format: 'compact' },
133 + eom: { label: 'Ease of Movement', category: 'volume', compute: eom, defaults: { length: 14, divisor: 10000 }, inputs: [I.int('length'), I.int('divisor', 1, 1e9)], pane: 'new', title: p => `EOM ${len(p)}`, levels: [0], plots: [line('eom', 3)], precision: 3 },
134 + klinger: { label: 'Klinger Oscillator', category: 'volume', compute: klinger, defaults: { fast: 34, slow: 55, signal: 13 }, inputs: [I.int('fast'), I.int('slow'), I.int('signal')], pane: 'new', title: p => `KVO ${p.fast} ${p.slow} ${p.signal}`, levels: [0], plots: [line('kvo', 0), line('signal', 1)], format: 'compact' },
135 + 'volume-osc': { label: 'Volume Oscillator', category: 'volume', compute: volumeOsc, defaults: { fast: 5, slow: 10 }, inputs: [I.int('fast'), I.int('slow')], pane: 'new', title: p => `VO ${p.fast} ${p.slow}`, levels: [0], plots: [line('vo', 3)], precision: 2 },
136 + 'volume-ma': { label: 'Volume', category: 'volume', compute: volumeMa, defaults: { length: 20 }, inputs: [I.int('length')], pane: 'new', title: p => `Vol MA ${len(p)}`, plots: [{ key: 'volume', kind: 'histogram', color: 'volume' }, line('ma', 3)], format: 'compact' },
73 137 }
74 138
75 139 export const INDICATOR_TYPES = Object.keys(REGISTRY)
140 +export const CATEGORIES = ['trend', 'momentum', 'volatility', 'volume', 'levels']
76 141
77 142 export function indicatorSpec(type) {
78 143 const spec = REGISTRY[type]
@@ -85,8 +150,28 @@ export function indicatorParams(type, params) {
85 150 return { ...indicatorSpec(type).defaults, ...(params || {}) }
86 151 }
87 152
88 −/** Compute an indicator by type. */
89 −export function computeIndicator(type, bars, params) {
153 +/** Compute an indicator by type. `range` = { from, to } is only used by dynamic (visible-range) indicators. */
154 +export function computeIndicator(type, bars, params, range) {
90 155 const spec = indicatorSpec(type)
91 − return spec.compute(bars, indicatorParams(type, params))
156 + return spec.compute(bars, indicatorParams(type, params), range)
157 +}
158 +
159 +/** Keys of the series an indicator plots (band / cloud plots expand to their two keys). */
160 +export function plotKeys(plot) {
161 + if (plot.kind === 'band') return [plot.upper, plot.lower]
162 + if (plot.kind === 'cloud') return [plot.a, plot.b]
163 + if (plot.kind === 'profile' || plot.kind === 'hlevels') return []
164 + return plot.key ? [plot.key] : []
165 +}
166 +
167 +/** Metadata for the indicator library UI (no compute functions): [{ id, name, category, pane, defaults, inputs, plots, levels, range, precision, dynamic }]. */
168 +export function listIndicators() {
169 + return INDICATOR_TYPES.map(id => {
170 + const s = REGISTRY[id]
171 + return {
172 + id, name: s.label, category: s.category || 'other', pane: s.pane, defaults: { ...s.defaults }, inputs: (s.inputs || []).map(i => ({ ...i })),
173 + plots: s.plots.map(p => ({ ...p })), levels: s.levels ? s.levels.slice() : [], zones: s.zones ? s.zones.map(z => z.slice()) : [], range: s.range ? { ...s.range } : null,
174 + precision: s.precision ?? null, format: s.format || 'price', dynamic: !!s.dynamic, title: s.title(s.defaults),
175 + }
176 + })
92 177 }
added hfmarketdata/web/src/charts/indicators/indicators-v2.test.js +224 −0
@@ -0,0 +1,224 @@
1 +// v2 indicators against hand-computed / closed-form reference values.
2 +import { test } from 'node:test'
3 +import assert from 'node:assert/strict'
4 +import {
5 + hma, dema, tema, kama, alma, lsma, envelope, vwap, avwap, psar, zigzag, pivots, pivotLevels, volumeProfile, autoFib,
6 + stochRsi, williamsR, roc, momentum, awesome, trix, ultimate, vortex, coppock, dpo, kst, tsi, rvi, cmo, aroon, choppiness, massIndex, elderRay,
7 + bollingerPctB, bollingerWidth, historicalVolatility, stddev, adLine, cmf, chaikinOsc, forceIndex, eom, klinger, volumeOsc,
8 + INDICATOR_TYPES, REGISTRY, computeIndicator, listIndicators, plotKeys, SOURCES,
9 +} from './index.js'
10 +
11 +const near = (a, b, eps = 1e-6) => assert.ok(a != null && Math.abs(a - b) <= eps, `${a} ≉ ${b}`)
12 +const nearArr = (arr, ref, eps = 1e-6) => {
13 + assert.equal(arr.length, ref.length)
14 + arr.forEach((v, i) => { if (ref[i] == null) assert.equal(v, null, `index ${i} should be null (got ${v})`); else near(v, ref[i], eps) })
15 +}
16 +const closes = cs => cs.map((c, i) => ({ t: i * 60_000, o: c, h: c + 1, l: c - 1, c, v: 100 }))
17 +const HLC = [
18 + { t: Date.UTC(2024, 0, 2, 10, 0), o: 9, h: 10, l: 8, c: 9, v: 100 },
19 + { t: Date.UTC(2024, 0, 2, 10, 1), o: 9, h: 11, l: 9, c: 10, v: 200 },
20 + { t: Date.UTC(2024, 0, 3, 9, 30), o: 10, h: 13, l: 10, c: 12, v: 300 },
21 +]
22 +
23 +/* ───── moving averages ───── */
24 +
25 +test('hma of a linear series is the series itself (WMA_n of x_i = x_i − (n−1)/3 cancels exactly)', () => {
26 + const bars = closes([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
27 + const h = hma(bars, { length: 4 }).hma
28 + assert.equal(h[3], null)
29 + for (let i = 4; i < 10; i++) near(h[i], i + 1)
30 +})
31 +
32 +test('dema / tema hand-computed with length 2 (and identity with length 1)', () => {
33 + nearArr(dema(closes([1, 2, 3, 4]), { length: 2 }).dema, [null, null, 3, 4])
34 + nearArr(tema(closes([1, 2, 4]), { length: 1 }).tema, [1, 2, 4])
35 + // TEMA(2) of 1,2,3,4: e1 = [_,1.5,2.5,3.5], e2 = [_,_,2,3], e3 = [_,_,_,2.5] → 3·3.5 − 3·3 + 2.5 = 4
36 + nearArr(tema(closes([1, 2, 3, 4]), { length: 2 }).tema, [null, null, null, 4])
37 +})
38 +
39 +test('kama: efficiency ratio 1 on a straight line gives SC = (2/(fast+1))²; flat series stays flat', () => {
40 + const k = kama(closes([1, 2, 3, 4, 5]), { length: 2, fast: 2, slow: 30 }).kama
41 + assert.equal(k[1], null)
42 + near(k[2], 2 + (4 / 9) * (3 - 2))
43 + near(k[3], k[2] + (4 / 9) * (4 - k[2]))
44 + nearArr(kama(closes([5, 5, 5, 5]), { length: 2 }).kama, [null, null, 5, 5])
45 +})
46 +
47 +test('alma: symmetric window (offset 0.5) on a linear series returns the middle value', () => {
48 + nearArr(alma(closes([1, 2, 3]), { length: 3, offset: 0.5, sigma: 6 }).alma, [null, null, 2])
49 + nearArr(alma(closes([7, 7, 7, 7]), { length: 3 }).alma, [null, null, 7, 7])
50 +})
51 +
52 +test('lsma: exact on a line, hand-computed regression on [1, 2, 4] with residual channel', () => {
53 + const lin = lsma(closes([1, 2, 3, 4, 5]), { length: 3, mult: 2 })
54 + nearArr(lin.lsma, [null, null, 3, 4, 5]); nearArr(lin.slope, [null, null, 1, 1, 1]); nearArr(lin.upper, [null, null, 3, 4, 5])
55 + const r = lsma(closes([1, 2, 4]), { length: 3, mult: 2 })
56 + near(r.lsma[2], 23 / 6); near(r.slope[2], 1.5)
57 + const sd = Math.sqrt((1 / 36 + 4 / 36 + 1 / 36) / 3)
58 + near(r.upper[2], 23 / 6 + 2 * sd); near(r.lower[2], 23 / 6 - 2 * sd)
59 +})
60 +
61 +test('envelope ± percent around the MA', () => {
62 + const e = envelope(closes([10, 20]), { length: 2, percent: 10 })
63 + nearArr(e.middle, [null, 15]); nearArr(e.upper, [null, 16.5]); nearArr(e.lower, [null, 13.5])
64 +})
65 +
66 +test('vwap σ bands and anchored vwap', () => {
67 + const v = vwap(HLC, { anchor: 'all', bands: 1 })
68 + near(v.upper[0], 9); near(v.lower[0], 9)
69 + const mean = 29 / 3, sd = Math.sqrt(28100 / 300 - mean * mean)
70 + near(v.upper[1], mean + sd); near(v.lower[1], mean - sd)
71 + assert.deepEqual(vwap(HLC).upper, [null, null, null]) // bands off by default
72 + const a = avwap(HLC, { anchor: HLC[1].t, bands: 0 })
73 + nearArr(a.avwap, [null, 10, (2000 + 3500) / 500])
74 + nearArr(avwap(HLC, { anchor: null, bands: 0 }).avwap, vwap(HLC, { anchor: 'all' }).vwap)
75 +})
76 +
77 +/* ───── price structure ───── */
78 +
79 +test('parabolic sar: acceleration, EP tracking and reversal to the extreme point', () => {
80 + const bars = [
81 + { t: 0, o: 9.5, h: 10, l: 9, c: 9.5 }, { t: 1, o: 10.5, h: 11, l: 10, c: 10.5 }, { t: 2, o: 11.5, h: 12, l: 11, c: 11.5 },
82 + { t: 3, o: 12, h: 12.5, l: 11.5, c: 12 }, { t: 4, o: 8.5, h: 10, l: 8, c: 8.5 },
83 + ]
84 + // bar 2: 9 + 0.02·(11−9) = 9.04, clamped to the prior two lows (9) ; EP → 12, AF → 0.04
85 + // bar 3: 9 + 0.04·(12−9) = 9.12 ; EP → 12.5, AF → 0.06 ; bar 4: low 8 < 9.3228 → reversal, SAR = EP = 12.5
86 + const r = psar(bars)
87 + nearArr(r.sar, [null, 9, 9, 9.12, 12.5])
88 + near(r.bull[3], 9.12); assert.equal(r.bear[3], null)
89 + near(r.bear[4], 12.5); assert.equal(r.bull[4], null)
90 +})
91 +
92 +test('zigzag pivots (10 % deviation) and per-bar direction', () => {
93 + const hl = [[10, 9], [12, 11], [13, 12], [11, 10], [9.5, 8.5], [11, 10]]
94 + const bars = hl.map(([h, l], i) => ({ t: i, o: (h + l) / 2, h, l, c: (h + l) / 2 }))
95 + const z = zigzag(bars, { deviation: 10 })
96 + nearArr(z.zigzag, [9, null, 13, null, 8.5, 11])
97 + assert.deepEqual(z.direction, [null, 1, 1, -1, -1, 1])
98 + const flat = zigzag(closes([5, 5, 5]), { deviation: 10 })
99 + assert.deepEqual(flat.zigzag, [null, null, null])
100 +})
101 +
102 +test('pivot points: classic / fibonacci / camarilla / woodie levels from the previous session', () => {
103 + const day = Date.UTC(2024, 0, 2, 10)
104 + const bars = [{ t: day, o: 9, h: 10, l: 8, c: 9 }, { t: day + 60_000, o: 9, h: 11, l: 9, c: 10 }, { t: day + 86_400_000, o: 10.5, h: 11, l: 10, c: 10.8 }]
105 + const p = pivots(bars, { type: 'classic', period: 'day' })
106 + nearArr(p.p, [null, null, 29 / 3]); near(p.r1[2], 58 / 3 - 8); near(p.s1[2], 58 / 3 - 11); near(p.r2[2], 29 / 3 + 3); near(p.s2[2], 29 / 3 - 3)
107 + near(p.r3[2], 11 + 2 * (29 / 3 - 8)); near(p.s3[2], 8 - 2 * (11 - 29 / 3)); assert.equal(p.r4[2], null)
108 + const f = pivotLevels('fibonacci', 11, 8, 10, 10.5)
109 + near(f.r1, 29 / 3 + 0.382 * 3); near(f.s3, 29 / 3 - 3)
110 + const c = pivotLevels('camarilla', 11, 8, 10, 10.5)
111 + near(c.r1, 10 + 3 * 1.1 / 12); near(c.r4, 10 + 3 * 1.1 / 2); near(c.s2, 10 - 3 * 1.1 / 6)
112 + const w = pivotLevels('woodie', 11, 8, 10, 10.5)
113 + near(w.p, 10); near(w.r1, 12); near(w.s1, 9)
114 + // Weekly grouping: bars on Mon/Tue of one week then Mon of the next.
115 + const mon = Date.UTC(2024, 0, 8), wk = pivots([{ t: mon, o: 1, h: 3, l: 1, c: 2 }, { t: mon + 86_400_000, o: 2, h: 4, l: 2, c: 3 }, { t: mon + 7 * 86_400_000, o: 3, h: 3, l: 3, c: 3 }], { period: 'week' })
116 + nearArr(wk.p, [null, null, (4 + 1 + 3) / 3])
117 +})
118 +
119 +test('volume profile: proportional spreading, POC and value area', () => {
120 + const bars = [{ t: 0, o: 10, h: 12, l: 10, c: 12, v: 100 }, { t: 1, o: 12, h: 12, l: 11, c: 11, v: 300 }]
121 + const vp = volumeProfile(bars, { rows: 2, valueArea: 70 })
122 + assert.equal(vp.rows.length, 2)
123 + near(vp.rows[0].vol, 50); near(vp.rows[1].vol, 350); near(vp.rows[0].up, 50); near(vp.rows[1].down, 300)
124 + near(vp.poc, 11.5); near(vp.vah, 12); near(vp.val, 11); near(vp.total, 400)
125 + const sub = volumeProfile(bars, { rows: 2 }, { from: 1, to: 1 })
126 + near(sub.total, 300); near(sub.rows[0].lo, 11)
127 + assert.equal(volumeProfile([], {}).poc, null)
128 +})
129 +
130 +test('auto fibonacci of a range: swing from the earlier extreme to the later one', () => {
131 + const bars = [{ t: 0, o: 10, h: 11, l: 10, c: 10 }, { t: 1, o: 12, h: 15, l: 12, c: 14 }, { t: 2, o: 13, h: 14, l: 13, c: 13 }]
132 + const f = autoFib(bars, { levels: [0, 0.5, 1] })
133 + assert.equal(f.up, true); assert.deepEqual(f.start, { index: 0, price: 10 }); assert.deepEqual(f.end, { index: 1, price: 15 })
134 + near(f.levels[0].price, 15); near(f.levels[1].price, 12.5); near(f.levels[2].price, 10)
135 + const down = autoFib(bars.slice().reverse().map((b, i) => ({ ...b, t: i })), { levels: [0.5] })
136 + assert.equal(down.up, false); near(down.levels[0].price, 12.5)
137 +})
138 +
139 +/* ───── momentum ───── */
140 +
141 +test('stoch rsi / williams %R / roc / momentum', () => {
142 + nearArr(stochRsi(closes([10, 11, 12, 11, 12]), { rsiLength: 2, stochLength: 3, k: 1, d: 1 }).k, [null, null, null, null, 50])
143 + nearArr(williamsR(HLC, { length: 2 }).r, [null, -100 / 3, -25])
144 + nearArr(roc(closes([1, 2, 3, 4]), { length: 1 }).roc, [null, 100, 50, 100 / 3])
145 + nearArr(momentum(closes([1, 2, 3, 4]), { length: 2 }).mom, [null, null, 2, 2])
146 +})
147 +
148 +test('awesome oscillator / trix / ultimate / vortex', () => {
149 + nearArr(awesome(closes([1, 2, 3]), { fast: 1, slow: 2 }).ao, [null, 0.5, 0.5])
150 + const t = trix(closes([1, 2, 4]), { length: 1, signal: 1 })
151 + nearArr(t.trix, [null, 100, 100]); nearArr(t.signal, [null, 100, 100])
152 + nearArr(ultimate(HLC, { fast: 1, mid: 1, slow: 1 }).uo, [null, 50, 200 / 3])
153 + const v = vortex(HLC, { length: 1 })
154 + nearArr(v.plus, [null, 1.5, 4 / 3]); nearArr(v.minus, [null, 0.5, 1 / 3])
155 +})
156 +
157 +test('coppock / dpo / kst / tsi', () => {
158 + nearArr(coppock(closes([1, 2, 4]), { wma: 1, long: 1, short: 1 }).coppock, [null, 200, 200])
159 + nearArr(dpo(closes([1, 2, 3, 4, 5]), { length: 2 }).dpo, [null, null, null, 2.5, 2.5])
160 + const k = kst(closes([1, 2, 4]), { roc1: 1, roc2: 1, roc3: 1, roc4: 1, sma1: 1, sma2: 1, sma3: 1, sma4: 1, signal: 1 })
161 + nearArr(k.kst, [null, 1000, 1000]); nearArr(k.signal, [null, 1000, 1000])
162 + nearArr(tsi(closes([1, 2, 4, 3]), { long: 1, short: 1, signal: 1 }).tsi, [null, 100, 100, -100])
163 +})
164 +
165 +test('relative vigor / cmo / aroon / choppiness / mass index / elder ray', () => {
166 + const same = Array.from({ length: 7 }, (_, i) => ({ t: i, o: 1, h: 3, l: 0, c: 2, v: 1 }))
167 + const r = rvi(same, { length: 1 })
168 + near(r.rvi[3], 1 / 3); assert.equal(r.rvi[2], null); near(r.signal[6], 1 / 3); assert.equal(r.signal[5], null)
169 + nearArr(cmo(closes([1, 2, 3, 2]), { length: 2 }).cmo, [null, null, 100, 0])
170 + const a = aroon(HLC, { length: 2 })
171 + nearArr(a.up, [null, null, 100]); nearArr(a.down, [null, null, 0])
172 + const c = choppiness(HLC, { length: 2 }).chop
173 + near(c[1], (100 * Math.log10(4 / 3)) / Math.log10(2)); near(c[2], (100 * Math.log10(5 / 4)) / Math.log10(2))
174 + nearArr(massIndex(HLC, { ema: 1, sum: 2 }).mass, [null, 2, 2])
175 + const e = elderRay(closes([5, 6, 7]), { length: 1 })
176 + nearArr(e.bull, [1, 1, 1]); nearArr(e.bear, [-1, -1, -1])
177 +})
178 +
179 +test('bollinger %B / bandwidth / historical volatility / stddev', () => {
180 + nearArr(bollingerPctB(closes([1, 3, 5]), { length: 2, mult: 2 }).pctb, [null, 0.75, 0.75])
181 + nearArr(bollingerWidth(closes([1, 3, 5]), { length: 2, mult: 2 }).bbw, [null, 200, 100])
182 + nearArr(historicalVolatility(closes([1, Math.E, Math.E * Math.E, Math.E * Math.E]), { length: 2, annual: 1 }).hv, [null, null, 0, 50])
183 + nearArr(stddev(closes([1, 3, 5]), { length: 2 }).stddev, [null, 1, 1])
184 +})
185 +
186 +/* ───── volume ───── */
187 +
188 +test('a/d line / cmf / chaikin oscillator / force index / ease of movement / klinger / volume oscillator', () => {
189 + nearArr(adLine(HLC).ad, [0, 0, 100])
190 + nearArr(cmf(HLC, { length: 3 }).cmf, [null, null, 1 / 6])
191 + nearArr(chaikinOsc(HLC, { fast: 1, slow: 2 }).osc, [null, 0, 100 / 3])
192 + nearArr(forceIndex(HLC, { length: 1 }).force, [null, 200, 600])
193 + nearArr(eom(HLC, { length: 1, divisor: 1 }).eom, [null, 0.01, 0.015])
194 + nearArr(klinger(HLC, { fast: 1, slow: 2, signal: 1 }).kvo, [null, null, 50])
195 + nearArr(volumeOsc(HLC, { fast: 1, slow: 2 }).vo, [null, 100 / 3, 20])
196 +})
197 +
198 +/* ───── registry metadata ───── */
199 +
200 +test('registry v2: ≥ 60 indicators, typed inputs matching defaults, listIndicators() is plain data', () => {
201 + assert.ok(INDICATOR_TYPES.length >= 60, `${INDICATOR_TYPES.length} indicators`)
202 + const types = new Set(['int', 'float', 'source', 'bool', 'select', 'time'])
203 + const bars = closes(Array.from({ length: 120 }, (_, i) => 100 + Math.sin(i / 6) * 5))
204 + for (const id of INDICATOR_TYPES) {
205 + const s = REGISTRY[id]
206 + assert.ok(['trend', 'momentum', 'volatility', 'volume', 'levels'].includes(s.category), `${id} category`)
207 + assert.ok(Array.isArray(s.inputs), `${id} inputs`)
208 + for (const inp of s.inputs) {
209 + assert.ok(types.has(inp.type), `${id}.${inp.name} type ${inp.type}`)
210 + assert.ok(inp.name in s.defaults, `${id}.${inp.name} has no default`)
211 + if (inp.type === 'source') assert.deepEqual(inp.options, SOURCES)
212 + if (inp.type === 'select') assert.ok(inp.options.includes(s.defaults[inp.name]), `${id}.${inp.name} default not in options`)
213 + }
214 + const values = computeIndicator(id, bars, {}, { from: 20, to: 100 })
215 + for (const plot of s.plots) for (const k of plotKeys(plot)) assert.ok(Array.isArray(values[k]), `${id}.${k} missing`)
216 + if (s.dynamic) assert.ok(typeof values === 'object')
217 + }
218 + const list = listIndicators()
219 + assert.equal(list.length, INDICATOR_TYPES.length)
220 + const rsi = list.find(x => x.id === 'rsi')
221 + assert.deepEqual(rsi.levels, [30, 70]); assert.deepEqual(rsi.range, { lo: 0, hi: 100 }); assert.equal(rsi.title, 'RSI 14')
222 + assert.ok(JSON.stringify(list).length > 1000) // serializable (no functions)
223 + assert.equal(JSON.parse(JSON.stringify(list)).find(x => x.id === 'volume-profile').dynamic, true)
224 +})
modified hfmarketdata/web/src/charts/indicators/indicators.test.js +2 −3
@@ -1,6 +1,6 @@
1 1 import { test } from 'node:test'
2 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'
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, plotKeys } from './index.js'
4 4
5 5 const near = (a, b, eps = 1e-6) => assert.ok(a != null && Math.abs(a - b) <= eps, `${a} ≉ ${b}`)
6 6 const nearArr = (arr, ref, eps = 1e-6) => {
@@ -112,8 +112,7 @@ test('registry: every type computes with defaults and emits the declared plot ke
112 112 const spec = REGISTRY[type]
113 113 const values = computeIndicator(type, bars, {})
114 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) {
115 + for (const k of plotKeys(plot)) {
117 116 assert.ok(Array.isArray(values[k]), `${type}.${k} missing`)
118 117 assert.ok(values[k].length >= bars.length, `${type}.${k} too short`)
119 118 for (const v of values[k]) assert.ok(v === null || Number.isFinite(v), `${type}.${k} has ${v}`)
added hfmarketdata/web/src/charts/indicators/momentum.js +268 −0
@@ -0,0 +1,268 @@
1 +// Momentum / volatility oscillators (v2): Stoch RSI, Williams %R, ROC, Momentum, Awesome Oscillator, TRIX,
2 +// Ultimate Oscillator, Vortex, Coppock, Detrended Price, KST, TSI, Relative Vigor, Chande Momentum, Aroon,
3 +// Choppiness, Mass Index, Elder Ray, Bollinger %B / Bandwidth, Historical Volatility, Standard Deviation.
4 +// Each function: (bars, params) → { key: (number|null)[] }; null until the window is full.
5 +
6 +import { source, rollingMean, rollingEma, rollingSum, rollingWma, rollingMax, rollingMin, rollingStd, trueRange, compactEma, compactMean, nullable, int, pos } from './util.js'
7 +import { rsi } from './oscillators.js'
8 +
9 +const toF = arr => { const out = new Float64Array(arr.length); for (let i = 0; i < arr.length; i++) out[i] = arr[i] == null ? NaN : arr[i]; return out }
10 +
11 +/** Stochastic RSI. Defaults: { rsiLength: 14, stochLength: 14, k: 3, d: 3, source: 'close' } → k, d (0–100). */
12 +export function stochRsi(bars, { rsiLength = 14, stochLength = 14, k = 3, d = 3, source: src = 'close' } = {}) {
13 + rsiLength = int(rsiLength, 14); stochLength = int(stochLength, 14); k = int(k, 3); d = int(d, 3)
14 + const r = toF(rsi(bars, { length: rsiLength, source: src }).rsi)
15 + const n = r.length
16 + let start = 0
17 + while (start < n && Number.isNaN(r[start])) start++
18 + const sub = r.subarray(start)
19 + const hh = rollingMax(sub, stochLength), ll = rollingMin(sub, stochLength)
20 + const raw = new Float64Array(n).fill(NaN)
21 + for (let i = 0; i < sub.length; i++) { const rg = hh[i] - ll[i]; if (Number.isNaN(rg)) continue; raw[start + i] = rg === 0 ? 0 : ((sub[i] - ll[i]) / rg) * 100 }
22 + const kk = compactMean(raw, k)
23 + const dd = compactMean(kk, d)
24 + return { k: nullable(kk), d: nullable(dd) }
25 +}
26 +
27 +/** Williams %R. Defaults: { length: 14 } → r (−100…0). */
28 +export function williamsR(bars, { length = 14 } = {}) {
29 + length = int(length, 14)
30 + const hh = rollingMax(source(bars, 'high'), length), ll = rollingMin(source(bars, 'low'), length)
31 + const n = bars.length
32 + const out = new Float64Array(n).fill(NaN)
33 + for (let i = 0; i < n; i++) { const rg = hh[i] - ll[i]; if (Number.isNaN(rg)) continue; out[i] = rg === 0 ? -50 : ((hh[i] - bars[i].c) / rg) * -100 }
34 + return { r: nullable(out) }
35 +}
36 +
37 +/** Rate of change in percent. Defaults: { length: 9, source: 'close' } → roc. */
38 +export function roc(bars, { length = 9, source: src = 'close' } = {}) {
39 + length = int(length, 9)
40 + const s = source(bars, src)
41 + const out = new Float64Array(s.length).fill(NaN)
42 + for (let i = length; i < s.length; i++) { const p = s[i - length]; if (p) out[i] = ((s[i] - p) / p) * 100 }
43 + return { roc: nullable(out) }
44 +}
45 +
46 +/** Momentum (price difference). Defaults: { length: 10, source: 'close' } → mom. */
47 +export function momentum(bars, { length = 10, source: src = 'close' } = {}) {
48 + length = int(length, 10)
49 + const s = source(bars, src)
50 + const out = new Float64Array(s.length).fill(NaN)
51 + for (let i = length; i < s.length; i++) out[i] = s[i] - s[i - length]
52 + return { mom: nullable(out) }
53 +}
54 +
55 +/** Awesome Oscillator: SMA(hl2, fast) − SMA(hl2, slow). Defaults: { fast: 5, slow: 34 } → ao. */
56 +export function awesome(bars, { fast = 5, slow = 34 } = {}) {
57 + fast = int(fast, 5); slow = int(slow, 34)
58 + const s = source(bars, 'hl2')
59 + const a = rollingMean(s, fast), b = rollingMean(s, slow)
60 + const out = new Float64Array(s.length)
61 + for (let i = 0; i < out.length; i++) out[i] = a[i] - b[i]
62 + return { ao: nullable(out) }
63 +}
64 +
65 +/** TRIX: 1-bar percent change (×100) of a triple EMA, with a signal EMA. Defaults: { length: 15, signal: 9, source: 'close' } → trix, signal. */
66 +export function trix(bars, { length = 15, signal = 9, source: src = 'close' } = {}) {
67 + length = int(length, 15); signal = int(signal, 9)
68 + const e1 = rollingEma(source(bars, src), length)
69 + const e2 = compactEma(e1, length), e3 = compactEma(e2, length)
70 + const n = e3.length
71 + const out = new Float64Array(n).fill(NaN)
72 + for (let i = 1; i < n; i++) { const p = e3[i - 1]; if (!Number.isNaN(p) && p !== 0) out[i] = ((e3[i] - p) / p) * 100 }
73 + return { trix: nullable(out), signal: nullable(compactEma(out, signal)) }
74 +}
75 +
76 +/** Ultimate Oscillator. Defaults: { fast: 7, mid: 14, slow: 28 } → uo (0–100). */
77 +export function ultimate(bars, { fast = 7, mid = 14, slow = 28 } = {}) {
78 + fast = int(fast, 7); mid = int(mid, 14); slow = int(slow, 28)
79 + const n = bars.length
80 + const bp = new Float64Array(n).fill(NaN), tr = new Float64Array(n).fill(NaN)
81 + for (let i = 1; i < n; i++) {
82 + const pc = bars[i - 1].c
83 + const lo = Math.min(bars[i].l, pc), hi = Math.max(bars[i].h, pc)
84 + bp[i] = bars[i].c - lo; tr[i] = hi - lo
85 + }
86 + const avg = len => { const a = rollingSum(bp.subarray(1), len), b = rollingSum(tr.subarray(1), len); const o = new Float64Array(n).fill(NaN); for (let i = 0; i < a.length; i++) o[i + 1] = b[i] === 0 ? 0 : a[i] / b[i]; return o }
87 + const a1 = avg(fast), a2 = avg(mid), a3 = avg(slow)
88 + const out = new Float64Array(n)
89 + for (let i = 0; i < n; i++) out[i] = (100 * (4 * a1[i] + 2 * a2[i] + a3[i])) / 7
90 + return { uo: nullable(out) }
91 +}
92 +
93 +/** Vortex Indicator. Defaults: { length: 14 } → plus, minus. */
94 +export function vortex(bars, { length = 14 } = {}) {
95 + length = int(length, 14)
96 + const n = bars.length
97 + const vmp = new Float64Array(n).fill(NaN), vmm = new Float64Array(n).fill(NaN)
98 + const tr = trueRange(bars)
99 + for (let i = 1; i < n; i++) { vmp[i] = Math.abs(bars[i].h - bars[i - 1].l); vmm[i] = Math.abs(bars[i].l - bars[i - 1].h) }
100 + const sp = rollingSum(vmp.subarray(1), length), sm = rollingSum(vmm.subarray(1), length), st = rollingSum(tr.subarray(1), length)
101 + const plus = new Float64Array(n).fill(NaN), minus = new Float64Array(n).fill(NaN)
102 + for (let i = 0; i < sp.length; i++) { if (Number.isNaN(st[i]) || st[i] === 0) continue; plus[i + 1] = sp[i] / st[i]; minus[i + 1] = sm[i] / st[i] }
103 + return { plus: nullable(plus), minus: nullable(minus) }
104 +}
105 +
106 +/** Coppock Curve: WMA(ROC(long) + ROC(short), wma). Defaults: { wma: 10, long: 14, short: 11, source: 'close' } → coppock. */
107 +export function coppock(bars, { wma: wmaLen = 10, long = 14, short = 11, source: src = 'close' } = {}) {
108 + wmaLen = int(wmaLen, 10); long = int(long, 14); short = int(short, 11)
109 + const a = toF(roc(bars, { length: long, source: src }).roc), b = toF(roc(bars, { length: short, source: src }).roc)
110 + const sum = new Float64Array(a.length)
111 + for (let i = 0; i < a.length; i++) sum[i] = a[i] + b[i]
112 + let start = 0
113 + while (start < sum.length && Number.isNaN(sum[start])) start++
114 + const out = new Float64Array(a.length).fill(NaN)
115 + const w = rollingWma(sum.subarray(start), wmaLen)
116 + for (let i = 0; i < w.length; i++) out[start + i] = w[i]
117 + return { coppock: nullable(out) }
118 +}
119 +
120 +/** Detrended Price Oscillator (non-centered): price − SMA(length) shifted back length/2 + 1. Defaults: { length: 20, source: 'close' } → dpo. */
121 +export function dpo(bars, { length = 20, source: src = 'close' } = {}) {
122 + length = int(length, 20)
123 + const s = source(bars, src)
124 + const ma = rollingMean(s, length)
125 + const shift = Math.floor(length / 2) + 1
126 + const out = new Float64Array(s.length).fill(NaN)
127 + for (let i = shift; i < s.length; i++) out[i] = s[i] - ma[i - shift]
128 + return { dpo: nullable(out) }
129 +}
130 +
131 +/** Know Sure Thing. Defaults: { roc1: 10, roc2: 15, roc3: 20, roc4: 30, sma1: 10, sma2: 10, sma3: 10, sma4: 15, signal: 9, source: 'close' } → kst, signal. */
132 +export function kst(bars, { roc1 = 10, roc2 = 15, roc3 = 20, roc4 = 30, sma1 = 10, sma2 = 10, sma3 = 10, sma4 = 15, signal = 9, source: src = 'close' } = {}) {
133 + const P = [[int(roc1, 10), int(sma1, 10)], [int(roc2, 15), int(sma2, 10)], [int(roc3, 20), int(sma3, 10)], [int(roc4, 30), int(sma4, 15)]]
134 + const n = bars.length
135 + const out = new Float64Array(n).fill(0)
136 + P.forEach(([r, s], k) => { const m = compactMean(toF(roc(bars, { length: r, source: src }).roc), s); for (let i = 0; i < n; i++) out[i] += (k + 1) * m[i] })
137 + return { kst: nullable(out), signal: nullable(compactMean(out, int(signal, 9))) }
138 +}
139 +
140 +/** True Strength Index. Defaults: { long: 25, short: 13, signal: 13, source: 'close' } → tsi, signal. */
141 +export function tsi(bars, { long = 25, short = 13, signal = 13, source: src = 'close' } = {}) {
142 + long = int(long, 25); short = int(short, 13); signal = int(signal, 13)
143 + const s = source(bars, src)
144 + const n = s.length
145 + const m = new Float64Array(n).fill(NaN), am = new Float64Array(n).fill(NaN)
146 + for (let i = 1; i < n; i++) { m[i] = s[i] - s[i - 1]; am[i] = Math.abs(m[i]) }
147 + const num = compactEma(compactEma(m, long), short), den = compactEma(compactEma(am, long), short)
148 + const out = new Float64Array(n).fill(NaN)
149 + for (let i = 0; i < n; i++) if (!Number.isNaN(den[i])) out[i] = den[i] === 0 ? 0 : (100 * num[i]) / den[i]
150 + return { tsi: nullable(out), signal: nullable(compactEma(out, signal)) }
151 +}
152 +
153 +/** Relative Vigor Index. Defaults: { length: 10 } → rvi, signal. */
154 +export function rvi(bars, { length = 10 } = {}) {
155 + length = int(length, 10)
156 + const n = bars.length
157 + const co = new Float64Array(n), hl = new Float64Array(n)
158 + for (let i = 0; i < n; i++) { co[i] = bars[i].c - bars[i].o; hl[i] = bars[i].h - bars[i].l }
159 + const sym = arr => { const o = new Float64Array(n).fill(NaN); for (let i = 3; i < n; i++) o[i] = (arr[i] + 2 * arr[i - 1] + 2 * arr[i - 2] + arr[i - 3]) / 6; return o }
160 + const num = compactMean(sym(co), length), den = compactMean(sym(hl), length)
161 + const out = new Float64Array(n).fill(NaN)
162 + for (let i = 0; i < n; i++) if (!Number.isNaN(den[i])) out[i] = den[i] === 0 ? 0 : num[i] / den[i]
163 + return { rvi: nullable(out), signal: nullable(sym(out)) }
164 +}
165 +
166 +/** Chande Momentum Oscillator. Defaults: { length: 9, source: 'close' } → cmo (−100…100). */
167 +export function cmo(bars, { length = 9, source: src = 'close' } = {}) {
168 + length = int(length, 9)
169 + const s = source(bars, src)
170 + const n = s.length
171 + const up = new Float64Array(n).fill(NaN), dn = new Float64Array(n).fill(NaN)
172 + for (let i = 1; i < n; i++) { const d = s[i] - s[i - 1]; up[i] = d > 0 ? d : 0; dn[i] = d < 0 ? -d : 0 }
173 + const su = rollingSum(up.subarray(1), length), sd = rollingSum(dn.subarray(1), length)
174 + const out = new Float64Array(n).fill(NaN)
175 + for (let i = 0; i < su.length; i++) { const t = su[i] + sd[i]; if (Number.isNaN(t)) continue; out[i + 1] = t === 0 ? 0 : (100 * (su[i] - sd[i])) / t }
176 + return { cmo: nullable(out) }
177 +}
178 +
179 +/** Aroon. Defaults: { length: 14 } → up, down (0–100). */
180 +export function aroon(bars, { length = 14 } = {}) {
181 + length = int(length, 14)
182 + const n = bars.length
183 + const up = new Float64Array(n).fill(NaN), down = new Float64Array(n).fill(NaN)
184 + for (let i = length; i < n; i++) {
185 + let hi = -Infinity, lo = Infinity, hiAge = 0, loAge = 0
186 + for (let j = 0; j <= length; j++) { const b = bars[i - j]; if (b.h > hi) { hi = b.h; hiAge = j } if (b.l < lo) { lo = b.l; loAge = j } }
187 + up[i] = (100 * (length - hiAge)) / length
188 + down[i] = (100 * (length - loAge)) / length
189 + }
190 + return { up: nullable(up), down: nullable(down) }
191 +}
192 +
193 +/** Choppiness Index. Defaults: { length: 14 } → chop (0–100). */
194 +export function choppiness(bars, { length = 14 } = {}) {
195 + length = int(length, 14)
196 + const n = bars.length
197 + const tr = rollingSum(trueRange(bars), length)
198 + const hh = rollingMax(source(bars, 'high'), length), ll = rollingMin(source(bars, 'low'), length)
199 + const out = new Float64Array(n).fill(NaN)
200 + const den = Math.log10(length)
201 + for (let i = 0; i < n; i++) { const rg = hh[i] - ll[i]; if (Number.isNaN(rg) || rg <= 0 || Number.isNaN(tr[i])) continue; out[i] = (100 * Math.log10(tr[i] / rg)) / den }
202 + return { chop: nullable(out) }
203 +}
204 +
205 +/** Mass Index: Σ over `sum` bars of EMA(H−L, ema) / EMA(EMA(H−L, ema), ema). Defaults: { ema: 9, sum: 25 } → mass. */
206 +export function massIndex(bars, { ema: emaLen = 9, sum = 25 } = {}) {
207 + emaLen = int(emaLen, 9); sum = int(sum, 25)
208 + const n = bars.length
209 + const hl = new Float64Array(n)
210 + for (let i = 0; i < n; i++) hl[i] = bars[i].h - bars[i].l
211 + const e1 = rollingEma(hl, emaLen), e2 = compactEma(e1, emaLen)
212 + const ratio = new Float64Array(n).fill(NaN)
213 + for (let i = 0; i < n; i++) if (!Number.isNaN(e2[i]) && e2[i] !== 0) ratio[i] = e1[i] / e2[i]
214 + let start = 0
215 + while (start < n && Number.isNaN(ratio[start])) start++
216 + const out = new Float64Array(n).fill(NaN)
217 + const s = rollingSum(ratio.subarray(start), sum)
218 + for (let i = 0; i < s.length; i++) out[start + i] = s[i]
219 + return { mass: nullable(out) }
220 +}
221 +
222 +/** Elder Ray: bull power = high − EMA, bear power = low − EMA. Defaults: { length: 13 } → bull, bear. */
223 +export function elderRay(bars, { length = 13 } = {}) {
224 + length = int(length, 13)
225 + const e = rollingEma(source(bars, 'close'), length)
226 + const n = bars.length
227 + const bull = new Float64Array(n), bear = new Float64Array(n)
228 + for (let i = 0; i < n; i++) { bull[i] = bars[i].h - e[i]; bear[i] = bars[i].l - e[i] }
229 + return { bull: nullable(bull), bear: nullable(bear) }
230 +}
231 +
232 +/** Bollinger %B. Defaults: { length: 20, mult: 2, source: 'close' } → pctb (0 = lower band, 1 = upper band). */
233 +export function bollingerPctB(bars, { length = 20, mult = 2, source: src = 'close' } = {}) {
234 + length = int(length, 20); mult = pos(mult, 2)
235 + const s = source(bars, src)
236 + const mid = rollingMean(s, length), sd = rollingStd(s, length)
237 + const out = new Float64Array(s.length).fill(NaN)
238 + for (let i = 0; i < s.length; i++) { if (Number.isNaN(mid[i])) continue; const w = 2 * mult * sd[i]; out[i] = w === 0 ? 0.5 : (s[i] - (mid[i] - mult * sd[i])) / w }
239 + return { pctb: nullable(out) }
240 +}
241 +
242 +/** Bollinger Bandwidth: (upper − lower) / middle × 100. Defaults: { length: 20, mult: 2, source: 'close' } → bbw. */
243 +export function bollingerWidth(bars, { length = 20, mult = 2, source: src = 'close' } = {}) {
244 + length = int(length, 20); mult = pos(mult, 2)
245 + const s = source(bars, src)
246 + const mid = rollingMean(s, length), sd = rollingStd(s, length)
247 + const out = new Float64Array(s.length).fill(NaN)
248 + for (let i = 0; i < s.length; i++) if (!Number.isNaN(mid[i]) && mid[i] !== 0) out[i] = ((2 * mult * sd[i]) / mid[i]) * 100
249 + return { bbw: nullable(out) }
250 +}
251 +
252 +/** Historical volatility: stdev of log returns × √annual × 100. Defaults: { length: 10, annual: 252 } → hv (%). */
253 +export function historicalVolatility(bars, { length = 10, annual = 252 } = {}) {
254 + length = int(length, 10); annual = pos(annual, 252)
255 + const n = bars.length
256 + const lr = new Float64Array(n).fill(NaN)
257 + for (let i = 1; i < n; i++) if (bars[i - 1].c > 0 && bars[i].c > 0) lr[i] = Math.log(bars[i].c / bars[i - 1].c)
258 + const sd = rollingStd(lr.subarray(1), length)
259 + const out = new Float64Array(n).fill(NaN)
260 + const k = Math.sqrt(annual) * 100
261 + for (let i = 0; i < sd.length; i++) out[i + 1] = sd[i] * k
262 + return { hv: nullable(out) }
263 +}
264 +
265 +/** Rolling standard deviation (population). Defaults: { length: 20, source: 'close' } → stddev. */
266 +export function stddev(bars, { length = 20, source: src = 'close' } = {}) {
267 + return { stddev: nullable(rollingStd(source(bars, src), int(length, 20))) }
268 +}
modified hfmarketdata/web/src/charts/indicators/moving-averages.js +157 −18
@@ -1,6 +1,6 @@
1 1 // Moving averages and volume-weighted price. Each function: (bars, params) → { key: (number|null)[] }.
2 2
3 −import { source, rollingMean, rollingEma, nullable, int, dayOf } from './util.js'
3 +import { source, rollingMean, rollingEma, rollingWma, rollingLinReg, compactEma, nullable, int, pos, dayOf } from './util.js'
4 4
5 5 /** Simple moving average. Defaults: { length: 20, source: 'close' }. */
6 6 export function sma(bars, { length = 20, source: src = 'close' } = {}) {
@@ -14,40 +14,179 @@ export function ema(bars, { length = 20, source: src = 'close' } = {}) {
14 14
15 15 /** Linearly weighted moving average. Defaults: { length: 20, source: 'close' }. */
16 16 export function wma(bars, { length = 20, source: src = 'close' } = {}) {
17 + return { wma: nullable(rollingWma(source(bars, src), int(length, 20))) }
18 +}
19 +
20 +/** Hull moving average: WMA(2·WMA(n/2) − WMA(n), √n). Defaults: { length: 16, source: 'close' }. */
21 +export function hma(bars, { length = 16, source: src = 'close' } = {}) {
22 + length = int(length, 16)
23 + const s = source(bars, src)
24 + const half = rollingWma(s, Math.max(1, Math.round(length / 2)))
25 + const full = rollingWma(s, length)
26 + const n = s.length
27 + const diff = new Float64Array(n)
28 + for (let i = 0; i < n; i++) diff[i] = 2 * half[i] - full[i]
29 + return { hma: nullable(compactWma(diff, Math.max(1, Math.round(Math.sqrt(length))))) }
30 +}
31 +
32 +function compactWma(arr, length) {
33 + const n = arr.length
34 + let start = 0
35 + while (start < n && Number.isNaN(arr[start])) start++
36 + const out = new Float64Array(n).fill(NaN)
37 + if (start >= n) return out
38 + const sub = rollingWma(arr.subarray(start), length)
39 + for (let i = 0; i < sub.length; i++) out[start + i] = sub[i]
40 + return out
41 +}
42 +
43 +/** Double EMA: 2·EMA − EMA(EMA). Defaults: { length: 20, source: 'close' }. */
44 +export function dema(bars, { length = 20, source: src = 'close' } = {}) {
45 + length = int(length, 20)
46 + const e1 = rollingEma(source(bars, src), length)
47 + const e2 = compactEma(e1, length)
48 + const out = new Float64Array(e1.length)
49 + for (let i = 0; i < out.length; i++) out[i] = 2 * e1[i] - e2[i]
50 + return { dema: nullable(out) }
51 +}
52 +
53 +/** Triple EMA: 3·E1 − 3·E2 + E3. Defaults: { length: 20, source: 'close' }. */
54 +export function tema(bars, { length = 20, source: src = 'close' } = {}) {
17 55 length = int(length, 20)
56 + const e1 = rollingEma(source(bars, src), length)
57 + const e2 = compactEma(e1, length)
58 + const e3 = compactEma(e2, length)
59 + const out = new Float64Array(e1.length)
60 + for (let i = 0; i < out.length; i++) out[i] = 3 * e1[i] - 3 * e2[i] + e3[i]
61 + return { tema: nullable(out) }
62 +}
63 +
64 +/**
65 + * Kaufman adaptive moving average. Defaults: { length: 10, fast: 2, slow: 30, source: 'close' }.
66 + * ER = |p − p[n]| / Σ|Δp| over n; SC = (ER·(2/(fast+1) − 2/(slow+1)) + 2/(slow+1))²; seeded with the source at n.
67 + */
68 +export function kama(bars, { length = 10, fast = 2, slow = 30, source: src = 'close' } = {}) {
69 + length = int(length, 10); fast = int(fast, 2); slow = int(slow, 30)
70 + const s = source(bars, src)
71 + const n = s.length
72 + const out = new Float64Array(n).fill(NaN)
73 + const fastSC = 2 / (fast + 1), slowSC = 2 / (slow + 1)
74 + let prev = NaN
75 + for (let i = length; i < n; i++) {
76 + let vol = 0, ok = true
77 + for (let j = i - length + 1; j <= i; j++) { const d = s[j] - s[j - 1]; if (Number.isNaN(d)) { ok = false; break } vol += Math.abs(d) }
78 + if (!ok) { prev = NaN; continue }
79 + const er = vol === 0 ? 0 : Math.abs(s[i] - s[i - length]) / vol
80 + const sc = Math.pow(er * (fastSC - slowSC) + slowSC, 2)
81 + if (Number.isNaN(prev)) prev = s[i - 1]
82 + prev = prev + sc * (s[i] - prev)
83 + out[i] = prev
84 + }
85 + return { kama: nullable(out) }
86 +}
87 +
88 +/** Arnaud Legoux moving average. Defaults: { length: 9, offset: 0.85, sigma: 6, source: 'close' }. */
89 +export function alma(bars, { length = 9, offset = 0.85, sigma = 6, source: src = 'close' } = {}) {
90 + length = int(length, 9); sigma = pos(sigma, 6)
91 + offset = Number.isFinite(Number(offset)) ? Math.min(1, Math.max(0, Number(offset))) : 0.85
18 92 const s = source(bars, src)
19 93 const n = s.length
94 + const m = Math.floor(offset * (length - 1))
95 + const sd = length / sigma
96 + const w = new Float64Array(length)
97 + let wsum = 0
98 + for (let i = 0; i < length; i++) { w[i] = Math.exp(-((i - m) * (i - m)) / (2 * sd * sd)); wsum += w[i] }
20 99 const out = new Float64Array(n).fill(NaN)
21 − const denom = (length * (length + 1)) / 2
22 100 for (let i = length - 1; i < n; i++) {
23 101 let acc = 0, ok = true
24 − for (let j = 0; j < length; j++) {
25 − const v = s[i - j]
26 − if (Number.isNaN(v)) { ok = false; break }
27 − acc += v * (length - j)
28 − }
29 − if (ok) out[i] = acc / denom
102 + for (let j = 0; j < length; j++) { const v = s[i - (length - 1) + j]; if (Number.isNaN(v)) { ok = false; break } acc += v * w[j] }
103 + if (ok) out[i] = acc / wsum
30 104 }
31 − return { wma: nullable(out) }
105 + return { alma: nullable(out) }
32 106 }
33 107
34 108 /**
35 − * Volume-weighted average price. Defaults: { anchor: 'session' } — the accumulation restarts at every calendar
36 − * day change (intraday). anchor 'all' accumulates from the first bar. Bars without volume yield null.
109 + * Least-squares moving average (linear regression value at the window's last bar) with a regression channel:
110 + * upper / lower = lsma ± mult × standard deviation of the residuals. Defaults: { length: 25, mult: 2, source: 'close' }.
37 111 */
38 −export function vwap(bars, { anchor = 'session' } = {}) {
112 +export function lsma(bars, { length = 25, mult = 2, source: src = 'close' } = {}) {
113 + length = int(length, 25); mult = pos(mult, 2)
114 + const s = source(bars, src)
115 + const n = s.length
116 + const { slope, intercept } = rollingLinReg(s, Math.max(2, length))
117 + const out = new Float64Array(n).fill(NaN), up = new Float64Array(n).fill(NaN), lo = new Float64Array(n).fill(NaN)
118 + for (let i = length - 1; i < n; i++) {
119 + const b = slope[i], a = intercept[i]
120 + if (Number.isNaN(b)) continue
121 + out[i] = a + b * (length - 1)
122 + let sq = 0
123 + for (let j = 0; j < length; j++) { const r = s[i - length + 1 + j] - (a + b * j); sq += r * r }
124 + const sd = Math.sqrt(sq / length)
125 + up[i] = out[i] + mult * sd; lo[i] = out[i] - mult * sd
126 + }
127 + return { lsma: nullable(out), upper: nullable(up), lower: nullable(lo), slope: nullable(slope) }
128 +}
129 +
130 +/** Moving-average envelope: MA ± percent. Defaults: { length: 20, percent: 2.5, ma: 'sma', source: 'close' }. */
131 +export function envelope(bars, { length = 20, percent = 2.5, ma = 'sma', source: src = 'close' } = {}) {
132 + length = int(length, 20); percent = pos(percent, 2.5)
133 + const s = source(bars, src)
134 + const mid = ma === 'ema' ? rollingEma(s, length) : rollingMean(s, length)
135 + const n = s.length
136 + const up = new Float64Array(n), lo = new Float64Array(n)
137 + for (let i = 0; i < n; i++) { up[i] = mid[i] * (1 + percent / 100); lo[i] = mid[i] * (1 - percent / 100) }
138 + return { upper: nullable(up), middle: nullable(mid), lower: nullable(lo) }
139 +}
140 +
141 +/**
142 + * Volume-weighted average price with optional σ bands. Defaults: { anchor: 'session', bands: 0 } — the
143 + * accumulation restarts at every calendar day change (intraday); anchor 'all' accumulates from the first bar.
144 + * `bands` > 0 adds upper / lower = vwap ± bands × σ (volume-weighted standard deviation of the typical price).
145 + * Bars without volume yield null.
146 + */
147 +export function vwap(bars, { anchor = 'session', bands = 0 } = {}) {
39 148 const n = bars.length
40 − const out = new Array(n).fill(null)
41 − let pv = 0, vol = 0, day = null
149 + const out = new Array(n).fill(null), up = new Array(n).fill(null), lo = new Array(n).fill(null)
150 + const k = Number(bands) > 0 ? Number(bands) : 0
151 + let pv = 0, vol = 0, pv2 = 0, day = null
42 152 for (let i = 0; i < n; i++) {
43 153 const b = bars[i]
44 154 const d = dayOf(b.t)
45 − if (anchor === 'session' && day !== null && d !== day) { pv = 0; vol = 0 }
155 + if (anchor === 'session' && day !== null && d !== day) { pv = 0; vol = 0; pv2 = 0 }
46 156 day = d
47 157 if (b.v == null || Number.isNaN(b.v)) continue
48 158 const tp = (b.h + b.l + b.c) / 3
49 − pv += tp * b.v; vol += b.v
50 − out[i] = vol > 0 ? pv / vol : null
159 + pv += tp * b.v; vol += b.v; pv2 += tp * tp * b.v
160 + if (vol > 0) {
161 + const m = pv / vol
162 + out[i] = m
163 + if (k) { const sd = Math.sqrt(Math.max(0, pv2 / vol - m * m)); up[i] = m + k * sd; lo[i] = m - k * sd }
164 + }
165 + }
166 + return { vwap: out, upper: up, lower: lo }
167 +}
168 +
169 +/**
170 + * Anchored VWAP: accumulation starts at the first bar whose time ≥ `anchor` (ms) with σ bands.
171 + * Defaults: { anchor: null → the first bar, bands: 1 }.
172 + */
173 +export function avwap(bars, { anchor = null, bands = 1 } = {}) {
174 + const n = bars.length
175 + const out = new Array(n).fill(null), up = new Array(n).fill(null), lo = new Array(n).fill(null)
176 + const k = Number(bands) > 0 ? Number(bands) : 0
177 + const start = anchor == null ? -Infinity : Number(anchor)
178 + let pv = 0, vol = 0, pv2 = 0
179 + for (let i = 0; i < n; i++) {
180 + const b = bars[i]
181 + if (b.t < start) continue
182 + if (b.v == null || Number.isNaN(b.v)) continue
183 + const tp = (b.h + b.l + b.c) / 3
184 + pv += tp * b.v; vol += b.v; pv2 += tp * tp * b.v
185 + if (vol > 0) {
186 + const m = pv / vol
187 + out[i] = m
188 + if (k) { const sd = Math.sqrt(Math.max(0, pv2 / vol - m * m)); up[i] = m + k * sd; lo[i] = m - k * sd }
189 + }
51 190 }
52 − return { vwap: out }
191 + return { avwap: out, upper: up, lower: lo }
53 192 }
added hfmarketdata/web/src/charts/indicators/overlays.js +199 −0
@@ -0,0 +1,199 @@
1 +// Price-structure overlays: Parabolic SAR, ZigZag, Pivot Points, Volume Profile (visible range), auto Fibonacci.
2 +// Each function: (bars, params[, range]) → { key: (number|null)[] | object }. Volume Profile and auto Fib are
3 +// "dynamic" indicators: the engine recomputes them on every visibleRangeChange with range = { from, to }.
4 +
5 +import { rollingRma, trueRange, nullable, int, pos, dayOf, weekOf, monthOf } from './util.js'
6 +
7 +/**
8 + * Parabolic SAR (Wilder). Defaults: { start: 0.02, increment: 0.02, max: 0.2 } → sar, plus `bull` / `bear`
9 + * (the SAR value while the trend is up / down, null otherwise) for two-color dot rendering.
10 + * Starts at bar 1 with an up trend when close[1] ≥ close[0].
11 + */
12 +export function psar(bars, { start = 0.02, increment = 0.02, max = 0.2 } = {}) {
13 + start = pos(start, 0.02); increment = pos(increment, 0.02); max = pos(max, 0.2)
14 + const n = bars.length
15 + const sar = new Float64Array(n).fill(NaN)
16 + const bull = new Array(n).fill(null), bear = new Array(n).fill(null)
17 + if (n < 2) return { sar: nullable(sar), bull, bear }
18 + let up = bars[1].c >= bars[0].c
19 + let af = start
20 + let ep = up ? Math.max(bars[0].h, bars[1].h) : Math.min(bars[0].l, bars[1].l)
21 + let cur = up ? Math.min(bars[0].l, bars[1].l) : Math.max(bars[0].h, bars[1].h)
22 + sar[1] = cur
23 + for (let i = 2; i < n; i++) {
24 + const b = bars[i]
25 + let next = cur + af * (ep - cur)
26 + // The SAR may not enter the previous two bars' range.
27 + if (up) next = Math.min(next, bars[i - 1].l, bars[i - 2].l)
28 + else next = Math.max(next, bars[i - 1].h, bars[i - 2].h)
29 + if (up) {
30 + if (b.l < next) { up = false; next = ep; ep = b.l; af = start } else if (b.h > ep) { ep = b.h; af = Math.min(max, af + increment) }
31 + } else {
32 + if (b.h > next) { up = true; next = ep; ep = b.h; af = start } else if (b.l < ep) { ep = b.l; af = Math.min(max, af + increment) }
33 + }
34 + cur = next
35 + sar[i] = cur
36 + }
37 + for (let i = 1; i < n; i++) { /* classify by position relative to close */ if (sar[i] <= bars[i].c) bull[i] = sar[i]; else bear[i] = sar[i] }
38 + return { sar: nullable(sar), bull, bear }
39 +}
40 +
41 +/**
42 + * ZigZag. Defaults: { deviation: 5, mode: 'percent' | 'atr', atrLength: 14, atrMult: 3 }.
43 + * A pivot is confirmed when price moves against the current leg by at least the deviation (percent of the pivot
44 + * price, or atrMult × ATR). `zigzag` holds the pivot prices (null elsewhere — render with connectGaps); the last
45 + * point is the provisional extreme of the current leg. `direction` = +1 while the leg goes up, −1 down.
46 + */
47 +export function zigzag(bars, { deviation = 5, mode = 'percent', atrLength = 14, atrMult = 3 } = {}) {
48 + deviation = pos(deviation, 5); atrLength = int(atrLength, 14); atrMult = pos(atrMult, 3)
49 + const n = bars.length
50 + const out = new Array(n).fill(null)
51 + const dir = new Array(n).fill(null)
52 + if (!n) return { zigzag: out, direction: dir }
53 + const atr = mode === 'atr' ? rollingRma(trueRange(bars), atrLength) : null
54 + const threshold = (i, ref) => (atr ? (Number.isNaN(atr[i]) ? Infinity : atr[i] * atrMult) : ref * deviation / 100)
55 + let trend = 0 // 0 unknown, +1 up leg, −1 down leg
56 + let pivotI = 0, pivotP = bars[0].c // last confirmed pivot
57 + let extI = 0, extP = bars[0].c // provisional extreme of the current leg
58 + let hiI = 0, loI = 0
59 + for (let i = 1; i < n; i++) {
60 + const h = bars[i].h, l = bars[i].l
61 + if (trend === 0) {
62 + if (h > bars[hiI].h) hiI = i
63 + if (l < bars[loI].l) loI = i
64 + if (bars[hiI].h - bars[0].l >= threshold(i, bars[0].l) && hiI > loI) { trend = 1; pivotI = loI; pivotP = bars[loI].l; extI = hiI; extP = bars[hiI].h; out[pivotI] = pivotP }
65 + else if (bars[0].h - bars[loI].l >= threshold(i, bars[0].h) && loI > hiI) { trend = -1; pivotI = hiI; pivotP = bars[hiI].h; extI = loI; extP = bars[loI].l; out[pivotI] = pivotP }
66 + continue
67 + }
68 + if (trend === 1) {
69 + if (h >= extP) { extI = i; extP = h }
70 + else if (extP - l >= threshold(i, extP)) { out[extI] = extP; pivotI = extI; pivotP = extP; trend = -1; extI = i; extP = l }
71 + } else {
72 + if (l <= extP) { extI = i; extP = l }
73 + else if (h - extP >= threshold(i, extP)) { out[extI] = extP; pivotI = extI; pivotP = extP; trend = 1; extI = i; extP = h }
74 + }
75 + }
76 + if (trend !== 0) out[extI] = extP
77 + // Direction per bar: sign of the leg containing the bar.
78 + let last = null, lastI = -1
79 + for (let i = 0; i < n; i++) {
80 + if (out[i] != null) { if (last != null) for (let j = lastI + 1; j <= i; j++) dir[j] = out[i] > last ? 1 : -1; last = out[i]; lastI = i }
81 + }
82 + void pivotI; void pivotP
83 + return { zigzag: out, direction: dir }
84 +}
85 +
86 +/**
87 + * Pivot Points computed per period from the previous period's H/L/C (the periods are grouped from the bars
88 + * themselves, so intraday bars give daily pivots, daily bars give weekly/monthly ones).
89 + * Defaults: { type: 'classic' | 'fibonacci' | 'camarilla' | 'woodie', period: 'day' | 'week' | 'month' }.
90 + * Outputs p, r1..r4, s1..s4 as stepwise series (r4/s4 are null except for camarilla).
91 + */
92 +export function pivots(bars, { type = 'classic', period = 'day' } = {}) {
93 + const n = bars.length
94 + const keys = ['p', 'r1', 'r2', 'r3', 'r4', 's1', 's2', 's3', 's4']
95 + const out = {}
96 + for (const k of keys) out[k] = new Array(n).fill(null)
97 + if (!n) return out
98 + const groupOf = period === 'week' ? weekOf : period === 'month' ? monthOf : dayOf
99 + let g = groupOf(bars[0].t)
100 + let H = -Infinity, L = Infinity, C = NaN, O = bars[0].o
101 + let prev = null // { H, L, C, O } of the previous period
102 + for (let i = 0; i < n; i++) {
103 + const b = bars[i]
104 + const gi = groupOf(b.t)
105 + if (gi !== g) { prev = { H, L, C, O }; g = gi; H = -Infinity; L = Infinity; O = b.o }
106 + if (b.h > H) H = b.h
107 + if (b.l < L) L = b.l
108 + C = b.c
109 + if (!prev) continue
110 + const lv = pivotLevels(type, prev.H, prev.L, prev.C, b.o)
111 + for (const k of keys) out[k][i] = lv[k] == null ? null : lv[k]
112 + }
113 + return out
114 +}
115 +
116 +export function pivotLevels(type, H, L, C, openCurrent) {
117 + const R = H - L
118 + if (type === 'fibonacci') {
119 + const P = (H + L + C) / 3
120 + return { p: P, r1: P + 0.382 * R, r2: P + 0.618 * R, r3: P + R, s1: P - 0.382 * R, s2: P - 0.618 * R, s3: P - R }
121 + }
122 + if (type === 'camarilla') {
123 + const P = (H + L + C) / 3
124 + return { p: P, r1: C + R * 1.1 / 12, r2: C + R * 1.1 / 6, r3: C + R * 1.1 / 4, r4: C + R * 1.1 / 2, s1: C - R * 1.1 / 12, s2: C - R * 1.1 / 6, s3: C - R * 1.1 / 4, s4: C - R * 1.1 / 2 }
125 + }
126 + if (type === 'woodie') {
127 + const P = (H + L + 2 * openCurrent) / 4
128 + return { p: P, r1: 2 * P - L, r2: P + R, r3: H + 2 * (P - L), s1: 2 * P - H, s2: P - R, s3: L - 2 * (H - P) }
129 + }
130 + const P = (H + L + C) / 3
131 + return { p: P, r1: 2 * P - L, r2: P + R, r3: H + 2 * (P - L), s1: 2 * P - H, s2: P - R, s3: L - 2 * (H - P) }
132 +}
133 +
134 +/**
135 + * Volume Profile of a bar range (dynamic: the engine passes the visible range). Each bar's volume is spread
136 + * uniformly over [low, high] into `rows` price buckets. Defaults: { rows: 24, valueArea: 70 }.
137 + * Returns { rows: [{ lo, hi, vol, up, down }], poc, vah, val, total, from, to } (poc = center of the busiest row;
138 + * vah / val = edges of the value area holding `valueArea` % of the volume, grown from the POC).
139 + */
140 +export function volumeProfile(bars, { rows = 24, valueArea = 70 } = {}, range) {
141 + rows = int(rows, 24); valueArea = Math.min(100, pos(valueArea, 70))
142 + const n = bars.length
143 + const from = Math.max(0, range ? range.from : 0), to = Math.min(n - 1, range ? range.to : n - 1)
144 + const empty = { rows: [], poc: null, vah: null, val: null, total: 0, from, to }
145 + if (to < from) return empty
146 + let lo = Infinity, hi = -Infinity
147 + for (let i = from; i <= to; i++) { if (bars[i].l < lo) lo = bars[i].l; if (bars[i].h > hi) hi = bars[i].h }
148 + if (!(hi > lo)) hi = lo + Math.abs(lo) * 1e-6 + 1e-9
149 + const step = (hi - lo) / rows
150 + const vol = new Float64Array(rows), up = new Float64Array(rows), down = new Float64Array(rows)
151 + let total = 0
152 + for (let i = from; i <= to; i++) {
153 + const b = bars[i]
154 + const v = b.v
155 + if (!(v > 0)) continue
156 + total += v
157 + const isUp = b.c >= b.o
158 + const a = Math.min(rows - 1, Math.max(0, Math.floor((b.l - lo) / step)))
159 + const z = Math.min(rows - 1, Math.max(0, Math.floor((b.h - lo) / step - 1e-12)))
160 + if (z <= a) { vol[a] += v; (isUp ? up : down)[a] += v; continue }
161 + const span = b.h - b.l
162 + for (let r = a; r <= z; r++) {
163 + const rl = Math.max(b.l, lo + r * step), rh = Math.min(b.h, lo + (r + 1) * step)
164 + const part = v * Math.max(0, rh - rl) / span
165 + vol[r] += part; (isUp ? up : down)[r] += part
166 + }
167 + }
168 + if (!(total > 0)) return empty
169 + let pocI = 0
170 + for (let r = 1; r < rows; r++) if (vol[r] > vol[pocI]) pocI = r
171 + // Value area: grow from the POC toward the heavier neighbour until the target volume is reached.
172 + let a = pocI, z = pocI, acc = vol[pocI]
173 + const target = total * valueArea / 100
174 + while (acc < target && (a > 0 || z < rows - 1)) {
175 + const below = a > 0 ? vol[a - 1] : -1, above = z < rows - 1 ? vol[z + 1] : -1
176 + if (above >= below) { z++; acc += vol[z] } else { a--; acc += vol[a] }
177 + }
178 + const out = []
179 + for (let r = 0; r < rows; r++) out.push({ lo: lo + r * step, hi: lo + (r + 1) * step, vol: vol[r], up: up[r], down: down[r] })
180 + return { rows: out, poc: lo + (pocI + 0.5) * step, vah: lo + (z + 1) * step, val: lo + a * step, total, from, to, maxVol: vol[pocI] }
181 +}
182 +
183 +/**
184 + * Automatic Fibonacci retracement of the visible range (dynamic): the swing runs from the earlier extreme to the
185 + * later one; level r sits at end − (end − start) × r. Defaults: { levels: [0, 0.236, 0.382, 0.5, 0.618, 0.786, 1] }.
186 + * Returns { levels: [{ ratio, price }], start: { index, price }, end: { index, price }, up }.
187 + */
188 +export function autoFib(bars, { levels = [0, 0.236, 0.382, 0.5, 0.618, 0.786, 1] } = {}, range) {
189 + const n = bars.length
190 + const from = Math.max(0, range ? range.from : 0), to = Math.min(n - 1, range ? range.to : n - 1)
191 + if (to < from) return { levels: [], start: null, end: null, up: true }
192 + let hi = -Infinity, lo = Infinity, iHi = from, iLo = from
193 + for (let i = from; i <= to; i++) { if (bars[i].h > hi) { hi = bars[i].h; iHi = i } if (bars[i].l < lo) { lo = bars[i].l; iLo = i } }
194 + const up = iHi >= iLo // low came first → upswing
195 + const start = up ? { index: iLo, price: lo } : { index: iHi, price: hi }
196 + const end = up ? { index: iHi, price: hi } : { index: iLo, price: lo }
197 + const list = (Array.isArray(levels) ? levels : [0, 0.236, 0.382, 0.5, 0.618, 0.786, 1]).map(r => ({ ratio: r, price: end.price - (end.price - start.price) * r }))
198 + return { levels: list, start, end, up }
199 +}
modified hfmarketdata/web/src/charts/indicators/util.js +82 −0
@@ -141,8 +141,90 @@ export function trueRange(bars) {
141 141 return out
142 142 }
143 143
144 +/** Rolling sum (NaN until the window is full or when the window has a NaN). */
145 +export function rollingSum(src, length) {
146 + const n = src.length
147 + const out = new Float64Array(n).fill(NaN)
148 + if (length < 1) return out
149 + let sum = 0, bad = 0
150 + for (let i = 0; i < n; i++) {
151 + const v = src[i]
152 + if (Number.isNaN(v)) bad++; else sum += v
153 + if (i >= length) { const old = src[i - length]; if (Number.isNaN(old)) bad--; else sum -= old }
154 + if (i >= length - 1 && bad === 0) out[i] = sum
155 + }
156 + return out
157 +}
158 +
159 +/** Linearly weighted moving average over a Float64Array (weights 1..length, newest heaviest). */
160 +export function rollingWma(src, length) {
161 + const n = src.length
162 + const out = new Float64Array(n).fill(NaN)
163 + if (length < 1) return out
164 + const denom = (length * (length + 1)) / 2
165 + for (let i = length - 1; i < n; i++) {
166 + let acc = 0, ok = true
167 + for (let j = 0; j < length; j++) { const v = src[i - j]; if (Number.isNaN(v)) { ok = false; break } acc += v * (length - j) }
168 + if (ok) out[i] = acc / denom
169 + }
170 + return out
171 +}
172 +
173 +/**
174 + * Rolling least-squares line: returns { slope, intercept } arrays where the fit at the window's LAST point is
175 + * intercept + slope × (length − 1) (x = 0 at the oldest point of the window).
176 + */
177 +export function rollingLinReg(src, length) {
178 + const n = src.length
179 + const slope = new Float64Array(n).fill(NaN), intercept = new Float64Array(n).fill(NaN)
180 + if (length < 2) return { slope, intercept }
181 + const sx = (length * (length - 1)) / 2
182 + const sxx = ((length - 1) * length * (2 * length - 1)) / 6
183 + const den = length * sxx - sx * sx
184 + for (let i = length - 1; i < n; i++) {
185 + let sy = 0, sxy = 0, ok = true
186 + for (let j = 0; j < length; j++) { const v = src[i - length + 1 + j]; if (Number.isNaN(v)) { ok = false; break } sy += v; sxy += j * v }
187 + if (!ok) continue
188 + const b = (length * sxy - sx * sy) / den
189 + slope[i] = b; intercept[i] = (sy - b * sx) / length
190 + }
191 + return { slope, intercept }
192 +}
193 +
194 +/** Element-wise helpers on Float64Arrays (NaN propagates). */
195 +export const map2 = (a, b, f) => { const n = Math.min(a.length, b.length); const out = new Float64Array(n); for (let i = 0; i < n; i++) out[i] = f(a[i], b[i]); return out }
196 +export const map1 = (a, f) => { const out = new Float64Array(a.length); for (let i = 0; i < a.length; i++) out[i] = f(a[i], i); return out }
197 +
198 +/** Seed-independent EMA that starts at the first non-NaN value of a series whose head is NaN (compacts, smooths, re-expands). */
199 +export function compactEma(arr, length) {
200 + const n = arr.length
201 + let start = 0
202 + while (start < n && Number.isNaN(arr[start])) start++
203 + const out = new Float64Array(n).fill(NaN)
204 + if (start >= n) return out
205 + const sub = rollingEma(arr.subarray(start), length)
206 + for (let i = 0; i < sub.length; i++) out[start + i] = sub[i]
207 + return out
208 +}
209 +
210 +/** Same for the SMA. */
211 +export function compactMean(arr, length) {
212 + const n = arr.length
213 + let start = 0
214 + while (start < n && Number.isNaN(arr[start])) start++
215 + const out = new Float64Array(n).fill(NaN)
216 + if (start >= n) return out
217 + const sub = rollingMean(arr.subarray(start), length)
218 + for (let i = 0; i < sub.length; i++) out[start + i] = sub[i]
219 + return out
220 +}
221 +
144 222 /** Calendar day (UTC-encoded wall clock) of a timestamp. */
145 223 export const dayOf = t => Math.floor(t / 86_400_000)
224 +/** ISO-like week index (weeks start on Monday; 1970-01-01 was a Thursday). */
225 +export const weekOf = t => Math.floor((dayOf(t) + 3) / 7)
226 +/** Month index (year × 12 + month). */
227 +export const monthOf = t => { const d = new Date(t); return d.getUTCFullYear() * 12 + d.getUTCMonth() }
146 228
147 229 export const int = (v, d) => { const n = Math.floor(Number(v)); return Number.isFinite(n) && n > 0 ? n : d }
148 230 export const pos = (v, d) => { const n = Number(v); return Number.isFinite(n) && n > 0 ? n : d }
modified hfmarketdata/web/src/charts/indicators/volume.js +97 −2
@@ -1,6 +1,101 @@
1 −// Volume-based indicators: OBV, MFI, volume with moving average.
1 +// Volume-based indicators: OBV, MFI, volume with moving average, CMF, Chaikin Oscillator, A/D line, Force Index,
2 +// Ease of Movement, Klinger, Volume Oscillator.
2 3
3 −import { rollingMean, nullable, int } from './util.js'
4 +import { rollingMean, rollingEma, rollingSum, compactEma, compactMean, nullable, int, pos } from './util.js'
5 +
6 +const volumeOf = bars => { const v = new Float64Array(bars.length); for (let i = 0; i < bars.length; i++) v[i] = bars[i].v == null ? NaN : bars[i].v; return v }
7 +
8 +/** Money flow multiplier × volume: ((C − L) − (H − C)) / (H − L) × V (0 when H = L). */
9 +function moneyFlowVolume(bars) {
10 + const n = bars.length
11 + const out = new Float64Array(n)
12 + for (let i = 0; i < n; i++) { const b = bars[i]; const v = b.v == null ? NaN : b.v; const rg = b.h - b.l; out[i] = rg === 0 ? 0 : (((b.c - b.l) - (b.h - b.c)) / rg) * v }
13 + return out
14 +}
15 +
16 +/** Accumulation / Distribution line (cumulative money-flow volume). No params → ad. */
17 +export function adLine(bars) {
18 + const mfv = moneyFlowVolume(bars)
19 + const out = new Float64Array(bars.length).fill(NaN)
20 + let acc = 0, started = false
21 + for (let i = 0; i < mfv.length; i++) { if (Number.isNaN(mfv[i])) continue; acc += mfv[i]; started = true; out[i] = acc }
22 + void started
23 + return { ad: nullable(out) }
24 +}
25 +
26 +/** Chaikin Money Flow: Σ MFV / Σ V over `length`. Defaults: { length: 20 } → cmf (−1…1). */
27 +export function cmf(bars, { length = 20 } = {}) {
28 + length = int(length, 20)
29 + const a = rollingSum(moneyFlowVolume(bars), length), b = rollingSum(volumeOf(bars), length)
30 + const out = new Float64Array(bars.length).fill(NaN)
31 + for (let i = 0; i < out.length; i++) if (!Number.isNaN(b[i])) out[i] = b[i] === 0 ? 0 : a[i] / b[i]
32 + return { cmf: nullable(out) }
33 +}
34 +
35 +/** Chaikin Oscillator: EMA(AD, fast) − EMA(AD, slow). Defaults: { fast: 3, slow: 10 } → osc. */
36 +export function chaikinOsc(bars, { fast = 3, slow = 10 } = {}) {
37 + fast = int(fast, 3); slow = int(slow, 10)
38 + const ad = new Float64Array(adLine(bars).ad.map(v => (v == null ? NaN : v)))
39 + const a = compactEma(ad, fast), b = compactEma(ad, slow)
40 + const out = new Float64Array(ad.length)
41 + for (let i = 0; i < out.length; i++) out[i] = a[i] - b[i]
42 + return { osc: nullable(out) }
43 +}
44 +
45 +/** Force Index: EMA((C − C[1]) × V, length). Defaults: { length: 13 } → force. */
46 +export function forceIndex(bars, { length = 13 } = {}) {
47 + length = int(length, 13)
48 + const n = bars.length
49 + const raw = new Float64Array(n).fill(NaN)
50 + for (let i = 1; i < n; i++) { const v = bars[i].v; if (v != null && !Number.isNaN(v)) raw[i] = (bars[i].c - bars[i - 1].c) * v }
51 + return { force: nullable(compactEma(raw, length)) }
52 +}
53 +
54 +/** Ease of Movement: SMA(distance / boxRatio) with boxRatio = (V / divisor) / (H − L). Defaults: { length: 14, divisor: 10000 } → eom. */
55 +export function eom(bars, { length = 14, divisor = 10000 } = {}) {
56 + length = int(length, 14); divisor = pos(divisor, 10000)
57 + const n = bars.length
58 + const raw = new Float64Array(n).fill(NaN)
59 + for (let i = 1; i < n; i++) {
60 + const b = bars[i], p = bars[i - 1]
61 + const v = b.v
62 + if (v == null || Number.isNaN(v)) continue
63 + const dist = (b.h + b.l) / 2 - (p.h + p.l) / 2
64 + const rg = b.h - b.l
65 + raw[i] = rg === 0 || v === 0 ? 0 : dist / ((v / divisor) / rg)
66 + }
67 + return { eom: nullable(compactMean(raw, length)) }
68 +}
69 +
70 +/**
71 + * Klinger Volume Oscillator (signed-volume form): sv = V when hlc3 rises else −V; kvo = EMA(sv, fast) − EMA(sv, slow),
72 + * signal = EMA(kvo, signal). Defaults: { fast: 34, slow: 55, signal: 13 } → kvo, signal.
73 + */
74 +export function klinger(bars, { fast = 34, slow = 55, signal = 13 } = {}) {
75 + fast = int(fast, 34); slow = int(slow, 55); signal = int(signal, 13)
76 + const n = bars.length
77 + const sv = new Float64Array(n).fill(NaN)
78 + for (let i = 1; i < n; i++) {
79 + const v = bars[i].v
80 + if (v == null || Number.isNaN(v)) continue
81 + const tp = (bars[i].h + bars[i].l + bars[i].c) / 3, pt = (bars[i - 1].h + bars[i - 1].l + bars[i - 1].c) / 3
82 + sv[i] = tp >= pt ? v : -v
83 + }
84 + const a = compactEma(sv, fast), b = compactEma(sv, slow)
85 + const kvo = new Float64Array(n)
86 + for (let i = 0; i < n; i++) kvo[i] = a[i] - b[i]
87 + return { kvo: nullable(kvo), signal: nullable(compactEma(kvo, signal)) }
88 +}
89 +
90 +/** Volume Oscillator: (EMA(V, fast) − EMA(V, slow)) / EMA(V, slow) × 100. Defaults: { fast: 5, slow: 10 } → vo (%). */
91 +export function volumeOsc(bars, { fast = 5, slow = 10 } = {}) {
92 + fast = int(fast, 5); slow = int(slow, 10)
93 + const v = volumeOf(bars)
94 + const a = rollingEma(v, fast), b = rollingEma(v, slow)
95 + const out = new Float64Array(v.length).fill(NaN)
96 + for (let i = 0; i < out.length; i++) if (!Number.isNaN(b[i]) && b[i] !== 0) out[i] = ((a[i] - b[i]) / b[i]) * 100
97 + return { vo: nullable(out) }
98 +}
4 99
5 100 /** On-Balance Volume. No params → obv (null while volume is missing). */
6 101 export function obv(bars) {
7 102