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/core.js
CHANGED
|
@@ -206,6 +206,106 @@ export const MIN = 60 * SEC;
|
|
|
206
206
|
export const HOUR = 60 * MIN;
|
|
207
207
|
export const DAY = 24 * HOUR;
|
|
208
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Below this, a numeric timestamp is read as seconds. 1e11 is the year 5138
|
|
211
|
+
* in seconds but 1973-03-03 in milliseconds, so it sits in the widest quiet
|
|
212
|
+
* gap between the two ranges. (The old 1e12 cutoff was inside the plausible
|
|
213
|
+
* millisecond range and silently multiplied every ms timestamp before
|
|
214
|
+
* 2001-09-09 by 1000 — all pre-2001 equity/index/FX history.)
|
|
215
|
+
*/
|
|
216
|
+
const MS_CUTOFF = 1e11;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Interpret a timestamp as milliseconds. Numbers may be seconds or ms, so
|
|
220
|
+
* some threshold is unavoidable; pass a `Date` for anything before 1973,
|
|
221
|
+
* which is unambiguous. Single source of truth — everything that reads a
|
|
222
|
+
* caller-supplied time goes through here.
|
|
223
|
+
* @param {number|Date} t
|
|
224
|
+
* @returns {number} milliseconds
|
|
225
|
+
*/
|
|
226
|
+
export const toMs = (t) =>
|
|
227
|
+
t instanceof Date ? t.getTime() : t < MS_CUTOFF ? t * 1000 : t;
|
|
228
|
+
|
|
229
|
+
/* ------------------------------------------------------------------ *
|
|
230
|
+
* Moved-method stub warnings (used by wick-chart.js; deleted in 3.0)
|
|
231
|
+
* ------------------------------------------------------------------ */
|
|
232
|
+
|
|
233
|
+
const warnedAliases = new Set();
|
|
234
|
+
|
|
235
|
+
/** Warn once per distinct message (used by the 2.0 moved-method stubs in
|
|
236
|
+
* wick-chart.js — deleted with them in 3.0). */
|
|
237
|
+
export function warnDeprecatedAlias(message) {
|
|
238
|
+
if (warnedAliases.has(message)) return;
|
|
239
|
+
warnedAliases.add(message);
|
|
240
|
+
if (typeof console !== 'undefined' && console.warn) {
|
|
241
|
+
console.warn('wickchart: ' + message);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/* ------------------------------------------------------------------ *
|
|
246
|
+
* Timezones
|
|
247
|
+
* ------------------------------------------------------------------ */
|
|
248
|
+
|
|
249
|
+
const zoneDtfCache = new Map();
|
|
250
|
+
|
|
251
|
+
/** Cached Intl formatter for a zone; null (→ UTC) if the zone is unusable. */
|
|
252
|
+
function zoneDtf(zone) {
|
|
253
|
+
let f = zoneDtfCache.get(zone);
|
|
254
|
+
if (f === undefined) {
|
|
255
|
+
try {
|
|
256
|
+
f = new Intl.DateTimeFormat('en-US', {
|
|
257
|
+
timeZone: zone,
|
|
258
|
+
hourCycle: 'h23',
|
|
259
|
+
year: 'numeric', month: '2-digit', day: '2-digit',
|
|
260
|
+
hour: '2-digit', minute: '2-digit',
|
|
261
|
+
});
|
|
262
|
+
} catch (_) {
|
|
263
|
+
// unknown zone: read as UTC rather than throwing on a render path, but
|
|
264
|
+
// say so once — a silently-UTC axis from a typo is hard to spot
|
|
265
|
+
f = null;
|
|
266
|
+
if (typeof console !== 'undefined') {
|
|
267
|
+
console.warn('wick-chart: unknown timezone "' + zone + '" — using UTC');
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
zoneDtfCache.set(zone, f);
|
|
271
|
+
}
|
|
272
|
+
return f;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const zoneOffCache = new Map();
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Milliseconds east of UTC in `zone` at the instant `at`.
|
|
279
|
+
* 'utc' → 0
|
|
280
|
+
* 'local' / null → the browser's zone (DST-correct via Date)
|
|
281
|
+
* number → a fixed offset in ms (exchange sessions)
|
|
282
|
+
* IANA name → DST-correct via Intl
|
|
283
|
+
* Never throws: an unusable zone reads as UTC.
|
|
284
|
+
* @param {number} at epoch ms
|
|
285
|
+
* @param {string|number|null} [zone]
|
|
286
|
+
* @returns {number} offset in ms
|
|
287
|
+
*/
|
|
288
|
+
export function zoneOffset(at, zone) {
|
|
289
|
+
if (zone === 'utc' || zone === 'UTC') return 0;
|
|
290
|
+
if (isNum(zone)) return zone;
|
|
291
|
+
if (zone == null || zone === 'local') return -new Date(at).getTimezoneOffset() * 60000;
|
|
292
|
+
const f = zoneDtf(zone);
|
|
293
|
+
if (!f) return 0;
|
|
294
|
+
// DST shifts land on hour/half-hour boundaries, so one Intl lookup per
|
|
295
|
+
// 30-minute bucket is exact — and keeps the slow part off the per-bar path.
|
|
296
|
+
const key = zone + '|' + Math.floor(at / 1800000);
|
|
297
|
+
const hit = zoneOffCache.get(key);
|
|
298
|
+
if (hit !== undefined) return hit;
|
|
299
|
+
const p = {};
|
|
300
|
+
for (const part of f.formatToParts(new Date(at))) p[part.type] = part.value;
|
|
301
|
+
// compare whole minutes: the formatted parts carry no seconds
|
|
302
|
+
const wall = Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour % 24, +p.minute);
|
|
303
|
+
const off = wall - Math.floor(at / 60000) * 60000;
|
|
304
|
+
if (zoneOffCache.size > 8192) zoneOffCache.clear();
|
|
305
|
+
zoneOffCache.set(key, off);
|
|
306
|
+
return off;
|
|
307
|
+
}
|
|
308
|
+
|
|
209
309
|
// Sub-day / day-aligned steps (ms), plus month/year handled separately.
|
|
210
310
|
export const TIME_STEPS = [
|
|
211
311
|
{ ms: MIN, label: 'time' },
|
|
@@ -229,7 +329,9 @@ const dtfCache = new Map();
|
|
|
229
329
|
function dtf(fmt) {
|
|
230
330
|
let f = dtfCache.get(fmt);
|
|
231
331
|
if (!f) {
|
|
232
|
-
|
|
332
|
+
// Locale stays the viewer's; the zone is pinned to UTC because callers
|
|
333
|
+
// pass an instant already shifted into the display zone (see _zt()).
|
|
334
|
+
f = new Intl.DateTimeFormat(undefined, { timeZone: 'UTC', ...fmt });
|
|
233
335
|
dtfCache.set(fmt, f);
|
|
234
336
|
}
|
|
235
337
|
return f;
|
|
@@ -239,21 +341,24 @@ const MON_FMT = { month: 'short' };
|
|
|
239
341
|
const MON_Y_FMT = { month: 'short', year: 'numeric' };
|
|
240
342
|
const YR_FMT = { year: 'numeric' };
|
|
241
343
|
|
|
344
|
+
/* Axis/legend formatters. Each takes an instant ALREADY shifted into the
|
|
345
|
+
* display zone (t + zoneOffset(t, zone)) and renders it as UTC, so one set of
|
|
346
|
+
* cached formatters serves every timezone. With the default 'local' zone the
|
|
347
|
+
* shift equals the browser offset and the output is what it always was. */
|
|
242
348
|
export const hhmm = (t) => {
|
|
243
349
|
const d = new Date(t);
|
|
244
|
-
return pad2(d.
|
|
350
|
+
return pad2(d.getUTCHours()) + ':' + pad2(d.getUTCMinutes());
|
|
245
351
|
};
|
|
246
352
|
export const fmtDay = (t) => dtf(DAY_FMT).format(t);
|
|
247
353
|
export const fmtMonth = (t, withYear) => dtf(withYear ? MON_Y_FMT : MON_FMT).format(t);
|
|
248
354
|
export const fmtYear = (t) => dtf(YR_FMT).format(t);
|
|
249
355
|
export const fmtFull = (t) => {
|
|
250
356
|
const d = new Date(t);
|
|
251
|
-
return dtf(DAY_FMT).format(d) + ' ' + pad2(d.
|
|
357
|
+
return dtf(DAY_FMT).format(d) + ' ' + pad2(d.getUTCHours()) + ':' + pad2(d.getUTCMinutes());
|
|
252
358
|
};
|
|
253
359
|
|
|
254
360
|
/* ------------------------------------------------------------------ *
|
|
255
|
-
* Themes (every key overridable via --wick-* CSS custom properties
|
|
256
|
-
* the 0.x --hab-* names still work as fallbacks)
|
|
361
|
+
* Themes (every key overridable via --wick-* CSS custom properties)
|
|
257
362
|
* ------------------------------------------------------------------ */
|
|
258
363
|
|
|
259
364
|
export const THEMES = {
|
|
@@ -501,20 +606,31 @@ export function calcATR(bars, period = 14) {
|
|
|
501
606
|
}
|
|
502
607
|
|
|
503
608
|
/**
|
|
504
|
-
* Volume-weighted average price over the hlc3 typical price,
|
|
505
|
-
* each
|
|
609
|
+
* Volume-weighted average price over the hlc3 typical price, resetting at
|
|
610
|
+
* each session boundary.
|
|
611
|
+
*
|
|
612
|
+
* The anchor defaults to the UTC day — the crypto convention, and what this
|
|
613
|
+
* has always done. Equities, futures and FX rarely open at UTC midnight, so
|
|
614
|
+
* pass the session's zone (or a fixed offset) to move the reset. Note this is
|
|
615
|
+
* deliberately independent of the chart's `timezone`, which only governs how
|
|
616
|
+
* times are displayed: changing the axis to Stockholm should not silently
|
|
617
|
+
* re-anchor a BTC chart's VWAP.
|
|
618
|
+
*
|
|
506
619
|
* @param {Bar[]} bars
|
|
620
|
+
* @param {string|number} [anchor='utc'] 'utc' | 'local' | IANA zone | fixed
|
|
621
|
+
* offset in ms — see zoneOffset()
|
|
507
622
|
* @returns {Array<number|null>}
|
|
508
623
|
*/
|
|
509
|
-
export function calcVWAP(bars) {
|
|
624
|
+
export function calcVWAP(bars, anchor) {
|
|
625
|
+
if (anchor == null) anchor = 'utc';
|
|
510
626
|
const out = new Array(bars.length).fill(null);
|
|
511
627
|
let pv = 0;
|
|
512
628
|
let vv = 0;
|
|
513
629
|
let day = null;
|
|
514
630
|
for (let i = 0; i < bars.length; i++) {
|
|
515
631
|
const b = bars[i];
|
|
516
|
-
const ms = b.time
|
|
517
|
-
const d = Math.floor(ms / DAY);
|
|
632
|
+
const ms = toMs(b.time);
|
|
633
|
+
const d = Math.floor((ms + zoneOffset(ms, anchor)) / DAY);
|
|
518
634
|
if (d !== day) {
|
|
519
635
|
day = d;
|
|
520
636
|
pv = 0;
|
|
@@ -905,28 +1021,6 @@ export function detectAnnotations(bars, i0, i1, rsi, opts = {}) {
|
|
|
905
1021
|
return out.length > 80 ? out.slice(0, 80) : out;
|
|
906
1022
|
}
|
|
907
1023
|
|
|
908
|
-
/**
|
|
909
|
-
* Map a price to a sonification frequency over the visible scale.
|
|
910
|
-
* Logarithmic scales map through log-space; result clamped to [lo, hi] Hz.
|
|
911
|
-
* @param {number} price
|
|
912
|
-
* @param {{min: number, max: number, useLog?: boolean}} scale
|
|
913
|
-
* @param {number} [freqLo=180]
|
|
914
|
-
* @param {number} [freqHi=880]
|
|
915
|
-
* @returns {number} frequency in Hz
|
|
916
|
-
*/
|
|
917
|
-
export function priceToFreq(price, scale, freqLo = 180, freqHi = 880) {
|
|
918
|
-
if (!scale || !(scale.max > scale.min)) return (freqLo + freqHi) / 2;
|
|
919
|
-
let t;
|
|
920
|
-
if (scale.useLog) {
|
|
921
|
-
// scale.min/max are already log10-transformed in this mode
|
|
922
|
-
t = (Math.log10(Math.max(price, 1e-12)) - scale.min) / (scale.max - scale.min || 1);
|
|
923
|
-
} else {
|
|
924
|
-
t = (price - scale.min) / (scale.max - scale.min);
|
|
925
|
-
}
|
|
926
|
-
t = t < 0 ? 0 : t > 1 ? 1 : t;
|
|
927
|
-
return freqLo + t * (freqHi - freqLo);
|
|
928
|
-
}
|
|
929
|
-
|
|
930
1024
|
/**
|
|
931
1025
|
* Volume profile over a visible bar range: volume distributed into price
|
|
932
1026
|
* rows, with POC and the value area (greedy expansion around the POC).
|
|
@@ -1079,7 +1173,9 @@ export const BUILTIN_INDICATORS = new Map(
|
|
|
1079
1173
|
vwap: {
|
|
1080
1174
|
kind: 'overlay',
|
|
1081
1175
|
params: {},
|
|
1082
|
-
|
|
1176
|
+
// `anchor` rides in on the params the chart builds, from its
|
|
1177
|
+
// `vwap-anchor` attribute; absent, calcVWAP defaults to the UTC day.
|
|
1178
|
+
compute: (bars, p) => calcVWAP(bars, p && p.anchor),
|
|
1083
1179
|
},
|
|
1084
1180
|
supertrend: {
|
|
1085
1181
|
kind: 'overlay',
|
|
@@ -1604,13 +1700,27 @@ function evalScriptCall(node, vars, n, bars) {
|
|
|
1604
1700
|
case 'rsi': return calcRSI(clean, p);
|
|
1605
1701
|
case 'hh':
|
|
1606
1702
|
case 'll': {
|
|
1703
|
+
// Sliding window in O(n) via a monotonic deque of indices. The naive
|
|
1704
|
+
// nested loop was O(n*p), which a shared chart URL could weaponize:
|
|
1705
|
+
// hh(close,50000) over 100k bars blocked the main thread for ~1.7s.
|
|
1706
|
+
// A separate NaN count reproduces the old behaviour of propagating a
|
|
1707
|
+
// warm-up gap through the whole window (Math.max/min do that for free,
|
|
1708
|
+
// a deque does not).
|
|
1607
1709
|
const out = new Array(n).fill(null);
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1710
|
+
const isMax = name === 'hh';
|
|
1711
|
+
const dq = []; // indices; their values decrease (hh) / increase (ll)
|
|
1712
|
+
let nan = 0;
|
|
1713
|
+
for (let i = 0; i < n; i++) {
|
|
1714
|
+
const v = clean[i];
|
|
1715
|
+
if (Number.isNaN(v)) nan++;
|
|
1716
|
+
if (i >= p && Number.isNaN(clean[i - p])) nan--;
|
|
1717
|
+
// drop values that can never win again while v is in the window
|
|
1718
|
+
while (dq.length && (isMax ? clean[dq[dq.length - 1]] <= v : clean[dq[dq.length - 1]] >= v)) {
|
|
1719
|
+
dq.pop();
|
|
1612
1720
|
}
|
|
1613
|
-
|
|
1721
|
+
dq.push(i);
|
|
1722
|
+
while (dq[0] < i - p + 1) dq.shift();
|
|
1723
|
+
if (i >= p - 1) out[i] = nan > 0 ? NaN : clean[dq[0]];
|
|
1614
1724
|
}
|
|
1615
1725
|
return out;
|
|
1616
1726
|
}
|
|
@@ -1685,7 +1795,7 @@ export function evalScript(compiled, bars) {
|
|
|
1685
1795
|
/**
|
|
1686
1796
|
* Build an indicator definition from a WickScript expression — used inline by
|
|
1687
1797
|
* `indicators="expr:{…}"` / `pexpr:{…}"`, or register it under a name:
|
|
1688
|
-
* `
|
|
1798
|
+
* `WickChart.registerIndicator('myspread', scriptIndicator('close - ema(close,21)'))`.
|
|
1689
1799
|
* @param {string} src
|
|
1690
1800
|
* @param {{pane?: boolean}} [opts]
|
|
1691
1801
|
* @returns {IndicatorDef}
|
|
@@ -1716,6 +1826,20 @@ export function positionPnl(pos, price) {
|
|
|
1716
1826
|
return (price - pos.entry) * dir * qty;
|
|
1717
1827
|
}
|
|
1718
1828
|
|
|
1829
|
+
/**
|
|
1830
|
+
* Percent return of a position at `price` — the move per unit, so it does
|
|
1831
|
+
* NOT scale with `qty` the way positionPnl() does. Deriving this by dividing
|
|
1832
|
+
* positionPnl() by the entry price reports qty × the true return.
|
|
1833
|
+
* @param {{side?: 'long'|'short', entry: number}} pos
|
|
1834
|
+
* @param {number} price
|
|
1835
|
+
* @returns {number} percent (10 means +10%)
|
|
1836
|
+
*/
|
|
1837
|
+
export function positionPnlPct(pos, price) {
|
|
1838
|
+
if (!pos || !isNum(pos.entry) || !pos.entry || !isNum(price)) return 0;
|
|
1839
|
+
const dir = pos.side === 'short' ? -1 : 1;
|
|
1840
|
+
return (((price - pos.entry) * dir) / pos.entry) * 100;
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1719
1843
|
/**
|
|
1720
1844
|
* Edge-triggered alert crossing test between two consecutive prices.
|
|
1721
1845
|
* @param {{price: number, direction?: 'above'|'below'|'cross'}} alert
|
|
@@ -1942,9 +2066,8 @@ export function parseVolShading(val) {
|
|
|
1942
2066
|
* Server-side overlays (zones & levels)
|
|
1943
2067
|
* ------------------------------------------------------------------ */
|
|
1944
2068
|
|
|
1945
|
-
/** Normalize a timestamp to milliseconds
|
|
1946
|
-
|
|
1947
|
-
const normMs = (t) => (t < 1e12 ? t * 1000 : t);
|
|
2069
|
+
/** Normalize a timestamp to milliseconds — see toMs(). */
|
|
2070
|
+
const normMs = toMs;
|
|
1948
2071
|
|
|
1949
2072
|
/**
|
|
1950
2073
|
* Index of the last bar whose time is <= `t` (binary search). Clamps to
|
|
@@ -2058,45 +2181,6 @@ export function resolveOverlayColor(raw, pal) {
|
|
|
2058
2181
|
* Scenario mode — ghost paths + volatility cones
|
|
2059
2182
|
* ------------------------------------------------------------------ */
|
|
2060
2183
|
|
|
2061
|
-
/**
|
|
2062
|
-
* σ-cone projection from realized per-bar volatility: price bands widening
|
|
2063
|
-
* with √h (GBM-style, exp(±z·σ·√h)) over `horizon` future bars.
|
|
2064
|
-
* @param {number} lastClose anchor price (bar 0)
|
|
2065
|
-
* @param {number} volPerBar per-bar stddev of log returns (from calcRealizedVol)
|
|
2066
|
-
* @param {number} horizon future bars (clamped 1–500, default 48)
|
|
2067
|
-
* @param {number[]} [levels] σ multipliers, e.g. [1, 2] (each clamped to 0–5)
|
|
2068
|
-
* @returns {{horizon: number, levels: number[], bands: Record<string, {up: number[], down: number[]}>}}
|
|
2069
|
-
* bands[z].up/.down are arrays indexed by h = 0…horizon ([0] === lastClose)
|
|
2070
|
-
*/
|
|
2071
|
-
export function calcVolCone(lastClose, volPerBar, horizon, levels) {
|
|
2072
|
-
const zs = (Array.isArray(levels) && levels.length ? levels : [1, 2])
|
|
2073
|
-
.map((z) => +z)
|
|
2074
|
-
.filter((z) => Number.isFinite(z) && z > 0 && z <= 5)
|
|
2075
|
-
.sort((a, b) => a - b);
|
|
2076
|
-
const lv = zs.length ? zs : [1];
|
|
2077
|
-
const H = Math.max(1, Math.min(500, Math.round(+horizon || 48)));
|
|
2078
|
-
const c = +lastClose;
|
|
2079
|
-
const v = +volPerBar;
|
|
2080
|
-
const bands = {};
|
|
2081
|
-
const flat = !Number.isFinite(c) || c <= 0 || !Number.isFinite(v) || v < 0;
|
|
2082
|
-
for (const z of lv) {
|
|
2083
|
-
const up = new Array(H + 1);
|
|
2084
|
-
const down = new Array(H + 1);
|
|
2085
|
-
for (let h = 0; h <= H; h++) {
|
|
2086
|
-
if (flat) {
|
|
2087
|
-
up[h] = c || 0;
|
|
2088
|
-
down[h] = c || 0;
|
|
2089
|
-
} else {
|
|
2090
|
-
const k = Math.exp(z * v * Math.sqrt(h));
|
|
2091
|
-
up[h] = c * k;
|
|
2092
|
-
down[h] = c / k;
|
|
2093
|
-
}
|
|
2094
|
-
}
|
|
2095
|
-
bands[z] = { up, down };
|
|
2096
|
-
}
|
|
2097
|
-
return { horizon: H, levels: lv, bands };
|
|
2098
|
-
}
|
|
2099
|
-
|
|
2100
2184
|
/**
|
|
2101
2185
|
* Validate a scenario spec: a ghost path of future prices (bars or API data)
|
|
2102
2186
|
* plus optional cone settings. Invalid entries are dropped, never thrown.
|
|
@@ -2210,61 +2294,6 @@ export function normalizeRiskPlan(spec) {
|
|
|
2210
2294
|
};
|
|
2211
2295
|
}
|
|
2212
2296
|
|
|
2213
|
-
/* ------------------------------------------------------------------ *
|
|
2214
|
-
* Bar-walk narrator — a timeline of what happened
|
|
2215
|
-
* ------------------------------------------------------------------ */
|
|
2216
|
-
|
|
2217
|
-
/**
|
|
2218
|
-
* Turn a bar window into an ordered story: the annotation events (pivot
|
|
2219
|
-
* highs/lows, volume spikes, gaps, RSI divergences) plus derived **legs** —
|
|
2220
|
-
* the move between consecutive opposite pivots ("+12.4% over 38 bars").
|
|
2221
|
-
* The timeline drives the bar-walk player and any caption UI.
|
|
2222
|
-
*
|
|
2223
|
-
* @param {Bar[]} bars full dataset
|
|
2224
|
-
* @param {number} i0 first index of the window
|
|
2225
|
-
* @param {number} i1 last index of the window
|
|
2226
|
-
* @param {{pivot?: number, volMult?: number, gapMult?: number, rsiPeriod?: number}} [opts]
|
|
2227
|
-
* pivot window defaults to 8 (denser than the annotations overlay's 20)
|
|
2228
|
-
* @returns {{i: number, time: number, type: string, side: string, note: string,
|
|
2229
|
-
* legPct?: number, legBars?: number}[]} sorted by index, capped at 60
|
|
2230
|
-
*/
|
|
2231
|
-
export function narrateWindow(bars, i0, i1, opts = {}) {
|
|
2232
|
-
if (!bars.length || i0 < 0 || i1 < i0 || i1 >= bars.length) return [];
|
|
2233
|
-
const rsi = calcRSI(bars.map((b) => b.close), Math.min(50, Math.max(2, +opts.rsiPeriod || 14)));
|
|
2234
|
-
const ann = detectAnnotations(bars, i0, i1, rsi, {
|
|
2235
|
-
pivot: opts.pivot ?? 8,
|
|
2236
|
-
volMult: opts.volMult,
|
|
2237
|
-
gapMult: opts.gapMult,
|
|
2238
|
-
});
|
|
2239
|
-
// legs: the move between consecutive opposite pivots, stamped at the
|
|
2240
|
-
// ending pivot so a walk player can speak it as it arrives
|
|
2241
|
-
const pivots = ann
|
|
2242
|
-
.filter((a) => a.type === 'pivothigh' || a.type === 'pivotlow')
|
|
2243
|
-
.sort((a, b) => a.i - b.i);
|
|
2244
|
-
const legs = [];
|
|
2245
|
-
for (let k = 1; k < pivots.length; k++) {
|
|
2246
|
-
const a = pivots[k - 1];
|
|
2247
|
-
const b = pivots[k];
|
|
2248
|
-
if (a.type === b.type) continue;
|
|
2249
|
-
const pa = a.type === 'pivothigh' ? bars[a.i].high : bars[a.i].low;
|
|
2250
|
-
const pb = b.type === 'pivothigh' ? bars[b.i].high : bars[b.i].low;
|
|
2251
|
-
if (!(pa > 0) || !Number.isFinite(pb)) continue;
|
|
2252
|
-
const pct = ((pb - pa) / pa) * 100;
|
|
2253
|
-
legs.push({
|
|
2254
|
-
type: 'leg',
|
|
2255
|
-
side: pct >= 0 ? 'high' : 'low',
|
|
2256
|
-
i: b.i,
|
|
2257
|
-
note: `${pct >= 0 ? '+' : ''}${pct.toFixed(1)}% over ${b.i - a.i} bars`,
|
|
2258
|
-
legPct: Math.round(pct * 100) / 100,
|
|
2259
|
-
legBars: b.i - a.i,
|
|
2260
|
-
});
|
|
2261
|
-
}
|
|
2262
|
-
return [...ann, ...legs]
|
|
2263
|
-
.sort((a, b) => a.i - b.i)
|
|
2264
|
-
.slice(0, 60)
|
|
2265
|
-
.map((e) => ({ ...e, time: bars[e.i].time }));
|
|
2266
|
-
}
|
|
2267
|
-
|
|
2268
2297
|
/* ------------------------------------------------------------------ *
|
|
2269
2298
|
* Delta brush — selection statistics
|
|
2270
2299
|
* ------------------------------------------------------------------ */
|
|
@@ -2310,165 +2339,6 @@ export function brushStats(bars, i0, i1) {
|
|
|
2310
2339
|
};
|
|
2311
2340
|
}
|
|
2312
2341
|
|
|
2313
|
-
/* ------------------------------------------------------------------ *
|
|
2314
|
-
* Story mode — guided tours of chart state
|
|
2315
|
-
* ------------------------------------------------------------------ */
|
|
2316
|
-
|
|
2317
|
-
/** Smoothest cheap easing for viewport pans: slow in, slow out. */
|
|
2318
|
-
export function easeInOutCubic(t) {
|
|
2319
|
-
const x = clamp(+t || 0, 0, 1);
|
|
2320
|
-
return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2;
|
|
2321
|
-
}
|
|
2322
|
-
|
|
2323
|
-
/**
|
|
2324
|
-
* Validate one story scene. Every field is optional except that a scene
|
|
2325
|
-
* must be an object; omitted fields simply don't change that aspect of
|
|
2326
|
-
* the chart when played. `scenario`/`riskPlan` use a 'clear' sentinel for
|
|
2327
|
-
* explicit "remove it" (null input means clear too when the KEY is present).
|
|
2328
|
-
*
|
|
2329
|
-
* { title: 'The breakout', note: 'What happened…',
|
|
2330
|
-
* range: { from, to }, // times (s or ms) — the camera pans there
|
|
2331
|
-
* indicators: 'sma:20 rsi:14', // optional indicator string
|
|
2332
|
-
* type: 'candles', // optional series type
|
|
2333
|
-
* overlays: [...], // optional zones/levels (normalizeOverlays)
|
|
2334
|
-
* scenario: {...} | null, // set / clear a scenario
|
|
2335
|
-
* riskPlan: {...} | null, // set / clear a risk plan
|
|
2336
|
-
* dwell: 2200 } // ms to hold after the pan (500–30000)
|
|
2337
|
-
*
|
|
2338
|
-
* @returns {object|null} normalized scene, or null for non-objects
|
|
2339
|
-
*/
|
|
2340
|
-
export function normalizeScene(scene) {
|
|
2341
|
-
if (!scene || typeof scene !== 'object') return null;
|
|
2342
|
-
const out = {
|
|
2343
|
-
title: scene.title != null ? String(scene.title).slice(0, 60) : '',
|
|
2344
|
-
note: scene.note != null ? String(scene.note).slice(0, 200) : '',
|
|
2345
|
-
dwell: clamp(Math.round(+scene.dwell || 2200), 500, 30000),
|
|
2346
|
-
};
|
|
2347
|
-
if (scene.range && Number.isFinite(+scene.range.from) && Number.isFinite(+scene.range.to)) {
|
|
2348
|
-
out.range = { from: +scene.range.from, to: +scene.range.to };
|
|
2349
|
-
}
|
|
2350
|
-
if (scene.indicators != null) {
|
|
2351
|
-
const s = String(scene.indicators).trim();
|
|
2352
|
-
if (s) out.indicators = s.slice(0, 200);
|
|
2353
|
-
}
|
|
2354
|
-
if (scene.type != null && SERIES_TYPES.includes(scene.type)) out.type = scene.type;
|
|
2355
|
-
if (scene.overlays != null) {
|
|
2356
|
-
const ovs = normalizeOverlays(scene.overlays);
|
|
2357
|
-
if (ovs.length) out.overlays = ovs;
|
|
2358
|
-
}
|
|
2359
|
-
if ('scenario' in scene) {
|
|
2360
|
-
if (scene.scenario == null) out.scenario = 'clear';
|
|
2361
|
-
else {
|
|
2362
|
-
const sc = normalizeScenario(scene.scenario);
|
|
2363
|
-
if (sc) out.scenario = sc;
|
|
2364
|
-
}
|
|
2365
|
-
}
|
|
2366
|
-
if ('riskPlan' in scene) {
|
|
2367
|
-
if (scene.riskPlan == null) out.riskPlan = 'clear';
|
|
2368
|
-
else {
|
|
2369
|
-
const rp = normalizeRiskPlan(scene.riskPlan);
|
|
2370
|
-
if (rp) out.riskPlan = rp;
|
|
2371
|
-
}
|
|
2372
|
-
}
|
|
2373
|
-
return out;
|
|
2374
|
-
}
|
|
2375
|
-
|
|
2376
|
-
/**
|
|
2377
|
-
* Validate a whole story: normalize each scene, drop junk, cap at 20.
|
|
2378
|
-
* @returns {object[]} possibly empty
|
|
2379
|
-
*/
|
|
2380
|
-
export function sceneList(story) {
|
|
2381
|
-
if (!Array.isArray(story)) return [];
|
|
2382
|
-
const out = [];
|
|
2383
|
-
for (const s of story) {
|
|
2384
|
-
const n = normalizeScene(s);
|
|
2385
|
-
if (n) out.push(n);
|
|
2386
|
-
if (out.length >= 20) break;
|
|
2387
|
-
}
|
|
2388
|
-
return out;
|
|
2389
|
-
}
|
|
2390
|
-
|
|
2391
|
-
/* ------------------------------------------------------------------ *
|
|
2392
|
-
* Co-view presence — peer viewport tracking with TTL expiry
|
|
2393
|
-
* ------------------------------------------------------------------ */
|
|
2394
|
-
|
|
2395
|
-
/**
|
|
2396
|
-
* Tracks other charts viewing the same room: last-sighting timestamps per
|
|
2397
|
-
* peer plus the viewport each one is looking at. Pure bookkeeping — the
|
|
2398
|
-
* transport (BroadcastChannel, WebSocket, …) lives in the component/app.
|
|
2399
|
-
*
|
|
2400
|
-
* Peers expire `ttl` ms after their last sighting, so a closed tab fades
|
|
2401
|
-
* out of the room without an explicit goodbye.
|
|
2402
|
-
*/
|
|
2403
|
-
export class PresenceTracker {
|
|
2404
|
-
/** @param {number} [ttl=12000] ms a peer survives without a sighting */
|
|
2405
|
-
constructor(ttl = 12000) {
|
|
2406
|
-
this.ttl = Math.max(1000, +ttl || 12000);
|
|
2407
|
-
/** @type {Map<string, {id: string, name: string|null, range: {from:number,to:number}|null, at: number}>} */
|
|
2408
|
-
this.peers = new Map();
|
|
2409
|
-
}
|
|
2410
|
-
|
|
2411
|
-
/**
|
|
2412
|
-
* Record a sighting. `patch.range` ({from,to} times) is validated and
|
|
2413
|
-
* normalized; a sighting without a range keeps the previous one.
|
|
2414
|
-
* @returns {boolean} true when this sighting is a join (new peer)
|
|
2415
|
-
*/
|
|
2416
|
-
track(id, patch = {}, now = Date.now()) {
|
|
2417
|
-
if (!id || typeof id !== 'string') return false;
|
|
2418
|
-
const existing = this.peers.get(id);
|
|
2419
|
-
if (existing) {
|
|
2420
|
-
if (patch && patch.range) {
|
|
2421
|
-
const f = +patch.range.from;
|
|
2422
|
-
const t = +patch.range.to;
|
|
2423
|
-
if (Number.isFinite(f) && Number.isFinite(t)) {
|
|
2424
|
-
existing.range = { from: Math.min(f, t), to: Math.max(f, t) };
|
|
2425
|
-
}
|
|
2426
|
-
}
|
|
2427
|
-
if (patch && patch.name != null) existing.name = String(patch.name).slice(0, 24) || null;
|
|
2428
|
-
existing.at = now;
|
|
2429
|
-
return false;
|
|
2430
|
-
}
|
|
2431
|
-
const f = patch && patch.range ? +patch.range.from : NaN;
|
|
2432
|
-
const t = patch && patch.range ? +patch.range.to : NaN;
|
|
2433
|
-
this.peers.set(id, {
|
|
2434
|
-
id,
|
|
2435
|
-
name: patch && patch.name != null ? (String(patch.name).slice(0, 24) || null) : null,
|
|
2436
|
-
range: Number.isFinite(f) && Number.isFinite(t)
|
|
2437
|
-
? { from: Math.min(f, t), to: Math.max(f, t) }
|
|
2438
|
-
: null,
|
|
2439
|
-
at: now,
|
|
2440
|
-
});
|
|
2441
|
-
return true;
|
|
2442
|
-
}
|
|
2443
|
-
|
|
2444
|
-
/** @returns {object|null} the removed peer entry, or null when unknown */
|
|
2445
|
-
drop(id) {
|
|
2446
|
-
const p = this.peers.get(id);
|
|
2447
|
-
this.peers.delete(id);
|
|
2448
|
-
return p || null;
|
|
2449
|
-
}
|
|
2450
|
-
|
|
2451
|
-
/** Expire peers not seen within the ttl.
|
|
2452
|
-
* @returns {object[]} the peer entries that left */
|
|
2453
|
-
sweep(now = Date.now()) {
|
|
2454
|
-
const left = [];
|
|
2455
|
-
for (const [id, p] of this.peers) {
|
|
2456
|
-
if (now - p.at > this.ttl) {
|
|
2457
|
-
this.peers.delete(id);
|
|
2458
|
-
left.push(p);
|
|
2459
|
-
}
|
|
2460
|
-
}
|
|
2461
|
-
return left;
|
|
2462
|
-
}
|
|
2463
|
-
|
|
2464
|
-
/** @returns {{id: string, name: string|null, range: object|null, at: number}[]} copies, oldest sighting first */
|
|
2465
|
-
list() {
|
|
2466
|
-
return [...this.peers.values()]
|
|
2467
|
-
.sort((a, b) => a.at - b.at)
|
|
2468
|
-
.map((p) => ({ ...p, range: p.range ? { ...p.range } : null }));
|
|
2469
|
-
}
|
|
2470
|
-
}
|
|
2471
|
-
|
|
2472
2342
|
/* ------------------------------------------------------------------ *
|
|
2473
2343
|
* AI-ready window summary
|
|
2474
2344
|
* ------------------------------------------------------------------ */
|
|
@@ -2718,165 +2588,3 @@ export function decodeStateQuery(str) {
|
|
|
2718
2588
|
return state;
|
|
2719
2589
|
}
|
|
2720
2590
|
|
|
2721
|
-
/* ------------------------------------------------------------------ *
|
|
2722
|
-
* AI agent interface — the chart as a tool surface
|
|
2723
|
-
* ------------------------------------------------------------------ */
|
|
2724
|
-
|
|
2725
|
-
/**
|
|
2726
|
-
* Tool manifest for LLM/agent control of a chart. Tools map 1:1 onto the
|
|
2727
|
-
* public element API; every op through applyChartOps is validated before it
|
|
2728
|
-
* touches the chart (LLM output is untrusted input).
|
|
2729
|
-
*/
|
|
2730
|
-
export const AI_TOOLS = [
|
|
2731
|
-
{
|
|
2732
|
-
tool: 'get_data_window',
|
|
2733
|
-
description:
|
|
2734
|
-
'Read the visible chart window: OHLC stats, trend (slope + fit), volatility percentile, indicator snapshots, detected patterns. Returns structured fields plus a markdown summary.',
|
|
2735
|
-
args: {},
|
|
2736
|
-
},
|
|
2737
|
-
{
|
|
2738
|
-
tool: 'set_indicators',
|
|
2739
|
-
description:
|
|
2740
|
-
'Replace the indicators. Tokens: sma:20 ema:50 bb:20 vwap supertrend:10/3 donchian:20 keltner:20 rsi:14 macd:12/26/9 stoch:14/3 atr:14 obv cci:20 wr:14 volume, @hexcolor suffixes, or WickScript expressions like expr:{close - sma(close,20)} / pexpr:{rsi(close,14)}. Empty string clears all.',
|
|
2741
|
-
args: { indicators: 'string — space/comma-separated tokens' },
|
|
2742
|
-
},
|
|
2743
|
-
{
|
|
2744
|
-
tool: 'set_overlays',
|
|
2745
|
-
description:
|
|
2746
|
-
'Draw zones & levels behind the candles (support/resistance, supply/demand). Zone: {type:"zone", from?, to?, priceFrom, priceTo, color?, alpha?, label?} — omit `to` (or pass null) to extend into future space past the last bar. Level: {type:"level", price, color?, dash?, label?}. Invalid entries are dropped.',
|
|
2747
|
-
args: { overlays: 'array of overlay objects' },
|
|
2748
|
-
},
|
|
2749
|
-
{ tool: 'clear_overlays', description: 'Remove all overlays.', args: {} },
|
|
2750
|
-
{
|
|
2751
|
-
tool: 'add_alert',
|
|
2752
|
-
description:
|
|
2753
|
-
'Price alert {price, direction:"above"|"below"|"cross"} or scripted predicate {when:"<WickScript>"} — e.g. when:"crossup(rsi(close,14), 30)" or when:"volume > sma(volume,20) * 3". Fires wick:alert.',
|
|
2754
|
-
args: {},
|
|
2755
|
-
},
|
|
2756
|
-
{
|
|
2757
|
-
tool: 'set_view',
|
|
2758
|
-
description: 'Set the visible time range (unix seconds or ms).',
|
|
2759
|
-
args: { from: 'timestamp', to: 'timestamp' },
|
|
2760
|
-
},
|
|
2761
|
-
{ tool: 'reset_view', description: 'Fit all loaded data.', args: {} },
|
|
2762
|
-
{
|
|
2763
|
-
tool: 'set_type',
|
|
2764
|
-
description: 'Change the series type.',
|
|
2765
|
-
args: { type: '"candles" | "line" | "area" | "bars" | "hollow" | "heikin"' },
|
|
2766
|
-
},
|
|
2767
|
-
{
|
|
2768
|
-
tool: 'set_volshading',
|
|
2769
|
-
description: 'Volatility-regime background shading (calm/normal/hot percentiles).',
|
|
2770
|
-
args: { enabled: 'boolean', low: 'percentile 0–98 (default 30)', high: 'percentile (default 70)' },
|
|
2771
|
-
},
|
|
2772
|
-
];
|
|
2773
|
-
|
|
2774
|
-
/**
|
|
2775
|
-
* Compact system prompt for agent control: paste into any LLM alongside the
|
|
2776
|
-
* tool manifest. The model answers with a JSON array of {tool, args} ops.
|
|
2777
|
-
* @returns {string}
|
|
2778
|
-
*/
|
|
2779
|
-
export function aiPromptText() {
|
|
2780
|
-
const lines = AI_TOOLS.map(
|
|
2781
|
-
(t) => `- ${t.tool}${Object.keys(t.args).length ? '(' + Object.keys(t.args).join(', ') + ')' : '()'}: ${t.description}`
|
|
2782
|
-
);
|
|
2783
|
-
return [
|
|
2784
|
-
'You are controlling a WickChart financial charting element through tool calls.',
|
|
2785
|
-
'Reply with ONLY a JSON array of operations to apply, each {"tool": name, "args": {...}}.',
|
|
2786
|
-
'Use get_data_window first when you need to see the chart before deciding.',
|
|
2787
|
-
'Available tools:',
|
|
2788
|
-
...lines,
|
|
2789
|
-
].join('\n');
|
|
2790
|
-
}
|
|
2791
|
-
|
|
2792
|
-
const AI_CHART_TYPES = SERIES_TYPES;
|
|
2793
|
-
|
|
2794
|
-
/**
|
|
2795
|
-
* Validate + apply a list of {tool, args} ops (typically LLM output) to a
|
|
2796
|
-
* chart-like target. Ops are whitelisted and their args validated — an op
|
|
2797
|
-
* never throws; it returns {ok: false, error} instead so the agent can
|
|
2798
|
-
* self-correct. Target contract: getDataWindow(), setAttribute(k, v),
|
|
2799
|
-
* setOverlays(list), clearOverlays(), addAlert(a), setVisibleRange(r),
|
|
2800
|
-
* fit(), and (static) _registry() for indicator name checks.
|
|
2801
|
-
* @param {object} target chart element (or test double)
|
|
2802
|
-
* @param {any} ops
|
|
2803
|
-
* @returns {Array<{ok: boolean, tool?: string, result?: any, error?: string}>}
|
|
2804
|
-
*/
|
|
2805
|
-
export function applyChartOps(target, ops) {
|
|
2806
|
-
if (!target) return [{ ok: false, error: 'no target' }];
|
|
2807
|
-
if (!Array.isArray(ops)) return [{ ok: false, error: 'ops must be an array of {tool, args} objects' }];
|
|
2808
|
-
return ops.map((op) => {
|
|
2809
|
-
if (!op || typeof op !== 'object' || Array.isArray(op)) {
|
|
2810
|
-
return { ok: false, error: 'each op must be an object: {tool, args}' };
|
|
2811
|
-
}
|
|
2812
|
-
const tool = String(op.tool || '');
|
|
2813
|
-
const args = op.args && typeof op.args === 'object' && !Array.isArray(op.args) ? op.args : {};
|
|
2814
|
-
const fail = (error) => ({ ok: false, tool, error });
|
|
2815
|
-
try {
|
|
2816
|
-
switch (tool) {
|
|
2817
|
-
case 'get_data_window':
|
|
2818
|
-
return { ok: true, tool, result: target.getDataWindow() };
|
|
2819
|
-
case 'set_indicators': {
|
|
2820
|
-
if (typeof args.indicators !== 'string') return fail('args.indicators must be a string');
|
|
2821
|
-
const reg = target.constructor && target.constructor._registry ? target.constructor._registry() : null;
|
|
2822
|
-
const parsed = parseIndicators(args.indicators, reg);
|
|
2823
|
-
if (parsed.unknown.length) {
|
|
2824
|
-
return fail(`unknown indicators: ${parsed.unknown.join(', ')}`);
|
|
2825
|
-
}
|
|
2826
|
-
target.setAttribute('indicators', args.indicators);
|
|
2827
|
-
return { ok: true, tool, result: { applied: args.indicators || '(cleared)' } };
|
|
2828
|
-
}
|
|
2829
|
-
case 'set_overlays': {
|
|
2830
|
-
if (!Array.isArray(args.overlays)) return fail('args.overlays must be an array');
|
|
2831
|
-
const norm = normalizeOverlays(args.overlays);
|
|
2832
|
-
if (!norm.length) return fail('no valid overlays in args.overlays');
|
|
2833
|
-
const ids = target.setOverlays(args.overlays);
|
|
2834
|
-
return { ok: true, tool, result: { applied: ids.length, dropped: args.overlays.length - ids.length } };
|
|
2835
|
-
}
|
|
2836
|
-
case 'clear_overlays':
|
|
2837
|
-
target.clearOverlays();
|
|
2838
|
-
return { ok: true, tool, result: { cleared: true } };
|
|
2839
|
-
case 'add_alert': {
|
|
2840
|
-
if (!isNum(args.price) && typeof args.when !== 'string') {
|
|
2841
|
-
return fail('args needs either price (number) or when (WickScript string)');
|
|
2842
|
-
}
|
|
2843
|
-
const id = target.addAlert(args);
|
|
2844
|
-
return id ? { ok: true, tool, result: { id } } : fail('invalid alert (bad predicate?)');
|
|
2845
|
-
}
|
|
2846
|
-
case 'set_view': {
|
|
2847
|
-
const r = {};
|
|
2848
|
-
if (isNum(args.from)) r.from = normMs(args.from);
|
|
2849
|
-
if (isNum(args.to)) r.to = normMs(args.to);
|
|
2850
|
-
if (!('from' in r) && !('to' in r)) return fail('args needs from and/or to timestamps');
|
|
2851
|
-
target.setVisibleRange(r);
|
|
2852
|
-
return { ok: true, tool, result: r };
|
|
2853
|
-
}
|
|
2854
|
-
case 'reset_view':
|
|
2855
|
-
target.fit();
|
|
2856
|
-
return { ok: true, tool, result: { reset: true } };
|
|
2857
|
-
case 'set_type': {
|
|
2858
|
-
if (!AI_CHART_TYPES.includes(args.type)) {
|
|
2859
|
-
return fail(`args.type must be one of ${AI_CHART_TYPES.join(' | ')}`);
|
|
2860
|
-
}
|
|
2861
|
-
target.setAttribute('type', args.type);
|
|
2862
|
-
return { ok: true, tool, result: { type: args.type } };
|
|
2863
|
-
}
|
|
2864
|
-
case 'set_volshading': {
|
|
2865
|
-
if (args.enabled === false) {
|
|
2866
|
-
target.setAttribute('volshading', 'false');
|
|
2867
|
-
return { ok: true, tool, result: { enabled: false } };
|
|
2868
|
-
}
|
|
2869
|
-
const p = parseVolShading(
|
|
2870
|
-
isNum(args.low) && isNum(args.high) ? `${args.low}/${args.high}` : ''
|
|
2871
|
-
);
|
|
2872
|
-
target.setAttribute('volshading', `${p.p1}/${p.p2}`);
|
|
2873
|
-
return { ok: true, tool, result: { enabled: true, low: p.p1, high: p.p2 } };
|
|
2874
|
-
}
|
|
2875
|
-
default:
|
|
2876
|
-
return fail(`unknown tool "${tool}"`);
|
|
2877
|
-
}
|
|
2878
|
-
} catch (err) {
|
|
2879
|
-
return fail(err && err.message ? err.message : String(err));
|
|
2880
|
-
}
|
|
2881
|
-
});
|
|
2882
|
-
}
|