wickchart 1.0.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/core.js CHANGED
@@ -1043,6 +1043,17 @@ function tokenizeScript(src) {
1043
1043
  i += m[0].length;
1044
1044
  continue;
1045
1045
  }
1046
+ const two = src.slice(i, i + 2);
1047
+ if (two === '>=' || two === '<=' || two === '==' || two === '!=') {
1048
+ toks.push({ t: 'op', v: two });
1049
+ i += 2;
1050
+ continue;
1051
+ }
1052
+ if (c === '>' || c === '<') {
1053
+ toks.push({ t: 'op', v: c });
1054
+ i++;
1055
+ continue;
1056
+ }
1046
1057
  if (c === '+' || c === '-' || c === '*' || c === '/' || c === '%') {
1047
1058
  toks.push({ t: 'op', v: c });
1048
1059
  i++;
@@ -1066,6 +1077,17 @@ function parseScript(src) {
1066
1077
  let p = 0;
1067
1078
  const peek = () => toks[p];
1068
1079
 
1080
+ const CMP_OPS = ['>', '<', '>=', '<=', '==', '!='];
1081
+
1082
+ /** Comparisons bind loosest (a > b + 1); chains associate left, each 1/0. */
1083
+ function parseCmp(depth) {
1084
+ let l = parseAdd(depth);
1085
+ while (peek() && peek().t === 'op' && CMP_OPS.includes(peek().v)) {
1086
+ const op = toks[p++].v;
1087
+ l = { type: 'bin', op, l, r: parseAdd(depth) };
1088
+ }
1089
+ return l;
1090
+ }
1069
1091
  function parseAdd(depth) {
1070
1092
  let l = parseMul(depth);
1071
1093
  while (peek() && peek().t === 'op' && (peek().v === '+' || peek().v === '-')) {
@@ -1103,10 +1125,10 @@ function parseScript(src) {
1103
1125
  p++;
1104
1126
  const args = [];
1105
1127
  if (peek() && peek().t !== ')') {
1106
- args.push(parseAdd(depth));
1128
+ args.push(parseCmp(depth));
1107
1129
  while (peek() && peek().t === ',') {
1108
1130
  p++;
1109
- args.push(parseAdd(depth));
1131
+ args.push(parseCmp(depth));
1110
1132
  }
1111
1133
  }
1112
1134
  const close = toks[p++];
@@ -1117,7 +1139,7 @@ function parseScript(src) {
1117
1139
  return { type: 'var', name };
1118
1140
  }
1119
1141
  if (t.t === '(') {
1120
- const e = parseAdd(depth);
1142
+ const e = parseCmp(depth);
1121
1143
  const close = toks[p++];
1122
1144
  if (!close || close.t !== ')') throw scriptErr('missing ")"');
1123
1145
  return e;
@@ -1125,7 +1147,7 @@ function parseScript(src) {
1125
1147
  throw scriptErr(`unexpected token "${t.t === 'op' ? t.v : t.t}"`);
1126
1148
  }
1127
1149
 
1128
- const ast = parseAdd(0);
1150
+ const ast = parseCmp(0);
1129
1151
  if (p < toks.length) throw scriptErr('unexpected trailing input');
1130
1152
  validateScriptNode(ast);
1131
1153
  return ast;
@@ -1179,6 +1201,20 @@ function binOp(op, a, b) {
1179
1201
  case '*': return a * b;
1180
1202
  case '/': return a / b;
1181
1203
  case '%': return a % b;
1204
+ // comparisons yield 1/0; NaN operands stay NaN so warm-up gaps survive
1205
+ case '>':
1206
+ case '<':
1207
+ case '>=':
1208
+ case '<=':
1209
+ case '==':
1210
+ case '!=':
1211
+ if (!Number.isFinite(a) || !Number.isFinite(b)) return NaN;
1212
+ if (op === '>') return a > b ? 1 : 0;
1213
+ if (op === '<') return a < b ? 1 : 0;
1214
+ if (op === '>=') return a >= b ? 1 : 0;
1215
+ if (op === '<=') return a <= b ? 1 : 0;
1216
+ if (op === '==') return a === b ? 1 : 0;
1217
+ return a !== b ? 1 : 0;
1182
1218
  }
1183
1219
  return NaN;
1184
1220
  }
@@ -1354,6 +1390,34 @@ export function checkAlertCross(alert, prevPrice, price) {
1354
1390
  return (prevPrice <= p && price > p) || (prevPrice >= p && price < p);
1355
1391
  }
1356
1392
 
1393
+ /**
1394
+ * Boolean truth series for a WickScript predicate: any numeric expression
1395
+ * where nonzero & finite counts as true (NaN / 0 / ±Infinity → false).
1396
+ * Powers scripted alerts — `addAlert({ when: 'crossup(close, sma(close,50))' })`.
1397
+ * @param {object|string} compiled compiled predicate (or raw source)
1398
+ * @param {Bar[]} bars
1399
+ * @returns {boolean[]}
1400
+ */
1401
+ export function predicateTrueSeries(compiled, bars) {
1402
+ const vals = evalScript(compiled, bars);
1403
+ return vals.map((v) => Number.isFinite(v) && v !== 0);
1404
+ }
1405
+
1406
+ /**
1407
+ * Edge-triggered step for a scripted alert. `armed` starts true; a rising
1408
+ * edge (false → true) fires once and disarms; a true → false transition
1409
+ * re-arms, so `once: false` alerts can fire again on the next edge while
1410
+ * `once: true` alerts are removed after their first fire.
1411
+ * @param {boolean} armed
1412
+ * @param {boolean} curTrue
1413
+ * @returns {{ fire: boolean, armed: boolean }}
1414
+ */
1415
+ export function scriptAlertStep(armed, curTrue) {
1416
+ if (curTrue && armed) return { fire: true, armed: false };
1417
+ if (!curTrue && !armed) return { fire: false, armed: true };
1418
+ return { fire: false, armed };
1419
+ }
1420
+
1357
1421
  /* ------------------------------------------------------------------ *
1358
1422
  * Visible-range statistics
1359
1423
  * ------------------------------------------------------------------ */
@@ -1528,10 +1592,541 @@ export function parseVolShading(val) {
1528
1592
  let p1 = isNum(parts[0]) ? clamp(parts[0], 0, 98) : 30;
1529
1593
  const p2 = isNum(parts[1]) ? clamp(parts[1], 2, 100) : 70;
1530
1594
  p1 = clamp(p1, 0, p2 - 2);
1531
- const period = isNum(parts[2]) ? Math.round(clamp(parts[2], 2, 500)) : 20;
1595
+ const period = isNum(parts[2]) ? clamp(parts[2], 2, 500) : 20;
1532
1596
  return { p1, p2, period };
1533
1597
  }
1534
1598
 
1599
+ /* ------------------------------------------------------------------ *
1600
+ * Server-side overlays (zones & levels)
1601
+ * ------------------------------------------------------------------ */
1602
+
1603
+ /** Normalize a timestamp to milliseconds (bar times and query times both
1604
+ * auto-detect seconds — anything below 1e12 is treated as seconds). */
1605
+ const normMs = (t) => (t < 1e12 ? t * 1000 : t);
1606
+
1607
+ /**
1608
+ * Index of the last bar whose time is <= `t` (binary search). Clamps to
1609
+ * [0, n-1]: a time before the first bar → 0, past the last bar → n-1.
1610
+ * Empty bars or a non-numeric time → null.
1611
+ * @param {object[]} bars normalized bar objects
1612
+ * @param {number} t timestamp in ms or s
1613
+ * @returns {number|null}
1614
+ */
1615
+ export function barIndexForTime(bars, t) {
1616
+ if (!Array.isArray(bars) || !bars.length || !isNum(t)) return null;
1617
+ const scale = normMs(bars[bars.length - 1].time) / bars[bars.length - 1].time;
1618
+ const target = normMs(t);
1619
+ let lo = 0;
1620
+ let hi = bars.length - 1;
1621
+ if (target <= bars[0].time * scale) return 0;
1622
+ if (target >= bars[hi].time * scale) return hi;
1623
+ while (lo < hi) {
1624
+ const mid = (lo + hi + 1) >> 1;
1625
+ if (bars[mid].time * scale <= target) lo = mid;
1626
+ else hi = mid - 1;
1627
+ }
1628
+ return lo;
1629
+ }
1630
+
1631
+ /**
1632
+ * Validate & normalize server-side overlay definitions. Overlays are data
1633
+ * from an API, so invalid entries are silently dropped — never thrown.
1634
+ *
1635
+ * zone: { type:'zone', from?: time|null, to?: time|null, priceFrom, priceTo,
1636
+ * color?, alpha?, border?, label?, id? } — a time×price rectangle.
1637
+ * `from`/`to` omitted (or null) anchor to the left/right chart edge;
1638
+ * a zone with no `to` extends into future space past the last bar.
1639
+ * level: { type:'level', price, from?, to?, color?, width?, dash?, label?, id? }
1640
+ * — a horizontal price line, full width by default.
1641
+ *
1642
+ * Colors go through safeColor(); `alpha` clamps to [0.02, 0.8] (default 0.22).
1643
+ * @param {any} list
1644
+ * @returns {object[]} normalized overlays (possibly empty)
1645
+ */
1646
+ export function normalizeOverlays(list) {
1647
+ if (!Array.isArray(list)) return [];
1648
+ const out = [];
1649
+ let n = 0;
1650
+ for (const raw of list) {
1651
+ if (!raw || typeof raw !== 'object') continue;
1652
+ let type = null;
1653
+ if (raw.type === 'zone') type = 'zone';
1654
+ else if (raw.type === 'level') type = 'level';
1655
+ if (!type) continue;
1656
+ const id = raw.id != null ? String(raw.id).slice(0, 64) : 'ov-' + ++n;
1657
+ const label = raw.label != null ? String(raw.label).slice(0, 40) : '';
1658
+ // palette keys first ('up' is 2 letters and would fail the generic name check)
1659
+ const rawColor = raw.color != null ? String(raw.color).trim() : '';
1660
+ const color = /^(up|down|accent)$/i.test(rawColor)
1661
+ ? rawColor.toLowerCase()
1662
+ : safeColor(rawColor) || null;
1663
+ const from = isNum(raw.from) ? raw.from : null;
1664
+ const to = isNum(raw.to) ? raw.to : null;
1665
+ if (type === 'zone') {
1666
+ if (!isNum(raw.priceFrom) || !isNum(raw.priceTo)) continue;
1667
+ out.push({
1668
+ id,
1669
+ type,
1670
+ from,
1671
+ to,
1672
+ priceFrom: Math.min(raw.priceFrom, raw.priceTo),
1673
+ priceTo: Math.max(raw.priceFrom, raw.priceTo),
1674
+ color,
1675
+ alpha: isNum(raw.alpha) ? clamp(raw.alpha, 0.02, 0.8) : 0.22,
1676
+ border: raw.border !== false,
1677
+ label,
1678
+ });
1679
+ } else {
1680
+ if (!isNum(raw.price)) continue;
1681
+ out.push({
1682
+ id,
1683
+ type,
1684
+ from,
1685
+ to,
1686
+ price: raw.price,
1687
+ color,
1688
+ width: isNum(raw.width) ? clamp(raw.width, 1, 4) : 1,
1689
+ dash: raw.dash === true,
1690
+ label,
1691
+ });
1692
+ }
1693
+ }
1694
+ return out;
1695
+ }
1696
+
1697
+ /**
1698
+ * Resolve an overlay color against the active palette: 'up'/'down'/'accent'
1699
+ * map to theme colors, anything else passes through safeColor(), and invalid
1700
+ * or missing values fall back to the accent color.
1701
+ * @param {any} raw
1702
+ * @param {object} pal active theme palette
1703
+ * @returns {string} a concrete CSS color
1704
+ */
1705
+ export function resolveOverlayColor(raw, pal) {
1706
+ if (typeof raw === 'string') {
1707
+ const key = raw.trim().toLowerCase();
1708
+ if (key === 'up' || key === 'down' || key === 'accent') return pal[key];
1709
+ const c = safeColor(raw);
1710
+ if (c) return c;
1711
+ }
1712
+ return pal.accent;
1713
+ }
1714
+
1715
+ /* ------------------------------------------------------------------ *
1716
+ * Scenario mode — ghost paths + volatility cones
1717
+ * ------------------------------------------------------------------ */
1718
+
1719
+ /**
1720
+ * σ-cone projection from realized per-bar volatility: price bands widening
1721
+ * with √h (GBM-style, exp(±z·σ·√h)) over `horizon` future bars.
1722
+ * @param {number} lastClose anchor price (bar 0)
1723
+ * @param {number} volPerBar per-bar stddev of log returns (from calcRealizedVol)
1724
+ * @param {number} horizon future bars (clamped 1–500, default 48)
1725
+ * @param {number[]} [levels] σ multipliers, e.g. [1, 2] (each clamped to 0–5)
1726
+ * @returns {{horizon: number, levels: number[], bands: Record<string, {up: number[], down: number[]}>}}
1727
+ * bands[z].up/.down are arrays indexed by h = 0…horizon ([0] === lastClose)
1728
+ */
1729
+ export function calcVolCone(lastClose, volPerBar, horizon, levels) {
1730
+ const zs = (Array.isArray(levels) && levels.length ? levels : [1, 2])
1731
+ .map((z) => +z)
1732
+ .filter((z) => Number.isFinite(z) && z > 0 && z <= 5)
1733
+ .sort((a, b) => a - b);
1734
+ const lv = zs.length ? zs : [1];
1735
+ const H = Math.max(1, Math.min(500, Math.round(+horizon || 48)));
1736
+ const c = +lastClose;
1737
+ const v = +volPerBar;
1738
+ const bands = {};
1739
+ const flat = !Number.isFinite(c) || c <= 0 || !Number.isFinite(v) || v < 0;
1740
+ for (const z of lv) {
1741
+ const up = new Array(H + 1);
1742
+ const down = new Array(H + 1);
1743
+ for (let h = 0; h <= H; h++) {
1744
+ if (flat) {
1745
+ up[h] = c || 0;
1746
+ down[h] = c || 0;
1747
+ } else {
1748
+ const k = Math.exp(z * v * Math.sqrt(h));
1749
+ up[h] = c * k;
1750
+ down[h] = c / k;
1751
+ }
1752
+ }
1753
+ bands[z] = { up, down };
1754
+ }
1755
+ return { horizon: H, levels: lv, bands };
1756
+ }
1757
+
1758
+ /**
1759
+ * Validate a scenario spec: a ghost path of future prices (bars or API data)
1760
+ * plus optional cone settings. Invalid entries are dropped, never thrown.
1761
+ *
1762
+ * { path: [64000, 65500, {price: 68000}], // future bars 1..N
1763
+ * horizon: 48, // alternative/additional: cone-only projection
1764
+ * cone: true, // σ-bands from realized vol (default true)
1765
+ * levels: [1, 2], // σ multipliers (default [1, 2])
1766
+ * color?, label? } // palette keys up|down|accent or safe CSS colors
1767
+ *
1768
+ * @param {any} spec
1769
+ * @returns {null|{path: {h:number, price:number}[], horizon: number,
1770
+ * cone: boolean, levels: number[], color: string|null, label: string}}
1771
+ */
1772
+ export function normalizeScenario(spec) {
1773
+ if (!spec || typeof spec !== 'object') return null;
1774
+ const rawPath = Array.isArray(spec.path) ? spec.path : null;
1775
+ const path = [];
1776
+ if (rawPath) {
1777
+ for (let i = 0; i < rawPath.length && path.length < 250; i++) {
1778
+ const p = rawPath[i];
1779
+ const price = p && typeof p === 'object' ? +p.price : +p;
1780
+ if (Number.isFinite(price) && price > 0) path.push({ h: path.length + 1, price });
1781
+ }
1782
+ }
1783
+ const hasHorizon = isNum(spec.horizon) && spec.horizon > 0;
1784
+ if (!path.length && !hasHorizon) return null;
1785
+ const horizon = Math.round(
1786
+ clamp(path.length ? (hasHorizon ? Math.max(path.length, spec.horizon) : path.length) : spec.horizon, 1, 500)
1787
+ );
1788
+ let levels = [1, 2];
1789
+ if (Array.isArray(spec.levels)) {
1790
+ const zs = spec.levels
1791
+ .map((z) => +z)
1792
+ .filter((z) => Number.isFinite(z) && z > 0 && z <= 5)
1793
+ .sort((a, b) => a - b);
1794
+ if (zs.length) levels = zs;
1795
+ }
1796
+ const rawColor = spec.color != null ? String(spec.color).trim() : '';
1797
+ const color = /^(up|down|accent)$/i.test(rawColor)
1798
+ ? rawColor.toLowerCase()
1799
+ : safeColor(rawColor) || null;
1800
+ return {
1801
+ path,
1802
+ horizon,
1803
+ cone: spec.cone !== false,
1804
+ levels,
1805
+ color,
1806
+ label: spec.label != null ? String(spec.label).slice(0, 40) : '',
1807
+ };
1808
+ }
1809
+
1810
+ /* ------------------------------------------------------------------ *
1811
+ * Risk planner — R-multiple grid
1812
+ * ------------------------------------------------------------------ */
1813
+
1814
+ /**
1815
+ * Validate a risk plan: entry + stop define 1R (the risk unit per trade);
1816
+ * reward levels are drawn at R multiples beyond the entry. Invalid input
1817
+ * is dropped, never thrown — same contract as setOverlays/setScenario.
1818
+ *
1819
+ * { entry: 64500, stop: 63800, // stop < entry ⇒ long; else short
1820
+ * multiples: [1, 2, 3], // R-multiple levels (default [1,2,3])
1821
+ * targets: [65900, 67300], // alternative: explicit prices → kR
1822
+ * label: 'breakout plan' } // ≤ 40 chars
1823
+ *
1824
+ * Explicit `targets` are converted to their (signed) R multiple; levels on
1825
+ * the wrong side of the entry (negative or ~zero R) are dropped. `multiples`
1826
+ * win when both are given. At most 8 levels, each ≤ 20R.
1827
+ *
1828
+ * @param {any} spec
1829
+ * @returns {null|{entry: number, stop: number, risk: number,
1830
+ * direction: 'long'|'short', levels: {k: number, price: number}[],
1831
+ * maxK: number, label: string}}
1832
+ */
1833
+ export function normalizeRiskPlan(spec) {
1834
+ if (!spec || typeof spec !== 'object') return null;
1835
+ const entry = +spec.entry;
1836
+ const stop = +spec.stop;
1837
+ if (
1838
+ !Number.isFinite(entry) || !Number.isFinite(stop) ||
1839
+ entry <= 0 || stop <= 0 || entry === stop
1840
+ ) return null;
1841
+ const risk = Math.abs(entry - stop);
1842
+ const sign = stop < entry ? 1 : -1;
1843
+ let ks = null;
1844
+ if (Array.isArray(spec.multiples)) {
1845
+ ks = spec.multiples.map((k) => +k).filter((k) => Number.isFinite(k) && k > 0 && k <= 20);
1846
+ } else if (Array.isArray(spec.targets)) {
1847
+ ks = [];
1848
+ for (const t of spec.targets) {
1849
+ const p = +t;
1850
+ if (!Number.isFinite(p) || p <= 0) continue;
1851
+ const k = ((p - entry) / risk) * sign;
1852
+ if (k > 0.005) ks.push(Math.round(k * 100) / 100);
1853
+ }
1854
+ }
1855
+ if (!ks || !ks.length) ks = [1, 2, 3];
1856
+ const levels = [...new Set(ks)]
1857
+ .sort((a, b) => a - b)
1858
+ .slice(0, 8)
1859
+ .map((k) => ({ k, price: entry + sign * k * risk }));
1860
+ return {
1861
+ entry,
1862
+ stop,
1863
+ risk,
1864
+ direction: sign > 0 ? 'long' : 'short',
1865
+ levels,
1866
+ maxK: levels.length ? levels[levels.length - 1].k : 0,
1867
+ label: spec.label != null ? String(spec.label).slice(0, 40) : '',
1868
+ };
1869
+ }
1870
+
1871
+ /* ------------------------------------------------------------------ *
1872
+ * Bar-walk narrator — a timeline of what happened
1873
+ * ------------------------------------------------------------------ */
1874
+
1875
+ /**
1876
+ * Turn a bar window into an ordered story: the annotation events (pivot
1877
+ * highs/lows, volume spikes, gaps, RSI divergences) plus derived **legs** —
1878
+ * the move between consecutive opposite pivots ("+12.4% over 38 bars").
1879
+ * The timeline drives the bar-walk player and any caption UI.
1880
+ *
1881
+ * @param {Bar[]} bars full dataset
1882
+ * @param {number} i0 first index of the window
1883
+ * @param {number} i1 last index of the window
1884
+ * @param {{pivot?: number, volMult?: number, gapMult?: number, rsiPeriod?: number}} [opts]
1885
+ * pivot window defaults to 8 (denser than the annotations overlay's 20)
1886
+ * @returns {{i: number, time: number, type: string, side: string, note: string,
1887
+ * legPct?: number, legBars?: number}[]} sorted by index, capped at 60
1888
+ */
1889
+ export function narrateWindow(bars, i0, i1, opts = {}) {
1890
+ if (!bars.length || i0 < 0 || i1 < i0 || i1 >= bars.length) return [];
1891
+ const rsi = calcRSI(bars.map((b) => b.close), Math.min(50, Math.max(2, +opts.rsiPeriod || 14)));
1892
+ const ann = detectAnnotations(bars, i0, i1, rsi, {
1893
+ pivot: opts.pivot ?? 8,
1894
+ volMult: opts.volMult,
1895
+ gapMult: opts.gapMult,
1896
+ });
1897
+ // legs: the move between consecutive opposite pivots, stamped at the
1898
+ // ending pivot so a walk player can speak it as it arrives
1899
+ const pivots = ann
1900
+ .filter((a) => a.type === 'pivothigh' || a.type === 'pivotlow')
1901
+ .sort((a, b) => a.i - b.i);
1902
+ const legs = [];
1903
+ for (let k = 1; k < pivots.length; k++) {
1904
+ const a = pivots[k - 1];
1905
+ const b = pivots[k];
1906
+ if (a.type === b.type) continue;
1907
+ const pa = a.type === 'pivothigh' ? bars[a.i].high : bars[a.i].low;
1908
+ const pb = b.type === 'pivothigh' ? bars[b.i].high : bars[b.i].low;
1909
+ if (!(pa > 0) || !Number.isFinite(pb)) continue;
1910
+ const pct = ((pb - pa) / pa) * 100;
1911
+ legs.push({
1912
+ type: 'leg',
1913
+ side: pct >= 0 ? 'high' : 'low',
1914
+ i: b.i,
1915
+ note: `${pct >= 0 ? '+' : ''}${pct.toFixed(1)}% over ${b.i - a.i} bars`,
1916
+ legPct: Math.round(pct * 100) / 100,
1917
+ legBars: b.i - a.i,
1918
+ });
1919
+ }
1920
+ return [...ann, ...legs]
1921
+ .sort((a, b) => a.i - b.i)
1922
+ .slice(0, 60)
1923
+ .map((e) => ({ ...e, time: bars[e.i].time }));
1924
+ }
1925
+
1926
+ /* ------------------------------------------------------------------ *
1927
+ * Delta brush — selection statistics
1928
+ * ------------------------------------------------------------------ */
1929
+
1930
+ /**
1931
+ * Stats for a brushed bar range: net move (open of the first bar → close
1932
+ * of the last), extremes, and summed volume. Powers the brush-selection
1933
+ * overlay and its `wick:brush` event.
1934
+ *
1935
+ * @param {Bar[]} bars full dataset
1936
+ * @param {number} i0 first selected index
1937
+ * @param {number} i1 last selected index
1938
+ * @returns {null|{bars: number, from: {index: number, time: number},
1939
+ * to: {index: number, time: number}, firstOpen: number,
1940
+ * lastClose: number, delta: number, deltaPct: number,
1941
+ * high: number, low: number, volume: number}}
1942
+ */
1943
+ export function brushStats(bars, i0, i1) {
1944
+ if (!bars.length || i0 < 0 || i1 < i0 || i1 >= bars.length) return null;
1945
+ const first = bars[i0];
1946
+ const last = bars[i1];
1947
+ let high = -Infinity;
1948
+ let low = Infinity;
1949
+ let vol = 0;
1950
+ for (let i = i0; i <= i1; i++) {
1951
+ const b = bars[i];
1952
+ if (b.high > high) high = b.high;
1953
+ if (b.low < low) low = b.low;
1954
+ vol += b.volume || 0;
1955
+ }
1956
+ const delta = last.close - first.open;
1957
+ return {
1958
+ bars: i1 - i0 + 1,
1959
+ from: { index: i0, time: first.time },
1960
+ to: { index: i1, time: last.time },
1961
+ firstOpen: first.open,
1962
+ lastClose: last.close,
1963
+ delta,
1964
+ deltaPct: first.open ? (delta / first.open) * 100 : 0,
1965
+ high,
1966
+ low,
1967
+ volume: vol,
1968
+ };
1969
+ }
1970
+
1971
+ /* ------------------------------------------------------------------ *
1972
+ * Story mode — guided tours of chart state
1973
+ * ------------------------------------------------------------------ */
1974
+
1975
+ /** Smoothest cheap easing for viewport pans: slow in, slow out. */
1976
+ export function easeInOutCubic(t) {
1977
+ const x = clamp(+t || 0, 0, 1);
1978
+ return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2;
1979
+ }
1980
+
1981
+ /**
1982
+ * Validate one story scene. Every field is optional except that a scene
1983
+ * must be an object; omitted fields simply don't change that aspect of
1984
+ * the chart when played. `scenario`/`riskPlan` use a 'clear' sentinel for
1985
+ * explicit "remove it" (null input means clear too when the KEY is present).
1986
+ *
1987
+ * { title: 'The breakout', note: 'What happened…',
1988
+ * range: { from, to }, // times (s or ms) — the camera pans there
1989
+ * indicators: 'sma:20 rsi:14', // optional indicator string
1990
+ * type: 'candles', // optional series type
1991
+ * overlays: [...], // optional zones/levels (normalizeOverlays)
1992
+ * scenario: {...} | null, // set / clear a scenario
1993
+ * riskPlan: {...} | null, // set / clear a risk plan
1994
+ * dwell: 2200 } // ms to hold after the pan (500–30000)
1995
+ *
1996
+ * @returns {object|null} normalized scene, or null for non-objects
1997
+ */
1998
+ export function normalizeScene(scene) {
1999
+ if (!scene || typeof scene !== 'object') return null;
2000
+ const out = {
2001
+ title: scene.title != null ? String(scene.title).slice(0, 60) : '',
2002
+ note: scene.note != null ? String(scene.note).slice(0, 200) : '',
2003
+ dwell: clamp(Math.round(+scene.dwell || 2200), 500, 30000),
2004
+ };
2005
+ if (scene.range && Number.isFinite(+scene.range.from) && Number.isFinite(+scene.range.to)) {
2006
+ out.range = { from: +scene.range.from, to: +scene.range.to };
2007
+ }
2008
+ if (scene.indicators != null) {
2009
+ const s = String(scene.indicators).trim();
2010
+ if (s) out.indicators = s.slice(0, 200);
2011
+ }
2012
+ if (scene.type != null && SERIES_TYPES.includes(scene.type)) out.type = scene.type;
2013
+ if (scene.overlays != null) {
2014
+ const ovs = normalizeOverlays(scene.overlays);
2015
+ if (ovs.length) out.overlays = ovs;
2016
+ }
2017
+ if ('scenario' in scene) {
2018
+ if (scene.scenario == null) out.scenario = 'clear';
2019
+ else {
2020
+ const sc = normalizeScenario(scene.scenario);
2021
+ if (sc) out.scenario = sc;
2022
+ }
2023
+ }
2024
+ if ('riskPlan' in scene) {
2025
+ if (scene.riskPlan == null) out.riskPlan = 'clear';
2026
+ else {
2027
+ const rp = normalizeRiskPlan(scene.riskPlan);
2028
+ if (rp) out.riskPlan = rp;
2029
+ }
2030
+ }
2031
+ return out;
2032
+ }
2033
+
2034
+ /**
2035
+ * Validate a whole story: normalize each scene, drop junk, cap at 20.
2036
+ * @returns {object[]} possibly empty
2037
+ */
2038
+ export function sceneList(story) {
2039
+ if (!Array.isArray(story)) return [];
2040
+ const out = [];
2041
+ for (const s of story) {
2042
+ const n = normalizeScene(s);
2043
+ if (n) out.push(n);
2044
+ if (out.length >= 20) break;
2045
+ }
2046
+ return out;
2047
+ }
2048
+
2049
+ /* ------------------------------------------------------------------ *
2050
+ * Co-view presence — peer viewport tracking with TTL expiry
2051
+ * ------------------------------------------------------------------ */
2052
+
2053
+ /**
2054
+ * Tracks other charts viewing the same room: last-sighting timestamps per
2055
+ * peer plus the viewport each one is looking at. Pure bookkeeping — the
2056
+ * transport (BroadcastChannel, WebSocket, …) lives in the component/app.
2057
+ *
2058
+ * Peers expire `ttl` ms after their last sighting, so a closed tab fades
2059
+ * out of the room without an explicit goodbye.
2060
+ */
2061
+ export class PresenceTracker {
2062
+ /** @param {number} [ttl=12000] ms a peer survives without a sighting */
2063
+ constructor(ttl = 12000) {
2064
+ this.ttl = Math.max(1000, +ttl || 12000);
2065
+ /** @type {Map<string, {id: string, name: string|null, range: {from:number,to:number}|null, at: number}>} */
2066
+ this.peers = new Map();
2067
+ }
2068
+
2069
+ /**
2070
+ * Record a sighting. `patch.range` ({from,to} times) is validated and
2071
+ * normalized; a sighting without a range keeps the previous one.
2072
+ * @returns {boolean} true when this sighting is a join (new peer)
2073
+ */
2074
+ track(id, patch = {}, now = Date.now()) {
2075
+ if (!id || typeof id !== 'string') return false;
2076
+ const existing = this.peers.get(id);
2077
+ if (existing) {
2078
+ if (patch && patch.range) {
2079
+ const f = +patch.range.from;
2080
+ const t = +patch.range.to;
2081
+ if (Number.isFinite(f) && Number.isFinite(t)) {
2082
+ existing.range = { from: Math.min(f, t), to: Math.max(f, t) };
2083
+ }
2084
+ }
2085
+ if (patch && patch.name != null) existing.name = String(patch.name).slice(0, 24) || null;
2086
+ existing.at = now;
2087
+ return false;
2088
+ }
2089
+ const f = patch && patch.range ? +patch.range.from : NaN;
2090
+ const t = patch && patch.range ? +patch.range.to : NaN;
2091
+ this.peers.set(id, {
2092
+ id,
2093
+ name: patch && patch.name != null ? (String(patch.name).slice(0, 24) || null) : null,
2094
+ range: Number.isFinite(f) && Number.isFinite(t)
2095
+ ? { from: Math.min(f, t), to: Math.max(f, t) }
2096
+ : null,
2097
+ at: now,
2098
+ });
2099
+ return true;
2100
+ }
2101
+
2102
+ /** @returns {object|null} the removed peer entry, or null when unknown */
2103
+ drop(id) {
2104
+ const p = this.peers.get(id);
2105
+ this.peers.delete(id);
2106
+ return p || null;
2107
+ }
2108
+
2109
+ /** Expire peers not seen within the ttl.
2110
+ * @returns {object[]} the peer entries that left */
2111
+ sweep(now = Date.now()) {
2112
+ const left = [];
2113
+ for (const [id, p] of this.peers) {
2114
+ if (now - p.at > this.ttl) {
2115
+ this.peers.delete(id);
2116
+ left.push(p);
2117
+ }
2118
+ }
2119
+ return left;
2120
+ }
2121
+
2122
+ /** @returns {{id: string, name: string|null, range: object|null, at: number}[]} copies, oldest sighting first */
2123
+ list() {
2124
+ return [...this.peers.values()]
2125
+ .sort((a, b) => a.at - b.at)
2126
+ .map((p) => ({ ...p, range: p.range ? { ...p.range } : null }));
2127
+ }
2128
+ }
2129
+
1535
2130
  /* ------------------------------------------------------------------ *
1536
2131
  * AI-ready window summary
1537
2132
  * ------------------------------------------------------------------ */
@@ -1780,3 +2375,166 @@ export function decodeStateQuery(str) {
1780
2375
  }
1781
2376
  return state;
1782
2377
  }
2378
+
2379
+ /* ------------------------------------------------------------------ *
2380
+ * AI agent interface — the chart as a tool surface
2381
+ * ------------------------------------------------------------------ */
2382
+
2383
+ /**
2384
+ * Tool manifest for LLM/agent control of a chart. Tools map 1:1 onto the
2385
+ * public element API; every op through applyChartOps is validated before it
2386
+ * touches the chart (LLM output is untrusted input).
2387
+ */
2388
+ export const AI_TOOLS = [
2389
+ {
2390
+ tool: 'get_data_window',
2391
+ description:
2392
+ 'Read the visible chart window: OHLC stats, trend (slope + fit), volatility percentile, indicator snapshots, detected patterns. Returns structured fields plus a markdown summary.',
2393
+ args: {},
2394
+ },
2395
+ {
2396
+ tool: 'set_indicators',
2397
+ description:
2398
+ 'Replace the indicators. Tokens: sma:20 ema:50 bb:20 vwap rsi:14 macd:12/26/9 volume, @hexcolor suffixes, or WickScript expressions like expr:{close - sma(close,20)} / pexpr:{rsi(close,14)}. Empty string clears all.',
2399
+ args: { indicators: 'string — space/comma-separated tokens' },
2400
+ },
2401
+ {
2402
+ tool: 'set_overlays',
2403
+ description:
2404
+ '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.',
2405
+ args: { overlays: 'array of overlay objects' },
2406
+ },
2407
+ { tool: 'clear_overlays', description: 'Remove all overlays.', args: {} },
2408
+ {
2409
+ tool: 'add_alert',
2410
+ description:
2411
+ '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.',
2412
+ args: {},
2413
+ },
2414
+ {
2415
+ tool: 'set_view',
2416
+ description: 'Set the visible time range (unix seconds or ms).',
2417
+ args: { from: 'timestamp', to: 'timestamp' },
2418
+ },
2419
+ { tool: 'reset_view', description: 'Fit all loaded data.', args: {} },
2420
+ {
2421
+ tool: 'set_type',
2422
+ description: 'Change the series type.',
2423
+ args: { type: '"candles" | "line" | "area" | "bars" | "hollow" | "heikin"' },
2424
+ },
2425
+ {
2426
+ tool: 'set_volshading',
2427
+ description: 'Volatility-regime background shading (calm/normal/hot percentiles).',
2428
+ args: { enabled: 'boolean', low: 'percentile 0–98 (default 30)', high: 'percentile (default 70)' },
2429
+ },
2430
+ ];
2431
+
2432
+ /**
2433
+ * Compact system prompt for agent control: paste into any LLM alongside the
2434
+ * tool manifest. The model answers with a JSON array of {tool, args} ops.
2435
+ * @returns {string}
2436
+ */
2437
+ export function aiPromptText() {
2438
+ const lines = AI_TOOLS.map(
2439
+ (t) => `- ${t.tool}${Object.keys(t.args).length ? '(' + Object.keys(t.args).join(', ') + ')' : '()'}: ${t.description}`
2440
+ );
2441
+ return [
2442
+ 'You are controlling a WickChart financial charting element through tool calls.',
2443
+ 'Reply with ONLY a JSON array of operations to apply, each {"tool": name, "args": {...}}.',
2444
+ 'Use get_data_window first when you need to see the chart before deciding.',
2445
+ 'Available tools:',
2446
+ ...lines,
2447
+ ].join('\n');
2448
+ }
2449
+
2450
+ const AI_CHART_TYPES = SERIES_TYPES;
2451
+
2452
+ /**
2453
+ * Validate + apply a list of {tool, args} ops (typically LLM output) to a
2454
+ * chart-like target. Ops are whitelisted and their args validated — an op
2455
+ * never throws; it returns {ok: false, error} instead so the agent can
2456
+ * self-correct. Target contract: getDataWindow(), setAttribute(k, v),
2457
+ * setOverlays(list), clearOverlays(), addAlert(a), setVisibleRange(r),
2458
+ * fit(), and (static) _registry() for indicator name checks.
2459
+ * @param {object} target chart element (or test double)
2460
+ * @param {any} ops
2461
+ * @returns {Array<{ok: boolean, tool?: string, result?: any, error?: string}>}
2462
+ */
2463
+ export function applyChartOps(target, ops) {
2464
+ if (!target) return [{ ok: false, error: 'no target' }];
2465
+ if (!Array.isArray(ops)) return [{ ok: false, error: 'ops must be an array of {tool, args} objects' }];
2466
+ return ops.map((op) => {
2467
+ if (!op || typeof op !== 'object' || Array.isArray(op)) {
2468
+ return { ok: false, error: 'each op must be an object: {tool, args}' };
2469
+ }
2470
+ const tool = String(op.tool || '');
2471
+ const args = op.args && typeof op.args === 'object' && !Array.isArray(op.args) ? op.args : {};
2472
+ const fail = (error) => ({ ok: false, tool, error });
2473
+ try {
2474
+ switch (tool) {
2475
+ case 'get_data_window':
2476
+ return { ok: true, tool, result: target.getDataWindow() };
2477
+ case 'set_indicators': {
2478
+ if (typeof args.indicators !== 'string') return fail('args.indicators must be a string');
2479
+ const reg = target.constructor && target.constructor._registry ? target.constructor._registry() : null;
2480
+ const parsed = parseIndicators(args.indicators, reg);
2481
+ if (parsed.unknown.length) {
2482
+ return fail(`unknown indicators: ${parsed.unknown.join(', ')}`);
2483
+ }
2484
+ target.setAttribute('indicators', args.indicators);
2485
+ return { ok: true, tool, result: { applied: args.indicators || '(cleared)' } };
2486
+ }
2487
+ case 'set_overlays': {
2488
+ if (!Array.isArray(args.overlays)) return fail('args.overlays must be an array');
2489
+ const norm = normalizeOverlays(args.overlays);
2490
+ if (!norm.length) return fail('no valid overlays in args.overlays');
2491
+ const ids = target.setOverlays(args.overlays);
2492
+ return { ok: true, tool, result: { applied: ids.length, dropped: args.overlays.length - ids.length } };
2493
+ }
2494
+ case 'clear_overlays':
2495
+ target.clearOverlays();
2496
+ return { ok: true, tool, result: { cleared: true } };
2497
+ case 'add_alert': {
2498
+ if (!isNum(args.price) && typeof args.when !== 'string') {
2499
+ return fail('args needs either price (number) or when (WickScript string)');
2500
+ }
2501
+ const id = target.addAlert(args);
2502
+ return id ? { ok: true, tool, result: { id } } : fail('invalid alert (bad predicate?)');
2503
+ }
2504
+ case 'set_view': {
2505
+ const r = {};
2506
+ if (isNum(args.from)) r.from = normMs(args.from);
2507
+ if (isNum(args.to)) r.to = normMs(args.to);
2508
+ if (!('from' in r) && !('to' in r)) return fail('args needs from and/or to timestamps');
2509
+ target.setVisibleRange(r);
2510
+ return { ok: true, tool, result: r };
2511
+ }
2512
+ case 'reset_view':
2513
+ target.fit();
2514
+ return { ok: true, tool, result: { reset: true } };
2515
+ case 'set_type': {
2516
+ if (!AI_CHART_TYPES.includes(args.type)) {
2517
+ return fail(`args.type must be one of ${AI_CHART_TYPES.join(' | ')}`);
2518
+ }
2519
+ target.setAttribute('type', args.type);
2520
+ return { ok: true, tool, result: { type: args.type } };
2521
+ }
2522
+ case 'set_volshading': {
2523
+ if (args.enabled === false) {
2524
+ target.setAttribute('volshading', 'false');
2525
+ return { ok: true, tool, result: { enabled: false } };
2526
+ }
2527
+ const p = parseVolShading(
2528
+ isNum(args.low) && isNum(args.high) ? `${args.low}/${args.high}` : ''
2529
+ );
2530
+ target.setAttribute('volshading', `${p.p1}/${p.p2}`);
2531
+ return { ok: true, tool, result: { enabled: true, low: p.p1, high: p.p2 } };
2532
+ }
2533
+ default:
2534
+ return fail(`unknown tool "${tool}"`);
2535
+ }
2536
+ } catch (err) {
2537
+ return fail(err && err.message ? err.message : String(err));
2538
+ }
2539
+ });
2540
+ }