wickchart 1.6.0 → 1.7.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
@@ -30,6 +30,15 @@ import {
30
30
  openBinanceSocket,
31
31
  tfToSeconds,
32
32
  BASE_PRICES,
33
+ parseAggregate,
34
+ aggregateTrades,
35
+ genSyntheticTrades,
36
+ makeSynthTradeStream,
37
+ synthTradesPerBar,
38
+ fetchBinanceAggTrades,
39
+ fetchBinanceAggTradesSince,
40
+ openBinanceTradeSocket,
41
+ normalizeTrades,
33
42
  } from './feeds.js';
34
43
 
35
44
  const LIVE_TICK_MS = 650;
@@ -38,7 +47,7 @@ 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() {
@@ -127,9 +136,19 @@ class WickFeed extends HTMLElementBase {
127
136
  });
128
137
  return;
129
138
  }
139
+ // aggregate="tick:200|volume:50|dollar:25000" switches the feed from
140
+ // time bars to information-based bars built client-side from raw trades
141
+ const agg = parseAggregate(this.getAttribute('aggregate'));
142
+ if (this.hasAttribute('aggregate') && !agg) {
143
+ this._setStatus('error', { message: 'aggregate must be tick|volume|dollar[:N]' });
144
+ return;
145
+ }
130
146
  if (!chart.hasAttribute('label')) {
131
147
  const sym = this.getAttribute('binance') || this.getAttribute('demo');
132
- if (sym) chart.setAttribute('label', `${String(sym).toUpperCase()} · ${this.getAttribute('tf') || '1h'}`);
148
+ if (sym) {
149
+ const sub = agg ? `${agg.kind}:${String(+(+agg.threshold).toFixed(6))} bars` : (this.getAttribute('tf') || '1h');
150
+ chart.setAttribute('label', `${String(sym).toUpperCase()} · ${sub}`);
151
+ }
133
152
  }
134
153
 
135
154
  const gen = this._gen;
@@ -140,6 +159,14 @@ class WickFeed extends HTMLElementBase {
140
159
  const url = this.getAttribute('url');
141
160
  const demo = this.getAttribute('demo');
142
161
 
162
+ if (agg) {
163
+ if (sym) this._binanceTrades(gen, chart, String(sym).toUpperCase(), agg, limit, live);
164
+ else if (url) this._restTrades(gen, chart, url, agg, limit, live);
165
+ else if (demo != null) {
166
+ this._syntheticTrades(gen, chart, demo === '' ? 'DEMO' : demo, agg, limit, live);
167
+ } else this._setStatus('idle');
168
+ return;
169
+ }
143
170
  if (sym) this._binance(gen, chart, String(sym).toUpperCase(), tfId, limit, live);
144
171
  else if (url) this._rest(gen, chart, url, limit, live);
145
172
  else if (demo != null) {
@@ -220,6 +247,180 @@ class WickFeed extends HTMLElementBase {
220
247
  this._synthetic(gen, chart, sym, tfId, limit, live, 'fallback');
221
248
  }
222
249
 
250
+ /* ---------------- aggregate sources (information-based bars) ------------- */
251
+
252
+ /**
253
+ * Wrap chart.update for aggregated bars: the chart keys bars by timestamp,
254
+ * and two groups can close inside the same millisecond, so emitted times
255
+ * are nudged +1ms to stay strictly increasing (keeps the one-bar-per-
256
+ * timestamp integrity invariant; display-only, values are untouched).
257
+ */
258
+ _aggEmit(gen, chart) {
259
+ let last = -Infinity;
260
+ return (bar) => {
261
+ if (!bar || this._gen !== gen || !this.isConnected) return;
262
+ if (bar.time <= last) bar = { ...bar, time: last + 1 };
263
+ last = bar.time;
264
+ chart.update(bar);
265
+ };
266
+ }
267
+
268
+ /** Seed + stream one aggregator onto the chart (shared by all sources). */
269
+ _aggAttach(gen, chart, res, bars, limit, status) {
270
+ chart.setData([...bars.slice(-limit), ...(res.pending ? [res.pending] : [])].slice(-limit));
271
+ this._setStatus(status);
272
+ return {
273
+ emit: this._aggEmit(gen, chart),
274
+ aggregator: res.aggregator,
275
+ /** Push one print through; emits the closed bar, then the forming one. */
276
+ push(t) {
277
+ const closed = res.aggregator.add(t);
278
+ if (closed) this.emit(closed);
279
+ this.emit(res.aggregator.current());
280
+ },
281
+ };
282
+ }
283
+
284
+ _syntheticTrades(gen, chart, key, agg, limit, live, status = 'live') {
285
+ const base = BASE_PRICES[key.toUpperCase()] || 100;
286
+ const perBar = synthTradesPerBar(agg, base);
287
+ const seed = Math.max(2000, Math.min(48000, Math.ceil(limit * perBar) * 2));
288
+ const full = aggregateTrades(genSyntheticTrades(`${key}:agg`, seed, base), agg.kind, agg.threshold);
289
+ const bars = full.bars;
290
+ chart.onloadmore = (fromTime) => bars.filter((b) => b.time < fromTime).slice(-limit);
291
+ const handle = this._aggAttach(gen, chart, full, bars, limit, status);
292
+ if (!live) return;
293
+ const lastPrice = (full.pending || bars[bars.length - 1] || { close: base }).close;
294
+ const next = makeSynthTradeStream(lastPrice);
295
+ const timer = setInterval(() => {
296
+ if (this._gen !== gen || !this.isConnected) return;
297
+ const n = 2 + ((Math.random() * 6) | 0); // a burst of prints per tick
298
+ for (let i = 0; i < n; i++) handle.push(next());
299
+ }, LIVE_TICK_MS);
300
+ this._closers.push(() => clearInterval(timer));
301
+ }
302
+
303
+ async _binanceTrades(gen, chart, sym, agg, limit, live) {
304
+ this._setStatus('loading');
305
+ try {
306
+ // prints-per-bar is instrument-specific — size the seed from a first page
307
+ const first = await fetchBinanceAggTrades(sym, 1000, 1);
308
+ let perBar = agg.kind === 'tick' ? agg.threshold : 40;
309
+ if (agg.kind !== 'tick' && first.trades.length) {
310
+ let vol = 0;
311
+ let notl = 0;
312
+ for (const t of first.trades) {
313
+ vol += t.size;
314
+ notl += t.price * t.size;
315
+ }
316
+ const n = first.trades.length;
317
+ perBar = agg.kind === 'volume'
318
+ ? agg.threshold / Math.max(1e-12, vol / n)
319
+ : agg.threshold / Math.max(1e-12, notl / n);
320
+ }
321
+ let trades = first.trades;
322
+ let oldestId = first.oldestId;
323
+ const target = Math.max(1000, Math.min(25000, Math.ceil(limit * perBar)));
324
+ if (trades.length < target) {
325
+ const older = await fetchBinanceAggTrades(sym, target - trades.length, 25, oldestId);
326
+ trades = older.trades.concat(trades);
327
+ if (older.oldestId != null) oldestId = older.oldestId;
328
+ }
329
+ if (this._gen !== gen || !this.isConnected) return;
330
+ const res = aggregateTrades(trades, agg.kind, agg.threshold);
331
+ const handle = this._aggAttach(gen, chart, res, res.bars, limit, 'loaded');
332
+ let lastId = trades.length ? trades[trades.length - 1].id : 0;
333
+ chart.onloadmore = async (fromTime) => {
334
+ const older = await fetchBinanceAggTrades(sym, Math.ceil(limit * perBar), 10, oldestId);
335
+ if (older.oldestId != null) oldestId = older.oldestId;
336
+ return aggregateTrades(older.trades, agg.kind, agg.threshold).bars
337
+ .filter((b) => b.time < fromTime)
338
+ .slice(-limit);
339
+ };
340
+ if (!live) return;
341
+ const ws = openBinanceTradeSocket(
342
+ sym,
343
+ (t) => {
344
+ if (this._gen !== gen || !this.isConnected) return;
345
+ if (!(t.id > lastId)) return; // seed/WS overlap
346
+ lastId = t.id;
347
+ handle.push(t);
348
+ this._setStatus('live');
349
+ },
350
+ () => {
351
+ if (this._gen !== gen || !this.isConnected) return;
352
+ this._pollBinanceTrades(gen, chart, sym, handle, lastId, agg, limit, live);
353
+ }
354
+ );
355
+ this._closers.push(() => ws.close());
356
+ } catch (err) {
357
+ if (this._gen !== gen || !this.isConnected) return;
358
+ this._fire('fallback', { reason: err && err.message });
359
+ this._syntheticTrades(gen, chart, sym, agg, limit, live, 'fallback');
360
+ }
361
+ }
362
+
363
+ _pollBinanceTrades(gen, chart, sym, handle, fromId, agg, limit, live) {
364
+ this._setStatus('polling');
365
+ let lastId = fromId;
366
+ const timer = setInterval(async () => {
367
+ if (this._gen !== gen || !this.isConnected) return;
368
+ try {
369
+ const { trades, latestId } = await fetchBinanceAggTradesSince(sym, lastId + 1);
370
+ for (const t of trades) {
371
+ if (!(t.id > lastId)) continue;
372
+ lastId = t.id;
373
+ handle.push(t);
374
+ }
375
+ if (latestId > lastId) lastId = latestId;
376
+ } catch (_) {
377
+ clearInterval(timer);
378
+ this._fire('fallback', { reason: 'trade poll failed' });
379
+ this._syntheticTrades(gen, chart, sym, agg, limit, live, 'fallback');
380
+ }
381
+ }, 10000);
382
+ this._closers.push(() => clearInterval(timer));
383
+ }
384
+
385
+ async _restTrades(gen, chart, url, agg, limit, live) {
386
+ this._setStatus('loading');
387
+ const pull = async () => {
388
+ const res = await fetch(url);
389
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
390
+ const body = await res.json();
391
+ return normalizeTrades(Array.isArray(body) ? body : body.trades || body.bars);
392
+ };
393
+ try {
394
+ const trades = await pull();
395
+ if (this._gen !== gen || !this.isConnected) return;
396
+ const res = aggregateTrades(trades, agg.kind, agg.threshold);
397
+ this._aggAttach(gen, chart, res, res.bars, limit, 'loaded');
398
+ if (!live) return;
399
+ const pollSec = Math.max(1, parseInt(this.getAttribute('poll') || '0', 10) || 0);
400
+ if (!pollSec) return;
401
+ // dedupe by print time: a same-ms reprint would double-count notional
402
+ let lastTime = trades.length ? trades[trades.length - 1].time : 0;
403
+ const emit = this._aggEmit(gen, chart);
404
+ const timer = setInterval(async () => {
405
+ if (this._gen !== gen || !this.isConnected) return;
406
+ try {
407
+ for (const t of await pull()) {
408
+ if (t.time <= lastTime) continue;
409
+ lastTime = t.time;
410
+ const closed = res.aggregator.add(t);
411
+ if (closed) emit(closed);
412
+ }
413
+ emit(res.aggregator.current());
414
+ this._setStatus('polling');
415
+ } catch (_) {}
416
+ }, pollSec * 1000);
417
+ this._closers.push(() => clearInterval(timer));
418
+ } catch (err) {
419
+ if (this._gen !== gen || !this.isConnected) return;
420
+ this._setStatus('error', { message: err && err.message });
421
+ }
422
+ }
423
+
223
424
  /* ---------------- generic REST source ---------------- */
224
425
 
225
426
  async _rest(gen, chart, url, limit, live) {
@@ -268,3 +469,13 @@ if (typeof customElements !== 'undefined') {
268
469
 
269
470
  export default WickFeed;
270
471
  export { WickFeed };
472
+ // pure aggregation helpers — exported so apps can pipe their own trade
473
+ // streams through the same machinery `aggregate=` uses
474
+ export {
475
+ parseAggregate,
476
+ TickBarAggregator,
477
+ aggregateTrades,
478
+ normalizeTrades,
479
+ genSyntheticTrades,
480
+ makeSynthTradeStream,
481
+ } 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
+ }
package/types/core.d.ts CHANGED
@@ -190,6 +190,27 @@ export declare const SEC = 1000;
190
190
  export declare const MIN: number;
191
191
  export declare const HOUR: number;
192
192
  export declare const DAY: number;
193
+ /**
194
+ * Interpret a timestamp as milliseconds. Numbers may be seconds or ms, so
195
+ * some threshold is unavoidable; pass a `Date` for anything before 1973,
196
+ * which is unambiguous. Single source of truth — everything that reads a
197
+ * caller-supplied time goes through here.
198
+ * @param {number|Date} t
199
+ * @returns {number} milliseconds
200
+ */
201
+ export declare const toMs: (t: number | Date) => number;
202
+ /**
203
+ * Milliseconds east of UTC in `zone` at the instant `at`.
204
+ * 'utc' → 0
205
+ * 'local' / null → the browser's zone (DST-correct via Date)
206
+ * number → a fixed offset in ms (exchange sessions)
207
+ * IANA name → DST-correct via Intl
208
+ * Never throws: an unusable zone reads as UTC.
209
+ * @param {number} at epoch ms
210
+ * @param {string|number|null} [zone]
211
+ * @returns {number} offset in ms
212
+ */
213
+ export declare function zoneOffset(at: number, zone?: string | number | null): number;
193
214
  export declare const TIME_STEPS: {
194
215
  ms: number;
195
216
  label: string;
@@ -308,12 +329,22 @@ export declare function calcTrueRange(bars: Bar[]): Array<number | null>;
308
329
  */
309
330
  export declare function calcATR(bars: Bar[], period?: number): Array<number | null>;
310
331
  /**
311
- * Volume-weighted average price over the hlc3 typical price, anchored to
312
- * each UTC day (resets at the session boundary).
332
+ * Volume-weighted average price over the hlc3 typical price, resetting at
333
+ * each session boundary.
334
+ *
335
+ * The anchor defaults to the UTC day — the crypto convention, and what this
336
+ * has always done. Equities, futures and FX rarely open at UTC midnight, so
337
+ * pass the session's zone (or a fixed offset) to move the reset. Note this is
338
+ * deliberately independent of the chart's `timezone`, which only governs how
339
+ * times are displayed: changing the axis to Stockholm should not silently
340
+ * re-anchor a BTC chart's VWAP.
341
+ *
313
342
  * @param {Bar[]} bars
343
+ * @param {string|number} [anchor='utc'] 'utc' | 'local' | IANA zone | fixed
344
+ * offset in ms — see zoneOffset()
314
345
  * @returns {Array<number|null>}
315
346
  */
316
- export declare function calcVWAP(bars: Bar[]): Array<number | null>;
347
+ export declare function calcVWAP(bars: Bar[], anchor?: string | number): Array<number | null>;
317
348
  /**
318
349
  * On-balance volume: cumulative volume signed by close-to-close direction.
319
350
  * @param {Bar[]} bars
@@ -523,10 +554,10 @@ export declare const BUILTIN_INDICATORS: Map<string, {
523
554
  smooth?: undefined;
524
555
  };
525
556
  compute: (bars: any, p: any) => number[];
526
- color?: undefined;
527
557
  guides?: undefined;
528
558
  range?: undefined;
529
559
  fmt?: undefined;
560
+ color?: undefined;
530
561
  } | {
531
562
  kind: string;
532
563
  params: {
@@ -538,10 +569,10 @@ export declare const BUILTIN_INDICATORS: Map<string, {
538
569
  smooth?: undefined;
539
570
  };
540
571
  compute: (bars: any, p: any) => number[];
541
- color?: undefined;
542
572
  guides?: undefined;
543
573
  range?: undefined;
544
574
  fmt?: undefined;
575
+ color?: undefined;
545
576
  } | {
546
577
  kind: string;
547
578
  params: {
@@ -552,11 +583,11 @@ export declare const BUILTIN_INDICATORS: Map<string, {
552
583
  smooth?: undefined;
553
584
  period?: undefined;
554
585
  };
555
- compute: (bars: any) => number[];
556
- color?: undefined;
586
+ compute: (bars: any, p: any) => number[];
557
587
  guides?: undefined;
558
588
  range?: undefined;
559
589
  fmt?: undefined;
590
+ color?: undefined;
560
591
  } | {
561
592
  kind: string;
562
593
  params: {
@@ -568,10 +599,10 @@ export declare const BUILTIN_INDICATORS: Map<string, {
568
599
  smooth?: undefined;
569
600
  };
570
601
  compute: (bars: any, p: any) => number[];
571
- color?: undefined;
572
602
  guides?: undefined;
573
603
  range?: undefined;
574
604
  fmt?: undefined;
605
+ color?: undefined;
575
606
  } | {
576
607
  kind: string;
577
608
  params: {
@@ -588,10 +619,10 @@ export declare const BUILTIN_INDICATORS: Map<string, {
588
619
  values: number[];
589
620
  }[];
590
621
  };
591
- color?: undefined;
592
622
  guides?: undefined;
593
623
  range?: undefined;
594
624
  fmt?: undefined;
625
+ color?: undefined;
595
626
  } | {
596
627
  kind: string;
597
628
  params: {
@@ -608,10 +639,10 @@ export declare const BUILTIN_INDICATORS: Map<string, {
608
639
  values: number[];
609
640
  }[];
610
641
  };
611
- color?: undefined;
612
642
  guides?: undefined;
613
643
  range?: undefined;
614
644
  fmt?: undefined;
645
+ color?: undefined;
615
646
  } | {
616
647
  kind: string;
617
648
  params: {
@@ -628,10 +659,10 @@ export declare const BUILTIN_INDICATORS: Map<string, {
628
659
  values: number[];
629
660
  }[];
630
661
  };
631
- color?: undefined;
632
662
  guides?: undefined;
633
663
  range?: undefined;
634
664
  fmt?: undefined;
665
+ color?: undefined;
635
666
  } | {
636
667
  kind: string;
637
668
  params: {
@@ -648,7 +679,6 @@ export declare const BUILTIN_INDICATORS: Map<string, {
648
679
  color: string;
649
680
  compute: (bars: any, p: any) => number[];
650
681
  } | {
651
- color?: undefined;
652
682
  kind: string;
653
683
  params: {
654
684
  mult?: undefined;
@@ -668,8 +698,8 @@ export declare const BUILTIN_INDICATORS: Map<string, {
668
698
  histogram: number[];
669
699
  };
670
700
  range?: undefined;
671
- } | {
672
701
  color?: undefined;
702
+ } | {
673
703
  kind: string;
674
704
  params: {
675
705
  mult?: undefined;
@@ -683,8 +713,8 @@ export declare const BUILTIN_INDICATORS: Map<string, {
683
713
  compute: (bars: any, p: any) => number[];
684
714
  guides?: undefined;
685
715
  range?: undefined;
686
- } | {
687
716
  color?: undefined;
717
+ } | {
688
718
  kind: string;
689
719
  params: {
690
720
  mult?: undefined;
@@ -703,8 +733,8 @@ export declare const BUILTIN_INDICATORS: Map<string, {
703
733
  values: number[];
704
734
  }[];
705
735
  };
706
- } | {
707
736
  color?: undefined;
737
+ } | {
708
738
  kind: string;
709
739
  params: {
710
740
  mult?: undefined;
@@ -718,8 +748,8 @@ export declare const BUILTIN_INDICATORS: Map<string, {
718
748
  compute: (bars: any) => number[];
719
749
  guides?: undefined;
720
750
  range?: undefined;
721
- } | {
722
751
  color?: undefined;
752
+ } | {
723
753
  kind: string;
724
754
  params: {
725
755
  mult?: undefined;
@@ -733,8 +763,8 @@ export declare const BUILTIN_INDICATORS: Map<string, {
733
763
  fmt: string;
734
764
  compute: (bars: any, p: any) => number[];
735
765
  range?: undefined;
736
- } | {
737
766
  color?: undefined;
767
+ } | {
738
768
  kind: string;
739
769
  params: {
740
770
  mult?: undefined;
@@ -748,6 +778,7 @@ export declare const BUILTIN_INDICATORS: Map<string, {
748
778
  range: number[];
749
779
  fmt: string;
750
780
  compute: (bars: any, p: any) => number[];
781
+ color?: undefined;
751
782
  }>;
752
783
  /**
753
784
  * Parse an `indicators` attribute string against a registry.
@@ -814,6 +845,18 @@ export declare function positionPnl(pos: {
814
845
  entry: number;
815
846
  qty?: number;
816
847
  }, price: number): number;
848
+ /**
849
+ * Percent return of a position at `price` — the move per unit, so it does
850
+ * NOT scale with `qty` the way positionPnl() does. Deriving this by dividing
851
+ * positionPnl() by the entry price reports qty × the true return.
852
+ * @param {{side?: 'long'|'short', entry: number}} pos
853
+ * @param {number} price
854
+ * @returns {number} percent (10 means +10%)
855
+ */
856
+ export declare function positionPnlPct(pos: {
857
+ side?: 'long' | 'short';
858
+ entry: number;
859
+ }, price: number): number;
817
860
  /**
818
861
  * Edge-triggered alert crossing test between two consecutive prices.
819
862
  * @param {{price: number, direction?: 'above'|'below'|'cross'}} alert