charts: squelette du moteur — formats, échelles (temps indexé, prix linéaire/log/%), store de barres, indicateurs purs
17 changed files +1,470 −0
added
hfmarketdata/web/src/charts/engine/core/animation.js
+48 −0
@@ -0,0 +1,48 @@ | ||
| 1 | +// Tweens driven by the chart's frame loop (no own rAF: the chart calls `tick(now)` once per frame). | |
| 2 | + | |
| 3 | +export const easeOutCubic = t => 1 - Math.pow(1 - t, 3) | |
| 4 | + | |
| 5 | +export class Animator { | |
| 6 | + constructor() { this._list = [] } | |
| 7 | + | |
| 8 | + /** | |
| 9 | + * Animate from `from` to `to` over `duration` ms, calling `apply(value)` each frame and `done()` at the end. | |
| 10 | + * Values may be numbers or plain objects of numbers (interpolated key by key). Returns a cancel function. | |
| 11 | + */ | |
| 12 | + tween({ from, to, duration = 180, ease = easeOutCubic, apply, done, tag }) { | |
| 13 | + if (tag) this.cancel(tag) | |
| 14 | + const item = { from, to, duration, ease, apply, done, tag, start: null, cancelled: false } | |
| 15 | + this._list.push(item) | |
| 16 | + return () => { item.cancelled = true } | |
| 17 | + } | |
| 18 | + | |
| 19 | + cancel(tag) { for (const it of this._list) if (it.tag === tag) it.cancelled = true } | |
| 20 | + | |
| 21 | + get active() { return this._list.length > 0 } | |
| 22 | + | |
| 23 | + /** Advance all tweens. Returns true while something is still running. */ | |
| 24 | + tick(now) { | |
| 25 | + if (!this._list.length) return false | |
| 26 | + const keep = [] | |
| 27 | + for (const it of this._list) { | |
| 28 | + if (it.cancelled) continue | |
| 29 | + if (it.start == null) it.start = now | |
| 30 | + const t = Math.min(1, (now - it.start) / it.duration) | |
| 31 | + const k = it.ease(t) | |
| 32 | + it.apply(interp(it.from, it.to, k)) | |
| 33 | + if (t < 1) keep.push(it) | |
| 34 | + else if (it.done) it.done() | |
| 35 | + } | |
| 36 | + this._list = keep | |
| 37 | + return keep.length > 0 | |
| 38 | + } | |
| 39 | + | |
| 40 | + clear() { this._list = [] } | |
| 41 | +} | |
| 42 | + | |
| 43 | +function interp(a, b, k) { | |
| 44 | + if (typeof a === 'number') return a + (b - a) * k | |
| 45 | + const out = {} | |
| 46 | + for (const key of Object.keys(b)) out[key] = a[key] + (b[key] - a[key]) * k | |
| 47 | + return out | |
| 48 | +} | |
added
hfmarketdata/web/src/charts/engine/core/emitter.js
+24 −0
@@ -0,0 +1,24 @@ | ||
| 1 | +// Minimal typed event emitter. `on` returns the unsubscribe function. | |
| 2 | + | |
| 3 | +export class Emitter { | |
| 4 | + constructor() { this._h = new Map() } | |
| 5 | + | |
| 6 | + on(event, fn) { | |
| 7 | + let set = this._h.get(event) | |
| 8 | + if (!set) { set = new Set(); this._h.set(event, set) } | |
| 9 | + set.add(fn) | |
| 10 | + return () => { set.delete(fn) } | |
| 11 | + } | |
| 12 | + | |
| 13 | + emit(event, payload) { | |
| 14 | + const set = this._h.get(event) | |
| 15 | + if (!set || set.size === 0) return | |
| 16 | + for (const fn of Array.from(set)) { | |
| 17 | + try { fn(payload) } catch (e) { if (typeof console !== 'undefined') console.error(e) } | |
| 18 | + } | |
| 19 | + } | |
| 20 | + | |
| 21 | + has(event) { const s = this._h.get(event); return !!s && s.size > 0 } | |
| 22 | + | |
| 23 | + clear() { this._h.clear() } | |
| 24 | +} | |
added
hfmarketdata/web/src/charts/engine/format/number.js
+109 −0
@@ -0,0 +1,109 @@ | ||
| 1 | +// Number formatting and "nice" step helpers. Pure functions, no DOM. | |
| 2 | + | |
| 3 | +const fmtCache = new Map() | |
| 4 | + | |
| 5 | +/** Cached Intl.NumberFormat with fixed decimals. */ | |
| 6 | +export function numberFormatter(locale = 'en-US', decimals = 2) { | |
| 7 | + const key = `${locale}|${decimals}` | |
| 8 | + let f = fmtCache.get(key) | |
| 9 | + if (!f) { | |
| 10 | + try { | |
| 11 | + f = new Intl.NumberFormat(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals }) | |
| 12 | + } catch { | |
| 13 | + f = new Intl.NumberFormat('en-US', { minimumFractionDigits: decimals, maximumFractionDigits: decimals }) | |
| 14 | + } | |
| 15 | + fmtCache.set(key, f) | |
| 16 | + } | |
| 17 | + return f | |
| 18 | +} | |
| 19 | + | |
| 20 | +/** Format a price with a fixed number of decimals (thousands separators from the locale). */ | |
| 21 | +export function formatPrice(v, decimals = 2, locale = 'en-US') { | |
| 22 | + if (v == null || !Number.isFinite(v)) return '—' | |
| 23 | + return numberFormatter(locale, decimals).format(v) | |
| 24 | +} | |
| 25 | + | |
| 26 | +/** Signed percent with 2 decimals: "+1.23%". */ | |
| 27 | +export function formatPercent(v, decimals = 2, locale = 'en-US') { | |
| 28 | + if (v == null || !Number.isFinite(v)) return '—' | |
| 29 | + const s = numberFormatter(locale, decimals).format(Math.abs(v)) | |
| 30 | + return (v > 0 ? '+' : v < 0 ? '−' : '') + s + '%' | |
| 31 | +} | |
| 32 | + | |
| 33 | +/** Compact volume: 1.2K · 3.45M · 6.7B. */ | |
| 34 | +export function formatCompact(v, locale = 'en-US') { | |
| 35 | + if (v == null || !Number.isFinite(v)) return '—' | |
| 36 | + const a = Math.abs(v) | |
| 37 | + const sign = v < 0 ? '−' : '' | |
| 38 | + if (a < 1000) return sign + numberFormatter(locale, a >= 100 || Number.isInteger(a) ? 0 : 2).format(a) | |
| 39 | + const units = [[1e12, 'T'], [1e9, 'B'], [1e6, 'M'], [1e3, 'K']] | |
| 40 | + for (const [div, suffix] of units) { | |
| 41 | + if (a >= div) { | |
| 42 | + const q = a / div | |
| 43 | + return sign + numberFormatter(locale, q >= 100 ? 0 : q >= 10 ? 1 : 2).format(q) + suffix | |
| 44 | + } | |
| 45 | + } | |
| 46 | + return sign + String(a) | |
| 47 | +} | |
| 48 | + | |
| 49 | +/** Round a raw step up to the nearest "nice" 1-2-5 × 10^k value. */ | |
| 50 | +export function niceStep(raw) { | |
| 51 | + if (!(raw > 0) || !Number.isFinite(raw)) return 1 | |
| 52 | + const exp = Math.floor(Math.log10(raw)) | |
| 53 | + const base = Math.pow(10, exp) | |
| 54 | + const m = raw / base | |
| 55 | + let nice | |
| 56 | + if (m <= 1) nice = 1 | |
| 57 | + else if (m <= 2) nice = 2 | |
| 58 | + else if (m <= 2.5) nice = 2.5 | |
| 59 | + else if (m <= 5) nice = 5 | |
| 60 | + else nice = 10 | |
| 61 | + return nice * base | |
| 62 | +} | |
| 63 | + | |
| 64 | +/** Number of decimals needed to display multiples of `step` without rounding artefacts (max 10). */ | |
| 65 | +export function decimalsForStep(step) { | |
| 66 | + if (!(step > 0)) return 0 | |
| 67 | + let d = 0 | |
| 68 | + let s = step | |
| 69 | + while (d < 10 && Math.abs(s - Math.round(s)) > 1e-9 * Math.max(1, Math.abs(s))) { s *= 10; d++ } | |
| 70 | + return d | |
| 71 | +} | |
| 72 | + | |
| 73 | +/** | |
| 74 | + * Infer display decimals from the data: minimum tick size observed and price magnitude. | |
| 75 | + * Never invents precision: caps at 8, floors at 0 (index-like prices) or 2 for regular equities. | |
| 76 | + */ | |
| 77 | +export function autoDecimals(bars, minMove) { | |
| 78 | + if (minMove && minMove > 0) return Math.min(8, decimalsForStep(minMove)) | |
| 79 | + if (!bars || !bars.length) return 2 | |
| 80 | + let maxDec = 0 | |
| 81 | + let minPrice = Infinity | |
| 82 | + const step = Math.max(1, Math.floor(bars.length / 400)) | |
| 83 | + for (let i = bars.length - 1, k = 0; i >= 0 && k < 400; i -= step, k++) { | |
| 84 | + const b = bars[i] | |
| 85 | + for (const p of [b.o, b.h, b.l, b.c]) { | |
| 86 | + if (!Number.isFinite(p)) continue | |
| 87 | + if (p > 0 && p < minPrice) minPrice = p | |
| 88 | + const d = decimalsOf(p) | |
| 89 | + if (d > maxDec) maxDec = d | |
| 90 | + } | |
| 91 | + } | |
| 92 | + if (!Number.isFinite(minPrice)) return 2 | |
| 93 | + let floor = 2 | |
| 94 | + if (minPrice < 0.01) floor = 6 | |
| 95 | + else if (minPrice < 1) floor = 4 | |
| 96 | + else if (minPrice < 10) floor = 3 | |
| 97 | + else if (minPrice >= 10000) floor = 1 | |
| 98 | + return Math.min(8, Math.max(floor, Math.min(maxDec, 6))) | |
| 99 | +} | |
| 100 | + | |
| 101 | +function decimalsOf(p) { | |
| 102 | + const s = String(p) | |
| 103 | + const e = s.indexOf('e') | |
| 104 | + if (e >= 0) return Math.min(10, Math.max(0, -parseInt(s.slice(e + 1), 10))) | |
| 105 | + const dot = s.indexOf('.') | |
| 106 | + return dot < 0 ? 0 : s.length - dot - 1 | |
| 107 | +} | |
| 108 | + | |
| 109 | +export const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v) | |
added
hfmarketdata/web/src/charts/engine/format/time.js
+99 −0
@@ -0,0 +1,99 @@ | ||
| 1 | +// Time formatting. Timestamps are "wall clock" values encoded with Date.UTC by the caller, so every accessor here | |
| 2 | +// is a UTC getter: the engine never converts time zones. | |
| 3 | + | |
| 4 | +export const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] | |
| 5 | +export const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] | |
| 6 | + | |
| 7 | +export const TF_MS = { '1min': 60_000, '5min': 300_000, '30min': 1_800_000, '1hour': 3_600_000, '1day': 86_400_000 } | |
| 8 | + | |
| 9 | +export const isIntraday = tf => tf !== '1day' | |
| 10 | + | |
| 11 | +const pad2 = n => (n < 10 ? '0' + n : '' + n) | |
| 12 | + | |
| 13 | +/** Broken-down UTC parts of a timestamp. */ | |
| 14 | +export function parts(t) { | |
| 15 | + const d = new Date(t) | |
| 16 | + return { | |
| 17 | + y: d.getUTCFullYear(), mo: d.getUTCMonth(), d: d.getUTCDate(), wd: d.getUTCDay(), | |
| 18 | + h: d.getUTCHours(), mi: d.getUTCMinutes(), s: d.getUTCSeconds(), | |
| 19 | + } | |
| 20 | +} | |
| 21 | + | |
| 22 | +export function fmtHM(t) { | |
| 23 | + const p = parts(t) | |
| 24 | + return `${pad2(p.h)}:${pad2(p.mi)}` | |
| 25 | +} | |
| 26 | + | |
| 27 | +export function fmtDate(t) { | |
| 28 | + const p = parts(t) | |
| 29 | + return `${p.d} ${MONTHS[p.mo]} ${p.y}` | |
| 30 | +} | |
| 31 | + | |
| 32 | +export function fmtDateShort(t) { | |
| 33 | + const p = parts(t) | |
| 34 | + return `${MONTHS[p.mo]} ${p.d}` | |
| 35 | +} | |
| 36 | + | |
| 37 | +/** Full crosshair / tooltip label: "Tue 12 Mar 2024 · 10:15 ET" (intraday) or "Tue 12 Mar 2024" (daily). */ | |
| 38 | +export function fmtFull(t, tf, sessionLabel = '') { | |
| 39 | + const p = parts(t) | |
| 40 | + const date = `${WEEKDAYS[p.wd]} ${p.d} ${MONTHS[p.mo]} ${p.y}` | |
| 41 | + if (!isIntraday(tf)) return date | |
| 42 | + const suffix = sessionLabel ? ` ${sessionLabel}` : '' | |
| 43 | + return `${date} · ${pad2(p.h)}:${pad2(p.mi)}${suffix}` | |
| 44 | +} | |
| 45 | + | |
| 46 | +/** | |
| 47 | + * Tick "level" of a bar relative to the previous one — the higher, the more important the label. | |
| 48 | + * 6 year change · 5 month change · 4 day change · 3 hour multiple of 6 (intraday) / Monday (daily) | |
| 49 | + * 2 hour change · 1.5 half hour · 1 quarter hour · 0.5 five minutes · 0 anything else | |
| 50 | + */ | |
| 51 | +export function tickLevel(t, prevT, tf) { | |
| 52 | + const p = parts(t) | |
| 53 | + if (prevT == null) return isIntraday(tf) ? 4 : 5 | |
| 54 | + const q = parts(prevT) | |
| 55 | + if (p.y !== q.y) return 6 | |
| 56 | + if (p.mo !== q.mo) return 5 | |
| 57 | + if (p.d !== q.d) return 4 | |
| 58 | + if (!isIntraday(tf)) return p.wd === 1 ? 3 : 0 | |
| 59 | + if (p.h !== q.h) return p.h % 6 === 0 ? 3 : 2 | |
| 60 | + if (p.mi === 0) return 2 | |
| 61 | + if (p.mi === 30) return 1.5 | |
| 62 | + if (p.mi % 15 === 0) return 1 | |
| 63 | + if (p.mi % 5 === 0) return 0.5 | |
| 64 | + return 0 | |
| 65 | +} | |
| 66 | + | |
| 67 | +/** Label for a tick at a given level. `strong` = day/month/year changes (drawn bold). */ | |
| 68 | +export function tickLabel(t, level, tf) { | |
| 69 | + const p = parts(t) | |
| 70 | + if (level >= 6) return { text: String(p.y), strong: true } | |
| 71 | + if (level >= 5) return { text: MONTHS[p.mo], strong: true } | |
| 72 | + if (level >= 4) return { text: String(p.d), strong: true } | |
| 73 | + if (!isIntraday(tf)) return { text: String(p.d), strong: false } | |
| 74 | + return { text: `${pad2(p.h)}:${pad2(p.mi)}`, strong: false } | |
| 75 | +} | |
| 76 | + | |
| 77 | +/** True when two timestamps fall on different (UTC-encoded wall clock) calendar days. */ | |
| 78 | +export function isDayChange(t, prevT) { | |
| 79 | + if (prevT == null) return false | |
| 80 | + const a = Math.floor(t / 86_400_000) | |
| 81 | + const b = Math.floor(prevT / 86_400_000) | |
| 82 | + return a !== b | |
| 83 | +} | |
| 84 | + | |
| 85 | +/** Human duration between two timestamps: "3d 4h 12m" · "45m" · "2y 3mo". */ | |
| 86 | +export function fmtDuration(ms) { | |
| 87 | + ms = Math.abs(ms) | |
| 88 | + const m = Math.round(ms / 60_000) | |
| 89 | + if (m < 60) return `${m}m` | |
| 90 | + const h = Math.floor(m / 60) | |
| 91 | + if (h < 24) return m % 60 ? `${h}h ${m % 60}m` : `${h}h` | |
| 92 | + const d = Math.floor(h / 24) | |
| 93 | + if (d < 60) return h % 24 ? `${d}d ${h % 24}h` : `${d}d` | |
| 94 | + const mo = Math.floor(d / 30.44) | |
| 95 | + if (mo < 24) return `${mo}mo` | |
| 96 | + const y = Math.floor(d / 365.25) | |
| 97 | + const rem = Math.floor((d - y * 365.25) / 30.44) | |
| 98 | + return rem ? `${y}y ${rem}mo` : `${y}y` | |
| 99 | +} | |
added
hfmarketdata/web/src/charts/engine/render/canvas.js
+101 −0
@@ -0,0 +1,101 @@ | ||
| 1 | +// Canvas layers with devicePixelRatio handling and crisp-line helpers. Drawing happens in CSS pixels. | |
| 2 | + | |
| 3 | +export function createLayer(parent, { zIndex = 0, pointerEvents = 'none' } = {}) { | |
| 4 | + const canvas = document.createElement('canvas') | |
| 5 | + canvas.style.cssText = `position:absolute;left:0;top:0;display:block;z-index:${zIndex};pointer-events:${pointerEvents};` | |
| 6 | + parent.appendChild(canvas) | |
| 7 | + const ctx = canvas.getContext('2d', { alpha: true }) | |
| 8 | + const layer = { | |
| 9 | + canvas, ctx, width: 0, height: 0, dpr: 1, | |
| 10 | + resize(w, h, dpr) { | |
| 11 | + w = Math.max(1, Math.round(w)); h = Math.max(1, Math.round(h)) | |
| 12 | + if (w === layer.width && h === layer.height && dpr === layer.dpr) return false | |
| 13 | + layer.width = w; layer.height = h; layer.dpr = dpr | |
| 14 | + canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr) | |
| 15 | + canvas.style.width = w + 'px'; canvas.style.height = h + 'px' | |
| 16 | + return true | |
| 17 | + }, | |
| 18 | + clear() { | |
| 19 | + ctx.setTransform(1, 0, 0, 1, 0, 0) | |
| 20 | + ctx.clearRect(0, 0, canvas.width, canvas.height) | |
| 21 | + ctx.setTransform(layer.dpr, 0, 0, layer.dpr, 0, 0) | |
| 22 | + }, | |
| 23 | + destroy() { canvas.remove() }, | |
| 24 | + } | |
| 25 | + return layer | |
| 26 | +} | |
| 27 | + | |
| 28 | +/** Snap a coordinate to the pixel center for 1 px strokes. */ | |
| 29 | +export const crisp = v => Math.round(v) + 0.5 | |
| 30 | +/** Snap to a pixel edge (for fills). */ | |
| 31 | +export const snap = v => Math.round(v) | |
| 32 | + | |
| 33 | +/** 1 px horizontal line. */ | |
| 34 | +export function hline(ctx, x0, x1, y) { | |
| 35 | + ctx.beginPath(); ctx.moveTo(x0, crisp(y)); ctx.lineTo(x1, crisp(y)); ctx.stroke() | |
| 36 | +} | |
| 37 | + | |
| 38 | +/** 1 px vertical line. */ | |
| 39 | +export function vline(ctx, x, y0, y1) { | |
| 40 | + ctx.beginPath(); ctx.moveTo(crisp(x), y0); ctx.lineTo(crisp(x), y1); ctx.stroke() | |
| 41 | +} | |
| 42 | + | |
| 43 | +/** Rounded rectangle path (no fill). */ | |
| 44 | +export function roundRect(ctx, x, y, w, h, r) { | |
| 45 | + r = Math.min(r, w / 2, h / 2) | |
| 46 | + ctx.beginPath() | |
| 47 | + ctx.moveTo(x + r, y) | |
| 48 | + ctx.arcTo(x + w, y, x + w, y + h, r) | |
| 49 | + ctx.arcTo(x + w, y + h, x, y + h, r) | |
| 50 | + ctx.arcTo(x, y + h, x, y, r) | |
| 51 | + ctx.arcTo(x, y, x + w, y, r) | |
| 52 | + ctx.closePath() | |
| 53 | +} | |
| 54 | + | |
| 55 | +/** Parse "#rgb", "#rrggbb", "#rrggbbaa", "rgb()/rgba()" into [r,g,b,a]. Unknown → null. */ | |
| 56 | +export function parseColor(c) { | |
| 57 | + if (!c) return null | |
| 58 | + c = String(c).trim() | |
| 59 | + if (c[0] === '#') { | |
| 60 | + let h = c.slice(1) | |
| 61 | + if (h.length === 3 || h.length === 4) h = h.split('').map(ch => ch + ch).join('') | |
| 62 | + if (h.length === 6 || h.length === 8) { | |
| 63 | + const n = parseInt(h.slice(0, 6), 16) | |
| 64 | + const a = h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1 | |
| 65 | + return [(n >> 16) & 255, (n >> 8) & 255, n & 255, a] | |
| 66 | + } | |
| 67 | + return null | |
| 68 | + } | |
| 69 | + const m = /^rgba?\(([^)]+)\)$/i.exec(c) | |
| 70 | + if (m) { | |
| 71 | + const p = m[1].split(/[\s,/]+/).filter(Boolean).map(Number) | |
| 72 | + return [p[0] || 0, p[1] || 0, p[2] || 0, p.length > 3 ? p[3] : 1] | |
| 73 | + } | |
| 74 | + return null | |
| 75 | +} | |
| 76 | + | |
| 77 | +/** Same color with a different alpha (falls back to the input when unparsable). */ | |
| 78 | +export function withAlpha(c, alpha) { | |
| 79 | + const p = parseColor(c) | |
| 80 | + if (!p) return c | |
| 81 | + return `rgba(${p[0]},${p[1]},${p[2]},${alpha})` | |
| 82 | +} | |
| 83 | + | |
| 84 | +/** Blend two colors (k = 0 → a, 1 → b). */ | |
| 85 | +export function mix(a, b, k) { | |
| 86 | + const pa = parseColor(a), pb = parseColor(b) | |
| 87 | + if (!pa || !pb) return k < 0.5 ? a : b | |
| 88 | + const r = Math.round(pa[0] + (pb[0] - pa[0]) * k) | |
| 89 | + const g = Math.round(pa[1] + (pb[1] - pa[1]) * k) | |
| 90 | + const bl = Math.round(pa[2] + (pb[2] - pa[2]) * k) | |
| 91 | + const al = pa[3] + (pb[3] - pa[3]) * k | |
| 92 | + return `rgba(${r},${g},${bl},${al})` | |
| 93 | +} | |
| 94 | + | |
| 95 | +/** Relative luminance (0 dark → 1 light) — used to pick label text colors. */ | |
| 96 | +export function luminance(c) { | |
| 97 | + const p = parseColor(c) | |
| 98 | + if (!p) return 0 | |
| 99 | + const f = v => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4) } | |
| 100 | + return 0.2126 * f(p[0]) + 0.7152 * f(p[1]) + 0.0722 * f(p[2]) | |
| 101 | +} | |
added
hfmarketdata/web/src/charts/engine/render/text.js
+53 −0
@@ -0,0 +1,53 @@ | ||
| 1 | +// Text helpers: font strings, measurement cache and axis "pill" labels. | |
| 2 | + | |
| 3 | +import { roundRect, luminance } from './canvas.js' | |
| 4 | + | |
| 5 | +export const FONT_SIZE = 11 | |
| 6 | +export const FONT_SIZE_LG = 12 | |
| 7 | + | |
| 8 | +export function font(theme, { size = FONT_SIZE, weight = 400, mono = false } = {}) { | |
| 9 | + return `${weight} ${size}px ${mono ? theme.mono : theme.font}` | |
| 10 | +} | |
| 11 | + | |
| 12 | +const measureCache = new Map() | |
| 13 | +let cacheFont = '' | |
| 14 | + | |
| 15 | +/** Cached text width for a font (cache is reset when the font changes). */ | |
| 16 | +export function measure(ctx, text, fontStr) { | |
| 17 | + if (fontStr !== cacheFont) { measureCache.clear(); cacheFont = fontStr; ctx.font = fontStr } | |
| 18 | + let w = measureCache.get(text) | |
| 19 | + if (w == null) { | |
| 20 | + if (ctx.font !== fontStr) ctx.font = fontStr | |
| 21 | + w = ctx.measureText(text).width | |
| 22 | + if (measureCache.size > 4000) measureCache.clear() | |
| 23 | + measureCache.set(text, w) | |
| 24 | + } | |
| 25 | + return w | |
| 26 | +} | |
| 27 | + | |
| 28 | +/** | |
| 29 | + * Filled label with rounded corners. `align`: 'left' (x is the left edge), 'right' (x is the right edge) or | |
| 30 | + * 'center'. Returns the label box { x, y, w, h }. | |
| 31 | + */ | |
| 32 | +export function pill(ctx, text, x, y, { bg, color, fontStr, padX = 5, h = 18, align = 'left', radius = 3, clampTo = null, mono = true } = {}) { | |
| 33 | + ctx.font = fontStr | |
| 34 | + const w = Math.ceil(ctx.measureText(text).width) + padX * 2 | |
| 35 | + let bx = align === 'right' ? x - w : align === 'center' ? x - w / 2 : x | |
| 36 | + let by = y - h / 2 | |
| 37 | + if (clampTo) { | |
| 38 | + bx = Math.min(Math.max(bx, clampTo.x0), clampTo.x1 - w) | |
| 39 | + by = Math.min(Math.max(by, clampTo.y0), clampTo.y1 - h) | |
| 40 | + } | |
| 41 | + bx = Math.round(bx); by = Math.round(by) | |
| 42 | + ctx.fillStyle = bg | |
| 43 | + roundRect(ctx, bx, by, w, h, radius) | |
| 44 | + ctx.fill() | |
| 45 | + ctx.fillStyle = color || (luminance(bg) > 0.5 ? '#12161d' : '#ffffff') | |
| 46 | + ctx.textBaseline = 'middle' | |
| 47 | + ctx.textAlign = 'left' | |
| 48 | + ctx.fillText(text, bx + padX, by + h / 2 + 0.5) | |
| 49 | + return { x: bx, y: by, w, h } | |
| 50 | +} | |
| 51 | + | |
| 52 | +/** Text color readable on `bg`. */ | |
| 53 | +export function inkFor(bg) { return luminance(bg) > 0.5 ? '#12161d' : '#ffffff' } | |
added
hfmarketdata/web/src/charts/engine/scales/price-scale.js
+146 −0
@@ -0,0 +1,146 @@ | ||
| 1 | +// Price scale for one pane: linear / log / percent, auto-range with breathing margins, manual stretch & scroll. | |
| 2 | +// Internally the range is kept in "internal units" (price, log10(price) or percent from a base) so every mode | |
| 3 | +// shares the same pixel mapping. | |
| 4 | + | |
| 5 | +import { linearTicks, logTicks } from './ticks.js' | |
| 6 | +import { formatPrice, formatPercent } from '../format/number.js' | |
| 7 | + | |
| 8 | +const LOG_FLOOR = 1e-9 | |
| 9 | + | |
| 10 | +export class PriceScale { | |
| 11 | + constructor({ mode = 'linear', margins = 0.08 } = {}) { | |
| 12 | + this.mode = mode | |
| 13 | + this.auto = true | |
| 14 | + this.invert = false | |
| 15 | + this.height = 0 | |
| 16 | + this.marginTop = margins | |
| 17 | + this.marginBottom = margins | |
| 18 | + this.lo = 0; this.hi = 1 // current (animated) internal range | |
| 19 | + this.targetLo = 0; this.targetHi = 1 | |
| 20 | + this.base = null // percent mode reference price | |
| 21 | + this.hasRange = false | |
| 22 | + this.fixedRange = null // e.g. { lo: 0, hi: 100 } for bounded oscillators | |
| 23 | + this.levels = [] // horizontal reference levels (price units), e.g. RSI 30/70 | |
| 24 | + } | |
| 25 | + | |
| 26 | + setMode(mode) { | |
| 27 | + if (mode === this.mode) return | |
| 28 | + // Convert current range to price and back so the view does not jump. | |
| 29 | + const pLo = this.fromInternal(this.lo), pHi = this.fromInternal(this.hi) | |
| 30 | + this.mode = mode | |
| 31 | + if (this.hasRange && Number.isFinite(pLo) && Number.isFinite(pHi)) { | |
| 32 | + const a = this.toInternal(pLo), b = this.toInternal(pHi) | |
| 33 | + this.lo = this.targetLo = Math.min(a, b); this.hi = this.targetHi = Math.max(a, b) | |
| 34 | + if (!(this.hi > this.lo)) { this.hi = this.lo + 1 } | |
| 35 | + } | |
| 36 | + } | |
| 37 | + | |
| 38 | + toInternal(p) { | |
| 39 | + if (this.mode === 'log') return Math.log10(Math.max(p, LOG_FLOOR)) | |
| 40 | + if (this.mode === 'percent') return this.base ? (p / this.base - 1) * 100 : p | |
| 41 | + return p | |
| 42 | + } | |
| 43 | + | |
| 44 | + fromInternal(v) { | |
| 45 | + if (this.mode === 'log') return Math.pow(10, v) | |
| 46 | + if (this.mode === 'percent') return this.base ? (v / 100 + 1) * this.base : v | |
| 47 | + return v | |
| 48 | + } | |
| 49 | + | |
| 50 | + /** Pixel y (within the pane plot area) of a price. */ | |
| 51 | + y(price) { | |
| 52 | + const v = this.toInternal(price) | |
| 53 | + return this.yInternal(v) | |
| 54 | + } | |
| 55 | + | |
| 56 | + yInternal(v) { | |
| 57 | + const span = this.hi - this.lo || 1 | |
| 58 | + const f = (v - this.lo) / span | |
| 59 | + return this.invert ? f * this.height : (1 - f) * this.height | |
| 60 | + } | |
| 61 | + | |
| 62 | + /** Price at pixel y. */ | |
| 63 | + priceAt(y) { | |
| 64 | + const span = this.hi - this.lo || 1 | |
| 65 | + const f = this.invert ? y / this.height : 1 - y / this.height | |
| 66 | + return this.fromInternal(this.lo + f * span) | |
| 67 | + } | |
| 68 | + | |
| 69 | + /** Auto-range from data extremes (price units). Margins are applied in internal space (8 % top/bottom). */ | |
| 70 | + setAutoRange(min, max) { | |
| 71 | + if (!Number.isFinite(min) || !Number.isFinite(max)) return | |
| 72 | + if (this.fixedRange) { min = this.fixedRange.lo; max = this.fixedRange.hi } | |
| 73 | + let a = this.toInternal(min), b = this.toInternal(max) | |
| 74 | + if (a > b) [a, b] = [b, a] | |
| 75 | + if (b - a < 1e-12) { | |
| 76 | + const pad = Math.abs(a) * 0.01 || 1 | |
| 77 | + a -= pad; b += pad | |
| 78 | + } | |
| 79 | + const span = b - a | |
| 80 | + const usable = 1 - this.marginTop - this.marginBottom | |
| 81 | + const lo = a - span * (this.marginBottom / usable) | |
| 82 | + const hi = b + span * (this.marginTop / usable) | |
| 83 | + this.targetLo = lo; this.targetHi = hi | |
| 84 | + if (!this.hasRange) { this.lo = lo; this.hi = hi; this.hasRange = true } | |
| 85 | + } | |
| 86 | + | |
| 87 | + /** Advance the animation toward the target. Returns true while still moving. */ | |
| 88 | + step(dt, reducedMotion) { | |
| 89 | + if (!this.auto && !this._forceAnim) return false | |
| 90 | + const dLo = this.targetLo - this.lo, dHi = this.targetHi - this.hi | |
| 91 | + const span = Math.abs(this.hi - this.lo) || 1 | |
| 92 | + if (reducedMotion || (Math.abs(dLo) < span * 5e-4 && Math.abs(dHi) < span * 5e-4)) { | |
| 93 | + this.lo = this.targetLo; this.hi = this.targetHi; this._forceAnim = false | |
| 94 | + return false | |
| 95 | + } | |
| 96 | + const k = 1 - Math.exp(-dt / 70) | |
| 97 | + this.lo += dLo * k; this.hi += dHi * k | |
| 98 | + return true | |
| 99 | + } | |
| 100 | + | |
| 101 | + snap() { this.lo = this.targetLo; this.hi = this.targetHi } | |
| 102 | + | |
| 103 | + /** Manual vertical zoom around `anchorY` (factor > 1 stretches / zooms in). */ | |
| 104 | + stretch(factor, anchorY) { | |
| 105 | + this.auto = false | |
| 106 | + const vA = this.lo + (this.invert ? anchorY / this.height : 1 - anchorY / this.height) * (this.hi - this.lo) | |
| 107 | + const lo = vA - (vA - this.lo) / factor | |
| 108 | + const hi = vA + (this.hi - vA) / factor | |
| 109 | + if (hi - lo < 1e-9) return | |
| 110 | + this.lo = this.targetLo = lo; this.hi = this.targetHi = hi | |
| 111 | + } | |
| 112 | + | |
| 113 | + /** Manual scroll by pixels. */ | |
| 114 | + scrollBy(dy) { | |
| 115 | + this.auto = false | |
| 116 | + const dv = (dy / this.height) * (this.hi - this.lo) * (this.invert ? -1 : 1) | |
| 117 | + this.lo = this.targetLo = this.lo + dv; this.hi = this.targetHi = this.hi + dv | |
| 118 | + } | |
| 119 | + | |
| 120 | + /** Ticks for the axis: [{ price, y, text }], plus the decimals used. */ | |
| 121 | + ticks(height, decimals, locale, minPx = 44) { | |
| 122 | + const out = [] | |
| 123 | + if (!(height > 0) || !(this.hi > this.lo)) return { ticks: out, decimals } | |
| 124 | + if (this.mode === 'log') { | |
| 125 | + const r = logTicks(this.lo, this.hi, height, minPx) | |
| 126 | + const d = Math.max(decimals, r.decimals) | |
| 127 | + for (const p of r.ticks) out.push({ price: p, y: this.y(p), text: formatPrice(p, Math.min(d, 8), locale) }) | |
| 128 | + return { ticks: out, decimals: d } | |
| 129 | + } | |
| 130 | + if (this.mode === 'percent') { | |
| 131 | + const r = linearTicks(this.lo, this.hi, height, minPx) | |
| 132 | + for (const v of r.ticks) out.push({ price: this.fromInternal(v), y: this.yInternal(v), text: formatPercent(v, r.decimals, locale) }) | |
| 133 | + return { ticks: out, decimals } | |
| 134 | + } | |
| 135 | + const r = linearTicks(this.lo, this.hi, height, minPx) | |
| 136 | + const d = Math.min(Math.max(decimals, r.decimals), 8) | |
| 137 | + for (const v of r.ticks) out.push({ price: v, y: this.yInternal(v), text: formatPrice(v, d, locale) }) | |
| 138 | + return { ticks: out, decimals: d } | |
| 139 | + } | |
| 140 | + | |
| 141 | + /** Label for a price on this scale (percent mode shows the % from base). */ | |
| 142 | + format(price, decimals, locale) { | |
| 143 | + if (this.mode === 'percent' && this.base) return formatPercent(this.toInternal(price), 2, locale) | |
| 144 | + return formatPrice(price, decimals, locale) | |
| 145 | + } | |
| 146 | +} | |
added
hfmarketdata/web/src/charts/engine/scales/ticks.js
+103 −0
@@ -0,0 +1,103 @@ | ||
| 1 | +// Tick generation for price and time axes. Pure functions. | |
| 2 | + | |
| 3 | +import { niceStep, decimalsForStep } from '../format/number.js' | |
| 4 | +import { tickLevel, tickLabel } from '../format/time.js' | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * Linear ticks over [lo, hi] for `pixels` of height, at least `minPx` apart. | |
| 8 | + * Returns { ticks: number[], step, decimals }. | |
| 9 | + */ | |
| 10 | +export function linearTicks(lo, hi, pixels, minPx = 44) { | |
| 11 | + if (!(hi > lo) || !(pixels > 0)) return { ticks: [], step: 1, decimals: 0 } | |
| 12 | + const count = Math.max(1, Math.floor(pixels / minPx)) | |
| 13 | + const step = niceStep((hi - lo) / count) | |
| 14 | + const decimals = decimalsForStep(step) | |
| 15 | + const first = Math.ceil(lo / step - 1e-9) * step | |
| 16 | + const ticks = [] | |
| 17 | + for (let v = first; v <= hi + step * 1e-9 && ticks.length < 200; v += step) ticks.push(roundTo(v, decimals)) | |
| 18 | + return { ticks, step, decimals } | |
| 19 | +} | |
| 20 | + | |
| 21 | +/** | |
| 22 | + * Log ticks: prices in [10^lo, 10^hi] with 1-2-5 mantissas, thinned to `minPx` in log space. | |
| 23 | + * Returns { ticks: price[], decimals }. | |
| 24 | + */ | |
| 25 | +export function logTicks(lo, hi, pixels, minPx = 44) { | |
| 26 | + if (!(hi > lo) || !(pixels > 0)) return { ticks: [], decimals: 2 } | |
| 27 | + const pxPerUnit = pixels / (hi - lo) | |
| 28 | + const minGap = minPx / pxPerUnit // in log10 units | |
| 29 | + const mantissas = minGap > 0.9 ? [1] : minGap > 0.5 ? [1, 3] : minGap > 0.25 ? [1, 2, 5] : minGap > 0.1 ? [1, 1.5, 2, 3, 5, 7] : null | |
| 30 | + const ticks = [] | |
| 31 | + if (mantissas) { | |
| 32 | + const e0 = Math.floor(lo) - 1 | |
| 33 | + const e1 = Math.ceil(hi) + 1 | |
| 34 | + let last = -Infinity | |
| 35 | + for (let e = e0; e <= e1; e++) { | |
| 36 | + for (const m of mantissas) { | |
| 37 | + const v = Math.log10(m) + e | |
| 38 | + if (v < lo || v > hi) continue | |
| 39 | + if (v - last < minGap * 0.999) continue | |
| 40 | + ticks.push(Math.pow(10, e) * m) | |
| 41 | + last = v | |
| 42 | + } | |
| 43 | + } | |
| 44 | + let decimals = 0 | |
| 45 | + for (const p of ticks) decimals = Math.max(decimals, decimalsForStep(p)) | |
| 46 | + return { ticks, decimals: Math.min(decimals, 8) } | |
| 47 | + } | |
| 48 | + // Dense zoom: fall back to linear ticks in price space (range is small enough). | |
| 49 | + const plo = Math.pow(10, lo), phi = Math.pow(10, hi) | |
| 50 | + const lin = linearTicks(plo, phi, pixels, minPx) | |
| 51 | + return { ticks: lin.ticks, decimals: lin.decimals } | |
| 52 | +} | |
| 53 | + | |
| 54 | +function roundTo(v, decimals) { | |
| 55 | + const f = Math.pow(10, decimals) | |
| 56 | + return Math.round(v * f) / f | |
| 57 | +} | |
| 58 | + | |
| 59 | +/** | |
| 60 | + * Time axis labels without collisions. Candidates are bars whose timestamp starts a new period; more important | |
| 61 | + * levels win (year › month › day › hour › …), then greedy placement by pixel distance. | |
| 62 | + * `measure(text, strong)` returns the label width in px. | |
| 63 | + * Returns [{ index, x, text, strong, level }]. | |
| 64 | + */ | |
| 65 | +export function timeTicks({ times, from, to, tf, xOf, width, measure, gap = 14, maxLabels = 60 }) { | |
| 66 | + if (to < from) return [] | |
| 67 | + const cands = [] | |
| 68 | + const span = to - from + 1 | |
| 69 | + // Coarse sampling protects against 200 000-bar sweeps: only evaluate every `stride` bars for low levels, | |
| 70 | + // but always test day/month/year changes (cheap: compare floor(t / 86400000)). | |
| 71 | + const stride = Math.max(1, Math.floor(span / 4000)) | |
| 72 | + for (let i = from; i <= to; i++) { | |
| 73 | + const t = times[i] | |
| 74 | + const prev = i > 0 ? times[i - 1] : null | |
| 75 | + let lvl | |
| 76 | + if (stride > 1 && prev != null && Math.floor(t / 86_400_000) === Math.floor(prev / 86_400_000)) { | |
| 77 | + if ((i - from) % stride !== 0) continue | |
| 78 | + lvl = tickLevel(t, prev, tf) | |
| 79 | + } else lvl = tickLevel(t, prev, tf) | |
| 80 | + if (lvl <= 0) continue | |
| 81 | + cands.push({ index: i, level: lvl, t }) | |
| 82 | + } | |
| 83 | + // Highest level first, then oldest first (stable across pans). | |
| 84 | + cands.sort((a, b) => b.level - a.level || a.index - b.index) | |
| 85 | + const placed = [] | |
| 86 | + for (const c of cands) { | |
| 87 | + if (placed.length >= maxLabels) break | |
| 88 | + const x = xOf(c.index) | |
| 89 | + if (x < -gap || x > width + gap) continue | |
| 90 | + const lab = tickLabel(c.t, c.level, tf) | |
| 91 | + const w = measure(lab.text, lab.strong) | |
| 92 | + const x0 = x - w / 2 - gap / 2 | |
| 93 | + const x1 = x + w / 2 + gap / 2 | |
| 94 | + let ok = true | |
| 95 | + for (const p of placed) { | |
| 96 | + if (x0 < p.x1 && x1 > p.x0) { ok = false; break } | |
| 97 | + } | |
| 98 | + if (!ok) continue | |
| 99 | + placed.push({ index: c.index, x, text: lab.text, strong: lab.strong, level: c.level, x0, x1 }) | |
| 100 | + } | |
| 101 | + placed.sort((a, b) => a.index - b.index) | |
| 102 | + return placed | |
| 103 | +} | |
added
hfmarketdata/web/src/charts/engine/scales/time-scale.js
+108 −0
@@ -0,0 +1,108 @@ | ||
| 1 | +// Index-based time scale: one bar = one step, whatever the wall-clock gap between bars. | |
| 2 | +// `leftIndex` is the (fractional) bar index sitting at x = 0 of the plot area; `barSpacing` is px per bar. | |
| 3 | + | |
| 4 | +import { clamp } from '../format/number.js' | |
| 5 | + | |
| 6 | +export class TimeScale { | |
| 7 | + constructor({ barSpacing = 8, minBarSpacing = 0.5, maxBarSpacing = 120, rightOffsetBars = 8 } = {}) { | |
| 8 | + this.width = 0 | |
| 9 | + this.barSpacing = barSpacing | |
| 10 | + this.defaultBarSpacing = barSpacing | |
| 11 | + this.minBarSpacing = minBarSpacing | |
| 12 | + this.maxBarSpacing = maxBarSpacing | |
| 13 | + this.rightOffsetBars = rightOffsetBars | |
| 14 | + this.leftIndex = 0 | |
| 15 | + this.count = 0 // number of bars in the data set | |
| 16 | + this.stickToRight = true // viewport follows appended bars while true | |
| 17 | + } | |
| 18 | + | |
| 19 | + get visibleBars() { return this.width / this.barSpacing } | |
| 20 | + | |
| 21 | + /** Center x of bar `i` (fractional indices allowed). */ | |
| 22 | + x(i) { return (i - this.leftIndex) * this.barSpacing + this.barSpacing / 2 } | |
| 23 | + | |
| 24 | + /** Fractional index under pixel x. */ | |
| 25 | + indexAt(x) { return this.leftIndex + (x - this.barSpacing / 2) / this.barSpacing } | |
| 26 | + | |
| 27 | + /** Index at the right edge of the plot. */ | |
| 28 | + get rightIndex() { return this.indexAt(this.width) } | |
| 29 | + set rightIndex(r) { this.leftIndex = r - (this.width - this.barSpacing / 2) / this.barSpacing } | |
| 30 | + | |
| 31 | + /** Integer range of bars intersecting the viewport, clamped to the data. Empty → from > to. */ | |
| 32 | + visibleRange() { | |
| 33 | + const from = Math.max(0, Math.floor(this.leftIndex)) | |
| 34 | + const to = Math.min(this.count - 1, Math.ceil(this.leftIndex + this.visibleBars)) | |
| 35 | + return { from, to } | |
| 36 | + } | |
| 37 | + | |
| 38 | + /** Keep the data reachable: never scroll the last bar off the left nor the first bar off the right. */ | |
| 39 | + clampScroll() { | |
| 40 | + if (this.count === 0) { this.leftIndex = -this.visibleBars + 1; return } | |
| 41 | + const vis = this.visibleBars | |
| 42 | + const minLeft = -(vis - 2) // first bar can go as far as the right edge (minus 2 bars) | |
| 43 | + const maxLeft = this.count - 2 // last bar can go as far as the left edge (plus 2 bars) | |
| 44 | + this.leftIndex = clamp(this.leftIndex, Math.min(minLeft, maxLeft), Math.max(minLeft, maxLeft)) | |
| 45 | + } | |
| 46 | + | |
| 47 | + /** Right-edge target when following live data. */ | |
| 48 | + latestRightIndex() { return this.count - 1 + this.rightOffsetBars } | |
| 49 | + | |
| 50 | + scrollToLatest() { this.rightIndex = this.latestRightIndex(); this.stickToRight = true } | |
| 51 | + | |
| 52 | + /** Is the viewport currently glued to the latest bar (within half a bar)? */ | |
| 53 | + isAtLatest() { return Math.abs(this.rightIndex - this.latestRightIndex()) < 0.5 } | |
| 54 | + | |
| 55 | + /** Pan by pixels (positive dx = content moves right, i.e. we look further back). */ | |
| 56 | + scrollPx(dx) { | |
| 57 | + this.leftIndex -= dx / this.barSpacing | |
| 58 | + this.clampScroll() | |
| 59 | + this.stickToRight = this.isAtLatest() | |
| 60 | + } | |
| 61 | + | |
| 62 | + /** Zoom by `factor` (>1 zooms in) keeping the bar under `anchorX` fixed on screen. */ | |
| 63 | + zoomAt(factor, anchorX = this.width) { | |
| 64 | + const next = clamp(this.barSpacing * factor, this.minBarSpacing, this.maxBarSpacing) | |
| 65 | + if (next === this.barSpacing) return false | |
| 66 | + const anchorIndex = this.indexAt(anchorX) | |
| 67 | + this.barSpacing = next | |
| 68 | + this.leftIndex = anchorIndex - (anchorX - next / 2) / next | |
| 69 | + this.clampScroll() | |
| 70 | + this.stickToRight = this.isAtLatest() | |
| 71 | + return true | |
| 72 | + } | |
| 73 | + | |
| 74 | + /** Bar spacing + leftIndex to show bars [from, to] with the right offset. */ | |
| 75 | + rangeToView(from, to, { withOffset = true } = {}) { | |
| 76 | + const bars = Math.max(1, to - from + 1) | |
| 77 | + const offset = withOffset ? this.rightOffsetBars : 0 | |
| 78 | + const bs = clamp(this.width / (bars + offset), this.minBarSpacing, this.maxBarSpacing) | |
| 79 | + const leftIndex = to + offset - (this.width - bs / 2) / bs | |
| 80 | + return { barSpacing: bs, leftIndex } | |
| 81 | + } | |
| 82 | + | |
| 83 | + /** Default view: the last `n` bars at the default spacing, or all bars if fewer. */ | |
| 84 | + fitView(n = 150) { | |
| 85 | + if (this.count === 0) return { barSpacing: this.defaultBarSpacing, leftIndex: 0 } | |
| 86 | + const target = Math.min(this.count, n) | |
| 87 | + const bs = clamp(this.width / (target + this.rightOffsetBars), this.minBarSpacing, this.maxBarSpacing) | |
| 88 | + const leftIndex = this.count - 1 + this.rightOffsetBars - (this.width - bs / 2) / bs | |
| 89 | + return { barSpacing: bs, leftIndex } | |
| 90 | + } | |
| 91 | + | |
| 92 | + apply(view) { this.barSpacing = view.barSpacing; this.leftIndex = view.leftIndex; this.clampScroll() } | |
| 93 | + | |
| 94 | + /** Older bars were prepended: shift so the viewport stays visually identical. */ | |
| 95 | + onPrepend(n) { this.leftIndex += n; this.count += n } | |
| 96 | + | |
| 97 | + /** Pixel width of the candle body for the current spacing: odd, ≥ 1, leaving ≥ 1 px gap. */ | |
| 98 | + bodyWidth() { | |
| 99 | + const bs = this.barSpacing | |
| 100 | + if (bs < 2) return 1 | |
| 101 | + let w = Math.floor(bs - Math.max(1, bs * 0.25)) | |
| 102 | + if (w % 2 === 0) w -= 1 | |
| 103 | + return Math.max(1, w) | |
| 104 | + } | |
| 105 | + | |
| 106 | + /** Below 2 px/bar candles collapse to a close line (readability). */ | |
| 107 | + get isCompressed() { return this.barSpacing < 2 } | |
| 108 | +} | |
added
hfmarketdata/web/src/charts/engine/theme.js
+71 −0
@@ -0,0 +1,71 @@ | ||
| 1 | +// Default themes. Every color the engine paints comes from a Theme object — these two are the documented defaults, | |
| 2 | +// aligned with the site tokens (theme.css: --bg-1 / --line / --fg / --up / --down) and the validated dataviz palette. | |
| 3 | + | |
| 4 | +export const darkTheme = { | |
| 5 | + bg: '#10131a', | |
| 6 | + paneBorder: '#232833', | |
| 7 | + grid: 'rgba(255,255,255,0.045)', | |
| 8 | + gridStrong: 'rgba(255,255,255,0.10)', | |
| 9 | + axisText: '#8f98a8', | |
| 10 | + axisLine: '#313847', | |
| 11 | + crosshair: 'rgba(180,188,200,0.55)', | |
| 12 | + crosshairLabelBg: '#313847', | |
| 13 | + crosshairLabelText: '#e8ebf1', | |
| 14 | + up: '#2fbf71', | |
| 15 | + down: '#e5484d', | |
| 16 | + upWick: '#2fbf71', | |
| 17 | + downWick: '#e5484d', | |
| 18 | + neutral: '#8b95a7', | |
| 19 | + volumeUp: 'rgba(47,191,113,0.28)', | |
| 20 | + volumeDown: 'rgba(229,72,77,0.28)', | |
| 21 | + series: ['#3987e5', '#d95926', '#199e70', '#c98500', '#d55181', '#008300', '#9085e9', '#e66767'], | |
| 22 | + text: '#e8ebf1', | |
| 23 | + textMuted: '#7d8797', | |
| 24 | + accent: '#34d399', | |
| 25 | + lastPriceUp: '#2fbf71', | |
| 26 | + lastPriceDown: '#e5484d', | |
| 27 | + font: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', | |
| 28 | + mono: '"JetBrains Mono", ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace', | |
| 29 | + drawing: '#3987e5', | |
| 30 | + drawingHandle: '#ffffff', | |
| 31 | + selection: 'rgba(57,135,229,0.35)', | |
| 32 | +} | |
| 33 | + | |
| 34 | +export const lightTheme = { | |
| 35 | + bg: '#ffffff', | |
| 36 | + paneBorder: '#e2e5eb', | |
| 37 | + grid: 'rgba(18,22,29,0.055)', | |
| 38 | + gridStrong: 'rgba(18,22,29,0.12)', | |
| 39 | + axisText: '#4f5a6b', | |
| 40 | + axisLine: '#cfd4dd', | |
| 41 | + crosshair: 'rgba(59,68,82,0.55)', | |
| 42 | + crosshairLabelBg: '#3b4452', | |
| 43 | + crosshairLabelText: '#ffffff', | |
| 44 | + up: '#15803d', | |
| 45 | + down: '#d92d20', | |
| 46 | + upWick: '#15803d', | |
| 47 | + downWick: '#d92d20', | |
| 48 | + neutral: '#64708a', | |
| 49 | + volumeUp: 'rgba(21,128,61,0.25)', | |
| 50 | + volumeDown: 'rgba(217,45,32,0.25)', | |
| 51 | + series: ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#008300', '#4a3aa7', '#e34948'], | |
| 52 | + text: '#12161d', | |
| 53 | + textMuted: '#5f6b7d', | |
| 54 | + accent: '#0f766e', | |
| 55 | + lastPriceUp: '#15803d', | |
| 56 | + lastPriceDown: '#d92d20', | |
| 57 | + font: darkTheme.font, | |
| 58 | + mono: darkTheme.mono, | |
| 59 | + drawing: '#2a78d6', | |
| 60 | + drawingHandle: '#ffffff', | |
| 61 | + selection: 'rgba(42,120,214,0.30)', | |
| 62 | +} | |
| 63 | + | |
| 64 | +/** Fill missing keys from the dark defaults so a partial theme never breaks rendering. */ | |
| 65 | +export function normalizeTheme(t) { | |
| 66 | + const out = { ...darkTheme, ...(t || {}) } | |
| 67 | + if (!out.upWick) out.upWick = out.up | |
| 68 | + if (!out.downWick) out.downWick = out.down | |
| 69 | + if (!Array.isArray(out.series) || out.series.length === 0) out.series = darkTheme.series | |
| 70 | + return out | |
| 71 | +} | |
added
hfmarketdata/web/src/charts/indicators/bands.js
+106 −0
@@ -0,0 +1,106 @@ | ||
| 1 | +// Volatility bands and channels: Bollinger, Keltner, Donchian, Supertrend, Ichimoku. | |
| 2 | + | |
| 3 | +import { source, rollingMean, rollingEma, rollingStd, rollingMax, rollingMin, rollingRma, trueRange, nullable, int, pos } from './util.js' | |
| 4 | + | |
| 5 | +/** Bollinger Bands. Defaults: { length: 20, mult: 2, source: 'close' } → upper, middle, lower. */ | |
| 6 | +export function bollinger(bars, { length = 20, mult = 2, source: src = 'close' } = {}) { | |
| 7 | + length = int(length, 20); mult = pos(mult, 2) | |
| 8 | + const s = source(bars, src) | |
| 9 | + const mid = rollingMean(s, length) | |
| 10 | + const sd = rollingStd(s, length) | |
| 11 | + const n = s.length | |
| 12 | + const up = new Float64Array(n), lo = new Float64Array(n) | |
| 13 | + for (let i = 0; i < n; i++) { up[i] = mid[i] + mult * sd[i]; lo[i] = mid[i] - mult * sd[i] } | |
| 14 | + return { upper: nullable(up), middle: nullable(mid), lower: nullable(lo) } | |
| 15 | +} | |
| 16 | + | |
| 17 | +/** Keltner Channels. Defaults: { length: 20, mult: 2, atrLength: 10 } → upper, middle (EMA), lower. */ | |
| 18 | +export function keltner(bars, { length = 20, mult = 2, atrLength = 10 } = {}) { | |
| 19 | + length = int(length, 20); mult = pos(mult, 2); atrLength = int(atrLength, 10) | |
| 20 | + const mid = rollingEma(source(bars, 'close'), length) | |
| 21 | + const atr = rollingRma(trueRange(bars), atrLength) | |
| 22 | + const n = bars.length | |
| 23 | + const up = new Float64Array(n), lo = new Float64Array(n) | |
| 24 | + for (let i = 0; i < n; i++) { up[i] = mid[i] + mult * atr[i]; lo[i] = mid[i] - mult * atr[i] } | |
| 25 | + return { upper: nullable(up), middle: nullable(mid), lower: nullable(lo) } | |
| 26 | +} | |
| 27 | + | |
| 28 | +/** Donchian Channels. Defaults: { length: 20 } → upper (highest high), lower (lowest low), middle. */ | |
| 29 | +export function donchian(bars, { length = 20 } = {}) { | |
| 30 | + length = int(length, 20) | |
| 31 | + const up = rollingMax(source(bars, 'high'), length) | |
| 32 | + const lo = rollingMin(source(bars, 'low'), length) | |
| 33 | + const n = bars.length | |
| 34 | + const mid = new Float64Array(n) | |
| 35 | + for (let i = 0; i < n; i++) mid[i] = (up[i] + lo[i]) / 2 | |
| 36 | + return { upper: nullable(up), middle: nullable(mid), lower: nullable(lo) } | |
| 37 | +} | |
| 38 | + | |
| 39 | +/** | |
| 40 | + * Supertrend. Defaults: { length: 10, mult: 3 } → supertrend (the active band), direction (+1 bullish / −1 bearish), | |
| 41 | + * up (line while bullish, else null) and down (line while bearish, else null) for two-color rendering. | |
| 42 | + */ | |
| 43 | +export function supertrend(bars, { length = 10, mult = 3 } = {}) { | |
| 44 | + length = int(length, 10); mult = pos(mult, 3) | |
| 45 | + const n = bars.length | |
| 46 | + const atr = rollingRma(trueRange(bars), length) | |
| 47 | + const st = new Float64Array(n).fill(NaN) | |
| 48 | + const dir = new Array(n).fill(null) | |
| 49 | + let prevUpper = NaN, prevLower = NaN, prevSt = NaN, prevDir = 1 | |
| 50 | + for (let i = 0; i < n; i++) { | |
| 51 | + const a = atr[i] | |
| 52 | + if (Number.isNaN(a)) continue | |
| 53 | + const b = bars[i] | |
| 54 | + const hl2 = (b.h + b.l) / 2 | |
| 55 | + let upper = hl2 + mult * a | |
| 56 | + let lower = hl2 - mult * a | |
| 57 | + const pc = i > 0 ? bars[i - 1].c : b.c | |
| 58 | + if (!Number.isNaN(prevLower) && (lower > prevLower || pc < prevLower)) { /* keep */ } else if (!Number.isNaN(prevLower)) lower = prevLower | |
| 59 | + if (!Number.isNaN(prevUpper) && (upper < prevUpper || pc > prevUpper)) { /* keep */ } else if (!Number.isNaN(prevUpper)) upper = prevUpper | |
| 60 | + let d | |
| 61 | + if (Number.isNaN(prevSt)) d = 1 | |
| 62 | + else if (prevSt === prevUpper) d = b.c > upper ? 1 : -1 | |
| 63 | + else d = b.c < lower ? -1 : 1 | |
| 64 | + st[i] = d === 1 ? lower : upper | |
| 65 | + dir[i] = d | |
| 66 | + prevUpper = upper; prevLower = lower; prevSt = st[i]; prevDir = d | |
| 67 | + } | |
| 68 | + const up = new Array(n).fill(null), down = new Array(n).fill(null) | |
| 69 | + for (let i = 0; i < n; i++) { | |
| 70 | + if (Number.isNaN(st[i])) continue | |
| 71 | + if (dir[i] === 1) { up[i] = st[i]; if (i > 0 && dir[i - 1] === -1) down[i] = st[i] } | |
| 72 | + else { down[i] = st[i]; if (i > 0 && dir[i - 1] === 1) up[i] = st[i] } | |
| 73 | + } | |
| 74 | + void prevDir | |
| 75 | + return { supertrend: nullable(st), direction: dir, up, down } | |
| 76 | +} | |
| 77 | + | |
| 78 | +/** | |
| 79 | + * Ichimoku Cloud. Defaults: { conversion: 9, base: 26, spanB: 52, displacement: 26 }. | |
| 80 | + * senkouA / senkouB are shifted `displacement` bars forward (arrays are longer than `bars`); chikou is the close | |
| 81 | + * shifted `displacement` bars back. | |
| 82 | + */ | |
| 83 | +export function ichimoku(bars, { conversion = 9, base = 26, spanB = 52, displacement = 26 } = {}) { | |
| 84 | + conversion = int(conversion, 9); base = int(base, 26); spanB = int(spanB, 52); displacement = int(displacement, 26) | |
| 85 | + const n = bars.length | |
| 86 | + const H = source(bars, 'high'), L = source(bars, 'low') | |
| 87 | + const mid = (len) => { | |
| 88 | + const hi = rollingMax(H, len), lo = rollingMin(L, len) | |
| 89 | + const out = new Float64Array(n) | |
| 90 | + for (let i = 0; i < n; i++) out[i] = (hi[i] + lo[i]) / 2 | |
| 91 | + return out | |
| 92 | + } | |
| 93 | + const tenkan = mid(conversion) | |
| 94 | + const kijun = mid(base) | |
| 95 | + const spanBRaw = mid(spanB) | |
| 96 | + const senkouA = new Array(n + displacement).fill(null) | |
| 97 | + const senkouB = new Array(n + displacement).fill(null) | |
| 98 | + for (let i = 0; i < n; i++) { | |
| 99 | + const a = (tenkan[i] + kijun[i]) / 2 | |
| 100 | + if (!Number.isNaN(a)) senkouA[i + displacement] = a | |
| 101 | + if (!Number.isNaN(spanBRaw[i])) senkouB[i + displacement] = spanBRaw[i] | |
| 102 | + } | |
| 103 | + const chikou = new Array(n).fill(null) | |
| 104 | + for (let i = 0; i + displacement < n; i++) chikou[i] = bars[i + displacement].c | |
| 105 | + return { tenkan: nullable(tenkan), kijun: nullable(kijun), senkouA, senkouB, chikou } | |
| 106 | +} | |
added
hfmarketdata/web/src/charts/indicators/heikin-ashi.js
+17 −0
@@ -0,0 +1,17 @@ | ||
| 1 | +// Heikin-Ashi transform: returns a new Bar[] (same t and v, smoothed OHLC). | |
| 2 | + | |
| 3 | +export function heikinAshi(bars) { | |
| 4 | + const n = bars.length | |
| 5 | + const out = new Array(n) | |
| 6 | + let prevO = NaN, prevC = NaN | |
| 7 | + for (let i = 0; i < n; i++) { | |
| 8 | + const b = bars[i] | |
| 9 | + const c = (b.o + b.h + b.l + b.c) / 4 | |
| 10 | + const o = i === 0 ? (b.o + b.c) / 2 : (prevO + prevC) / 2 | |
| 11 | + const h = Math.max(b.h, o, c) | |
| 12 | + const l = Math.min(b.l, o, c) | |
| 13 | + out[i] = { t: b.t, o, h, l, c, v: b.v, oi: b.oi } | |
| 14 | + prevO = o; prevC = c | |
| 15 | + } | |
| 16 | + return out | |
| 17 | +} | |
added
hfmarketdata/web/src/charts/indicators/index.js
+92 −0
@@ -0,0 +1,92 @@ | ||
| 1 | +// Indicator registry: pure compute functions + rendering metadata used by the engine. | |
| 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'). | |
| 6 | + | |
| 7 | +import { sma, ema, wma, vwap } from './moving-averages.js' | |
| 8 | +import { bollinger, keltner, donchian, supertrend, ichimoku } from './bands.js' | |
| 9 | +import { rsi, macd, stoch, atr, adx, cci } from './oscillators.js' | |
| 10 | +import { obv, mfi, volumeMa } from './volume.js' | |
| 11 | +import { heikinAshi } from './heikin-ashi.js' | |
| 12 | + | |
| 13 | +export { sma, ema, wma, vwap, bollinger, keltner, donchian, supertrend, ichimoku, rsi, macd, stoch, atr, adx, cci, obv, mfi, volumeMa, heikinAshi } | |
| 14 | + | |
| 15 | +const len = p => `${p.length}` | |
| 16 | + | |
| 17 | +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 | + ], | |
| 44 | + }, | |
| 45 | + 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'], | |
| 49 | + }, | |
| 50 | + ichimoku: { | |
| 51 | + label: 'Ichimoku Cloud', compute: ichimoku, defaults: { conversion: 9, base: 26, spanB: 52, displacement: 26 }, pane: 'main', | |
| 52 | + title: p => `Ichimoku ${p.conversion} ${p.base} ${p.spanB}`, | |
| 53 | + plots: [ | |
| 54 | + { 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 }, | |
| 58 | + ], | |
| 59 | + }, | |
| 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 }] }, | |
| 61 | + macd: { | |
| 62 | + label: 'MACD', compute: macd, defaults: { fast: 12, slow: 26, signal: 9, source: 'close' }, pane: 'new', | |
| 63 | + 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 }], | |
| 65 | + }, | |
| 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' }, | |
| 73 | +} | |
| 74 | + | |
| 75 | +export const INDICATOR_TYPES = Object.keys(REGISTRY) | |
| 76 | + | |
| 77 | +export function indicatorSpec(type) { | |
| 78 | + const spec = REGISTRY[type] | |
| 79 | + if (!spec) throw new Error(`Unknown indicator type: ${type}`) | |
| 80 | + return spec | |
| 81 | +} | |
| 82 | + | |
| 83 | +/** Merge user params over the documented defaults. */ | |
| 84 | +export function indicatorParams(type, params) { | |
| 85 | + return { ...indicatorSpec(type).defaults, ...(params || {}) } | |
| 86 | +} | |
| 87 | + | |
| 88 | +/** Compute an indicator by type. */ | |
| 89 | +export function computeIndicator(type, bars, params) { | |
| 90 | + const spec = indicatorSpec(type) | |
| 91 | + return spec.compute(bars, indicatorParams(type, params)) | |
| 92 | +} | |
added
hfmarketdata/web/src/charts/indicators/moving-averages.js
+53 −0
@@ -0,0 +1,53 @@ | ||
| 1 | +// Moving averages and volume-weighted price. Each function: (bars, params) → { key: (number|null)[] }. | |
| 2 | + | |
| 3 | +import { source, rollingMean, rollingEma, nullable, int, dayOf } from './util.js' | |
| 4 | + | |
| 5 | +/** Simple moving average. Defaults: { length: 20, source: 'close' }. */ | |
| 6 | +export function sma(bars, { length = 20, source: src = 'close' } = {}) { | |
| 7 | + return { sma: nullable(rollingMean(source(bars, src), int(length, 20))) } | |
| 8 | +} | |
| 9 | + | |
| 10 | +/** Exponential moving average (SMA seed). Defaults: { length: 20, source: 'close' }. */ | |
| 11 | +export function ema(bars, { length = 20, source: src = 'close' } = {}) { | |
| 12 | + return { ema: nullable(rollingEma(source(bars, src), int(length, 20))) } | |
| 13 | +} | |
| 14 | + | |
| 15 | +/** Linearly weighted moving average. Defaults: { length: 20, source: 'close' }. */ | |
| 16 | +export function wma(bars, { length = 20, source: src = 'close' } = {}) { | |
| 17 | + length = int(length, 20) | |
| 18 | + const s = source(bars, src) | |
| 19 | + const n = s.length | |
| 20 | + const out = new Float64Array(n).fill(NaN) | |
| 21 | + const denom = (length * (length + 1)) / 2 | |
| 22 | + for (let i = length - 1; i < n; i++) { | |
| 23 | + 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 | |
| 30 | + } | |
| 31 | + return { wma: nullable(out) } | |
| 32 | +} | |
| 33 | + | |
| 34 | +/** | |
| 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. | |
| 37 | + */ | |
| 38 | +export function vwap(bars, { anchor = 'session' } = {}) { | |
| 39 | + const n = bars.length | |
| 40 | + const out = new Array(n).fill(null) | |
| 41 | + let pv = 0, vol = 0, day = null | |
| 42 | + for (let i = 0; i < n; i++) { | |
| 43 | + const b = bars[i] | |
| 44 | + const d = dayOf(b.t) | |
| 45 | + if (anchor === 'session' && day !== null && d !== day) { pv = 0; vol = 0 } | |
| 46 | + day = d | |
| 47 | + if (b.v == null || Number.isNaN(b.v)) continue | |
| 48 | + 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 | |
| 51 | + } | |
| 52 | + return { vwap: out } | |
| 53 | +} | |
added
hfmarketdata/web/src/charts/indicators/oscillators.js
+133 −0
@@ -0,0 +1,133 @@ | ||
| 1 | +// Oscillators and volatility: RSI, MACD, Stochastic, ATR, ADX, CCI. | |
| 2 | + | |
| 3 | +import { source, rollingMean, rollingEma, rollingRma, rollingMax, rollingMin, trueRange, nullable, int } from './util.js' | |
| 4 | + | |
| 5 | +/** Relative Strength Index (Wilder). Defaults: { length: 14, source: 'close' } → rsi (0–100). */ | |
| 6 | +export function rsi(bars, { length = 14, source: src = 'close' } = {}) { | |
| 7 | + length = int(length, 14) | |
| 8 | + const s = source(bars, src) | |
| 9 | + const n = s.length | |
| 10 | + const gains = new Float64Array(n).fill(NaN), losses = new Float64Array(n).fill(NaN) | |
| 11 | + for (let i = 1; i < n; i++) { | |
| 12 | + const d = s[i] - s[i - 1] | |
| 13 | + if (Number.isNaN(d)) continue | |
| 14 | + gains[i] = d > 0 ? d : 0 | |
| 15 | + losses[i] = d < 0 ? -d : 0 | |
| 16 | + } | |
| 17 | + const ag = rollingRma(gains.subarray(1), length) | |
| 18 | + const al = rollingRma(losses.subarray(1), length) | |
| 19 | + const out = new Float64Array(n).fill(NaN) | |
| 20 | + for (let i = 1; i < n; i++) { | |
| 21 | + const g = ag[i - 1], l = al[i - 1] | |
| 22 | + if (Number.isNaN(g) || Number.isNaN(l)) continue | |
| 23 | + out[i] = l === 0 ? 100 : g === 0 ? 0 : 100 - 100 / (1 + g / l) | |
| 24 | + } | |
| 25 | + return { rsi: nullable(out) } | |
| 26 | +} | |
| 27 | + | |
| 28 | +/** MACD. Defaults: { fast: 12, slow: 26, signal: 9, source: 'close' } → macd, signal, hist. */ | |
| 29 | +export function macd(bars, { fast = 12, slow = 26, signal = 9, source: src = 'close' } = {}) { | |
| 30 | + fast = int(fast, 12); slow = int(slow, 26); signal = int(signal, 9) | |
| 31 | + const s = source(bars, src) | |
| 32 | + const n = s.length | |
| 33 | + const ef = rollingEma(s, fast), es = rollingEma(s, slow) | |
| 34 | + const line = new Float64Array(n).fill(NaN) | |
| 35 | + for (let i = 0; i < n; i++) line[i] = ef[i] - es[i] | |
| 36 | + // The signal EMA must start where the MACD line starts: compact, smooth, then re-expand. | |
| 37 | + let start = 0 | |
| 38 | + while (start < n && Number.isNaN(line[start])) start++ | |
| 39 | + const sig = new Float64Array(n).fill(NaN) | |
| 40 | + if (start < n) { | |
| 41 | + const sub = rollingEma(line.subarray(start), signal) | |
| 42 | + for (let i = 0; i < sub.length; i++) sig[start + i] = sub[i] | |
| 43 | + } | |
| 44 | + const hist = new Float64Array(n).fill(NaN) | |
| 45 | + for (let i = 0; i < n; i++) hist[i] = line[i] - sig[i] | |
| 46 | + return { macd: nullable(line), signal: nullable(sig), hist: nullable(hist) } | |
| 47 | +} | |
| 48 | + | |
| 49 | +/** Stochastic oscillator. Defaults: { k: 14, d: 3, smooth: 3 } → k (smoothed %K), d (%D). */ | |
| 50 | +export function stoch(bars, { k = 14, d = 3, smooth = 3 } = {}) { | |
| 51 | + k = int(k, 14); d = int(d, 3); smooth = int(smooth, 3) | |
| 52 | + const n = bars.length | |
| 53 | + const hh = rollingMax(source(bars, 'high'), k) | |
| 54 | + const ll = rollingMin(source(bars, 'low'), k) | |
| 55 | + const raw = new Float64Array(n).fill(NaN) | |
| 56 | + for (let i = 0; i < n; i++) { | |
| 57 | + const range = hh[i] - ll[i] | |
| 58 | + if (Number.isNaN(range)) continue | |
| 59 | + raw[i] = range === 0 ? 50 : ((bars[i].c - ll[i]) / range) * 100 | |
| 60 | + } | |
| 61 | + const kk = compactMean(raw, smooth) | |
| 62 | + const dd = compactMean(kk, d) | |
| 63 | + return { k: nullable(kk), d: nullable(dd) } | |
| 64 | +} | |
| 65 | + | |
| 66 | +/** SMA that starts at the first defined value of a series whose head is NaN. */ | |
| 67 | +function compactMean(arr, length) { | |
| 68 | + const n = arr.length | |
| 69 | + let start = 0 | |
| 70 | + while (start < n && Number.isNaN(arr[start])) start++ | |
| 71 | + const out = new Float64Array(n).fill(NaN) | |
| 72 | + if (start >= n) return out | |
| 73 | + const sub = rollingMean(arr.subarray(start), length) | |
| 74 | + for (let i = 0; i < sub.length; i++) out[start + i] = sub[i] | |
| 75 | + return out | |
| 76 | +} | |
| 77 | + | |
| 78 | +/** Average True Range (Wilder). Defaults: { length: 14 } → atr. */ | |
| 79 | +export function atr(bars, { length = 14 } = {}) { | |
| 80 | + length = int(length, 14) | |
| 81 | + return { atr: nullable(rollingRma(trueRange(bars), length)) } | |
| 82 | +} | |
| 83 | + | |
| 84 | +/** Average Directional Index (Wilder). Defaults: { length: 14 } → adx, plusDI, minusDI. */ | |
| 85 | +export function adx(bars, { length = 14 } = {}) { | |
| 86 | + length = int(length, 14) | |
| 87 | + const n = bars.length | |
| 88 | + const tr = trueRange(bars) | |
| 89 | + const pdm = new Float64Array(n), mdm = new Float64Array(n) | |
| 90 | + for (let i = 1; i < n; i++) { | |
| 91 | + const up = bars[i].h - bars[i - 1].h | |
| 92 | + const dn = bars[i - 1].l - bars[i].l | |
| 93 | + pdm[i] = up > dn && up > 0 ? up : 0 | |
| 94 | + mdm[i] = dn > up && dn > 0 ? dn : 0 | |
| 95 | + } | |
| 96 | + const atrS = rollingRma(tr.subarray(1), length) | |
| 97 | + const pS = rollingRma(pdm.subarray(1), length) | |
| 98 | + const mS = rollingRma(mdm.subarray(1), length) | |
| 99 | + const plus = new Float64Array(n).fill(NaN), minus = new Float64Array(n).fill(NaN), dx = new Float64Array(n).fill(NaN) | |
| 100 | + for (let i = 1; i < n; i++) { | |
| 101 | + const a = atrS[i - 1] | |
| 102 | + if (Number.isNaN(a) || a === 0) continue | |
| 103 | + plus[i] = (100 * pS[i - 1]) / a | |
| 104 | + minus[i] = (100 * mS[i - 1]) / a | |
| 105 | + const sum = plus[i] + minus[i] | |
| 106 | + dx[i] = sum === 0 ? 0 : (100 * Math.abs(plus[i] - minus[i])) / sum | |
| 107 | + } | |
| 108 | + let start = 0 | |
| 109 | + while (start < n && Number.isNaN(dx[start])) start++ | |
| 110 | + const out = new Float64Array(n).fill(NaN) | |
| 111 | + if (start < n) { | |
| 112 | + const sub = rollingRma(dx.subarray(start), length) | |
| 113 | + for (let i = 0; i < sub.length; i++) out[start + i] = sub[i] | |
| 114 | + } | |
| 115 | + return { adx: nullable(out), plusDI: nullable(plus), minusDI: nullable(minus) } | |
| 116 | +} | |
| 117 | + | |
| 118 | +/** Commodity Channel Index. Defaults: { length: 20 } → cci. */ | |
| 119 | +export function cci(bars, { length = 20 } = {}) { | |
| 120 | + length = int(length, 20) | |
| 121 | + const tp = source(bars, 'hlc3') | |
| 122 | + const n = tp.length | |
| 123 | + const mean = rollingMean(tp, length) | |
| 124 | + const out = new Float64Array(n).fill(NaN) | |
| 125 | + for (let i = length - 1; i < n; i++) { | |
| 126 | + if (Number.isNaN(mean[i])) continue | |
| 127 | + let dev = 0 | |
| 128 | + for (let j = i - length + 1; j <= i; j++) dev += Math.abs(tp[j] - mean[i]) | |
| 129 | + dev /= length | |
| 130 | + out[i] = dev === 0 ? 0 : (tp[i] - mean[i]) / (0.015 * dev) | |
| 131 | + } | |
| 132 | + return { cci: nullable(out) } | |
| 133 | +} | |
added
hfmarketdata/web/src/charts/indicators/util.js
+148 −0
@@ -0,0 +1,148 @@ | ||
| 1 | +// Shared helpers for indicator functions. All work on plain arrays; NaN marks "not available yet" internally and | |
| 2 | +// is converted to null at the boundary so callers never see invented values. | |
| 3 | + | |
| 4 | +export const num = v => (v == null || Number.isNaN(v) ? null : v) | |
| 5 | + | |
| 6 | +/** Convert NaN entries to null (output contract). */ | |
| 7 | +export function nullable(arr) { | |
| 8 | + const out = new Array(arr.length) | |
| 9 | + for (let i = 0; i < arr.length; i++) { const v = arr[i]; out[i] = v == null || Number.isNaN(v) ? null : v } | |
| 10 | + return out | |
| 11 | +} | |
| 12 | + | |
| 13 | +/** Price source of a bar. */ | |
| 14 | +export function source(bars, name = 'close') { | |
| 15 | + const n = bars.length | |
| 16 | + const out = new Float64Array(n) | |
| 17 | + for (let i = 0; i < n; i++) { | |
| 18 | + const b = bars[i] | |
| 19 | + let v | |
| 20 | + switch (name) { | |
| 21 | + case 'open': v = b.o; break | |
| 22 | + case 'high': v = b.h; break | |
| 23 | + case 'low': v = b.l; break | |
| 24 | + case 'hl2': v = (b.h + b.l) / 2; break | |
| 25 | + case 'hlc3': v = (b.h + b.l + b.c) / 3; break | |
| 26 | + case 'ohlc4': v = (b.o + b.h + b.l + b.c) / 4; break | |
| 27 | + case 'volume': v = b.v == null ? NaN : b.v; break | |
| 28 | + default: v = b.c | |
| 29 | + } | |
| 30 | + out[i] = v == null ? NaN : v | |
| 31 | + } | |
| 32 | + return out | |
| 33 | +} | |
| 34 | + | |
| 35 | +/** Simple moving average over a Float64Array (NaN until the window is full or when the window has a NaN). */ | |
| 36 | +export function rollingMean(src, length) { | |
| 37 | + const n = src.length | |
| 38 | + const out = new Float64Array(n).fill(NaN) | |
| 39 | + if (length < 1) return out | |
| 40 | + let sum = 0, bad = 0 | |
| 41 | + for (let i = 0; i < n; i++) { | |
| 42 | + const v = src[i] | |
| 43 | + if (Number.isNaN(v)) bad++; else sum += v | |
| 44 | + if (i >= length) { | |
| 45 | + const old = src[i - length] | |
| 46 | + if (Number.isNaN(old)) bad--; else sum -= old | |
| 47 | + } | |
| 48 | + if (i >= length - 1 && bad === 0) out[i] = sum / length | |
| 49 | + } | |
| 50 | + return out | |
| 51 | +} | |
| 52 | + | |
| 53 | +/** Exponential moving average seeded with the SMA of the first `length` values (industry standard). */ | |
| 54 | +export function rollingEma(src, length) { | |
| 55 | + const n = src.length | |
| 56 | + const out = new Float64Array(n).fill(NaN) | |
| 57 | + if (length < 1 || n === 0) return out | |
| 58 | + const k = 2 / (length + 1) | |
| 59 | + let prev = NaN | |
| 60 | + let sum = 0, count = 0 | |
| 61 | + for (let i = 0; i < n; i++) { | |
| 62 | + const v = src[i] | |
| 63 | + if (Number.isNaN(v)) { prev = NaN; sum = 0; count = 0; continue } | |
| 64 | + if (Number.isNaN(prev)) { | |
| 65 | + sum += v; count++ | |
| 66 | + if (count === length) { prev = sum / length; out[i] = prev } | |
| 67 | + continue | |
| 68 | + } | |
| 69 | + prev = v * k + prev * (1 - k) | |
| 70 | + out[i] = prev | |
| 71 | + } | |
| 72 | + return out | |
| 73 | +} | |
| 74 | + | |
| 75 | +/** Wilder smoothing (RMA): first value = SMA, then prev + (v - prev) / length. */ | |
| 76 | +export function rollingRma(src, length) { | |
| 77 | + const n = src.length | |
| 78 | + const out = new Float64Array(n).fill(NaN) | |
| 79 | + if (length < 1 || n === 0) return out | |
| 80 | + let prev = NaN, sum = 0, count = 0 | |
| 81 | + for (let i = 0; i < n; i++) { | |
| 82 | + const v = src[i] | |
| 83 | + if (Number.isNaN(v)) { prev = NaN; sum = 0; count = 0; continue } | |
| 84 | + if (Number.isNaN(prev)) { | |
| 85 | + sum += v; count++ | |
| 86 | + if (count === length) { prev = sum / length; out[i] = prev } | |
| 87 | + continue | |
| 88 | + } | |
| 89 | + prev = prev + (v - prev) / length | |
| 90 | + out[i] = prev | |
| 91 | + } | |
| 92 | + return out | |
| 93 | +} | |
| 94 | + | |
| 95 | +/** Population standard deviation over a rolling window. */ | |
| 96 | +export function rollingStd(src, length) { | |
| 97 | + const n = src.length | |
| 98 | + const out = new Float64Array(n).fill(NaN) | |
| 99 | + if (length < 1) return out | |
| 100 | + for (let i = length - 1; i < n; i++) { | |
| 101 | + let sum = 0, ok = true | |
| 102 | + for (let j = i - length + 1; j <= i; j++) { const v = src[j]; if (Number.isNaN(v)) { ok = false; break } sum += v } | |
| 103 | + if (!ok) continue | |
| 104 | + const mean = sum / length | |
| 105 | + let sq = 0 | |
| 106 | + for (let j = i - length + 1; j <= i; j++) { const d = src[j] - mean; sq += d * d } | |
| 107 | + out[i] = Math.sqrt(sq / length) | |
| 108 | + } | |
| 109 | + return out | |
| 110 | +} | |
| 111 | + | |
| 112 | +/** Rolling max / min with a monotonic deque (O(n)). */ | |
| 113 | +export function rollingMax(src, length) { return rollingExtreme(src, length, (a, b) => a >= b) } | |
| 114 | +export function rollingMin(src, length) { return rollingExtreme(src, length, (a, b) => a <= b) } | |
| 115 | + | |
| 116 | +function rollingExtreme(src, length, better) { | |
| 117 | + const n = src.length | |
| 118 | + const out = new Float64Array(n).fill(NaN) | |
| 119 | + if (length < 1) return out | |
| 120 | + const dq = [] | |
| 121 | + for (let i = 0; i < n; i++) { | |
| 122 | + const v = src[i] | |
| 123 | + while (dq.length && better(v, src[dq[dq.length - 1]])) dq.pop() | |
| 124 | + dq.push(i) | |
| 125 | + while (dq[0] <= i - length) dq.shift() | |
| 126 | + if (i >= length - 1) out[i] = src[dq[0]] | |
| 127 | + } | |
| 128 | + return out | |
| 129 | +} | |
| 130 | + | |
| 131 | +/** True range series (first bar: high − low). */ | |
| 132 | +export function trueRange(bars) { | |
| 133 | + const n = bars.length | |
| 134 | + const out = new Float64Array(n) | |
| 135 | + for (let i = 0; i < n; i++) { | |
| 136 | + const b = bars[i] | |
| 137 | + if (i === 0) { out[i] = b.h - b.l; continue } | |
| 138 | + const pc = bars[i - 1].c | |
| 139 | + out[i] = Math.max(b.h - b.l, Math.abs(b.h - pc), Math.abs(b.l - pc)) | |
| 140 | + } | |
| 141 | + return out | |
| 142 | +} | |
| 143 | + | |
| 144 | +/** Calendar day (UTC-encoded wall clock) of a timestamp. */ | |
| 145 | +export const dayOf = t => Math.floor(t / 86_400_000) | |
| 146 | + | |
| 147 | +export const int = (v, d) => { const n = Math.floor(Number(v)); return Number.isFinite(n) && n > 0 ? n : d } | |
| 148 | +export const pos = (v, d) => { const n = Number(v); return Number.isFinite(n) && n > 0 ? n : d } | |
added
hfmarketdata/web/src/charts/indicators/volume.js
+59 −0
@@ -0,0 +1,59 @@ | ||
| 1 | +// Volume-based indicators: OBV, MFI, volume with moving average. | |
| 2 | + | |
| 3 | +import { rollingMean, nullable, int } from './util.js' | |
| 4 | + | |
| 5 | +/** On-Balance Volume. No params → obv (null while volume is missing). */ | |
| 6 | +export function obv(bars) { | |
| 7 | + const n = bars.length | |
| 8 | + const out = new Array(n).fill(null) | |
| 9 | + let acc = 0, started = false | |
| 10 | + for (let i = 0; i < n; i++) { | |
| 11 | + const b = bars[i] | |
| 12 | + if (b.v == null || Number.isNaN(b.v)) continue | |
| 13 | + if (!started) { acc = 0; started = true; out[i] = 0; continue } | |
| 14 | + const pc = bars[i - 1].c | |
| 15 | + if (b.c > pc) acc += b.v | |
| 16 | + else if (b.c < pc) acc -= b.v | |
| 17 | + out[i] = acc | |
| 18 | + } | |
| 19 | + return { obv: out } | |
| 20 | +} | |
| 21 | + | |
| 22 | +/** Money Flow Index. Defaults: { length: 14 } → mfi (0–100). */ | |
| 23 | +export function mfi(bars, { length = 14 } = {}) { | |
| 24 | + length = int(length, 14) | |
| 25 | + const n = bars.length | |
| 26 | + const posF = new Float64Array(n).fill(NaN), negF = new Float64Array(n).fill(NaN) | |
| 27 | + let prevTp = NaN | |
| 28 | + for (let i = 0; i < n; i++) { | |
| 29 | + const b = bars[i] | |
| 30 | + const tp = (b.h + b.l + b.c) / 3 | |
| 31 | + const v = b.v == null ? NaN : b.v | |
| 32 | + if (i > 0 && !Number.isNaN(v) && !Number.isNaN(prevTp)) { | |
| 33 | + const flow = tp * v | |
| 34 | + posF[i] = tp > prevTp ? flow : 0 | |
| 35 | + negF[i] = tp < prevTp ? flow : 0 | |
| 36 | + } | |
| 37 | + prevTp = tp | |
| 38 | + } | |
| 39 | + const out = new Float64Array(n).fill(NaN) | |
| 40 | + let ps = 0, ns = 0, bad = 0 | |
| 41 | + for (let i = 1; i < n; i++) { | |
| 42 | + if (Number.isNaN(posF[i])) bad++; else { ps += posF[i]; ns += negF[i] } | |
| 43 | + if (i - length >= 1) { | |
| 44 | + const j = i - length | |
| 45 | + if (Number.isNaN(posF[j])) bad--; else { ps -= posF[j]; ns -= negF[j] } | |
| 46 | + } | |
| 47 | + if (i >= length && bad === 0) out[i] = ns === 0 ? 100 : 100 - 100 / (1 + ps / ns) | |
| 48 | + } | |
| 49 | + return { mfi: nullable(out) } | |
| 50 | +} | |
| 51 | + | |
| 52 | +/** Volume histogram with its moving average. Defaults: { length: 20 } → volume, ma. */ | |
| 53 | +export function volumeMa(bars, { length = 20 } = {}) { | |
| 54 | + length = int(length, 20) | |
| 55 | + const n = bars.length | |
| 56 | + const vol = new Float64Array(n) | |
| 57 | + for (let i = 0; i < n; i++) vol[i] = bars[i].v == null ? NaN : bars[i].v | |
| 58 | + return { volume: nullable(vol), ma: nullable(rollingMean(vol, length)) } | |
| 59 | +} | |
| 60 | ||