SPB Git forge

spb/hfmarketdata

Public

Open high-frequency market data platform — FirstRate full-history downloader, DuckDB/Parquet lake, open REST API and React docs platform (www.hfmarketdata.io)

127commits 1branches 0releases
24.7 MBsize
maindefault branch
11 days agolast push
JavaScript 53.7% Python 38.3% CSS 4.6% TypeScript 3.1%

charts: squelette du moteur — formats, échelles (temps indexé, prix linéaire/log/%), store de barres, indicateurs purs

Simon-Pierre Boucher committed 18 days ago (Sep 7, 2026) parent 973acc9

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