wickchart 0.4.0 → 1.2.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
@@ -1,5 +1,5 @@
1
1
  /* ==========================================================================
2
- * HabView core — pure, DOM-free functions shared by <hab-chart> and tests.
2
+ * WickChart core — pure, DOM-free functions shared by <wick-chart> and tests.
3
3
  * Importable in the browser (ESM) and in Node (`node --test`).
4
4
  * MIT License.
5
5
  * ========================================================================== */
@@ -252,7 +252,8 @@ export const fmtFull = (t) => {
252
252
  };
253
253
 
254
254
  /* ------------------------------------------------------------------ *
255
- * Themes (every key overridable via --hab-* CSS custom properties)
255
+ * Themes (every key overridable via --wick-* CSS custom properties;
256
+ * the 0.x --hab-* names still work as fallbacks)
256
257
  * ------------------------------------------------------------------ */
257
258
 
258
259
  export const THEMES = {
@@ -867,7 +868,7 @@ export const BUILTIN_INDICATORS = new Map(
867
868
  /**
868
869
  * Parse an `indicators` attribute string against a registry.
869
870
  * Token: `name[:param[/param…]][@color]`, the `volume` keyword, and
870
- * HabScript blobs `expr:{…}` (overlay) / `pexpr:{…}` (separate pane).
871
+ * WickScript blobs `expr:{…}` (overlay) / `pexpr:{…}` (separate pane).
871
872
  * @param {string|null|undefined} str
872
873
  * @param {Map<string, IndicatorDef>} registry
873
874
  * @returns {{overlays: IndicatorEntry[], panes: IndicatorEntry[], volume: boolean, unknown: string[]}}
@@ -940,11 +941,12 @@ export function parseIndicators(str, registry) {
940
941
  }
941
942
 
942
943
  /* ------------------------------------------------------------------ *
943
- * HabScript — safe expression mini-language for custom indicators
944
+ * WickScript — safe expression mini-language for custom indicators
944
945
  *
945
946
  * `expr:{(close - sma(close,20)) / sma(close,20)}` compiles through a
946
947
  * hand-written tokenizer + recursive-descent parser (no eval / Function)
947
- * and evaluates element-wise over the bar series.
948
+ * and evaluates element-wise over the bar series. (Called "HabScript" in
949
+ * 0.x releases.)
948
950
  * ------------------------------------------------------------------ */
949
951
 
950
952
  const SCRIPT_MAX_LEN = 512;
@@ -1041,6 +1043,17 @@ function tokenizeScript(src) {
1041
1043
  i += m[0].length;
1042
1044
  continue;
1043
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
+ }
1044
1057
  if (c === '+' || c === '-' || c === '*' || c === '/' || c === '%') {
1045
1058
  toks.push({ t: 'op', v: c });
1046
1059
  i++;
@@ -1064,6 +1077,17 @@ function parseScript(src) {
1064
1077
  let p = 0;
1065
1078
  const peek = () => toks[p];
1066
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
+ }
1067
1091
  function parseAdd(depth) {
1068
1092
  let l = parseMul(depth);
1069
1093
  while (peek() && peek().t === 'op' && (peek().v === '+' || peek().v === '-')) {
@@ -1101,10 +1125,10 @@ function parseScript(src) {
1101
1125
  p++;
1102
1126
  const args = [];
1103
1127
  if (peek() && peek().t !== ')') {
1104
- args.push(parseAdd(depth));
1128
+ args.push(parseCmp(depth));
1105
1129
  while (peek() && peek().t === ',') {
1106
1130
  p++;
1107
- args.push(parseAdd(depth));
1131
+ args.push(parseCmp(depth));
1108
1132
  }
1109
1133
  }
1110
1134
  const close = toks[p++];
@@ -1115,7 +1139,7 @@ function parseScript(src) {
1115
1139
  return { type: 'var', name };
1116
1140
  }
1117
1141
  if (t.t === '(') {
1118
- const e = parseAdd(depth);
1142
+ const e = parseCmp(depth);
1119
1143
  const close = toks[p++];
1120
1144
  if (!close || close.t !== ')') throw scriptErr('missing ")"');
1121
1145
  return e;
@@ -1123,7 +1147,7 @@ function parseScript(src) {
1123
1147
  throw scriptErr(`unexpected token "${t.t === 'op' ? t.v : t.t}"`);
1124
1148
  }
1125
1149
 
1126
- const ast = parseAdd(0);
1150
+ const ast = parseCmp(0);
1127
1151
  if (p < toks.length) throw scriptErr('unexpected trailing input');
1128
1152
  validateScriptNode(ast);
1129
1153
  return ast;
@@ -1156,7 +1180,7 @@ function validateScriptNode(n) {
1156
1180
  }
1157
1181
 
1158
1182
  /**
1159
- * Compile a HabScript expression. Throws a descriptive error on any syntax
1183
+ * Compile a WickScript expression. Throws a descriptive error on any syntax
1160
1184
  * or semantic problem — never evaluates strings at runtime.
1161
1185
  * @param {string} src
1162
1186
  * @returns {{src: string, ast: object}}
@@ -1177,6 +1201,20 @@ function binOp(op, a, b) {
1177
1201
  case '*': return a * b;
1178
1202
  case '/': return a / b;
1179
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;
1180
1218
  }
1181
1219
  return NaN;
1182
1220
  }
@@ -1303,7 +1341,7 @@ export function evalScript(compiled, bars) {
1303
1341
  }
1304
1342
 
1305
1343
  /**
1306
- * Build an indicator definition from a HabScript expression — used inline by
1344
+ * Build an indicator definition from a WickScript expression — used inline by
1307
1345
  * `indicators="expr:{…}"` / `pexpr:{…}"`, or register it under a name:
1308
1346
  * `HabChart.registerIndicator('myspread', scriptIndicator('close - ema(close,21)'))`.
1309
1347
  * @param {string} src
@@ -1352,6 +1390,34 @@ export function checkAlertCross(alert, prevPrice, price) {
1352
1390
  return (prevPrice <= p && price > p) || (prevPrice >= p && price < p);
1353
1391
  }
1354
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
+
1355
1421
  /* ------------------------------------------------------------------ *
1356
1422
  * Visible-range statistics
1357
1423
  * ------------------------------------------------------------------ */
@@ -1526,10 +1592,221 @@ export function parseVolShading(val) {
1526
1592
  let p1 = isNum(parts[0]) ? clamp(parts[0], 0, 98) : 30;
1527
1593
  const p2 = isNum(parts[1]) ? clamp(parts[1], 2, 100) : 70;
1528
1594
  p1 = clamp(p1, 0, p2 - 2);
1529
- 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;
1530
1596
  return { p1, p2, period };
1531
1597
  }
1532
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
+
1533
1810
  /* ------------------------------------------------------------------ *
1534
1811
  * AI-ready window summary
1535
1812
  * ------------------------------------------------------------------ */
@@ -1778,3 +2055,166 @@ export function decodeStateQuery(str) {
1778
2055
  }
1779
2056
  return state;
1780
2057
  }
2058
+
2059
+ /* ------------------------------------------------------------------ *
2060
+ * AI agent interface — the chart as a tool surface
2061
+ * ------------------------------------------------------------------ */
2062
+
2063
+ /**
2064
+ * Tool manifest for LLM/agent control of a chart. Tools map 1:1 onto the
2065
+ * public element API; every op through applyChartOps is validated before it
2066
+ * touches the chart (LLM output is untrusted input).
2067
+ */
2068
+ export const AI_TOOLS = [
2069
+ {
2070
+ tool: 'get_data_window',
2071
+ description:
2072
+ 'Read the visible chart window: OHLC stats, trend (slope + fit), volatility percentile, indicator snapshots, detected patterns. Returns structured fields plus a markdown summary.',
2073
+ args: {},
2074
+ },
2075
+ {
2076
+ tool: 'set_indicators',
2077
+ description:
2078
+ '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.',
2079
+ args: { indicators: 'string — space/comma-separated tokens' },
2080
+ },
2081
+ {
2082
+ tool: 'set_overlays',
2083
+ description:
2084
+ '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.',
2085
+ args: { overlays: 'array of overlay objects' },
2086
+ },
2087
+ { tool: 'clear_overlays', description: 'Remove all overlays.', args: {} },
2088
+ {
2089
+ tool: 'add_alert',
2090
+ description:
2091
+ '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.',
2092
+ args: {},
2093
+ },
2094
+ {
2095
+ tool: 'set_view',
2096
+ description: 'Set the visible time range (unix seconds or ms).',
2097
+ args: { from: 'timestamp', to: 'timestamp' },
2098
+ },
2099
+ { tool: 'reset_view', description: 'Fit all loaded data.', args: {} },
2100
+ {
2101
+ tool: 'set_type',
2102
+ description: 'Change the series type.',
2103
+ args: { type: '"candles" | "line" | "area" | "bars" | "hollow" | "heikin"' },
2104
+ },
2105
+ {
2106
+ tool: 'set_volshading',
2107
+ description: 'Volatility-regime background shading (calm/normal/hot percentiles).',
2108
+ args: { enabled: 'boolean', low: 'percentile 0–98 (default 30)', high: 'percentile (default 70)' },
2109
+ },
2110
+ ];
2111
+
2112
+ /**
2113
+ * Compact system prompt for agent control: paste into any LLM alongside the
2114
+ * tool manifest. The model answers with a JSON array of {tool, args} ops.
2115
+ * @returns {string}
2116
+ */
2117
+ export function aiPromptText() {
2118
+ const lines = AI_TOOLS.map(
2119
+ (t) => `- ${t.tool}${Object.keys(t.args).length ? '(' + Object.keys(t.args).join(', ') + ')' : '()'}: ${t.description}`
2120
+ );
2121
+ return [
2122
+ 'You are controlling a WickChart financial charting element through tool calls.',
2123
+ 'Reply with ONLY a JSON array of operations to apply, each {"tool": name, "args": {...}}.',
2124
+ 'Use get_data_window first when you need to see the chart before deciding.',
2125
+ 'Available tools:',
2126
+ ...lines,
2127
+ ].join('\n');
2128
+ }
2129
+
2130
+ const AI_CHART_TYPES = SERIES_TYPES;
2131
+
2132
+ /**
2133
+ * Validate + apply a list of {tool, args} ops (typically LLM output) to a
2134
+ * chart-like target. Ops are whitelisted and their args validated — an op
2135
+ * never throws; it returns {ok: false, error} instead so the agent can
2136
+ * self-correct. Target contract: getDataWindow(), setAttribute(k, v),
2137
+ * setOverlays(list), clearOverlays(), addAlert(a), setVisibleRange(r),
2138
+ * fit(), and (static) _registry() for indicator name checks.
2139
+ * @param {object} target chart element (or test double)
2140
+ * @param {any} ops
2141
+ * @returns {Array<{ok: boolean, tool?: string, result?: any, error?: string}>}
2142
+ */
2143
+ export function applyChartOps(target, ops) {
2144
+ if (!target) return [{ ok: false, error: 'no target' }];
2145
+ if (!Array.isArray(ops)) return [{ ok: false, error: 'ops must be an array of {tool, args} objects' }];
2146
+ return ops.map((op) => {
2147
+ if (!op || typeof op !== 'object' || Array.isArray(op)) {
2148
+ return { ok: false, error: 'each op must be an object: {tool, args}' };
2149
+ }
2150
+ const tool = String(op.tool || '');
2151
+ const args = op.args && typeof op.args === 'object' && !Array.isArray(op.args) ? op.args : {};
2152
+ const fail = (error) => ({ ok: false, tool, error });
2153
+ try {
2154
+ switch (tool) {
2155
+ case 'get_data_window':
2156
+ return { ok: true, tool, result: target.getDataWindow() };
2157
+ case 'set_indicators': {
2158
+ if (typeof args.indicators !== 'string') return fail('args.indicators must be a string');
2159
+ const reg = target.constructor && target.constructor._registry ? target.constructor._registry() : null;
2160
+ const parsed = parseIndicators(args.indicators, reg);
2161
+ if (parsed.unknown.length) {
2162
+ return fail(`unknown indicators: ${parsed.unknown.join(', ')}`);
2163
+ }
2164
+ target.setAttribute('indicators', args.indicators);
2165
+ return { ok: true, tool, result: { applied: args.indicators || '(cleared)' } };
2166
+ }
2167
+ case 'set_overlays': {
2168
+ if (!Array.isArray(args.overlays)) return fail('args.overlays must be an array');
2169
+ const norm = normalizeOverlays(args.overlays);
2170
+ if (!norm.length) return fail('no valid overlays in args.overlays');
2171
+ const ids = target.setOverlays(args.overlays);
2172
+ return { ok: true, tool, result: { applied: ids.length, dropped: args.overlays.length - ids.length } };
2173
+ }
2174
+ case 'clear_overlays':
2175
+ target.clearOverlays();
2176
+ return { ok: true, tool, result: { cleared: true } };
2177
+ case 'add_alert': {
2178
+ if (!isNum(args.price) && typeof args.when !== 'string') {
2179
+ return fail('args needs either price (number) or when (WickScript string)');
2180
+ }
2181
+ const id = target.addAlert(args);
2182
+ return id ? { ok: true, tool, result: { id } } : fail('invalid alert (bad predicate?)');
2183
+ }
2184
+ case 'set_view': {
2185
+ const r = {};
2186
+ if (isNum(args.from)) r.from = normMs(args.from);
2187
+ if (isNum(args.to)) r.to = normMs(args.to);
2188
+ if (!('from' in r) && !('to' in r)) return fail('args needs from and/or to timestamps');
2189
+ target.setVisibleRange(r);
2190
+ return { ok: true, tool, result: r };
2191
+ }
2192
+ case 'reset_view':
2193
+ target.fit();
2194
+ return { ok: true, tool, result: { reset: true } };
2195
+ case 'set_type': {
2196
+ if (!AI_CHART_TYPES.includes(args.type)) {
2197
+ return fail(`args.type must be one of ${AI_CHART_TYPES.join(' | ')}`);
2198
+ }
2199
+ target.setAttribute('type', args.type);
2200
+ return { ok: true, tool, result: { type: args.type } };
2201
+ }
2202
+ case 'set_volshading': {
2203
+ if (args.enabled === false) {
2204
+ target.setAttribute('volshading', 'false');
2205
+ return { ok: true, tool, result: { enabled: false } };
2206
+ }
2207
+ const p = parseVolShading(
2208
+ isNum(args.low) && isNum(args.high) ? `${args.low}/${args.high}` : ''
2209
+ );
2210
+ target.setAttribute('volshading', `${p.p1}/${p.p2}`);
2211
+ return { ok: true, tool, result: { enabled: true, low: p.p1, high: p.p2 } };
2212
+ }
2213
+ default:
2214
+ return fail(`unknown tool "${tool}"`);
2215
+ }
2216
+ } catch (err) {
2217
+ return fail(err && err.message ? err.message : String(err));
2218
+ }
2219
+ });
2220
+ }
package/src/feeds.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /* ==========================================================================
2
- * HabView feeds — data-source helpers shared by <hab-feed> and the demo app.
2
+ * WickChart feeds — data-source helpers shared by <wick-feed> and the demo app.
3
3
  * Browser module (uses fetch/WebSocket inside functions); importable in Node
4
4
  * for unit-testing the pure generators.
5
5
  * MIT License.