wickchart 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/core.js ADDED
@@ -0,0 +1,1055 @@
1
+ /* ==========================================================================
2
+ * HabView core — pure, DOM-free functions shared by <hab-chart> and tests.
3
+ * Importable in the browser (ESM) and in Node (`node --test`).
4
+ * MIT License.
5
+ * ========================================================================== */
6
+
7
+ /* ------------------------------------------------------------------ *
8
+ * Public types (JSDoc — the source of truth for the generated .d.ts)
9
+ * ------------------------------------------------------------------ */
10
+
11
+ /**
12
+ * A single OHLCV bar. `time` is milliseconds (second-based input is
13
+ * auto-detected and converted).
14
+ * @typedef {object} Bar
15
+ * @property {number} time
16
+ * @property {number} open
17
+ * @property {number} high
18
+ * @property {number} low
19
+ * @property {number} close
20
+ * @property {number} [volume]
21
+ */
22
+
23
+ /**
24
+ * One plotted line of an indicator result.
25
+ * @typedef {object} IndicatorLine
26
+ * @property {string} [name]
27
+ * @property {Array<number|null>} values
28
+ * @property {string} [color] #hex / rgb() / CSS name / palette key
29
+ */
30
+
31
+ /**
32
+ * An indicator definition for {@link registerIndicator}.
33
+ * @typedef {object} IndicatorDef
34
+ * @property {'overlay'|'pane'} [kind] overlay on the price pane, or a stacked sub-pane
35
+ * @property {Record<string, number>} [params] defaults; set via `name:p1/p2` tokens
36
+ * @property {(bars: Bar[], params: Record<string, number>) => (Array<number|null>|{lines?: IndicatorLine[], histogram?: Array<number|null>})} compute
37
+ * @property {number[]} [guides] pane only: dashed horizontal levels
38
+ * @property {[number, number]} [range] pane only: fixed scale (else autoscale)
39
+ * @property {'price'|'fixed1'} [fmt] legend/axis number format
40
+ * @property {string} [color]
41
+ */
42
+
43
+ /**
44
+ * A position/order visualization.
45
+ * @typedef {object} Position
46
+ * @property {string} id
47
+ * @property {'long'|'short'} side
48
+ * @property {number} entry
49
+ * @property {number|null} stop
50
+ * @property {number|null} target
51
+ * @property {number|null} qty
52
+ */
53
+
54
+ /**
55
+ * A price alert (edge-triggered on streamed crossings).
56
+ * @typedef {object} Alert
57
+ * @property {string} id
58
+ * @property {number} price
59
+ * @property {'above'|'below'|'cross'} direction
60
+ * @property {boolean} once
61
+ */
62
+
63
+ /**
64
+ * Serializable chart snapshot (see `getState()` / `setState()`).
65
+ * @typedef {object} ChartState
66
+ * @property {'candles'|'line'|'area'|'bars'|'hollow'|'heikin'} [type]
67
+ * @property {'dark'|'light'} [theme]
68
+ * @property {boolean} [log]
69
+ * @property {boolean} [stats]
70
+ * @property {boolean} [profile] volume profile overlay (POC + value area)
71
+ * @property {boolean} [annotations] smart annotations (spikes/gaps/pivots/divergences)
72
+ * @property {string} [label]
73
+ * @property {string} [indicators]
74
+ * @property {{from: number, to: number}} [view] visible time window (ms)
75
+ * @property {Array<Position & {id?: string, stop?: number, target?: number, qty?: number}>} [positions] partial positions to add
76
+ * @property {Array<Alert & {id?: string, once?: boolean}>} [alerts] partial alerts to add
77
+ */
78
+
79
+ /**
80
+ * A parsed indicator entry (internal token → def binding).
81
+ * @typedef {object} IndicatorEntry
82
+ * @property {string} name
83
+ * @property {IndicatorDef} def
84
+ * @property {Record<string, number>} params
85
+ * @property {string|null} color
86
+ * @property {string} key
87
+ */
88
+
89
+ /* ------------------------------------------------------------------ *
90
+ * Small utilities
91
+ * ------------------------------------------------------------------ */
92
+
93
+ export const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
94
+ export const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
95
+ export const pad2 = (n) => (n < 10 ? '0' + n : '' + n);
96
+
97
+ const nfCache = new Map();
98
+ export function numberFmt(p) {
99
+ let nf = nfCache.get(p);
100
+ if (!nf) {
101
+ nf = new Intl.NumberFormat(undefined, {
102
+ minimumFractionDigits: p,
103
+ maximumFractionDigits: p,
104
+ });
105
+ nfCache.set(p, nf);
106
+ }
107
+ return nf;
108
+ }
109
+
110
+ let compactFmt = null;
111
+ export function fmtCompact(v) {
112
+ if (!compactFmt) {
113
+ try {
114
+ compactFmt = new Intl.NumberFormat(undefined, {
115
+ notation: 'compact',
116
+ maximumFractionDigits: 1,
117
+ });
118
+ } catch (_) {
119
+ compactFmt = numberFmt(0);
120
+ }
121
+ }
122
+ return compactFmt.format(v);
123
+ }
124
+
125
+ export function autoPrecision(v) {
126
+ const a = Math.abs(v);
127
+ if (a >= 1000) return 2;
128
+ if (a >= 10) return 2;
129
+ if (a >= 1) return 3;
130
+ if (a >= 0.01) return 5;
131
+ return 8;
132
+ }
133
+
134
+ /** Nice round step (1, 2, 5 × 10^n) covering `range` in ~`target` steps. */
135
+ export function niceStep(range, target) {
136
+ if (!(range > 0) || !isNum(range)) return 1;
137
+ const raw = range / Math.max(1, target);
138
+ const exp = Math.floor(Math.log10(raw));
139
+ const f = raw / Math.pow(10, exp);
140
+ const nice = f <= 1 ? 1 : f <= 2 ? 2 : f <= 5 ? 5 : 10;
141
+ return nice * Math.pow(10, exp);
142
+ }
143
+
144
+ export function hexToRgba(color, alpha) {
145
+ if (typeof color === 'string') {
146
+ let c = color.trim();
147
+ if (c[0] === '#') {
148
+ let hex = c.slice(1);
149
+ if (hex.length === 3) hex = hex.replace(/./g, '$&$&');
150
+ if (hex.length === 6) {
151
+ const n = parseInt(hex, 16);
152
+ return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${alpha})`;
153
+ }
154
+ }
155
+ const m = c.match(/^rgba?\(([^)]+)\)$/);
156
+ if (m) {
157
+ const parts = m[1].split(/[,\s/]+/).filter(Boolean);
158
+ if (parts.length >= 3) {
159
+ const a = parts.length > 3 ? parseFloat(parts[3]) : 1;
160
+ return `rgba(${parts[0]},${parts[1]},${parts[2]},${alpha * a})`;
161
+ }
162
+ }
163
+ }
164
+ return color;
165
+ }
166
+
167
+ /**
168
+ * Strict CSS color validator — accepts #hex, rgb()/rgba(), and CSS named
169
+ * colors only. Anything else (breakout attempts, URLs, quotes) → null.
170
+ * Use before interpolating untrusted colors into HTML or canvas styles.
171
+ */
172
+ const SAFE_COLOR_RE =
173
+ /^(#[0-9a-fA-F]{3,8}|rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(,\s*(0?\.\d+|1|0)\s*)?\)|[a-zA-Z]{3,20})$/;
174
+ export function safeColor(s) {
175
+ if (typeof s !== 'string') return null;
176
+ const t = s.trim();
177
+ return SAFE_COLOR_RE.test(t) ? t : null;
178
+ }
179
+
180
+ export const FONT_STACK =
181
+ "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif";
182
+ export const axisFont = (w = 500) => `${w} 11px ${FONT_STACK}`;
183
+ export const pillFont = () => `600 11px ${FONT_STACK}`;
184
+
185
+ export function roundRectPath(ctx, x, y, w, h, r) {
186
+ if (ctx.roundRect) {
187
+ ctx.beginPath();
188
+ ctx.roundRect(x, y, w, h, r);
189
+ return;
190
+ }
191
+ ctx.beginPath();
192
+ ctx.moveTo(x + r, y);
193
+ ctx.arcTo(x + w, y, x + w, y + h, r);
194
+ ctx.arcTo(x + w, y + h, x, y + h, r);
195
+ ctx.arcTo(x, y + h, x, y, r);
196
+ ctx.arcTo(x, y, x + w, y, r);
197
+ ctx.closePath();
198
+ }
199
+
200
+ /* ------------------------------------------------------------------ *
201
+ * Time axis helpers
202
+ * ------------------------------------------------------------------ */
203
+
204
+ export const SEC = 1000;
205
+ export const MIN = 60 * SEC;
206
+ export const HOUR = 60 * MIN;
207
+ export const DAY = 24 * HOUR;
208
+
209
+ // Sub-day / day-aligned steps (ms), plus month/year handled separately.
210
+ export const TIME_STEPS = [
211
+ { ms: MIN, label: 'time' },
212
+ { ms: 5 * MIN, label: 'time' },
213
+ { ms: 15 * MIN, label: 'time' },
214
+ { ms: 30 * MIN, label: 'time' },
215
+ { ms: HOUR, label: 'time' },
216
+ { ms: 2 * HOUR, label: 'time' },
217
+ { ms: 3 * HOUR, label: 'time' },
218
+ { ms: 4 * HOUR, label: 'time' },
219
+ { ms: 6 * HOUR, label: 'time' },
220
+ { ms: 12 * HOUR, label: 'time' },
221
+ { ms: DAY, label: 'day' },
222
+ { ms: 2 * DAY, label: 'day' },
223
+ { ms: 7 * DAY, label: 'day' },
224
+ ];
225
+
226
+ // Cached DateTimeFormats — constructing one per call costs ~30µs, which is
227
+ // disastrous in per-frame rendering paths.
228
+ const dtfCache = new Map();
229
+ function dtf(fmt) {
230
+ let f = dtfCache.get(fmt);
231
+ if (!f) {
232
+ f = new Intl.DateTimeFormat(undefined, fmt);
233
+ dtfCache.set(fmt, f);
234
+ }
235
+ return f;
236
+ }
237
+ const DAY_FMT = { month: 'short', day: 'numeric' };
238
+ const MON_FMT = { month: 'short' };
239
+ const MON_Y_FMT = { month: 'short', year: 'numeric' };
240
+ const YR_FMT = { year: 'numeric' };
241
+
242
+ export const hhmm = (t) => {
243
+ const d = new Date(t);
244
+ return pad2(d.getHours()) + ':' + pad2(d.getMinutes());
245
+ };
246
+ export const fmtDay = (t) => dtf(DAY_FMT).format(t);
247
+ export const fmtMonth = (t, withYear) => dtf(withYear ? MON_Y_FMT : MON_FMT).format(t);
248
+ export const fmtYear = (t) => dtf(YR_FMT).format(t);
249
+ export const fmtFull = (t) => {
250
+ const d = new Date(t);
251
+ return dtf(DAY_FMT).format(d) + ' ' + pad2(d.getHours()) + ':' + pad2(d.getMinutes());
252
+ };
253
+
254
+ /* ------------------------------------------------------------------ *
255
+ * Themes (every key overridable via --hab-* CSS custom properties)
256
+ * ------------------------------------------------------------------ */
257
+
258
+ export const THEMES = {
259
+ dark: {
260
+ bg: '#0d1117',
261
+ text: '#8b949e',
262
+ textStrong: '#e6edf3',
263
+ grid: 'rgba(230,237,243,0.05)',
264
+ border: 'rgba(230,237,243,0.09)',
265
+ up: '#16c784',
266
+ down: '#ea3943',
267
+ accent: '#4c8dff',
268
+ crosshair: 'rgba(230,237,243,0.42)',
269
+ crosshairBg: '#e6edf3',
270
+ crosshairText: '#0d1117',
271
+ pillText: '#ffffff',
272
+ rsi: '#a78bfa',
273
+ guide: 'rgba(230,237,243,0.16)',
274
+ volAlpha: 0.33,
275
+ overlay: ['#f0b429', '#38bdf8', '#e64980', '#34d399', '#a78bfa'],
276
+ },
277
+ light: {
278
+ bg: '#ffffff',
279
+ text: '#6b7280',
280
+ textStrong: '#111827',
281
+ grid: 'rgba(15,23,42,0.055)',
282
+ border: 'rgba(15,23,42,0.12)',
283
+ up: '#059669',
284
+ down: '#dc2626',
285
+ accent: '#2563eb',
286
+ crosshair: 'rgba(15,23,42,0.45)',
287
+ crosshairBg: '#111827',
288
+ crosshairText: '#ffffff',
289
+ pillText: '#ffffff',
290
+ rsi: '#7c3aed',
291
+ guide: 'rgba(15,23,42,0.18)',
292
+ volAlpha: 0.35,
293
+ overlay: ['#d97706', '#0284c7', '#db2777', '#059669', '#7c3aed'],
294
+ },
295
+ };
296
+
297
+ /* ------------------------------------------------------------------ *
298
+ * Indicators (pure functions over arrays)
299
+ * ------------------------------------------------------------------ */
300
+
301
+ /**
302
+ * @param {number[]} values
303
+ * @param {number} period
304
+ * @returns {Array<number|null>}
305
+ */
306
+ export function calcSMA(values, period) {
307
+ const out = new Array(values.length).fill(null);
308
+ if (period < 1) return out;
309
+ let sum = 0;
310
+ for (let i = 0; i < values.length; i++) {
311
+ sum += values[i];
312
+ if (i >= period) sum -= values[i - period];
313
+ if (i >= period - 1) out[i] = sum / period;
314
+ }
315
+ return out;
316
+ }
317
+
318
+ /**
319
+ * @param {number[]} values
320
+ * @param {number} period
321
+ * @returns {Array<number|null>}
322
+ */
323
+ export function calcEMA(values, period) {
324
+ const out = new Array(values.length).fill(null);
325
+ if (period < 1 || values.length < period) return out;
326
+ const k = 2 / (period + 1);
327
+ let seed = 0;
328
+ for (let i = 0; i < period; i++) seed += values[i];
329
+ let prev = seed / period;
330
+ out[period - 1] = prev;
331
+ for (let i = period; i < values.length; i++) {
332
+ prev = values[i] * k + prev * (1 - k);
333
+ out[i] = prev;
334
+ }
335
+ return out;
336
+ }
337
+
338
+ /** EMA over a series that may contain leading nulls (e.g. MACD line).
339
+ * @param {Array<number|null>} values
340
+ * @param {number} period
341
+ * @returns {Array<number|null>}
342
+ */
343
+ export function calcEMASparse(values, period) {
344
+ const out = new Array(values.length).fill(null);
345
+ let start = 0;
346
+ while (start < values.length && !isNum(values[start])) start++;
347
+ if (start + period > values.length) return out;
348
+ const k = 2 / (period + 1);
349
+ let seed = 0;
350
+ for (let i = start; i < start + period; i++) seed += values[i];
351
+ let prev = seed / period;
352
+ out[start + period - 1] = prev;
353
+ for (let i = start + period; i < values.length; i++) {
354
+ prev = values[i] * k + prev * (1 - k);
355
+ out[i] = prev;
356
+ }
357
+ return out;
358
+ }
359
+
360
+ /**
361
+ * @param {number[]} closes
362
+ * @param {number} period
363
+ * @returns {Array<number|null>}
364
+ */
365
+ export function calcRSI(closes, period) {
366
+ const out = new Array(closes.length).fill(null);
367
+ if (closes.length <= period) return out;
368
+ let gain = 0;
369
+ let loss = 0;
370
+ for (let i = 1; i <= period; i++) {
371
+ const d = closes[i] - closes[i - 1];
372
+ if (d >= 0) gain += d;
373
+ else loss -= d;
374
+ }
375
+ let avgG = gain / period;
376
+ let avgL = loss / period;
377
+ out[period] = avgL === 0 ? 100 : 100 - 100 / (1 + avgG / avgL);
378
+ for (let i = period + 1; i < closes.length; i++) {
379
+ const d = closes[i] - closes[i - 1];
380
+ const g = d > 0 ? d : 0;
381
+ const l = d < 0 ? -d : 0;
382
+ avgG = (avgG * (period - 1) + g) / period;
383
+ avgL = (avgL * (period - 1) + l) / period;
384
+ out[i] = avgL === 0 ? 100 : 100 - 100 / (1 + avgG / avgL);
385
+ }
386
+ return out;
387
+ }
388
+
389
+ /** Rolling standard deviation (population) over `period`, aligned like SMA. */
390
+ export function calcStdDev(values, period) {
391
+ const out = new Array(values.length).fill(null);
392
+ if (period < 2 || values.length < period) return out;
393
+ // Welford-style rolling via sums for O(n)
394
+ let sum = 0;
395
+ let sumSq = 0;
396
+ for (let i = 0; i < values.length; i++) {
397
+ sum += values[i];
398
+ sumSq += values[i] * values[i];
399
+ if (i >= period) {
400
+ const old = values[i - period];
401
+ sum -= old;
402
+ sumSq -= old * old;
403
+ }
404
+ if (i >= period - 1) {
405
+ const n = period;
406
+ const varr = Math.max(0, sumSq / n - (sum / n) * (sum / n));
407
+ out[i] = Math.sqrt(varr);
408
+ }
409
+ }
410
+ return out;
411
+ }
412
+
413
+ /**
414
+ * Bollinger Bands.
415
+ * @param {number[]} closes
416
+ * @param {number} period
417
+ * @param {number} [mult]
418
+ * @returns {{mid:Array<number|null>, upper:Array<number|null>, lower:Array<number|null>}}
419
+ */
420
+ export function calcBollinger(closes, period, mult = 2) {
421
+ const mid = calcSMA(closes, period);
422
+ const sd = calcStdDev(closes, period);
423
+ const upper = mid.map((m, i) => (isNum(m) && isNum(sd[i]) ? m + mult * sd[i] : null));
424
+ const lower = mid.map((m, i) => (isNum(m) && isNum(sd[i]) ? m - mult * sd[i] : null));
425
+ return { mid, upper, lower };
426
+ }
427
+
428
+ /**
429
+ * MACD.
430
+ * @param {number[]} closes
431
+ * @param {number} [fast]
432
+ * @param {number} [slow]
433
+ * @param {number} [signal]
434
+ * @returns {{macd:Array<number|null>, signal:Array<number|null>, hist:Array<number|null>}}
435
+ */
436
+ export function calcMACD(closes, fast = 12, slow = 26, signal = 9) {
437
+ const emaF = calcEMA(closes, fast);
438
+ const emaS = calcEMA(closes, slow);
439
+ const macd = closes.map((_, i) =>
440
+ isNum(emaF[i]) && isNum(emaS[i]) ? emaF[i] - emaS[i] : null
441
+ );
442
+ const sig = calcEMASparse(macd, signal);
443
+ const hist = macd.map((m, i) => (isNum(m) && isNum(sig[i]) ? m - sig[i] : null));
444
+ return { macd, signal: sig, hist };
445
+ }
446
+
447
+ /* ------------------------------------------------------------------ *
448
+ * Data merging & gaps
449
+ * ------------------------------------------------------------------ */
450
+
451
+ /**
452
+ * Merge older (backfilled) bars in front of `existing`.
453
+ * Dedupes by time (existing bars win); only strictly older bars are prepended.
454
+ * @param {Bar[]} existing
455
+ * @param {Bar[]} older
456
+ * @returns {{bars: Bar[], added: number}} merged array and count prepended.
457
+ */
458
+ export function mergeOlderData(existing, older) {
459
+ if (!Array.isArray(older) || !older.length) return { bars: existing, added: 0 };
460
+ const first = existing.length ? existing[0].time : Infinity;
461
+ const seen = new Set(existing.map((b) => b.time));
462
+ const prepend = [];
463
+ for (const b of older) {
464
+ if (!b || !isNum(b.time)) continue;
465
+ if (existing.length && b.time >= first) continue;
466
+ if (seen.has(b.time)) continue;
467
+ seen.add(b.time);
468
+ prepend.push(b);
469
+ }
470
+ if (!prepend.length) return { bars: existing, added: 0 };
471
+ prepend.sort((a, b) => a.time - b.time);
472
+ return { bars: prepend.concat(existing), added: prepend.length };
473
+ }
474
+
475
+ /**
476
+ * Indices of visible bars whose time jump from the previous bar exceeds
477
+ * `threshold × dt` (sessions breaks, weekends, missing data).
478
+ * @param {Bar[]} bars
479
+ * @param {number} i0
480
+ * @param {number} i1
481
+ * @param {number} dtMs
482
+ * @param {number} [threshold]
483
+ * @returns {number[]}
484
+ */
485
+ export function detectGaps(bars, i0, i1, dtMs, threshold = 3) {
486
+ const gaps = [];
487
+ const th = (dtMs > 0 ? dtMs : HOUR) * threshold;
488
+ for (let i = Math.max(1, i0); i <= i1; i++) {
489
+ if (bars[i].time - bars[i - 1].time > th) gaps.push(i);
490
+ }
491
+ return gaps;
492
+ }
493
+
494
+ /**
495
+ * Aggregate a visible bar range into ~1px-wide columns for deep zoom-outs.
496
+ * `xOf(i)` must be non-decreasing in i (index-space x mapping guarantees it).
497
+ * Each column keeps first open / max high / min low / last close / volume sum.
498
+ * @param {Bar[]} bars
499
+ * @param {number} i0
500
+ * @param {number} i1
501
+ * @param {(i: number) => number} xOf
502
+ * @param {number} plotRight plot width in px (column count)
503
+ * @returns {Array<{x: number, i0: number, i1: number, open: number, high: number, low: number, close: number, volume: number}>}
504
+ */
505
+ export function buildColumns(bars, i0, i1, xOf, plotRight) {
506
+ const byIndex = [];
507
+ for (let i = i0; i <= i1; i++) {
508
+ const b = bars[i];
509
+ const x = Math.floor(xOf(i));
510
+ const k = x < 0 ? 0 : x >= plotRight ? plotRight - 1 : x;
511
+ let c = byIndex[k];
512
+ if (!c) {
513
+ byIndex[k] = {
514
+ x: k,
515
+ i0: i,
516
+ i1: i,
517
+ open: b.open,
518
+ high: b.high,
519
+ low: b.low,
520
+ close: b.close,
521
+ volume: b.volume || 0,
522
+ };
523
+ } else {
524
+ c.i1 = i;
525
+ if (b.high > c.high) c.high = b.high;
526
+ if (b.low < c.low) c.low = b.low;
527
+ c.close = b.close;
528
+ c.volume += b.volume || 0;
529
+ }
530
+ }
531
+ const cols = [];
532
+ for (let k = 0; k < byIndex.length; k++) if (byIndex[k]) cols.push(byIndex[k]);
533
+ return cols;
534
+ }
535
+
536
+ /**
537
+ * Detect notable events over a visible bar window.
538
+ * Volume spikes (> volMult × SMA(volume)), price gaps beyond the previous
539
+ * bar's range (> gapMult × average range), pivot highs/lows (± pivot bars),
540
+ * and RSI divergences (later higher high with weaker RSI, and symmetric lows).
541
+ *
542
+ * @param {Bar[]} bars full dataset (raw)
543
+ * @param {number} i0 first visible index
544
+ * @param {number} i1 last visible index
545
+ * @param {Array<number|null>|null} rsi precomputed RSI series (or null to skip divergences)
546
+ * @param {{pivot?: number, volMult?: number, gapMult?: number, divDist?: number}} [opts]
547
+ * @returns {Array<{type: string, side: 'high'|'low', i: number, note: string}>}
548
+ */
549
+ export function detectAnnotations(bars, i0, i1, rsi, opts = {}) {
550
+ const N = opts.pivot ?? 20;
551
+ const volK = opts.volMult ?? 3;
552
+ const gapK = opts.gapMult ?? 0.5;
553
+ const minDist = opts.divDist ?? 10;
554
+ const out = [];
555
+ if (!bars.length || i0 < 0 || i1 < i0 || i1 >= bars.length) return out;
556
+ const n = bars.length;
557
+
558
+ const period = Math.min(20, Math.max(2, Math.floor(n / 2)));
559
+ const volSma = calcSMA(bars.map((b) => b.volume), period);
560
+ const rngSma = calcSMA(bars.map((b) => b.high - b.low), period);
561
+
562
+ for (let i = i0; i <= i1; i++) {
563
+ const b = bars[i];
564
+ const vs = volSma[i];
565
+ if (isNum(vs) && vs > 0 && b.volume > volK * vs) {
566
+ out.push({
567
+ type: 'volspike',
568
+ side: 'high',
569
+ i,
570
+ note: `Volume ${(b.volume / vs).toFixed(1)}× average`,
571
+ });
572
+ continue;
573
+ }
574
+ if (i > 0) {
575
+ const prev = bars[i - 1];
576
+ const rs = rngSma[i];
577
+ if (isNum(rs) && rs > 0 && prev.close > 0) {
578
+ const upGap = b.open - prev.high;
579
+ const dnGap = prev.low - b.open;
580
+ if (upGap > gapK * rs) {
581
+ out.push({ type: 'gap', side: 'low', i, note: `Gapped up +${((upGap / prev.close) * 100).toFixed(2)}%` });
582
+ } else if (dnGap > gapK * rs) {
583
+ out.push({ type: 'gap', side: 'low', i, note: `Gapped down −${((dnGap / prev.close) * 100).toFixed(2)}%` });
584
+ }
585
+ }
586
+ }
587
+ }
588
+
589
+ // pivot highs / lows (strict extremum over the ±N window)
590
+ for (let i = Math.max(i0, N); i <= Math.min(i1, n - 1 - N); i++) {
591
+ let isHigh = true;
592
+ let isLow = true;
593
+ const hi = bars[i].high;
594
+ const lo = bars[i].low;
595
+ for (let j = i - N; j <= i + N && (isHigh || isLow); j++) {
596
+ if (j === i) continue;
597
+ if (bars[j].high >= hi) isHigh = false;
598
+ if (bars[j].low <= lo) isLow = false;
599
+ }
600
+ if (isHigh) out.push({ type: 'pivothigh', side: 'high', i, note: `${2 * N + 1}-bar high` });
601
+ if (isLow) out.push({ type: 'pivotlow', side: 'low', i, note: `${2 * N + 1}-bar low` });
602
+ }
603
+
604
+ // RSI divergences between the two strongest extremes of the window
605
+ if (rsi) {
606
+ const top2 = (value) => {
607
+ let e1 = -1;
608
+ for (let i = i0; i <= i1; i++) if (e1 < 0 || value(i) > value(e1)) e1 = i;
609
+ let e2 = -1;
610
+ for (let i = i0; i <= i1; i++) {
611
+ if (Math.abs(i - e1) < minDist) continue;
612
+ if (e2 < 0 || value(i) > value(e2)) e2 = i;
613
+ }
614
+ return [e1, e2];
615
+ };
616
+ const [h1, h2] = top2((i) => bars[i].high);
617
+ if (h1 >= 0 && h2 >= 0 && isNum(rsi[h1]) && isNum(rsi[h2])) {
618
+ const later = Math.max(h1, h2);
619
+ const earlier = Math.min(h1, h2);
620
+ if (bars[later].high > bars[earlier].high && rsi[later] < rsi[earlier] - 2) {
621
+ out.push({ type: 'divbear', side: 'high', i: later, note: 'Bearish RSI divergence' });
622
+ }
623
+ }
624
+ const [l1, l2] = top2((i) => -bars[i].low);
625
+ if (l1 >= 0 && l2 >= 0 && isNum(rsi[l1]) && isNum(rsi[l2])) {
626
+ const later = Math.max(l1, l2);
627
+ const earlier = Math.min(l1, l2);
628
+ if (bars[later].low < bars[earlier].low && rsi[later] > rsi[earlier] + 2) {
629
+ out.push({ type: 'divbull', side: 'low', i: later, note: 'Bullish RSI divergence' });
630
+ }
631
+ }
632
+ }
633
+
634
+ return out.length > 80 ? out.slice(0, 80) : out;
635
+ }
636
+
637
+ /**
638
+ * Map a price to a sonification frequency over the visible scale.
639
+ * Logarithmic scales map through log-space; result clamped to [lo, hi] Hz.
640
+ * @param {number} price
641
+ * @param {{min: number, max: number, useLog?: boolean}} scale
642
+ * @param {number} [freqLo=180]
643
+ * @param {number} [freqHi=880]
644
+ * @returns {number} frequency in Hz
645
+ */
646
+ export function priceToFreq(price, scale, freqLo = 180, freqHi = 880) {
647
+ if (!scale || !(scale.max > scale.min)) return (freqLo + freqHi) / 2;
648
+ let t;
649
+ if (scale.useLog) {
650
+ // scale.min/max are already log10-transformed in this mode
651
+ t = (Math.log10(Math.max(price, 1e-12)) - scale.min) / (scale.max - scale.min || 1);
652
+ } else {
653
+ t = (price - scale.min) / (scale.max - scale.min);
654
+ }
655
+ t = t < 0 ? 0 : t > 1 ? 1 : t;
656
+ return freqLo + t * (freqHi - freqLo);
657
+ }
658
+
659
+ /**
660
+ * Volume profile over a visible bar range: volume distributed into price
661
+ * rows, with POC and the value area (greedy expansion around the POC).
662
+ * @param {Bar[]} bars
663
+ * @param {number} i0
664
+ * @param {number} i1
665
+ * @param {{rows?: number, valueAreaPct?: number}} [opts]
666
+ * @returns {null|{
667
+ * rows: Array<{v: number, up: number, dn: number}>, maxV: number, total: number,
668
+ * rowH: number, priceMin: number, priceMax: number,
669
+ * pocIndex: number, valIndex: number, vahIndex: number,
670
+ * poc: number, val: number, vah: number
671
+ * }}
672
+ */
673
+ export function computeVolumeProfile(bars, i0, i1, opts) {
674
+ const rowCount = (opts && opts.rows) || 100;
675
+ const vaPct = (opts && opts.valueAreaPct) || 0.7;
676
+ if (!bars.length || i0 < 0 || i1 < i0 || i1 >= bars.length) return null;
677
+ let lo = Infinity;
678
+ let hi = -Infinity;
679
+ for (let i = i0; i <= i1; i++) {
680
+ const b = bars[i];
681
+ if (b.low < lo) lo = b.low;
682
+ if (b.high > hi) hi = b.high;
683
+ }
684
+ if (!isFinite(lo) || !isFinite(hi) || !(hi > lo)) return null;
685
+ const rowH = (hi - lo) / rowCount;
686
+ const up = new Array(rowCount).fill(0);
687
+ const dn = new Array(rowCount).fill(0);
688
+ for (let i = i0; i <= i1; i++) {
689
+ const b = bars[i];
690
+ const v = isNum(b.volume) ? b.volume : 0;
691
+ if (v <= 0) continue;
692
+ let r0 = Math.floor((b.low - lo) / rowH);
693
+ let r1 = Math.floor((b.high - lo) / rowH);
694
+ r0 = clamp(r0, 0, rowCount - 1);
695
+ r1 = clamp(r1, 0, rowCount - 1);
696
+ const per = v / (r1 - r0 + 1);
697
+ const target = b.close >= b.open ? up : dn;
698
+ for (let r = r0; r <= r1; r++) target[r] += per;
699
+ }
700
+ const tot = new Array(rowCount);
701
+ let maxV = 0;
702
+ let total = 0;
703
+ let pocIndex = 0;
704
+ for (let r = 0; r < rowCount; r++) {
705
+ tot[r] = up[r] + dn[r];
706
+ total += tot[r];
707
+ if (tot[r] > maxV) {
708
+ maxV = tot[r];
709
+ pocIndex = r;
710
+ }
711
+ }
712
+ if (!maxV) return null;
713
+ // value area: greedily expand around the POC until vaPct of volume is covered
714
+ let loI = pocIndex;
715
+ let hiI = pocIndex;
716
+ let acc = tot[pocIndex];
717
+ const goal = total * vaPct;
718
+ while (acc < goal && (loI > 0 || hiI < rowCount - 1)) {
719
+ const below = loI > 0 ? tot[loI - 1] : -1;
720
+ const above = hiI < rowCount - 1 ? tot[hiI + 1] : -1;
721
+ if (above >= below) {
722
+ hiI++;
723
+ acc += tot[hiI];
724
+ } else {
725
+ loI--;
726
+ acc += tot[loI];
727
+ }
728
+ }
729
+ const priceAt = (r) => lo + (r + 0.5) * rowH;
730
+ return {
731
+ rows: tot.map((v, r) => ({ v, up: up[r], dn: dn[r] })),
732
+ maxV,
733
+ total,
734
+ rowH,
735
+ priceMin: lo,
736
+ priceMax: hi,
737
+ pocIndex,
738
+ valIndex: loI,
739
+ vahIndex: hiI,
740
+ poc: priceAt(pocIndex),
741
+ val: priceAt(loI),
742
+ vah: priceAt(hiI),
743
+ };
744
+ }
745
+
746
+ /** Supported values for the `type` attribute. */
747
+ export const SERIES_TYPES = ['candles', 'line', 'area', 'bars', 'hollow', 'heikin'];
748
+
749
+ /**
750
+ * Heikin-Ashi transform (smoothed candles; time/volume pass through).
751
+ * @param {Bar[]} bars
752
+ * @returns {Bar[]}
753
+ */
754
+ export function calcHeikinAshi(bars) {
755
+ const out = new Array(bars.length);
756
+ let po = null;
757
+ let pc = null;
758
+ for (let i = 0; i < bars.length; i++) {
759
+ const b = bars[i];
760
+ const close = (b.open + b.high + b.low + b.close) / 4;
761
+ const open = po == null ? (b.open + b.close) / 2 : (po + pc) / 2;
762
+ out[i] = {
763
+ time: b.time,
764
+ open,
765
+ close,
766
+ high: Math.max(b.high, open, close),
767
+ low: Math.min(b.low, open, close),
768
+ volume: b.volume,
769
+ };
770
+ po = open;
771
+ pc = close;
772
+ }
773
+ return out;
774
+ }
775
+
776
+ /* ------------------------------------------------------------------ *
777
+ * Indicator registry
778
+ * ------------------------------------------------------------------ */
779
+
780
+ /**
781
+ * Normalize an indicator compute() result to
782
+ * `{ lines: [{name, values, color?}], histogram: number[] | null }`.
783
+ */
784
+ export function normalizeIndicatorResult(res) {
785
+ if (!res) return { lines: [], histogram: null };
786
+ if (Array.isArray(res)) return { lines: [{ name: '', values: res }], histogram: null };
787
+ return {
788
+ lines: Array.isArray(res.lines) ? res.lines : [],
789
+ histogram: Array.isArray(res.histogram) ? res.histogram : null,
790
+ };
791
+ }
792
+
793
+ const closesOf = (bars) => bars.map((b) => b.close);
794
+
795
+ /** Built-in indicator definitions (name → def). */
796
+ export const BUILTIN_INDICATORS = new Map(
797
+ Object.entries({
798
+ sma: {
799
+ kind: 'overlay',
800
+ params: { period: 20 },
801
+ compute: (bars, p) => calcSMA(closesOf(bars), p.period),
802
+ },
803
+ ema: {
804
+ kind: 'overlay',
805
+ params: { period: 50 },
806
+ compute: (bars, p) => calcEMA(closesOf(bars), p.period),
807
+ },
808
+ bb: {
809
+ kind: 'overlay',
810
+ params: { period: 20, mult: 2 },
811
+ compute: (bars, p) => {
812
+ const { mid, upper, lower } = calcBollinger(closesOf(bars), p.period, p.mult);
813
+ return {
814
+ lines: [
815
+ { name: 'upper', values: upper },
816
+ { name: 'mid', values: mid },
817
+ { name: 'lower', values: lower },
818
+ ],
819
+ };
820
+ },
821
+ },
822
+ rsi: {
823
+ kind: 'pane',
824
+ params: { period: 14 },
825
+ guides: [30, 70],
826
+ range: [0, 100],
827
+ fmt: 'fixed1',
828
+ color: 'rsi',
829
+ compute: (bars, p) => calcRSI(closesOf(bars), p.period),
830
+ },
831
+ macd: {
832
+ kind: 'pane',
833
+ params: { fast: 12, slow: 26, signal: 9 },
834
+ guides: [0],
835
+ fmt: 'price',
836
+ compute: (bars, p) => {
837
+ const r = calcMACD(closesOf(bars), p.fast, p.slow, p.signal);
838
+ return {
839
+ lines: [
840
+ { name: 'macd', values: r.macd },
841
+ { name: 'signal', values: r.signal },
842
+ ],
843
+ histogram: r.hist,
844
+ };
845
+ },
846
+ },
847
+ })
848
+ );
849
+
850
+ /**
851
+ * Parse an `indicators` attribute string against a registry.
852
+ * Token: `name[:param[/param…]][@color]`, plus the `volume` keyword.
853
+ * @param {string|null|undefined} str
854
+ * @param {Map<string, IndicatorDef>} registry
855
+ * @returns {{overlays: IndicatorEntry[], panes: IndicatorEntry[], volume: boolean, unknown: string[]}}
856
+ */
857
+ export function parseIndicators(str, registry) {
858
+ const out = { overlays: [], panes: [], volume: false, unknown: [] };
859
+ if (str == null || str === '') return out;
860
+ const seen = new Set();
861
+ for (const raw of String(str).split(/[\s,;]+/)) {
862
+ if (!raw) continue;
863
+ const m = raw.match(/^([A-Za-z][A-Za-z0-9_]*)(?::([^@]*))?(@.+)?$/);
864
+ if (!m) continue;
865
+ const [, name, paramStr, colorStr] = m;
866
+ if (name === 'volume') {
867
+ out.volume = true;
868
+ continue;
869
+ }
870
+ const key = name.toLowerCase();
871
+ const def = registry.get(key);
872
+ if (!def) {
873
+ out.unknown.push(name);
874
+ continue;
875
+ }
876
+ const dedupe = key + ':' + (paramStr || '');
877
+ if (seen.has(dedupe)) continue;
878
+ seen.add(dedupe);
879
+
880
+ const defaults = def.params || {};
881
+ const params = {};
882
+ const parts = paramStr ? paramStr.split('/').map((s) => parseFloat(s)) : [];
883
+ Object.keys(defaults).forEach((k, i) => {
884
+ params[k] = isNum(parts[i]) ? parts[i] : defaults[k];
885
+ });
886
+
887
+ const entry = {
888
+ name: key,
889
+ def,
890
+ params,
891
+ color: colorStr ? colorStr.slice(1) : null,
892
+ key: dedupe,
893
+ };
894
+ if (def.kind === 'pane') out.panes.push(entry);
895
+ else out.overlays.push(entry);
896
+ }
897
+ return out;
898
+ }
899
+
900
+ /* ------------------------------------------------------------------ *
901
+ * Trading overlays
902
+ * ------------------------------------------------------------------ */
903
+
904
+ /**
905
+ * Unrealized P&L of a position at `price`.
906
+ * @param {{side?: 'long'|'short', entry: number, qty?: number}} pos
907
+ * @param {number} price
908
+ * @returns {number}
909
+ */
910
+ export function positionPnl(pos, price) {
911
+ if (!pos || !isNum(pos.entry) || !isNum(price)) return 0;
912
+ const dir = pos.side === 'short' ? -1 : 1;
913
+ const qty = isNum(pos.qty) ? pos.qty : 1;
914
+ return (price - pos.entry) * dir * qty;
915
+ }
916
+
917
+ /**
918
+ * Edge-triggered alert crossing test between two consecutive prices.
919
+ * @param {{price: number, direction?: 'above'|'below'|'cross'}} alert
920
+ * @param {number} prevPrice
921
+ * @param {number} price
922
+ * @returns {boolean}
923
+ */
924
+ export function checkAlertCross(alert, prevPrice, price) {
925
+ if (!alert || !isNum(alert.price) || !isNum(prevPrice) || !isNum(price)) return false;
926
+ const p = alert.price;
927
+ const dir = alert.direction || 'cross';
928
+ if (dir === 'above') return prevPrice <= p && price > p;
929
+ if (dir === 'below') return prevPrice >= p && price < p;
930
+ return (prevPrice <= p && price > p) || (prevPrice >= p && price < p);
931
+ }
932
+
933
+ /* ------------------------------------------------------------------ *
934
+ * Visible-range statistics
935
+ * ------------------------------------------------------------------ */
936
+
937
+ /**
938
+ * Statistics over a visible slice of bars.
939
+ * @param {Bar[]} bars
940
+ * @param {number} i0
941
+ * @param {number} i1
942
+ * @param {number} dtMs
943
+ * @returns {null|{n:number, changePct:number, min:number, max:number, maxDDPct:number, annVolPct:number, up:number, dn:number, avgVolume:number}}
944
+ */
945
+ export function computeStats(bars, i0, i1, dtMs) {
946
+ const n = i1 - i0 + 1;
947
+ if (!bars.length || n < 2 || i0 < 0 || i1 >= bars.length) return null;
948
+ const first = bars[i0].close;
949
+ const last = bars[i1].close;
950
+ let min = Infinity;
951
+ let max = -Infinity;
952
+ let peak = -Infinity;
953
+ let maxDD = 0;
954
+ let up = 0;
955
+ let dn = 0;
956
+ let volSum = 0;
957
+ let volBars = 0;
958
+ let lrSum = 0;
959
+ let lrSumSq = 0;
960
+ let lrN = 0;
961
+ let prev = first;
962
+ for (let i = i0; i <= i1; i++) {
963
+ const b = bars[i];
964
+ if (b.close < min) min = b.close;
965
+ if (b.close > max) max = b.close;
966
+ if (b.close > peak) peak = b.close;
967
+ const dd = peak > 0 ? (peak - b.close) / peak : 0;
968
+ if (dd > maxDD) maxDD = dd;
969
+ if (i > i0) {
970
+ if (b.close >= prev) up++;
971
+ else dn++;
972
+ if (prev > 0 && b.close > 0) {
973
+ const lr = Math.log(b.close / prev);
974
+ lrSum += lr;
975
+ lrSumSq += lr * lr;
976
+ lrN++;
977
+ }
978
+ }
979
+ if (isNum(b.volume) && b.volume > 0) {
980
+ volSum += b.volume;
981
+ volBars++;
982
+ }
983
+ prev = b.close;
984
+ }
985
+ const variance = lrN > 1 ? Math.max(0, lrSumSq / lrN - (lrSum / lrN) * (lrSum / lrN)) : 0;
986
+ const sd = Math.sqrt(variance);
987
+ const periodsPerYear = dtMs > 0 ? (365 * 24 * 3600e3) / dtMs : 252;
988
+ return {
989
+ n,
990
+ changePct: first ? ((last - first) / first) * 100 : 0,
991
+ min,
992
+ max,
993
+ maxDDPct: maxDD * 100,
994
+ annVolPct: sd * Math.sqrt(periodsPerYear) * 100,
995
+ up,
996
+ dn,
997
+ avgVolume: volBars ? volSum / volBars : 0,
998
+ };
999
+ }
1000
+
1001
+ /* ------------------------------------------------------------------ *
1002
+ * State serialization (shareable URLs)
1003
+ * ------------------------------------------------------------------ */
1004
+
1005
+ /**
1006
+ * Encode a chart state (from getState()) as a compact query string.
1007
+ * View times are encoded in whole seconds.
1008
+ * @param {ChartState|null} state
1009
+ * @returns {string}
1010
+ */
1011
+ export function encodeStateQuery(state) {
1012
+ if (!state || typeof state !== 'object') return '';
1013
+ const p = new URLSearchParams();
1014
+ if (state.type) p.set('type', state.type);
1015
+ if (state.theme) p.set('theme', state.theme);
1016
+ if (state.log) p.set('log', '1');
1017
+ if (state.stats) p.set('stats', '1');
1018
+ if (state.profile) p.set('profile', '1');
1019
+ if (state.annotations) p.set('ann', '1');
1020
+ if (state.indicators) p.set('ind', state.indicators.trim().replace(/\s+/g, ','));
1021
+ if (state.view) {
1022
+ if (isNum(state.view.from)) p.set('from', String(Math.floor(state.view.from / 1000)));
1023
+ if (isNum(state.view.to)) p.set('to', String(Math.floor(state.view.to / 1000)));
1024
+ }
1025
+ return p.toString();
1026
+ }
1027
+
1028
+ /**
1029
+ * Decode a query string (from encodeStateQuery) back into a partial state.
1030
+ * @param {string} str
1031
+ * @returns {ChartState}
1032
+ */
1033
+ export function decodeStateQuery(str) {
1034
+ const p = new URLSearchParams(typeof str === 'string' ? str : '');
1035
+ const state = {};
1036
+ const type = p.get('type');
1037
+ if (type) state.type = type;
1038
+ const theme = p.get('theme');
1039
+ if (theme) state.theme = theme;
1040
+ if (p.get('log') === '1') state.log = true;
1041
+ if (p.get('stats') === '1') state.stats = true;
1042
+ if (p.get('profile') === '1') state.profile = true;
1043
+ if (p.get('ann') === '1') state.annotations = true;
1044
+ const ind = p.get('ind');
1045
+ if (ind) state.indicators = ind.split(',').map((s) => s.trim()).filter(Boolean).join(' ');
1046
+ const from = p.get('from');
1047
+ const to = p.get('to');
1048
+ if (from != null || to != null) {
1049
+ state.view = {
1050
+ from: from != null && isNum(+from) ? +from * 1000 : undefined,
1051
+ to: to != null && isNum(+to) ? +to * 1000 : undefined,
1052
+ };
1053
+ }
1054
+ return state;
1055
+ }