wickchart 1.6.0 → 2.0.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 +236 -21
- package/package.json +19 -5
- package/src/core.js +165 -457
- package/src/feeds.js +333 -1
- package/src/react-core.js +7 -1
- package/src/report.js +309 -0
- package/src/wick-chart.js +627 -974
- package/src/wick-feed.js +217 -14
- package/src/worker-core.js +89 -0
- package/src/worker.js +128 -0
- package/types/core.d.ts +64 -251
- package/types/feeds.d.ts +165 -0
- package/types/report.d.ts +114 -0
- package/types/wick-chart.d.ts +119 -265
- package/types/wick-feed.d.ts +21 -2
- package/types/worker-core.d.ts +11 -0
- package/types/worker.d.ts +36 -0
package/src/wick-feed.js
CHANGED
|
@@ -19,7 +19,6 @@
|
|
|
19
19
|
* streaming. Status is reflected in the `status` attribute and via
|
|
20
20
|
* `wick-feed:status` events (loading / live / polling / fallback / loaded /
|
|
21
21
|
* waiting / idle). `wick-feed:fallback` fires when a live source degrades.
|
|
22
|
-
* (The 0.x event names `hab-feed:*` still fire as deprecated aliases.)
|
|
23
22
|
* ========================================================================== */
|
|
24
23
|
|
|
25
24
|
import './wick-chart.js';
|
|
@@ -30,15 +29,25 @@ import {
|
|
|
30
29
|
openBinanceSocket,
|
|
31
30
|
tfToSeconds,
|
|
32
31
|
BASE_PRICES,
|
|
32
|
+
parseAggregate,
|
|
33
|
+
aggregateTrades,
|
|
34
|
+
genSyntheticTrades,
|
|
35
|
+
makeSynthTradeStream,
|
|
36
|
+
synthTradesPerBar,
|
|
37
|
+
fetchBinanceAggTrades,
|
|
38
|
+
fetchBinanceAggTradesSince,
|
|
39
|
+
openBinanceTradeSocket,
|
|
40
|
+
normalizeTrades,
|
|
33
41
|
} from './feeds.js';
|
|
34
42
|
|
|
43
|
+
|
|
35
44
|
const LIVE_TICK_MS = 650;
|
|
36
45
|
|
|
37
46
|
const HTMLElementBase = typeof HTMLElement !== 'undefined' ? HTMLElement : class {};
|
|
38
47
|
|
|
39
48
|
class WickFeed extends HTMLElementBase {
|
|
40
49
|
static get observedAttributes() {
|
|
41
|
-
return ['for', 'binance', 'demo', 'url', 'tf', 'limit', 'poll', 'live'];
|
|
50
|
+
return ['for', 'binance', 'demo', 'url', 'tf', 'limit', 'poll', 'live', 'aggregate'];
|
|
42
51
|
}
|
|
43
52
|
|
|
44
53
|
constructor() {
|
|
@@ -90,21 +99,19 @@ class WickFeed extends HTMLElementBase {
|
|
|
90
99
|
this._fire('status', { status, ...detail });
|
|
91
100
|
}
|
|
92
101
|
|
|
93
|
-
/** Dispatch `wick-feed:name
|
|
102
|
+
/** Dispatch `wick-feed:name`. */
|
|
94
103
|
_fire(name, detail) {
|
|
95
104
|
this.dispatchEvent(new CustomEvent('wick-feed:' + name, { detail }));
|
|
96
|
-
this.dispatchEvent(new CustomEvent('hab-feed:' + name, { detail }));
|
|
97
105
|
}
|
|
98
|
-
|
|
99
106
|
/** Resolve the target chart (by `for` id, else the first chart element —
|
|
100
|
-
* <wick-chart>
|
|
107
|
+
* <wick-chart>). */
|
|
101
108
|
_resolveChart() {
|
|
102
109
|
const id = this.getAttribute('for');
|
|
103
110
|
if (id) {
|
|
104
111
|
const el = document.getElementById(id);
|
|
105
112
|
return el && (el.tagName === 'WICK-CHART' || el.tagName === 'HAB-CHART') ? el : null;
|
|
106
113
|
}
|
|
107
|
-
return document.querySelector('wick-chart')
|
|
114
|
+
return document.querySelector('wick-chart');
|
|
108
115
|
}
|
|
109
116
|
|
|
110
117
|
_restart() {
|
|
@@ -127,9 +134,19 @@ class WickFeed extends HTMLElementBase {
|
|
|
127
134
|
});
|
|
128
135
|
return;
|
|
129
136
|
}
|
|
137
|
+
// aggregate="tick:200|volume:50|dollar:25000" switches the feed from
|
|
138
|
+
// time bars to information-based bars built client-side from raw trades
|
|
139
|
+
const agg = parseAggregate(this.getAttribute('aggregate'));
|
|
140
|
+
if (this.hasAttribute('aggregate') && !agg) {
|
|
141
|
+
this._setStatus('error', { message: 'aggregate must be tick|volume|dollar[:N]' });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
130
144
|
if (!chart.hasAttribute('label')) {
|
|
131
145
|
const sym = this.getAttribute('binance') || this.getAttribute('demo');
|
|
132
|
-
if (sym)
|
|
146
|
+
if (sym) {
|
|
147
|
+
const sub = agg ? `${agg.kind}:${String(+(+agg.threshold).toFixed(6))} bars` : (this.getAttribute('tf') || '1h');
|
|
148
|
+
chart.setAttribute('label', `${String(sym).toUpperCase()} · ${sub}`);
|
|
149
|
+
}
|
|
133
150
|
}
|
|
134
151
|
|
|
135
152
|
const gen = this._gen;
|
|
@@ -140,6 +157,14 @@ class WickFeed extends HTMLElementBase {
|
|
|
140
157
|
const url = this.getAttribute('url');
|
|
141
158
|
const demo = this.getAttribute('demo');
|
|
142
159
|
|
|
160
|
+
if (agg) {
|
|
161
|
+
if (sym) this._binanceTrades(gen, chart, String(sym).toUpperCase(), agg, limit, live);
|
|
162
|
+
else if (url) this._restTrades(gen, chart, url, agg, limit, live);
|
|
163
|
+
else if (demo != null) {
|
|
164
|
+
this._syntheticTrades(gen, chart, demo === '' ? 'DEMO' : demo, agg, limit, live);
|
|
165
|
+
} else this._setStatus('idle');
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
143
168
|
if (sym) this._binance(gen, chart, String(sym).toUpperCase(), tfId, limit, live);
|
|
144
169
|
else if (url) this._rest(gen, chart, url, limit, live);
|
|
145
170
|
else if (demo != null) {
|
|
@@ -220,6 +245,180 @@ class WickFeed extends HTMLElementBase {
|
|
|
220
245
|
this._synthetic(gen, chart, sym, tfId, limit, live, 'fallback');
|
|
221
246
|
}
|
|
222
247
|
|
|
248
|
+
/* ---------------- aggregate sources (information-based bars) ------------- */
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Wrap chart.update for aggregated bars: the chart keys bars by timestamp,
|
|
252
|
+
* and two groups can close inside the same millisecond, so emitted times
|
|
253
|
+
* are nudged +1ms to stay strictly increasing (keeps the one-bar-per-
|
|
254
|
+
* timestamp integrity invariant; display-only, values are untouched).
|
|
255
|
+
*/
|
|
256
|
+
_aggEmit(gen, chart) {
|
|
257
|
+
let last = -Infinity;
|
|
258
|
+
return (bar) => {
|
|
259
|
+
if (!bar || this._gen !== gen || !this.isConnected) return;
|
|
260
|
+
if (bar.time <= last) bar = { ...bar, time: last + 1 };
|
|
261
|
+
last = bar.time;
|
|
262
|
+
chart.update(bar);
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Seed + stream one aggregator onto the chart (shared by all sources). */
|
|
267
|
+
_aggAttach(gen, chart, res, bars, limit, status) {
|
|
268
|
+
chart.setData([...bars.slice(-limit), ...(res.pending ? [res.pending] : [])].slice(-limit));
|
|
269
|
+
this._setStatus(status);
|
|
270
|
+
return {
|
|
271
|
+
emit: this._aggEmit(gen, chart),
|
|
272
|
+
aggregator: res.aggregator,
|
|
273
|
+
/** Push one print through; emits the closed bar, then the forming one. */
|
|
274
|
+
push(t) {
|
|
275
|
+
const closed = res.aggregator.add(t);
|
|
276
|
+
if (closed) this.emit(closed);
|
|
277
|
+
this.emit(res.aggregator.current());
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
_syntheticTrades(gen, chart, key, agg, limit, live, status = 'live') {
|
|
283
|
+
const base = BASE_PRICES[key.toUpperCase()] || 100;
|
|
284
|
+
const perBar = synthTradesPerBar(agg, base);
|
|
285
|
+
const seed = Math.max(2000, Math.min(48000, Math.ceil(limit * perBar) * 2));
|
|
286
|
+
const full = aggregateTrades(genSyntheticTrades(`${key}:agg`, seed, base), agg.kind, agg.threshold);
|
|
287
|
+
const bars = full.bars;
|
|
288
|
+
chart.onloadmore = (fromTime) => bars.filter((b) => b.time < fromTime).slice(-limit);
|
|
289
|
+
const handle = this._aggAttach(gen, chart, full, bars, limit, status);
|
|
290
|
+
if (!live) return;
|
|
291
|
+
const lastPrice = (full.pending || bars[bars.length - 1] || { close: base }).close;
|
|
292
|
+
const next = makeSynthTradeStream(lastPrice);
|
|
293
|
+
const timer = setInterval(() => {
|
|
294
|
+
if (this._gen !== gen || !this.isConnected) return;
|
|
295
|
+
const n = 2 + ((Math.random() * 6) | 0); // a burst of prints per tick
|
|
296
|
+
for (let i = 0; i < n; i++) handle.push(next());
|
|
297
|
+
}, LIVE_TICK_MS);
|
|
298
|
+
this._closers.push(() => clearInterval(timer));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async _binanceTrades(gen, chart, sym, agg, limit, live) {
|
|
302
|
+
this._setStatus('loading');
|
|
303
|
+
try {
|
|
304
|
+
// prints-per-bar is instrument-specific — size the seed from a first page
|
|
305
|
+
const first = await fetchBinanceAggTrades(sym, 1000, 1);
|
|
306
|
+
let perBar = agg.kind === 'tick' ? agg.threshold : 40;
|
|
307
|
+
if (agg.kind !== 'tick' && first.trades.length) {
|
|
308
|
+
let vol = 0;
|
|
309
|
+
let notl = 0;
|
|
310
|
+
for (const t of first.trades) {
|
|
311
|
+
vol += t.size;
|
|
312
|
+
notl += t.price * t.size;
|
|
313
|
+
}
|
|
314
|
+
const n = first.trades.length;
|
|
315
|
+
perBar = agg.kind === 'volume'
|
|
316
|
+
? agg.threshold / Math.max(1e-12, vol / n)
|
|
317
|
+
: agg.threshold / Math.max(1e-12, notl / n);
|
|
318
|
+
}
|
|
319
|
+
let trades = first.trades;
|
|
320
|
+
let oldestId = first.oldestId;
|
|
321
|
+
const target = Math.max(1000, Math.min(25000, Math.ceil(limit * perBar)));
|
|
322
|
+
if (trades.length < target) {
|
|
323
|
+
const older = await fetchBinanceAggTrades(sym, target - trades.length, 25, oldestId);
|
|
324
|
+
trades = older.trades.concat(trades);
|
|
325
|
+
if (older.oldestId != null) oldestId = older.oldestId;
|
|
326
|
+
}
|
|
327
|
+
if (this._gen !== gen || !this.isConnected) return;
|
|
328
|
+
const res = aggregateTrades(trades, agg.kind, agg.threshold);
|
|
329
|
+
const handle = this._aggAttach(gen, chart, res, res.bars, limit, 'loaded');
|
|
330
|
+
let lastId = trades.length ? trades[trades.length - 1].id : 0;
|
|
331
|
+
chart.onloadmore = async (fromTime) => {
|
|
332
|
+
const older = await fetchBinanceAggTrades(sym, Math.ceil(limit * perBar), 10, oldestId);
|
|
333
|
+
if (older.oldestId != null) oldestId = older.oldestId;
|
|
334
|
+
return aggregateTrades(older.trades, agg.kind, agg.threshold).bars
|
|
335
|
+
.filter((b) => b.time < fromTime)
|
|
336
|
+
.slice(-limit);
|
|
337
|
+
};
|
|
338
|
+
if (!live) return;
|
|
339
|
+
const ws = openBinanceTradeSocket(
|
|
340
|
+
sym,
|
|
341
|
+
(t) => {
|
|
342
|
+
if (this._gen !== gen || !this.isConnected) return;
|
|
343
|
+
if (!(t.id > lastId)) return; // seed/WS overlap
|
|
344
|
+
lastId = t.id;
|
|
345
|
+
handle.push(t);
|
|
346
|
+
this._setStatus('live');
|
|
347
|
+
},
|
|
348
|
+
() => {
|
|
349
|
+
if (this._gen !== gen || !this.isConnected) return;
|
|
350
|
+
this._pollBinanceTrades(gen, chart, sym, handle, lastId, agg, limit, live);
|
|
351
|
+
}
|
|
352
|
+
);
|
|
353
|
+
this._closers.push(() => ws.close());
|
|
354
|
+
} catch (err) {
|
|
355
|
+
if (this._gen !== gen || !this.isConnected) return;
|
|
356
|
+
this._fire('fallback', { reason: err && err.message });
|
|
357
|
+
this._syntheticTrades(gen, chart, sym, agg, limit, live, 'fallback');
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
_pollBinanceTrades(gen, chart, sym, handle, fromId, agg, limit, live) {
|
|
362
|
+
this._setStatus('polling');
|
|
363
|
+
let lastId = fromId;
|
|
364
|
+
const timer = setInterval(async () => {
|
|
365
|
+
if (this._gen !== gen || !this.isConnected) return;
|
|
366
|
+
try {
|
|
367
|
+
const { trades, latestId } = await fetchBinanceAggTradesSince(sym, lastId + 1);
|
|
368
|
+
for (const t of trades) {
|
|
369
|
+
if (!(t.id > lastId)) continue;
|
|
370
|
+
lastId = t.id;
|
|
371
|
+
handle.push(t);
|
|
372
|
+
}
|
|
373
|
+
if (latestId > lastId) lastId = latestId;
|
|
374
|
+
} catch (_) {
|
|
375
|
+
clearInterval(timer);
|
|
376
|
+
this._fire('fallback', { reason: 'trade poll failed' });
|
|
377
|
+
this._syntheticTrades(gen, chart, sym, agg, limit, live, 'fallback');
|
|
378
|
+
}
|
|
379
|
+
}, 10000);
|
|
380
|
+
this._closers.push(() => clearInterval(timer));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async _restTrades(gen, chart, url, agg, limit, live) {
|
|
384
|
+
this._setStatus('loading');
|
|
385
|
+
const pull = async () => {
|
|
386
|
+
const res = await fetch(url);
|
|
387
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
388
|
+
const body = await res.json();
|
|
389
|
+
return normalizeTrades(Array.isArray(body) ? body : body.trades || body.bars);
|
|
390
|
+
};
|
|
391
|
+
try {
|
|
392
|
+
const trades = await pull();
|
|
393
|
+
if (this._gen !== gen || !this.isConnected) return;
|
|
394
|
+
const res = aggregateTrades(trades, agg.kind, agg.threshold);
|
|
395
|
+
this._aggAttach(gen, chart, res, res.bars, limit, 'loaded');
|
|
396
|
+
if (!live) return;
|
|
397
|
+
const pollSec = Math.max(1, parseInt(this.getAttribute('poll') || '0', 10) || 0);
|
|
398
|
+
if (!pollSec) return;
|
|
399
|
+
// dedupe by print time: a same-ms reprint would double-count notional
|
|
400
|
+
let lastTime = trades.length ? trades[trades.length - 1].time : 0;
|
|
401
|
+
const emit = this._aggEmit(gen, chart);
|
|
402
|
+
const timer = setInterval(async () => {
|
|
403
|
+
if (this._gen !== gen || !this.isConnected) return;
|
|
404
|
+
try {
|
|
405
|
+
for (const t of await pull()) {
|
|
406
|
+
if (t.time <= lastTime) continue;
|
|
407
|
+
lastTime = t.time;
|
|
408
|
+
const closed = res.aggregator.add(t);
|
|
409
|
+
if (closed) emit(closed);
|
|
410
|
+
}
|
|
411
|
+
emit(res.aggregator.current());
|
|
412
|
+
this._setStatus('polling');
|
|
413
|
+
} catch (_) {}
|
|
414
|
+
}, pollSec * 1000);
|
|
415
|
+
this._closers.push(() => clearInterval(timer));
|
|
416
|
+
} catch (err) {
|
|
417
|
+
if (this._gen !== gen || !this.isConnected) return;
|
|
418
|
+
this._setStatus('error', { message: err && err.message });
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
223
422
|
/* ---------------- generic REST source ---------------- */
|
|
224
423
|
|
|
225
424
|
async _rest(gen, chart, url, limit, live) {
|
|
@@ -258,13 +457,17 @@ if (typeof customElements !== 'undefined') {
|
|
|
258
457
|
if (!customElements.get('wick-feed')) {
|
|
259
458
|
customElements.define('wick-feed', WickFeed);
|
|
260
459
|
}
|
|
261
|
-
// 0.x alias: same element under its old tag name (deprecated, removed in 2.0)
|
|
262
|
-
if (!customElements.get('hab-feed')) {
|
|
263
|
-
/** @deprecated use <wick-feed> */
|
|
264
|
-
class HabFeed extends WickFeed {}
|
|
265
|
-
customElements.define('hab-feed', HabFeed);
|
|
266
|
-
}
|
|
267
460
|
}
|
|
268
461
|
|
|
269
462
|
export default WickFeed;
|
|
270
463
|
export { WickFeed };
|
|
464
|
+
// pure aggregation helpers — exported so apps can pipe their own trade
|
|
465
|
+
// streams through the same machinery `aggregate=` uses
|
|
466
|
+
export {
|
|
467
|
+
parseAggregate,
|
|
468
|
+
TickBarAggregator,
|
|
469
|
+
aggregateTrades,
|
|
470
|
+
normalizeTrades,
|
|
471
|
+
genSyntheticTrades,
|
|
472
|
+
makeSynthTradeStream,
|
|
473
|
+
} from './feeds.js';
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/* ==========================================================================
|
|
2
|
+
* worker-core — the worker side of the worker compute path.
|
|
3
|
+
*
|
|
4
|
+
* Runs inside a module Worker spawned by src/worker.js. Tasks arrive with a
|
|
5
|
+
* correlation id; replies carry { id, ok, ... }. The chart sends its dataset
|
|
6
|
+
* once per data epoch as six Float64Arrays (transferred, not cloned — a
|
|
7
|
+
* structured clone of a million bar objects costs ~1 s, the columnar fill
|
|
8
|
+
* ~25 ms), and indicator tasks afterwards reference that data by
|
|
9
|
+
* (sid, epoch). Sessions are keyed per chart so one shared worker can serve
|
|
10
|
+
* several charts; the two most recent are kept (1M bars of columns plus the
|
|
11
|
+
* rebuilt object tape is ~50 MB each).
|
|
12
|
+
*
|
|
13
|
+
* handle() is exported (and pure with respect to its state object) so the
|
|
14
|
+
* dispatch is unit-testable in Node with no Worker at all.
|
|
15
|
+
* ========================================================================== */
|
|
16
|
+
|
|
17
|
+
import { BUILTIN_INDICATORS } from './core.js';
|
|
18
|
+
|
|
19
|
+
/** Rebuild the bar-object tape from a columnar snapshot (worker-side only —
|
|
20
|
+
* this cost never touches the main thread). */
|
|
21
|
+
export function barsFromCols(cols) {
|
|
22
|
+
const n = cols.time.length;
|
|
23
|
+
const out = new Array(n);
|
|
24
|
+
for (let i = 0; i < n; i++) {
|
|
25
|
+
out[i] = {
|
|
26
|
+
time: cols.time[i],
|
|
27
|
+
open: cols.open[i],
|
|
28
|
+
high: cols.high[i],
|
|
29
|
+
low: cols.low[i],
|
|
30
|
+
close: cols.close[i],
|
|
31
|
+
volume: cols.volume[i],
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Create a worker-core state + dispatcher.
|
|
39
|
+
* @returns {{sessions: Map, handle(message: object): object}} the reply object
|
|
40
|
+
*/
|
|
41
|
+
export function createWorkerCore() {
|
|
42
|
+
const sessions = new Map(); // sid → { epoch, cols, bars: Array|null }
|
|
43
|
+
return {
|
|
44
|
+
sessions,
|
|
45
|
+
|
|
46
|
+
handle(msg) {
|
|
47
|
+
const { id, type, sid } = msg || {};
|
|
48
|
+
if (type === 'epoch') {
|
|
49
|
+
// keep at most two chart sessions resident
|
|
50
|
+
while (sessions.size >= 2) sessions.delete(sessions.keys().next().value);
|
|
51
|
+
sessions.set(sid, { epoch: msg.epoch, cols: msg.cols, bars: null });
|
|
52
|
+
return { id, ok: true };
|
|
53
|
+
}
|
|
54
|
+
if (type === 'indicator') {
|
|
55
|
+
const s = sessions.get(sid);
|
|
56
|
+
if (!s || s.epoch !== msg.epoch) {
|
|
57
|
+
// the worker no longer holds this epoch's data (evicted, or the
|
|
58
|
+
// chart is ahead) — the chart resends and retries
|
|
59
|
+
return { id, ok: false, stale: true };
|
|
60
|
+
}
|
|
61
|
+
const def = BUILTIN_INDICATORS.get(msg.name);
|
|
62
|
+
if (!def) return { id, ok: false, error: 'unknown indicator: ' + msg.name };
|
|
63
|
+
let res = null;
|
|
64
|
+
try {
|
|
65
|
+
if (!s.bars) s.bars = barsFromCols(s.cols); // once per epoch
|
|
66
|
+
res = def.compute(s.bars, msg.params || {});
|
|
67
|
+
} catch (_) {
|
|
68
|
+
res = null; // same contract as the sync path: a failed compute draws nothing
|
|
69
|
+
}
|
|
70
|
+
return { id, ok: true, res };
|
|
71
|
+
}
|
|
72
|
+
return { id, ok: false, error: 'unknown task type: ' + type };
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* ---------------- worker bootstrap (real Worker scope only) ---------------- */
|
|
78
|
+
|
|
79
|
+
const inWorker =
|
|
80
|
+
typeof WorkerGlobalScope !== 'undefined' &&
|
|
81
|
+
typeof self !== 'undefined' &&
|
|
82
|
+
self instanceof WorkerGlobalScope;
|
|
83
|
+
|
|
84
|
+
if (inWorker) {
|
|
85
|
+
const core = createWorkerCore();
|
|
86
|
+
self.onmessage = (e) => {
|
|
87
|
+
self.postMessage(core.handle(e.data));
|
|
88
|
+
};
|
|
89
|
+
}
|
package/src/worker.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/* ==========================================================================
|
|
2
|
+
* <wick-chart> worker compute path — the main-thread side.
|
|
3
|
+
*
|
|
4
|
+
* import 'wickchart/worker'; // once, anywhere
|
|
5
|
+
* <wick-chart worker indicators="sma:20 bb:20 rsi:14"></wick-chart>
|
|
6
|
+
*
|
|
7
|
+
* Importing this module wires a shared ChartWorkerPool into the chart class;
|
|
8
|
+
* from then on, any chart with the `worker` attribute computes its built-in
|
|
9
|
+
* indicators off the main thread once the dataset crosses WORKER_MIN_BARS
|
|
10
|
+
* (50k bars). Custom/scripted indicators are closures and stay sync; so does
|
|
11
|
+
* everything below the threshold. Data crosses once per bulk load as six
|
|
12
|
+
* transferable Float64Arrays — never as cloned objects.
|
|
13
|
+
*
|
|
14
|
+
* import { ChartWorkerPool, getSharedPool, setChartWorkerPool } from 'wickchart/worker';
|
|
15
|
+
* const pool = new ChartWorkerPool(); // a private pool (e.g. one per tab view)
|
|
16
|
+
* setChartWorkerPool(pool);
|
|
17
|
+
*
|
|
18
|
+
* No Worker available (old browsers, non-HTTP contexts where module workers
|
|
19
|
+
* fail, Node)? pool.available is false and every chart silently stays on the
|
|
20
|
+
* synchronous path — the attribute is an optimization, never a dependency.
|
|
21
|
+
* ========================================================================== */
|
|
22
|
+
|
|
23
|
+
import { WickChart } from './wick-chart.js';
|
|
24
|
+
|
|
25
|
+
export { WickChart };
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A single-Worker task runner. The factory is injectable so tests can drive
|
|
29
|
+
* the protocol with a fake Worker; the default spawns the module worker
|
|
30
|
+
* sitting next to this file.
|
|
31
|
+
*/
|
|
32
|
+
export class ChartWorkerPool {
|
|
33
|
+
/** @param {() => Worker} [factory] */
|
|
34
|
+
constructor(factory) {
|
|
35
|
+
this._factory =
|
|
36
|
+
typeof factory === 'function'
|
|
37
|
+
? factory
|
|
38
|
+
: typeof Worker === 'function'
|
|
39
|
+
? () => new Worker(new URL('./worker-core.js', import.meta.url), { type: 'module' })
|
|
40
|
+
: null;
|
|
41
|
+
this.available = this._factory != null;
|
|
42
|
+
this._worker = null;
|
|
43
|
+
this._seq = 0;
|
|
44
|
+
this._pending = new Map(); // id → { resolve, reject }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Post a task; resolves with the reply's `res`, rejects on failure or
|
|
49
|
+
* worker death (a `stale` rejection means: resend the epoch data first).
|
|
50
|
+
* @param {object} msg task message without the id
|
|
51
|
+
* @returns {Promise<any>}
|
|
52
|
+
*/
|
|
53
|
+
run(msg) {
|
|
54
|
+
if (!this.available) return Promise.reject(new Error('worker unavailable'));
|
|
55
|
+
if (!this._worker) this._spawn();
|
|
56
|
+
const id = ++this._seq;
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
this._pending.set(id, { resolve, reject });
|
|
59
|
+
try {
|
|
60
|
+
this._worker.postMessage({ ...msg, id });
|
|
61
|
+
} catch (err) {
|
|
62
|
+
this._pending.delete(id);
|
|
63
|
+
reject(err);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
_spawn() {
|
|
69
|
+
try {
|
|
70
|
+
this._worker = this._factory();
|
|
71
|
+
} catch (err) {
|
|
72
|
+
this.available = false;
|
|
73
|
+
throw err;
|
|
74
|
+
}
|
|
75
|
+
this._worker.onmessage = (e) => {
|
|
76
|
+
const reply = e.data || {};
|
|
77
|
+
const p = this._pending.get(reply.id);
|
|
78
|
+
if (!p) return;
|
|
79
|
+
this._pending.delete(reply.id);
|
|
80
|
+
if (reply.stale) p.reject(Object.assign(new Error('stale epoch'), { stale: true }));
|
|
81
|
+
else if (reply.ok) p.resolve(reply.res);
|
|
82
|
+
else p.reject(new Error(reply.error || 'worker task failed'));
|
|
83
|
+
};
|
|
84
|
+
this._worker.onerror = () => this._die();
|
|
85
|
+
this._worker.onmessageerror = () => this._die();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Reject everything in flight and drop the worker; the next run() respawns
|
|
89
|
+
* — a transient worker crash must not permanently kill the mode. */
|
|
90
|
+
_die() {
|
|
91
|
+
for (const p of this._pending.values()) p.reject(new Error('worker died'));
|
|
92
|
+
this._pending.clear();
|
|
93
|
+
if (this._worker) {
|
|
94
|
+
try {
|
|
95
|
+
this._worker.terminate();
|
|
96
|
+
} catch (_) {}
|
|
97
|
+
}
|
|
98
|
+
this._worker = null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Shut the pool down for good (in-flight tasks reject). */
|
|
102
|
+
terminate() {
|
|
103
|
+
this._die();
|
|
104
|
+
this.available = false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let shared = null;
|
|
109
|
+
|
|
110
|
+
/** The page-wide pool (created on first use). */
|
|
111
|
+
export function getSharedPool() {
|
|
112
|
+
return shared || (shared = new ChartWorkerPool());
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Point the chart class at a pool (or null to disable the worker path).
|
|
117
|
+
* @param {ChartWorkerPool|null} pool
|
|
118
|
+
*/
|
|
119
|
+
export function setChartWorkerPool(pool) {
|
|
120
|
+
WickChart._workerPool = pool;
|
|
121
|
+
return pool;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// importing 'wickchart/worker' is the whole setup: wire the shared pool into
|
|
125
|
+
// the chart class so <wick-chart worker> just works afterwards
|
|
126
|
+
if (typeof Worker === 'function') {
|
|
127
|
+
setChartWorkerPool(getSharedPool());
|
|
128
|
+
}
|