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