wickchart 1.3.0 → 1.5.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 CHANGED
@@ -6,6 +6,9 @@ A TradingView-style financial chart as a single framework-agnostic Web Component
6
6
  One file, zero dependencies, one HTML tag. Canvas-rendered, fast, themeable, and
7
7
  streaming-ready.
8
8
 
9
+ <img width="759" height="390" alt="image" src="https://github.com/user-attachments/assets/f335cecc-d5b3-4d27-a982-600b6bc72d6f" />
10
+
11
+
9
12
  ## Install
10
13
 
11
14
  ```bash
@@ -226,7 +229,7 @@ chart.setData([
226
229
  | ------------- | ---------- | ------------------------------------------------------------------ |
227
230
  | `theme` | `dark` | `dark` or `light` |
228
231
  | `type` | `candles` | `candles`, `line`, `area`, `bars` (OHLC), `hollow` (hollow up-candles), `heikin` (Heikin-Ashi) |
229
- | `indicators` | `volume`* | Space/comma-separated: `sma:20`, `ema:50`, `bb:20`, `rsi:14`, `macd:12/26/9`, `volume`, or any registered indicator |
232
+ | `indicators` | `volume`* | Space/comma-separated: `sma:20`, `ema:50`, `bb:20`, `vwap`, `supertrend:10/3`, `donchian:20`, `keltner:20/2`, `rsi:14`, `macd:12/26/9`, `stoch:14/3`, `atr:14`, `obv`, `cci:20`, `wr:14`, `volume`, or any registered indicator |
230
233
  | `label` | – | Text shown in the legend (e.g. `"BTC · 1h"`) |
231
234
  | `log` | off | Logarithmic price scale |
232
235
  | `auto` | on | Keep the right edge pinned to the latest bar while streaming |
@@ -247,8 +250,17 @@ chart.setData([
247
250
  | `sma` | overlay | `period` (20) | |
248
251
  | `ema` | overlay | `period` (50) | |
249
252
  | `bb` | overlay | `period`, `mult` (20, 2) | Bollinger bands (3 lines) |
253
+ | `vwap` | overlay | – | hlc3 VWAP, resets each UTC day |
254
+ | `supertrend` | overlay | `period`, `mult` (10, 3) | ATR trend line, breaks at flips |
255
+ | `donchian` | overlay | `period` (20) | high/low channel + mid |
256
+ | `keltner` | overlay | `period`, `mult` (20, 2) | EMA ± mult×ATR channel |
250
257
  | `rsi` | pane | `period` (14) | fixed 0–100 scale, 30/70 guides |
251
258
  | `macd` | pane | `fast/slow/signal` (12/26/9) | 2 lines + histogram |
259
+ | `stoch` | pane | `period`, `smooth` (14, 3) | %K + %D, fixed 0–100, 20/80 guides |
260
+ | `atr` | pane | `period` (14) | Wilder ATR |
261
+ | `obv` | pane | – | on-balance volume |
262
+ | `cci` | pane | `period` (20) | ±100 guides |
263
+ | `wr` | pane | `period` (14) | Williams %R, fixed −100–0, −80/−20 guides |
252
264
  | `volume` | overlay | – | histogram at the bottom of the price pane |
253
265
 
254
266
  ### Custom indicators
@@ -257,9 +269,9 @@ Register your own — anything from a one-liner moving average to a multi-line
257
269
  pane:
258
270
 
259
271
  ```js
260
- WickChart.registerIndicator('vwap', {
272
+ WickChart.registerIndicator('cvwap', { // cumulative VWAP over the whole dataset
261
273
  kind: 'overlay', // or 'pane'
262
- params: { period: 20 }, // defaults; set via indicators="vwap:30"
274
+ params: { period: 20 }, // defaults; set via indicators="cvwap:30"
263
275
  compute(bars, params) { // bars: normalized {time,open,high,low,close,volume}
264
276
  const out = new Array(bars.length).fill(null);
265
277
  let pv = 0, vv = 0;
@@ -270,9 +282,9 @@ WickChart.registerIndicator('vwap', {
270
282
  }
271
283
  return out; // single series — or { lines:[{name,values}], histogram }
272
284
  },
273
- // pane-only extras: guides:[30,70], range:[0,100], fmt:'price'|'fixed1'
285
+ // pane-only extras: guides:[30,70], range:[0,100], fmt:'price'|'fixed1'|'compact'
274
286
  });
275
- chart.indicators = 'vwap:20';
287
+ chart.indicators = 'cvwap';
276
288
  ```
277
289
 
278
290
  `import WickChart from 'wickchart'` gives you the class for
@@ -306,6 +318,7 @@ shareable URLs.
306
318
  | `prev(x[,k])` `change(x)` | shifted series / bar-to-bar delta |
307
319
  | `abs(x)` `sqrt(x)` `log(x)` `min(a,b)` `max(a,b)` | element-wise math |
308
320
  | `crossup(a,b)` `crossdown(a,b)` | 1 on a strict cross, else 0 |
321
+ | `vwap()` `obv()` `atr(n)` | bar-level series — callable anywhere, e.g. `crossup(close, vwap())` in alerts |
309
322
 
310
323
  Operators are `+ - * / %` with usual precedence, unary `-`, and parentheses.
311
324
  Values before a window fills are `NaN` (not drawn), division by zero yields
@@ -633,6 +646,67 @@ WickChart.registerIndicator('vwap', {
633
646
  chart.indicators = 'vwap';
634
647
  ```
635
648
 
649
+ ### Plugin layers — extend without forking
650
+
651
+ `addLayer()` is the whole extension surface: an external draw hook that paints
652
+ into the render pipeline (above chart content, under the crosshair) and can
653
+ claim pointer gestures so drags reach your code instead of panning the chart.
654
+ Four public coordinate transforms — `timeToX`/`xToTime` (extrapolating past
655
+ the last bar into future space) and `priceToY`/`yToPrice` — anchor your
656
+ content in data space so it rides along with zoom and pan:
657
+
658
+ ```js
659
+ chart.addLayer({
660
+ id: 'flags',
661
+ draw(api) {
662
+ const x = api.timeToX(t), y = api.priceToY(p); // anchors, not pixels
663
+ api.ctx.fillStyle = api.palette.accent;
664
+ // …paint in CSS pixels
665
+ },
666
+ onPointer(ev) {
667
+ if (ev.type === 'down' && hitsMyContent(ev)) return true; // claim the drag
668
+ },
669
+ });
670
+ chart.requestDraw(); // repaint hook for interactive layers
671
+ chart.removeLayer('flags'); // detach by handle or id
672
+ ```
673
+
674
+ A claimed gesture delivers `move`/`up` (and `cancel` on Escape) to the layer
675
+ while the chart suppresses pan/brush/measure. Markers, watermarks, signal
676
+ badges — or a whole drawing toolkit — plug in without the core growing a
677
+ single tool. The main entry is covered by a CI gzip budget (68 KB) so it
678
+ stays that way.
679
+
680
+ ### Drawings — the `wickchart-draw` plugin
681
+
682
+ The first official plugin: TradingView-style drawing tools as opt-in bytes
683
+ (~8 KB gz, own CI budget). Trendlines (segment/ray), horizontal levels,
684
+ rectangles, fibonacci retracements and text — all plain `{ time, price }`
685
+ data that rides zoom & pan, survives reloads, extrapolates into future
686
+ space, and serializes to JSON. Anchors magnet-snap to bar times and OHLC.
687
+
688
+ ```js
689
+ npm install wickchart wickchart-draw // drawings are a separate opt-in package
690
+
691
+ import { attachDrawings } from 'wickchart-draw';
692
+
693
+ const draw = attachDrawings(chart);
694
+ draw.setTool('trendline'); // drag to draw; setTool(null) = select/move mode
695
+ draw.getDrawings(); // → JSON array (save it); setDrawings(saved)
696
+ draw.undo(); draw.clear();
697
+ draw.setShare(true); // shared drawings: draw on one tab, appears on all
698
+ chart.addEventListener('wick:drawings', (e) => save(e.detail.drawings));
699
+ ```
700
+
701
+ Select mode: click a drawing to select it, drag to move, drag the square
702
+ handles to re-anchor, `Delete` removes, `Esc` cancels a gesture; clicks on
703
+ empty space fall through to the chart. Placing a note opens an inline editor
704
+ (type + `Enter`); click a selected note again to re-edit. `setShare(true)`
705
+ reuses the chart's `co-view` room (or pass an explicit room name) — last
706
+ writer wins, remote updates never touch the local undo stack. Peer
707
+ dependency: wickchart ≥ 1.4. See the live playground in the docs (Drawings
708
+ section — it shares a room, so open it twice and draw on either chart).
709
+
636
710
  ## Methods
637
711
 
638
712
  | Method | Description |
@@ -647,6 +721,10 @@ chart.indicators = 'vwap';
647
721
  | `getDataWindow()` | → AI-ready summary of the visible window (see below) |
648
722
  | `getState()` | → serializable snapshot (type, indicators, view, positions, alerts) |
649
723
  | `setState(state)` | Apply a snapshot; a pending view applies after the next `setData()` |
724
+ | `addLayer(layer)` / `removeLayer(idOrHandle)` | Register/detach a plugin layer (draw hook + optional pointer claim) |
725
+ | `requestDraw()` | Repaint on the next frame (interactive layers) |
726
+ | `timeToX(t)` / `xToTime(x)` | Bar time ⇄ x-pixel; extrapolates into future space |
727
+ | `priceToY(p)` / `yToPrice(y)` | Price ⇄ y-pixel in the main pane (log-aware) |
650
728
 
651
729
  ### Infinite history (`loadMore`)
652
730
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wickchart",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "<wick-chart> — a modern, dependency-free financial charting web component. Candles, line & area charts, crosshair, zoom/pan, indicators (incl. a safe expression mini-language), live streaming via <wick-feed>, theming.",
5
5
  "type": "module",
6
6
  "main": "src/wick-chart.js",
@@ -42,10 +42,10 @@
42
42
  },
43
43
  "scripts": {
44
44
  "dev": "npx --yes serve . -l 5173",
45
- "test": "node --test \"tests/*.test.mjs\"",
45
+ "test": "node --test \"tests/*.test.mjs\" \"plugins/draw/tests/*.test.mjs\"",
46
46
  "build:types": "node -e \"require('fs').rmSync('types', { recursive: true, force: true });\" && tsc -p tsconfig.json",
47
47
  "prepack": "npm run build:types",
48
- "ci": "npm run build:types && npm test && node --check src/wick-chart.js && node --check src/wick-feed.js && node --check src/core.js && node --check src/react.js && node --check src/react-core.js && node --check demo/app.js"
48
+ "ci": "npm run build:types && npm test && node --check src/wick-chart.js && node --check src/wick-feed.js && node --check src/core.js && node --check src/react.js && node --check src/react-core.js && node --check demo/app.js && node --check plugins/draw/core.mjs && node --check plugins/draw/draw.mjs"
49
49
  },
50
50
  "keywords": [
51
51
  "chart",
@@ -57,7 +57,14 @@
57
57
  "custom-element",
58
58
  "canvas",
59
59
  "zero-dependency",
60
- "tradingview"
60
+ "tradingview",
61
+ "vwap",
62
+ "supertrend",
63
+ "bollinger-bands",
64
+ "macd",
65
+ "rsi",
66
+ "stochastic",
67
+ "technical-analysis"
61
68
  ],
62
69
  "license": "MIT",
63
70
  "repository": {
package/src/core.js CHANGED
@@ -462,6 +462,259 @@ export function calcMACD(closes, fast = 12, slow = 26, signal = 9) {
462
462
  return { macd, signal: sig, hist };
463
463
  }
464
464
 
465
+ /** True range: max(h−l, |h−prev close|, |l−prev close|); first bar is h−l.
466
+ * @param {Bar[]} bars
467
+ * @returns {Array<number|null>}
468
+ */
469
+ export function calcTrueRange(bars) {
470
+ const out = new Array(bars.length).fill(null);
471
+ for (let i = 0; i < bars.length; i++) {
472
+ const b = bars[i];
473
+ out[i] =
474
+ i === 0
475
+ ? b.high - b.low
476
+ : Math.max(b.high - b.low, Math.abs(b.high - bars[i - 1].close), Math.abs(b.low - bars[i - 1].close));
477
+ }
478
+ return out;
479
+ }
480
+
481
+ /**
482
+ * Average True Range (Wilder smoothing; seeded with the SMA of the first
483
+ * `period` true ranges).
484
+ * @param {Bar[]} bars
485
+ * @param {number} period
486
+ * @returns {Array<number|null>}
487
+ */
488
+ export function calcATR(bars, period = 14) {
489
+ const out = new Array(bars.length).fill(null);
490
+ if (period < 1 || bars.length < period) return out;
491
+ const tr = calcTrueRange(bars);
492
+ let sum = 0;
493
+ for (let i = 0; i < period; i++) sum += tr[i];
494
+ let prev = sum / period;
495
+ out[period - 1] = prev;
496
+ for (let i = period; i < bars.length; i++) {
497
+ prev = (prev * (period - 1) + tr[i]) / period;
498
+ out[i] = prev;
499
+ }
500
+ return out;
501
+ }
502
+
503
+ /**
504
+ * Volume-weighted average price over the hlc3 typical price, anchored to
505
+ * each UTC day (resets at the session boundary).
506
+ * @param {Bar[]} bars
507
+ * @returns {Array<number|null>}
508
+ */
509
+ export function calcVWAP(bars) {
510
+ const out = new Array(bars.length).fill(null);
511
+ let pv = 0;
512
+ let vv = 0;
513
+ let day = null;
514
+ for (let i = 0; i < bars.length; i++) {
515
+ const b = bars[i];
516
+ const ms = b.time < 1e12 ? b.time * 1000 : b.time;
517
+ const d = Math.floor(ms / DAY);
518
+ if (d !== day) {
519
+ day = d;
520
+ pv = 0;
521
+ vv = 0;
522
+ }
523
+ pv += ((b.high + b.low + b.close) / 3) * b.volume;
524
+ vv += b.volume;
525
+ out[i] = vv > 0 ? pv / vv : null;
526
+ }
527
+ return out;
528
+ }
529
+
530
+ /**
531
+ * On-balance volume: cumulative volume signed by close-to-close direction.
532
+ * @param {Bar[]} bars
533
+ * @returns {Array<number|null>}
534
+ */
535
+ export function calcOBV(bars) {
536
+ const out = new Array(bars.length).fill(null);
537
+ let obv = 0;
538
+ for (let i = 0; i < bars.length; i++) {
539
+ if (i > 0) {
540
+ const d = bars[i].close - bars[i - 1].close;
541
+ obv += d > 0 ? bars[i].volume : d < 0 ? -bars[i].volume : 0;
542
+ }
543
+ out[i] = obv;
544
+ }
545
+ return out;
546
+ }
547
+
548
+ /** Highest-high / lowest-low window ending at `i` (shared by stoch/wr/donchian). */
549
+ function winHL(bars, i, period) {
550
+ let hh = -Infinity;
551
+ let ll = Infinity;
552
+ for (let j = i - period + 1; j <= i; j++) {
553
+ if (bars[j].high > hh) hh = bars[j].high;
554
+ if (bars[j].low < ll) ll = bars[j].low;
555
+ }
556
+ return [hh, ll];
557
+ }
558
+
559
+ /** SMA that tolerates leading nulls (windows over sparse raw series). */
560
+ function smaSparse(values, period) {
561
+ const out = new Array(values.length).fill(null);
562
+ let sum = 0;
563
+ let count = 0;
564
+ for (let i = 0; i < values.length; i++) {
565
+ const v = values[i];
566
+ if (isNum(v)) {
567
+ sum += v;
568
+ count++;
569
+ }
570
+ if (i >= period && isNum(values[i - period])) {
571
+ sum -= values[i - period];
572
+ count--;
573
+ }
574
+ if (count === period) out[i] = sum / period;
575
+ }
576
+ return out;
577
+ }
578
+
579
+ /**
580
+ * Stochastic oscillator (slow): raw %K over `period`, smoothed by `smooth`;
581
+ * %D is the SMA of %K.
582
+ * @param {Bar[]} bars
583
+ * @param {number} period
584
+ * @param {number} smooth
585
+ * @returns {{k:Array<number|null>, d:Array<number|null>}}
586
+ */
587
+ export function calcStoch(bars, period = 14, smooth = 3) {
588
+ const n = bars.length;
589
+ const raw = new Array(n).fill(null);
590
+ for (let i = period - 1; i < n; i++) {
591
+ const [hh, ll] = winHL(bars, i, period);
592
+ const span = hh - ll;
593
+ raw[i] = span > 0 ? ((bars[i].close - ll) / span) * 100 : null;
594
+ }
595
+ const k = smooth > 1 ? smaSparse(raw, smooth) : raw;
596
+ const d = smooth > 1 ? smaSparse(k, smooth) : k;
597
+ return { k, d };
598
+ }
599
+
600
+ /**
601
+ * Commodity Channel Index: typical price vs its SMA, scaled by mean deviation.
602
+ * @param {Bar[]} bars
603
+ * @param {number} period
604
+ * @returns {Array<number|null>}
605
+ */
606
+ export function calcCCI(bars, period = 20) {
607
+ const n = bars.length;
608
+ const out = new Array(n).fill(null);
609
+ if (period < 1 || n < period) return out;
610
+ const tp = bars.map((b) => (b.high + b.low + b.close) / 3);
611
+ const ma = calcSMA(tp, period);
612
+ for (let i = period - 1; i < n; i++) {
613
+ let md = 0;
614
+ for (let j = i - period + 1; j <= i; j++) md += Math.abs(tp[j] - ma[i]);
615
+ md /= period;
616
+ out[i] = md > 0 ? (tp[i] - ma[i]) / (0.015 * md) : 0;
617
+ }
618
+ return out;
619
+ }
620
+
621
+ /**
622
+ * Williams %R: −100 at the period low, 0 at the period high.
623
+ * @param {Bar[]} bars
624
+ * @param {number} period
625
+ * @returns {Array<number|null>}
626
+ */
627
+ export function calcWilliamsR(bars, period = 14) {
628
+ const n = bars.length;
629
+ const out = new Array(n).fill(null);
630
+ for (let i = period - 1; i < n; i++) {
631
+ const [hh, ll] = winHL(bars, i, period);
632
+ const span = hh - ll;
633
+ if (span <= 0) continue;
634
+ const r = ((hh - bars[i].close) / span) * -100;
635
+ out[i] = r === 0 ? 0 : r; // avoid −0 on the axis
636
+ }
637
+ return out;
638
+ }
639
+
640
+ /**
641
+ * Donchian channels: highest high / lowest low over `period`, plus mid.
642
+ * @param {Bar[]} bars
643
+ * @param {number} period
644
+ * @returns {{upper:Array<number|null>, mid:Array<number|null>, lower:Array<number|null>}}
645
+ */
646
+ export function calcDonchian(bars, period = 20) {
647
+ const n = bars.length;
648
+ const upper = new Array(n).fill(null);
649
+ const mid = new Array(n).fill(null);
650
+ const lower = new Array(n).fill(null);
651
+ for (let i = period - 1; i < n; i++) {
652
+ const [hh, ll] = winHL(bars, i, period);
653
+ upper[i] = hh;
654
+ lower[i] = ll;
655
+ mid[i] = (hh + ll) / 2;
656
+ }
657
+ return { upper, mid, lower };
658
+ }
659
+
660
+ /**
661
+ * Keltner channels: EMA mid ± mult × ATR.
662
+ * @param {Bar[]} bars
663
+ * @param {number} period
664
+ * @param {number} [mult]
665
+ * @returns {{upper:Array<number|null>, mid:Array<number|null>, lower:Array<number|null>}}
666
+ */
667
+ export function calcKeltner(bars, period = 20, mult = 2) {
668
+ const mid = calcEMA(bars.map((b) => b.close), period);
669
+ const atr = calcATR(bars, period);
670
+ const band = (f) => mid.map((m, i) => (isNum(m) && isNum(atr[i]) ? f(m, atr[i]) : null));
671
+ return { upper: band((m, a) => m + mult * a), mid, lower: band((m, a) => m - mult * a) };
672
+ }
673
+
674
+ /**
675
+ * SuperTrend: ATR bands that flip with the trend. Returns the trend line
676
+ * (support in uptrends, resistance in downtrends) with a one-bar null gap
677
+ * at flips so the renderer breaks the line.
678
+ * @param {Bar[]} bars
679
+ * @param {number} period
680
+ * @param {number} [mult]
681
+ * @returns {Array<number|null>}
682
+ */
683
+ export function calcSuperTrend(bars, period = 10, mult = 3) {
684
+ const n = bars.length;
685
+ const out = new Array(n).fill(null);
686
+ if (period < 1 || n < period) return out;
687
+ const atr = calcATR(bars, period);
688
+ let dir = 1;
689
+ let fUp = Infinity;
690
+ let fLo = -Infinity;
691
+ let started = false;
692
+ for (let i = 0; i < n; i++) {
693
+ if (!isNum(atr[i])) continue;
694
+ const b = bars[i];
695
+ const hl2 = (b.high + b.low) / 2;
696
+ const bUp = hl2 + mult * atr[i];
697
+ const bLo = hl2 - mult * atr[i];
698
+ if (!started) {
699
+ started = true;
700
+ fUp = bUp;
701
+ fLo = bLo;
702
+ dir = b.close >= hl2 ? 1 : -1;
703
+ out[i] = dir > 0 ? fLo : fUp;
704
+ continue;
705
+ }
706
+ const pc = bars[i - 1].close;
707
+ // carry a band forward unless it tightened, or the previous close broke it
708
+ fUp = bUp < fUp || pc > fUp ? bUp : fUp;
709
+ fLo = bLo > fLo || pc < fLo ? bLo : fLo;
710
+ const prevDir = dir;
711
+ if (b.close > fUp) dir = 1;
712
+ else if (b.close < fLo) dir = -1;
713
+ out[i] = dir === prevDir ? (dir > 0 ? fLo : fUp) : null;
714
+ }
715
+ return out;
716
+ }
717
+
465
718
  /* ------------------------------------------------------------------ *
466
719
  * Data merging & gaps
467
720
  * ------------------------------------------------------------------ */
@@ -823,6 +1076,44 @@ export const BUILTIN_INDICATORS = new Map(
823
1076
  params: { period: 50 },
824
1077
  compute: (bars, p) => calcEMA(closesOf(bars), p.period),
825
1078
  },
1079
+ vwap: {
1080
+ kind: 'overlay',
1081
+ params: {},
1082
+ compute: (bars) => calcVWAP(bars),
1083
+ },
1084
+ supertrend: {
1085
+ kind: 'overlay',
1086
+ params: { period: 10, mult: 3 },
1087
+ compute: (bars, p) => calcSuperTrend(bars, p.period, p.mult),
1088
+ },
1089
+ donchian: {
1090
+ kind: 'overlay',
1091
+ params: { period: 20 },
1092
+ compute: (bars, p) => {
1093
+ const c = calcDonchian(bars, p.period);
1094
+ return {
1095
+ lines: [
1096
+ { name: 'upper', values: c.upper },
1097
+ { name: 'mid', values: c.mid },
1098
+ { name: 'lower', values: c.lower },
1099
+ ],
1100
+ };
1101
+ },
1102
+ },
1103
+ keltner: {
1104
+ kind: 'overlay',
1105
+ params: { period: 20, mult: 2 },
1106
+ compute: (bars, p) => {
1107
+ const c = calcKeltner(bars, p.period, p.mult);
1108
+ return {
1109
+ lines: [
1110
+ { name: 'upper', values: c.upper },
1111
+ { name: 'mid', values: c.mid },
1112
+ { name: 'lower', values: c.lower },
1113
+ ],
1114
+ };
1115
+ },
1116
+ },
826
1117
  bb: {
827
1118
  kind: 'overlay',
828
1119
  params: { period: 20, mult: 2 },
@@ -862,6 +1153,49 @@ export const BUILTIN_INDICATORS = new Map(
862
1153
  };
863
1154
  },
864
1155
  },
1156
+ atr: {
1157
+ kind: 'pane',
1158
+ params: { period: 14 },
1159
+ fmt: 'price',
1160
+ compute: (bars, p) => calcATR(bars, p.period),
1161
+ },
1162
+ stoch: {
1163
+ kind: 'pane',
1164
+ params: { period: 14, smooth: 3 },
1165
+ guides: [20, 80],
1166
+ range: [0, 100],
1167
+ fmt: 'fixed1',
1168
+ compute: (bars, p) => {
1169
+ const r = calcStoch(bars, p.period, p.smooth);
1170
+ return {
1171
+ lines: [
1172
+ { name: 'k', values: r.k },
1173
+ { name: 'd', values: r.d },
1174
+ ],
1175
+ };
1176
+ },
1177
+ },
1178
+ obv: {
1179
+ kind: 'pane',
1180
+ params: {},
1181
+ fmt: 'compact',
1182
+ compute: (bars) => calcOBV(bars),
1183
+ },
1184
+ cci: {
1185
+ kind: 'pane',
1186
+ params: { period: 20 },
1187
+ guides: [-100, 100],
1188
+ fmt: 'fixed1',
1189
+ compute: (bars, p) => calcCCI(bars, p.period),
1190
+ },
1191
+ wr: {
1192
+ kind: 'pane',
1193
+ params: { period: 14 },
1194
+ guides: [-80, -20],
1195
+ range: [-100, 0],
1196
+ fmt: 'fixed1',
1197
+ compute: (bars, p) => calcWilliamsR(bars, p.period),
1198
+ },
865
1199
  })
866
1200
  );
867
1201
 
@@ -977,6 +1311,10 @@ const SCRIPT_FUNCS = {
977
1311
  max: { min: 2, max: 2 },
978
1312
  crossup: { min: 2, max: 2 },
979
1313
  crossdown: { min: 2, max: 2 },
1314
+ // bar-level functions — no leading series argument, they read OHLCV directly
1315
+ vwap: { min: 0, max: 0 },
1316
+ obv: { min: 0, max: 0 },
1317
+ atr: { min: 1, max: 1, scalar: [0] },
980
1318
  };
981
1319
 
982
1320
  const scriptErr = (msg) => new Error('script: ' + msg);
@@ -1219,20 +1557,20 @@ function binOp(op, a, b) {
1219
1557
  return NaN;
1220
1558
  }
1221
1559
 
1222
- function evalScriptNode(node, vars, n) {
1560
+ function evalScriptNode(node, vars, n, bars) {
1223
1561
  switch (node.type) {
1224
1562
  case 'num':
1225
1563
  return node.v;
1226
1564
  case 'var':
1227
1565
  return vars[node.name];
1228
1566
  case 'neg': {
1229
- const e = evalScriptNode(node.e, vars, n);
1567
+ const e = evalScriptNode(node.e, vars, n, bars);
1230
1568
  if (!Array.isArray(e)) return -e;
1231
1569
  return e.map((x) => (x == null ? NaN : -x));
1232
1570
  }
1233
1571
  case 'bin': {
1234
- const l = evalScriptNode(node.l, vars, n);
1235
- const r = evalScriptNode(node.r, vars, n);
1572
+ const l = evalScriptNode(node.l, vars, n, bars);
1573
+ const r = evalScriptNode(node.r, vars, n, bars);
1236
1574
  if (!Array.isArray(l) && !Array.isArray(r)) return binOp(node.op, l, r);
1237
1575
  const a = Array.isArray(l) ? l : new Array(n).fill(l);
1238
1576
  const b = Array.isArray(r) ? r : new Array(n).fill(r);
@@ -1241,14 +1579,18 @@ function evalScriptNode(node, vars, n) {
1241
1579
  return out;
1242
1580
  }
1243
1581
  case 'call':
1244
- return evalScriptCall(node, vars, n);
1582
+ return evalScriptCall(node, vars, n, bars);
1245
1583
  }
1246
1584
  return NaN;
1247
1585
  }
1248
1586
 
1249
- function evalScriptCall(node, vars, n) {
1587
+ function evalScriptCall(node, vars, n, bars) {
1250
1588
  const { name, args } = node;
1251
- const s0 = evalScriptNode(args[0], vars, n);
1589
+ // bar-level functions read several series at once — no leading series argument
1590
+ if (name === 'vwap') return calcVWAP(bars);
1591
+ if (name === 'obv') return calcOBV(bars);
1592
+ if (name === 'atr') return calcATR(bars, args[0].type === 'num' ? args[0].v : 1);
1593
+ const s0 = evalScriptNode(args[0], vars, n, bars);
1252
1594
  const a = Array.isArray(s0) ? s0 : new Array(n).fill(s0);
1253
1595
  // window functions must not read leading nulls as 0 — NaN them so results stay honest
1254
1596
  const clean = a.map((x) => (x == null ? NaN : x));
@@ -1287,13 +1629,13 @@ function evalScriptCall(node, vars, n) {
1287
1629
  case 'log': return clean.map((x) => (x <= 0 ? NaN : Math.log(x)));
1288
1630
  case 'min':
1289
1631
  case 'max': {
1290
- const b0 = evalScriptNode(args[1], vars, n);
1632
+ const b0 = evalScriptNode(args[1], vars, n, bars);
1291
1633
  const b = Array.isArray(b0) ? b0 : new Array(n).fill(b0);
1292
1634
  return a.map((x, i) => (name === 'min' ? Math.min(scriptNum(x), scriptNum(b[i])) : Math.max(scriptNum(x), scriptNum(b[i]))));
1293
1635
  }
1294
1636
  case 'crossup':
1295
1637
  case 'crossdown': {
1296
- const b0 = evalScriptNode(args[1], vars, n);
1638
+ const b0 = evalScriptNode(args[1], vars, n, bars);
1297
1639
  const b = Array.isArray(b0) ? b0 : new Array(n).fill(b0);
1298
1640
  const out = new Array(n).fill(0);
1299
1641
  for (let i = 1; i < n; i++) {
@@ -1331,7 +1673,7 @@ export function evalScript(compiled, bars) {
1331
1673
  hlc3: bars.map((b) => (b.high + b.low + b.close) / 3),
1332
1674
  ohlc4: bars.map((b) => (b.open + b.high + b.low + b.close) / 4),
1333
1675
  };
1334
- const res = evalScriptNode(c.ast, vars, n);
1676
+ const res = evalScriptNode(c.ast, vars, n, bars);
1335
1677
  const arr = Array.isArray(res) ? res : new Array(n).fill(res);
1336
1678
  for (let i = 0; i < n; i++) {
1337
1679
  const v = arr[i];
@@ -2395,7 +2737,7 @@ export const AI_TOOLS = [
2395
2737
  {
2396
2738
  tool: 'set_indicators',
2397
2739
  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.',
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.',
2399
2741
  args: { indicators: 'string — space/comma-separated tokens' },
2400
2742
  },
2401
2743
  {