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/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` plus the deprecated `hab-feed:name` alias. */
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> or the deprecated <hab-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') || document.querySelector('hab-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) chart.setAttribute('label', `${String(sym).toUpperCase()} · ${this.getAttribute('tf') || '1h'}`);
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
+ }