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/README.md +404 -0
- package/package.json +57 -0
- package/src/core.js +1055 -0
- package/src/feeds.js +192 -0
- package/src/hab-chart.js +2621 -0
- package/src/hab-feed.js +255 -0
- package/types/core.d.ts +572 -0
- package/types/feeds.d.ts +58 -0
- package/types/hab-chart.d.ts +377 -0
- package/types/hab-feed.d.ts +27 -0
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
|
+
}
|