wickchart 0.3.0 → 0.4.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
@@ -125,6 +125,7 @@ chart.setData([
125
125
  | `stats` | off | Live statistics chip for the visible range |
126
126
  | `profile` | off | Volume profile overlay (POC + 70% value area) |
127
127
  | `annotations` | off | Smart annotations (volume spikes, gaps, pivots, RSI divergences) |
128
+ | `volshading` | off | Volatility-regime background shading (see below) |
128
129
 
129
130
  \* `indicators=""` disables everything, including volume. Token syntax:
130
131
  `name[:param[/param…]][@color]` — e.g. `sma:20@#ff0000`, `macd:12/26/9`.
@@ -168,6 +169,112 @@ chart.indicators = 'vwap:20';
168
169
  `HabChart.registerIndicator(...)` (the element is registered as a side effect
169
170
  of importing the package).
170
171
 
172
+ ### HabScript — custom indicators as expressions
173
+
174
+ No build step, no JS: write an indicator inline in the attribute. `expr:{…}`
175
+ draws on the price chart; `pexpr:{…}` gets its own pane. Add an optional
176
+ `@color`, mix freely with named indicators, and it all round-trips through
177
+ shareable URLs.
178
+
179
+ ```html
180
+ <hab-chart indicators="sma:20 expr:{(close - sma(close,20)) / sma(close,20) * 100}@ff6a00"></hab-chart>
181
+
182
+ <!-- oscillator in its own pane -->
183
+ <hab-chart indicators="pexpr:{rsi(close,14)} pexpr:{change(close) / close * 100}"></hab-chart>
184
+ ```
185
+
186
+ | Series variables | |
187
+ |---|---|
188
+ | `open` `high` `low` `close` `volume` | raw bar fields |
189
+ | `hl2` `hlc3` `ohlc4` | classic derived prices |
190
+
191
+ | Functions | |
192
+ |---|---|
193
+ | `sma(x,n)` `ema(x,n)` `wma(x,n)` `stddev(x,n)` | moving stats (window `n` must be a whole number ≥ 1) |
194
+ | `rsi(x,n)` | RSI of any series |
195
+ | `hh(x,n)` `ll(x,n)` | rolling highest / lowest |
196
+ | `prev(x[,k])` `change(x)` | shifted series / bar-to-bar delta |
197
+ | `abs(x)` `sqrt(x)` `log(x)` `min(a,b)` `max(a,b)` | element-wise math |
198
+ | `crossup(a,b)` `crossdown(a,b)` | 1 on a strict cross, else 0 |
199
+
200
+ Operators are `+ - * / %` with usual precedence, unary `-`, and parentheses.
201
+ Values before a window fills are `NaN` (not drawn), division by zero yields
202
+ `NaN`, and identifiers are case-insensitive.
203
+
204
+ The expression is compiled by a hand-written tokenizer + recursive-descent
205
+ parser in `wickchart/core` — **no `eval`, no `new Function`** — with caps on
206
+ length (512), tokens (128) and nesting (24). Invalid scripts are reported via
207
+ the parse result's `unknown` list and simply not drawn; they can never execute
208
+ anything.
209
+
210
+ Programmatically, compile once and reuse, or register it under a name for the
211
+ attribute syntax:
212
+
213
+ ```js
214
+ import { scriptIndicator } from 'wickchart/core';
215
+
216
+ HabChart.registerIndicator('spread', scriptIndicator('close - ema(close,21)'));
217
+ chart.indicators = 'spread'; // now usable like any built-in
218
+ ```
219
+
220
+ The demo has a live input for it (type an expression, optionally tick *pane*,
221
+ press **+ Expr** — invalid expressions show the compiler's error inline).
222
+
223
+ ### Volatility-regime shading
224
+
225
+ `<hab-chart volshading>` tints the price pane background by realized
226
+ volatility — the rolling stddev of log returns (20 bars by default),
227
+ classified against its own full-history percentiles: **calm** (≤ 30th
228
+ percentile, subtle blue), **normal** (untinted), **hot** (≥ 70th percentile,
229
+ subtle red). Market state at a glance: quiet ranges and violent expansions
230
+ read instantly, and the legend shows the hovered bar's regime and
231
+ percentile (`VOL 30/70 · hot · 94%ile`).
232
+
233
+ ```html
234
+ <hab-chart volshading></hab-chart> <!-- defaults 30/70, 20 bars -->
235
+ <hab-chart volshading="20/85"></hab-chart> <!-- custom cutoffs -->
236
+ <hab-chart volshading="20/85/50"></hab-chart> <!-- + 50-bar vol window -->
237
+ ```
238
+
239
+ Cutoffs are clamped so the low percentile always stays at least 2 points
240
+ below the high one; the toggle and custom cutoffs round-trip through
241
+ shareable URLs (`vsh=1` / `vsh=20/85`). A degenerate history (flat series)
242
+ classifies everything as normal. The pieces are exported from
243
+ `wickchart/core` (`calcRealizedVol`, `volRegimeBands`, `percentileOfSorted`)
244
+ if you want to build on them.
245
+
246
+ ### AI-ready data window — `getDataWindow()`
247
+
248
+ One call turns whatever is on screen into a compact, LLM-pasteable summary.
249
+ Everything is computed locally from the visible bars — trend (least-squares
250
+ drift + fit), realized-vol percentile, SMA/RSI snapshot, up/down bar mix,
251
+ volume profile notes, and the same pattern detection that powers smart
252
+ annotations (gaps, spikes, pivots, divergences). Nothing leaves the page
253
+ until you copy it somewhere.
254
+
255
+ ```js
256
+ const s = chart.getDataWindow();
257
+ s.text; // markdown — ready to paste into any AI chat
258
+ s.trend; // { label: 'strong uptrend', slopePctPerBar: 0.77, r2: 0.94 }
259
+ s.volPctile; // 84 → hot regime relative to the window itself
260
+ s.patterns; // [{ time, note }] — most recent first
261
+ ```
262
+
263
+ `text` renders like:
264
+
265
+ ```
266
+ CHART SUMMARY — BTC · 1h · 214 bars · 2026-08-21 → 2026-09-07
267
+ - Close 97.03 (−1.20% over window). High 104.20 on 2026-08-28, low 91.40 on 2026-09-01. Max drawdown 8.1%.
268
+ - Trend: downtrend (drift −0.061%/bar, fit r² 0.58). Price below SMA20 (99.10). RSI(14) 41.3.
269
+ - Volatility: annualized 48%; latest realized vol at the 84th percentile of the window (hot regime).
270
+ - Bars: 96 up / 117 down. Volume avg 1.2K/bar, peak 8.9K on 2026-09-01.
271
+ - Notable: Gapped down −1.42% (2026-09-01); Volume 4.1× average (2026-09-03).
272
+ ```
273
+
274
+ The demo's **Explain** button shows this in a panel with a one-click copy.
275
+ The pure function behind it (`windowSummary(bars, i0, i1, opts)`) is exported
276
+ from `wickchart/core` for server-side use.
277
+
171
278
  ### Sonification — the chart by ear
172
279
 
173
280
  `<hab-chart sonify>` maps price to pitch (180–880 Hz across the visible
@@ -243,6 +350,7 @@ chart.indicators = 'vwap';
243
350
  | `getVisibleRange()` | → `{ from, to }` (ms timestamps) |
244
351
  | `setVisibleRange({from, to})` | Jump to a time window |
245
352
  | `exportPNG()` | → PNG data URL of the current canvas |
353
+ | `getDataWindow()` | → AI-ready summary of the visible window (see below) |
246
354
  | `getState()` | → serializable snapshot (type, indicators, view, positions, alerts) |
247
355
  | `setState(state)` | Apply a snapshot; a pending view applies after the next `setData()` |
248
356
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wickchart",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "<hab-chart> — a modern, dependency-free financial charting web component. Candles, line & area charts, crosshair, zoom/pan, indicators, live streaming, theming.",
5
5
  "type": "module",
6
6
  "main": "src/hab-chart.js",
package/src/core.js CHANGED
@@ -410,6 +410,23 @@ export function calcStdDev(values, period) {
410
410
  return out;
411
411
  }
412
412
 
413
+ /** Linear-weighted moving average (most recent bar weighs `period`), aligned like SMA.
414
+ * @param {number[]} values
415
+ * @param {number} period
416
+ * @returns {Array<number|null>}
417
+ */
418
+ export function calcWMA(values, period) {
419
+ const out = new Array(values.length).fill(null);
420
+ if (period < 1 || values.length < period) return out;
421
+ const denom = (period * (period + 1)) / 2;
422
+ for (let i = period - 1; i < values.length; i++) {
423
+ let sum = 0;
424
+ for (let j = 0; j < period; j++) sum += values[i - j] * (period - j);
425
+ out[i] = sum / denom;
426
+ }
427
+ return out;
428
+ }
429
+
413
430
  /**
414
431
  * Bollinger Bands.
415
432
  * @param {number[]} closes
@@ -849,7 +866,8 @@ export const BUILTIN_INDICATORS = new Map(
849
866
 
850
867
  /**
851
868
  * Parse an `indicators` attribute string against a registry.
852
- * Token: `name[:param[/param…]][@color]`, plus the `volume` keyword.
869
+ * Token: `name[:param[/param…]][@color]`, the `volume` keyword, and
870
+ * HabScript blobs `expr:{…}` (overlay) / `pexpr:{…}` (separate pane).
853
871
  * @param {string|null|undefined} str
854
872
  * @param {Map<string, IndicatorDef>} registry
855
873
  * @returns {{overlays: IndicatorEntry[], panes: IndicatorEntry[], volume: boolean, unknown: string[]}}
@@ -858,8 +876,32 @@ export function parseIndicators(str, registry) {
858
876
  const out = { overlays: [], panes: [], volume: false, unknown: [] };
859
877
  if (str == null || str === '') return out;
860
878
  const seen = new Set();
861
- for (const raw of String(str).split(/[\s,;]+/)) {
862
- if (!raw) continue;
879
+ for (const raw of splitIndicatorTokens(str)) {
880
+ const em = raw.match(/^(p?expr):\{([^{}]*)\}(@\S*)?$/i);
881
+ if (em) {
882
+ const pane = em[1].toLowerCase() === 'pexpr';
883
+ const src = em[2].trim();
884
+ let def;
885
+ try {
886
+ def = scriptIndicator(src, { pane });
887
+ } catch (err) {
888
+ out.unknown.push(em[1] + ':{' + src + '}');
889
+ continue;
890
+ }
891
+ const key = (pane ? 'pexpr' : 'expr') + ':{' + src.toLowerCase() + '}';
892
+ if (seen.has(key)) continue;
893
+ seen.add(key);
894
+ const entry = {
895
+ name: pane ? 'pexpr' : 'expr',
896
+ def,
897
+ params: {},
898
+ color: em[3] ? em[3].slice(1) : null,
899
+ key,
900
+ };
901
+ if (pane) out.panes.push(entry);
902
+ else out.overlays.push(entry);
903
+ continue;
904
+ }
863
905
  const m = raw.match(/^([A-Za-z][A-Za-z0-9_]*)(?::([^@]*))?(@.+)?$/);
864
906
  if (!m) continue;
865
907
  const [, name, paramStr, colorStr] = m;
@@ -897,6 +939,386 @@ export function parseIndicators(str, registry) {
897
939
  return out;
898
940
  }
899
941
 
942
+ /* ------------------------------------------------------------------ *
943
+ * HabScript — safe expression mini-language for custom indicators
944
+ *
945
+ * `expr:{(close - sma(close,20)) / sma(close,20)}` compiles through a
946
+ * hand-written tokenizer + recursive-descent parser (no eval / Function)
947
+ * and evaluates element-wise over the bar series.
948
+ * ------------------------------------------------------------------ */
949
+
950
+ const SCRIPT_MAX_LEN = 512;
951
+ const SCRIPT_MAX_TOKENS = 128;
952
+ const SCRIPT_MAX_DEPTH = 24;
953
+
954
+ /** Series variables available inside expressions. */
955
+ const SCRIPT_VARS = ['open', 'high', 'low', 'close', 'volume', 'hl2', 'hlc3', 'ohlc4'];
956
+
957
+ /**
958
+ * Functions available inside expressions. `scalar` lists argument indexes
959
+ * that must be plain whole-number literals (periods / shifts).
960
+ */
961
+ const SCRIPT_FUNCS = {
962
+ sma: { min: 2, max: 2, scalar: [1] },
963
+ ema: { min: 2, max: 2, scalar: [1] },
964
+ wma: { min: 2, max: 2, scalar: [1] },
965
+ stddev: { min: 2, max: 2, scalar: [1] },
966
+ rsi: { min: 2, max: 2, scalar: [1] },
967
+ hh: { min: 2, max: 2, scalar: [1] },
968
+ ll: { min: 2, max: 2, scalar: [1] },
969
+ prev: { min: 1, max: 2, scalar: [1] },
970
+ change: { min: 1, max: 1 },
971
+ abs: { min: 1, max: 1 },
972
+ sqrt: { min: 1, max: 1 },
973
+ log: { min: 1, max: 1 },
974
+ min: { min: 2, max: 2 },
975
+ max: { min: 2, max: 2 },
976
+ crossup: { min: 2, max: 2 },
977
+ crossdown: { min: 2, max: 2 },
978
+ };
979
+
980
+ const scriptErr = (msg) => new Error('script: ' + msg);
981
+
982
+ /**
983
+ * Split an indicators string into tokens, keeping `expr:{…}` / `pexpr:{…}`
984
+ * blobs atomic — spaces and commas inside the braces are preserved, and an
985
+ * optional `@color` suffix directly after `}` stays attached.
986
+ * Separators are whitespace, `,` and `;`.
987
+ * @param {string|null|undefined} str
988
+ * @returns {string[]}
989
+ */
990
+ export function splitIndicatorTokens(str) {
991
+ const out = [];
992
+ const s = String(str == null ? '' : str);
993
+ let i = 0;
994
+ while (i < s.length) {
995
+ while (i < s.length && /[\s,;]/.test(s[i])) i++;
996
+ if (i >= s.length) break;
997
+ let j = i;
998
+ if (/^(p?expr):\{/i.test(s.slice(i))) {
999
+ const end = s.indexOf('}', i);
1000
+ if (end === -1) {
1001
+ out.push(s.slice(i)); // unterminated → caller rejects the token
1002
+ break;
1003
+ }
1004
+ j = end + 1;
1005
+ if (s[j] === '@') {
1006
+ j++;
1007
+ while (j < s.length && !/[\s,;]/.test(s[j])) j++;
1008
+ }
1009
+ } else {
1010
+ while (j < s.length && !/[\s,;]/.test(s[j])) j++;
1011
+ }
1012
+ out.push(s.slice(i, j));
1013
+ i = j;
1014
+ }
1015
+ return out.filter(Boolean);
1016
+ }
1017
+
1018
+ /** Tokenize an expression (numbers, identifiers, operators, `( ) ,`). */
1019
+ function tokenizeScript(src) {
1020
+ if (typeof src !== 'string' || !src.trim()) throw scriptErr('empty expression');
1021
+ if (src.length > SCRIPT_MAX_LEN) throw scriptErr(`expression longer than ${SCRIPT_MAX_LEN} chars`);
1022
+ const toks = [];
1023
+ let i = 0;
1024
+ while (i < src.length) {
1025
+ const c = src[i];
1026
+ if (c === ' ' || c === '\t' || c === '\n' || c === '\r') {
1027
+ i++;
1028
+ continue;
1029
+ }
1030
+ const isDigit = c >= '0' && c <= '9';
1031
+ if (isDigit || (c === '.' && src[i + 1] >= '0' && src[i + 1] <= '9')) {
1032
+ const m = src.slice(i).match(/^\d*\.?\d+/);
1033
+ toks.push({ t: 'num', v: parseFloat(m[0]) });
1034
+ i += m[0].length;
1035
+ continue;
1036
+ }
1037
+ const isAlpha = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c === '_';
1038
+ if (isAlpha) {
1039
+ const m = src.slice(i).match(/^[A-Za-z_][A-Za-z0-9_]*/);
1040
+ toks.push({ t: 'id', v: m[0] });
1041
+ i += m[0].length;
1042
+ continue;
1043
+ }
1044
+ if (c === '+' || c === '-' || c === '*' || c === '/' || c === '%') {
1045
+ toks.push({ t: 'op', v: c });
1046
+ i++;
1047
+ continue;
1048
+ }
1049
+ if (c === '(' || c === ')' || c === ',') {
1050
+ toks.push({ t: c });
1051
+ i++;
1052
+ continue;
1053
+ }
1054
+ throw scriptErr(`unexpected character "${c}"`);
1055
+ }
1056
+ if (!toks.length) throw scriptErr('empty expression');
1057
+ if (toks.length > SCRIPT_MAX_TOKENS) throw scriptErr(`more than ${SCRIPT_MAX_TOKENS} tokens`);
1058
+ return toks;
1059
+ }
1060
+
1061
+ /** Recursive-descent parse into a small AST; validates identifiers, calls and arities. */
1062
+ function parseScript(src) {
1063
+ const toks = tokenizeScript(src);
1064
+ let p = 0;
1065
+ const peek = () => toks[p];
1066
+
1067
+ function parseAdd(depth) {
1068
+ let l = parseMul(depth);
1069
+ while (peek() && peek().t === 'op' && (peek().v === '+' || peek().v === '-')) {
1070
+ const op = toks[p++].v;
1071
+ l = { type: 'bin', op, l, r: parseMul(depth) };
1072
+ }
1073
+ return l;
1074
+ }
1075
+ function parseMul(depth) {
1076
+ let l = parseUnary(depth);
1077
+ while (peek() && peek().t === 'op' && (peek().v === '*' || peek().v === '/' || peek().v === '%')) {
1078
+ const op = toks[p++].v;
1079
+ l = { type: 'bin', op, l, r: parseUnary(depth) };
1080
+ }
1081
+ return l;
1082
+ }
1083
+ function parseUnary(depth) {
1084
+ if (depth > SCRIPT_MAX_DEPTH) throw scriptErr('expression too deeply nested');
1085
+ const t = peek();
1086
+ if (t && t.t === 'op' && (t.v === '-' || t.v === '+')) {
1087
+ p++;
1088
+ const e = parseUnary(depth + 1);
1089
+ return t.v === '-' ? { type: 'neg', e } : e;
1090
+ }
1091
+ return parseAtom(depth + 1);
1092
+ }
1093
+ function parseAtom(depth) {
1094
+ if (depth > SCRIPT_MAX_DEPTH) throw scriptErr('expression too deeply nested');
1095
+ const t = toks[p++];
1096
+ if (!t) throw scriptErr('unexpected end of expression');
1097
+ if (t.t === 'num') return { type: 'num', v: t.v };
1098
+ if (t.t === 'id') {
1099
+ const name = t.v.toLowerCase();
1100
+ if (peek() && peek().t === '(') {
1101
+ p++;
1102
+ const args = [];
1103
+ if (peek() && peek().t !== ')') {
1104
+ args.push(parseAdd(depth));
1105
+ while (peek() && peek().t === ',') {
1106
+ p++;
1107
+ args.push(parseAdd(depth));
1108
+ }
1109
+ }
1110
+ const close = toks[p++];
1111
+ if (!close || close.t !== ')') throw scriptErr(`missing ")" after ${name}(`);
1112
+ return { type: 'call', name, args };
1113
+ }
1114
+ if (!SCRIPT_VARS.includes(name)) throw scriptErr(`unknown identifier "${t.v}"`);
1115
+ return { type: 'var', name };
1116
+ }
1117
+ if (t.t === '(') {
1118
+ const e = parseAdd(depth);
1119
+ const close = toks[p++];
1120
+ if (!close || close.t !== ')') throw scriptErr('missing ")"');
1121
+ return e;
1122
+ }
1123
+ throw scriptErr(`unexpected token "${t.t === 'op' ? t.v : t.t}"`);
1124
+ }
1125
+
1126
+ const ast = parseAdd(0);
1127
+ if (p < toks.length) throw scriptErr('unexpected trailing input');
1128
+ validateScriptNode(ast);
1129
+ return ast;
1130
+ }
1131
+
1132
+ function validateScriptNode(n) {
1133
+ if (!n || n.type === 'num' || n.type === 'var') return;
1134
+ if (n.type === 'neg') return validateScriptNode(n.e);
1135
+ if (n.type === 'bin') {
1136
+ validateScriptNode(n.l);
1137
+ validateScriptNode(n.r);
1138
+ return;
1139
+ }
1140
+ if (n.type === 'call') {
1141
+ const spec = SCRIPT_FUNCS[n.name];
1142
+ if (!spec) throw scriptErr(`unknown function "${n.name}"`);
1143
+ if (n.args.length < spec.min || n.args.length > spec.max) {
1144
+ const want = spec.min === spec.max ? String(spec.min) : `${spec.min}–${spec.max}`;
1145
+ throw scriptErr(`${n.name}() takes ${want} argument${spec.max === 1 ? '' : 's'} (got ${n.args.length})`);
1146
+ }
1147
+ n.args.forEach((a, i) => {
1148
+ if (spec.scalar && spec.scalar.includes(i)) {
1149
+ if (a.type !== 'num' || !Number.isInteger(a.v) || a.v < 1) {
1150
+ throw scriptErr(`${n.name}() argument ${i + 1} must be a whole number ≥ 1`);
1151
+ }
1152
+ }
1153
+ validateScriptNode(a);
1154
+ });
1155
+ }
1156
+ }
1157
+
1158
+ /**
1159
+ * Compile a HabScript expression. Throws a descriptive error on any syntax
1160
+ * or semantic problem — never evaluates strings at runtime.
1161
+ * @param {string} src
1162
+ * @returns {{src: string, ast: object}}
1163
+ */
1164
+ export function compileScript(src) {
1165
+ const s = String(src == null ? '' : src).trim();
1166
+ return { src: s, ast: parseScript(s) };
1167
+ }
1168
+
1169
+ /** null → NaN so sparse calc helpers compose safely inside expressions. */
1170
+ const scriptNum = (x) => (x == null || Number.isFinite(x) ? x : NaN);
1171
+
1172
+ function binOp(op, a, b) {
1173
+ if (a == null || b == null) return NaN;
1174
+ switch (op) {
1175
+ case '+': return a + b;
1176
+ case '-': return a - b;
1177
+ case '*': return a * b;
1178
+ case '/': return a / b;
1179
+ case '%': return a % b;
1180
+ }
1181
+ return NaN;
1182
+ }
1183
+
1184
+ function evalScriptNode(node, vars, n) {
1185
+ switch (node.type) {
1186
+ case 'num':
1187
+ return node.v;
1188
+ case 'var':
1189
+ return vars[node.name];
1190
+ case 'neg': {
1191
+ const e = evalScriptNode(node.e, vars, n);
1192
+ if (!Array.isArray(e)) return -e;
1193
+ return e.map((x) => (x == null ? NaN : -x));
1194
+ }
1195
+ case 'bin': {
1196
+ const l = evalScriptNode(node.l, vars, n);
1197
+ const r = evalScriptNode(node.r, vars, n);
1198
+ if (!Array.isArray(l) && !Array.isArray(r)) return binOp(node.op, l, r);
1199
+ const a = Array.isArray(l) ? l : new Array(n).fill(l);
1200
+ const b = Array.isArray(r) ? r : new Array(n).fill(r);
1201
+ const out = new Array(n);
1202
+ for (let i = 0; i < n; i++) out[i] = binOp(node.op, a[i], b[i]);
1203
+ return out;
1204
+ }
1205
+ case 'call':
1206
+ return evalScriptCall(node, vars, n);
1207
+ }
1208
+ return NaN;
1209
+ }
1210
+
1211
+ function evalScriptCall(node, vars, n) {
1212
+ const { name, args } = node;
1213
+ const s0 = evalScriptNode(args[0], vars, n);
1214
+ const a = Array.isArray(s0) ? s0 : new Array(n).fill(s0);
1215
+ // window functions must not read leading nulls as 0 — NaN them so results stay honest
1216
+ const clean = a.map((x) => (x == null ? NaN : x));
1217
+ const p = args.length > 1 && args[1].type === 'num' ? args[1].v : 1;
1218
+
1219
+ switch (name) {
1220
+ case 'sma': return calcSMA(clean, p);
1221
+ case 'ema': return calcEMA(clean, p);
1222
+ case 'wma': return calcWMA(clean, p);
1223
+ case 'stddev': return calcStdDev(clean, p);
1224
+ case 'rsi': return calcRSI(clean, p);
1225
+ case 'hh':
1226
+ case 'll': {
1227
+ const out = new Array(n).fill(null);
1228
+ for (let i = p - 1; i < n; i++) {
1229
+ let v = clean[i];
1230
+ for (let j = i - p + 1; j <= i; j++) {
1231
+ v = name === 'hh' ? Math.max(v, clean[j]) : Math.min(v, clean[j]);
1232
+ }
1233
+ out[i] = v;
1234
+ }
1235
+ return out;
1236
+ }
1237
+ case 'prev': {
1238
+ const out = new Array(n).fill(null);
1239
+ for (let i = p; i < n; i++) out[i] = a[i - p];
1240
+ return out;
1241
+ }
1242
+ case 'change': {
1243
+ const out = new Array(n).fill(null);
1244
+ for (let i = 1; i < n; i++) out[i] = scriptNum(a[i]) - scriptNum(a[i - 1]);
1245
+ return out;
1246
+ }
1247
+ case 'abs': return clean.map((x) => Math.abs(x));
1248
+ case 'sqrt': return clean.map((x) => (x < 0 ? NaN : Math.sqrt(x)));
1249
+ case 'log': return clean.map((x) => (x <= 0 ? NaN : Math.log(x)));
1250
+ case 'min':
1251
+ case 'max': {
1252
+ const b0 = evalScriptNode(args[1], vars, n);
1253
+ const b = Array.isArray(b0) ? b0 : new Array(n).fill(b0);
1254
+ return a.map((x, i) => (name === 'min' ? Math.min(scriptNum(x), scriptNum(b[i])) : Math.max(scriptNum(x), scriptNum(b[i]))));
1255
+ }
1256
+ case 'crossup':
1257
+ case 'crossdown': {
1258
+ const b0 = evalScriptNode(args[1], vars, n);
1259
+ const b = Array.isArray(b0) ? b0 : new Array(n).fill(b0);
1260
+ const out = new Array(n).fill(0);
1261
+ for (let i = 1; i < n; i++) {
1262
+ const x0 = scriptNum(a[i - 1]);
1263
+ const x1 = scriptNum(a[i]);
1264
+ const y0 = scriptNum(b[i - 1]);
1265
+ const y1 = scriptNum(b[i]);
1266
+ if (Number.isNaN(x0) || Number.isNaN(x1) || Number.isNaN(y0) || Number.isNaN(y1)) continue;
1267
+ out[i] = name === 'crossup' ? (x0 <= y0 && x1 > y1 ? 1 : 0) : (x0 >= y0 && x1 < y1 ? 1 : 0);
1268
+ }
1269
+ return out;
1270
+ }
1271
+ }
1272
+ return new Array(n).fill(NaN);
1273
+ }
1274
+
1275
+ /**
1276
+ * Evaluate a compiled script (or a raw expression string) over bars.
1277
+ * @param {{src:string, ast:object}|string} compiled
1278
+ * @param {Bar[]} bars
1279
+ * @returns {number[]} length `bars.length`; non-finite values become NaN
1280
+ */
1281
+ export function evalScript(compiled, bars) {
1282
+ const c = typeof compiled === 'string' ? compileScript(compiled) : compiled;
1283
+ const n = bars.length;
1284
+ const out = new Array(n).fill(NaN);
1285
+ if (!n) return out;
1286
+ const vars = {
1287
+ open: bars.map((b) => b.open),
1288
+ high: bars.map((b) => b.high),
1289
+ low: bars.map((b) => b.low),
1290
+ close: bars.map((b) => b.close),
1291
+ volume: bars.map((b) => b.volume),
1292
+ hl2: bars.map((b) => (b.high + b.low) / 2),
1293
+ hlc3: bars.map((b) => (b.high + b.low + b.close) / 3),
1294
+ ohlc4: bars.map((b) => (b.open + b.high + b.low + b.close) / 4),
1295
+ };
1296
+ const res = evalScriptNode(c.ast, vars, n);
1297
+ const arr = Array.isArray(res) ? res : new Array(n).fill(res);
1298
+ for (let i = 0; i < n; i++) {
1299
+ const v = arr[i];
1300
+ out[i] = v != null && Number.isFinite(v) ? v : NaN;
1301
+ }
1302
+ return out;
1303
+ }
1304
+
1305
+ /**
1306
+ * Build an indicator definition from a HabScript expression — used inline by
1307
+ * `indicators="expr:{…}"` / `pexpr:{…}"`, or register it under a name:
1308
+ * `HabChart.registerIndicator('myspread', scriptIndicator('close - ema(close,21)'))`.
1309
+ * @param {string} src
1310
+ * @param {{pane?: boolean}} [opts]
1311
+ * @returns {IndicatorDef}
1312
+ */
1313
+ export function scriptIndicator(src, opts = {}) {
1314
+ const compiled = compileScript(src);
1315
+ const label = compiled.src.length > 24 ? compiled.src.slice(0, 23) + '…' : compiled.src;
1316
+ return {
1317
+ kind: opts.pane ? 'pane' : 'overlay',
1318
+ compute: (bars) => ({ lines: [{ name: label, values: evalScript(compiled, bars) }] }),
1319
+ };
1320
+ }
1321
+
900
1322
  /* ------------------------------------------------------------------ *
901
1323
  * Trading overlays
902
1324
  * ------------------------------------------------------------------ */
@@ -998,6 +1420,304 @@ export function computeStats(bars, i0, i1, dtMs) {
998
1420
  };
999
1421
  }
1000
1422
 
1423
+ /* ------------------------------------------------------------------ *
1424
+ * Volatility-regime shading
1425
+ * ------------------------------------------------------------------ */
1426
+
1427
+ /**
1428
+ * Rolling realized volatility: population stddev of log returns over the
1429
+ * last `period` bars (per-bar value, aligned like SMA — null until the
1430
+ * window fills).
1431
+ * @param {number[]} closes
1432
+ * @param {number} [period=20]
1433
+ * @returns {Array<number|null>}
1434
+ */
1435
+ export function calcRealizedVol(closes, period = 20) {
1436
+ const n = closes.length;
1437
+ const out = new Array(n).fill(null);
1438
+ if (period < 2 || n < 2) return out;
1439
+ const rets = new Array(n).fill(0);
1440
+ let sum = 0;
1441
+ let sumSq = 0;
1442
+ let cnt = 0;
1443
+ for (let i = 1; i < n; i++) {
1444
+ const r = closes[i - 1] > 0 && closes[i] > 0 ? Math.log(closes[i] / closes[i - 1]) : NaN;
1445
+ rets[i] = r;
1446
+ if (Number.isFinite(r)) {
1447
+ sum += r;
1448
+ sumSq += r * r;
1449
+ cnt++;
1450
+ }
1451
+ const j = i - period; // return that falls out of the window
1452
+ if (j >= 1 && Number.isFinite(rets[j])) {
1453
+ sum -= rets[j];
1454
+ sumSq -= rets[j] * rets[j];
1455
+ cnt--;
1456
+ }
1457
+ if (i >= period && cnt === period) {
1458
+ const mean = sum / period;
1459
+ out[i] = Math.sqrt(Math.max(0, sumSq / period - mean * mean));
1460
+ }
1461
+ }
1462
+ return out;
1463
+ }
1464
+
1465
+ /**
1466
+ * Classify a realized-vol series into regimes by empirical percentile over
1467
+ * the whole series: 0 = calm (≤ qLow), 1 = normal, 2 = hot (≥ qHigh),
1468
+ * -1 = unknown (null input). A degenerate spread (qHigh ≤ qLow, e.g. a
1469
+ * flat series) classifies everything as normal.
1470
+ * @param {Array<number|null>} vol
1471
+ * @param {number} [qLow=30]
1472
+ * @param {number} [qHigh=70]
1473
+ * @returns {{regimes:number[], sorted:number[], q1:number, q2:number}}
1474
+ */
1475
+ export function volRegimeBands(vol, qLow = 30, qHigh = 70) {
1476
+ const n = vol.length;
1477
+ const regimes = new Array(n).fill(-1);
1478
+ const sorted = [];
1479
+ for (let i = 0; i < n; i++) if (isNum(vol[i])) sorted.push(vol[i]);
1480
+ sorted.sort((a, b) => a - b);
1481
+ const q = (p) => {
1482
+ if (!sorted.length) return NaN;
1483
+ const pos = clamp((p / 100) * (sorted.length - 1), 0, sorted.length - 1);
1484
+ const lo = Math.floor(pos);
1485
+ const hi = Math.ceil(pos);
1486
+ return lo === hi ? sorted[lo] : sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo);
1487
+ };
1488
+ const q1 = q(Math.min(qLow, qHigh));
1489
+ const q2 = q(Math.max(qLow, qHigh));
1490
+ const degenerate = !(q2 > q1);
1491
+ for (let i = 0; i < n; i++) {
1492
+ if (!isNum(vol[i])) continue;
1493
+ regimes[i] = degenerate ? 1 : vol[i] <= q1 ? 0 : vol[i] >= q2 ? 2 : 1;
1494
+ }
1495
+ return { regimes, sorted, q1, q2 };
1496
+ }
1497
+
1498
+ /**
1499
+ * Percentile (0–100) of `v` within an ascending `sorted` array.
1500
+ * @param {number[]} sorted
1501
+ * @param {number} v
1502
+ * @returns {number}
1503
+ */
1504
+ export function percentileOfSorted(sorted, v) {
1505
+ if (!sorted.length || !isNum(v)) return NaN;
1506
+ let lo = 0;
1507
+ let hi = sorted.length;
1508
+ while (lo < hi) {
1509
+ const mid = (lo + hi) >> 1;
1510
+ if (sorted[mid] < v) lo = mid + 1;
1511
+ else hi = mid;
1512
+ }
1513
+ if (sorted.length === 1) return sorted[0] === v ? 50 : sorted[0] < v ? 100 : 0;
1514
+ return clamp((lo / (sorted.length - 1)) * 100, 0, 100);
1515
+ }
1516
+
1517
+ /**
1518
+ * Parse a `volshading` attribute value: `""` / `"true"` → defaults (30/70,
1519
+ * period 20); `"30/70"` custom cutoffs; `"30/70/14"` cutoffs + period.
1520
+ * Inputs are clamped so qLow always stays at least 2 points below qHigh.
1521
+ * @param {string|null|undefined} val
1522
+ * @returns {{p1:number, p2:number, period:number}}
1523
+ */
1524
+ export function parseVolShading(val) {
1525
+ const parts = String(val == null ? '' : val).split('/').map((s) => parseFloat(s));
1526
+ let p1 = isNum(parts[0]) ? clamp(parts[0], 0, 98) : 30;
1527
+ const p2 = isNum(parts[1]) ? clamp(parts[1], 2, 100) : 70;
1528
+ p1 = clamp(p1, 0, p2 - 2);
1529
+ const period = isNum(parts[2]) ? Math.round(clamp(parts[2], 2, 500)) : 20;
1530
+ return { p1, p2, period };
1531
+ }
1532
+
1533
+ /* ------------------------------------------------------------------ *
1534
+ * AI-ready window summary
1535
+ * ------------------------------------------------------------------ */
1536
+
1537
+ /** Least-squares trend of a value sequence: slope per bar + goodness of fit. */
1538
+ function lsTrend(vals) {
1539
+ const n = vals.length;
1540
+ let sx = 0;
1541
+ let sy = 0;
1542
+ let sxx = 0;
1543
+ let sxy = 0;
1544
+ for (let i = 0; i < n; i++) {
1545
+ sx += i;
1546
+ sy += vals[i];
1547
+ sxx += i * i;
1548
+ sxy += i * vals[i];
1549
+ }
1550
+ const denom = n * sxx - sx * sx;
1551
+ if (!denom) return { slope: 0, r2: 0 };
1552
+ const slope = (n * sxy - sx * sy) / denom;
1553
+ const intercept = (sy - slope * sx) / n;
1554
+ const meanY = sy / n;
1555
+ let ssTot = 0;
1556
+ let ssRes = 0;
1557
+ for (let i = 0; i < n; i++) {
1558
+ ssTot += (vals[i] - meanY) * (vals[i] - meanY);
1559
+ ssRes += (vals[i] - (intercept + slope * i)) * (vals[i] - (intercept + slope * i));
1560
+ }
1561
+ return { slope, r2: ssTot ? Math.max(0, 1 - ssRes / ssTot) : 0 };
1562
+ }
1563
+
1564
+ export const tfLabelOf = (dtMs) => {
1565
+ if (!isNum(dtMs) || dtMs <= 0) return '';
1566
+ const s = Math.round(dtMs / 1000);
1567
+ if (s < 60) return s + 's';
1568
+ const m = Math.round(s / 60);
1569
+ if (m < 60) return m + 'm';
1570
+ const h = Math.round(m / 60);
1571
+ if (h < 24) return h + 'h';
1572
+ const d = Math.round(h / 24);
1573
+ if (d < 7) return d + 'd';
1574
+ return Math.round(d / 7) + 'w';
1575
+ };
1576
+
1577
+ /**
1578
+ * Compact, LLM-friendly summary of a bar window: structured fields plus a
1579
+ * ready-to-paste markdown rendering (`text`). Built entirely from local
1580
+ * data — nothing leaves the page until the user pastes it somewhere.
1581
+ *
1582
+ * @param {Bar[]} bars full dataset
1583
+ * @param {number} i0 first index of the window
1584
+ * @param {number} i1 last index of the window
1585
+ * @param {{dtMs?: number, label?: string}} [opts] bar spacing (ms) + chart label
1586
+ * @returns {object|null} null when the window is empty or out of range
1587
+ */
1588
+ export function windowSummary(bars, i0, i1, opts = {}) {
1589
+ const n = i1 - i0 + 1;
1590
+ if (!bars.length || n < 2 || i0 < 0 || i1 >= bars.length) return null;
1591
+ const closes = bars.map((b) => b.close);
1592
+ const stats = computeStats(bars, i0, i1, opts.dtMs || 0);
1593
+
1594
+ let hi = -Infinity;
1595
+ let lo = Infinity;
1596
+ let hiI = i0;
1597
+ let loI = i0;
1598
+ let vMax = -Infinity;
1599
+ let vMaxI = i0;
1600
+ for (let i = i0; i <= i1; i++) {
1601
+ if (bars[i].high > hi) {
1602
+ hi = bars[i].high;
1603
+ hiI = i;
1604
+ }
1605
+ if (bars[i].low < lo) {
1606
+ lo = bars[i].low;
1607
+ loI = i;
1608
+ }
1609
+ if (isNum(bars[i].volume) && bars[i].volume > vMax) {
1610
+ vMax = bars[i].volume;
1611
+ vMaxI = i;
1612
+ }
1613
+ }
1614
+
1615
+ // trend over the window: % drift per bar + fit quality
1616
+ const win = closes.slice(i0, i1 + 1);
1617
+ const t = lsTrend(win);
1618
+ const meanY = win.reduce((a, b) => a + b, 0) / n;
1619
+ const slopePct = meanY ? (t.slope / meanY) * 100 : 0;
1620
+ let trendLabel;
1621
+ if (t.r2 < 0.25) trendLabel = 'range-bound';
1622
+ else if (slopePct >= 0.15) trendLabel = 'strong uptrend';
1623
+ else if (slopePct <= -0.15) trendLabel = 'strong downtrend';
1624
+ else if (slopePct >= 0.05) trendLabel = 'uptrend';
1625
+ else if (slopePct <= -0.05) trendLabel = 'downtrend';
1626
+ else trendLabel = 'mild drift ' + (slopePct >= 0 ? 'up' : 'down');
1627
+
1628
+ // realized-vol percentile of the latest bar within the window itself
1629
+ let volPctile = null;
1630
+ const wvol = calcRealizedVol(win, Math.min(20, Math.max(2, Math.floor(n / 3))));
1631
+ let lastVol = null;
1632
+ for (let i = wvol.length - 1; i >= 0; i--) {
1633
+ if (isNum(wvol[i])) {
1634
+ lastVol = wvol[i];
1635
+ break;
1636
+ }
1637
+ }
1638
+ if (lastVol != null) {
1639
+ const sorted = wvol.filter((x) => isNum(x)).sort((a, b) => a - b);
1640
+ volPctile = Math.round(percentileOfSorted(sorted, lastVol));
1641
+ }
1642
+
1643
+ const sma20 = n >= 20 ? calcSMA(win, 20)[n - 1] : null;
1644
+ const rsi14 = n > 15 ? calcRSI(win, 14)[n - 1] : null;
1645
+
1646
+ // notable events (most recent first, capped)
1647
+ const ann = detectAnnotations(bars, i0, i1, calcRSI(closes, 14))
1648
+ .sort((a, b) => b.i - a.i)
1649
+ .slice(0, 8)
1650
+ .map((a) => ({ time: bars[a.i].time, note: a.note }));
1651
+
1652
+ const f = numberFmt(autoPrecision(closes[i1]));
1653
+ const day = (ms) => new Date(ms).toISOString().slice(0, 10);
1654
+ const from = bars[i0].time;
1655
+ const to = bars[i1].time;
1656
+ const label = opts.label || 'Chart';
1657
+ const tf = tfLabelOf(opts.dtMs);
1658
+ const showTf = tf && !label.includes(tf) ? ` · ${tf}` : '';
1659
+
1660
+ const out = {
1661
+ label,
1662
+ bars: n,
1663
+ from,
1664
+ to,
1665
+ timeframe: tf,
1666
+ open: closes[i0],
1667
+ close: closes[i1],
1668
+ changePct: stats.changePct,
1669
+ high: hi,
1670
+ highTime: bars[hiI].time,
1671
+ low: lo,
1672
+ lowTime: bars[loI].time,
1673
+ maxDDPct: stats.maxDDPct,
1674
+ upBars: stats.up,
1675
+ downBars: stats.dn,
1676
+ avgVolume: stats.avgVolume,
1677
+ maxVolume: vMax,
1678
+ maxVolumeTime: bars[vMaxI].time,
1679
+ annVolPct: stats.annVolPct,
1680
+ volPctile,
1681
+ trend: { slopePctPerBar: slopePct, r2: t.r2, label: trendLabel },
1682
+ sma20: isNum(sma20) ? { value: sma20, priceAbove: closes[i1] >= sma20 } : null,
1683
+ rsi14: isNum(rsi14) ? rsi14 : null,
1684
+ patterns: ann,
1685
+ };
1686
+
1687
+ const lines = [];
1688
+ lines.push(
1689
+ `CHART SUMMARY — ${label}${showTf} · ${n} bars · ${day(from)} → ${day(to)}`
1690
+ );
1691
+ lines.push(
1692
+ `- Close ${f.format(out.close)} (${out.changePct >= 0 ? '+' : ''}${out.changePct.toFixed(2)}% over window). ` +
1693
+ `High ${f.format(hi)} on ${day(out.highTime)}, low ${f.format(lo)} on ${day(out.lowTime)}. ` +
1694
+ `Max drawdown ${out.maxDDPct.toFixed(1)}%.`
1695
+ );
1696
+ lines.push(
1697
+ `- Trend: ${trendLabel} (drift ${slopePct >= 0 ? '+' : ''}${slopePct.toFixed(3)}%/bar, fit r² ${t.r2.toFixed(2)}).` +
1698
+ (out.sma20 ? ` Price ${out.sma20.priceAbove ? 'above' : 'below'} SMA20 (${f.format(out.sma20.value)}).` : '') +
1699
+ (out.rsi14 != null ? ` RSI(14) ${out.rsi14.toFixed(1)}.` : '')
1700
+ );
1701
+ lines.push(
1702
+ `- Volatility: annualized ${out.annVolPct.toFixed(0)}%` +
1703
+ (volPctile != null
1704
+ ? `; latest realized vol at the ${volPctile}th percentile of the window ` +
1705
+ `(${volPctile >= 70 ? 'hot' : volPctile <= 30 ? 'calm' : 'normal'} regime).`
1706
+ : '.')
1707
+ );
1708
+ lines.push(
1709
+ `- Bars: ${out.upBars} up / ${out.downBars} down. Volume avg ${fmtCompact(out.avgVolume)}/bar, ` +
1710
+ `peak ${fmtCompact(vMax)} on ${day(out.maxVolumeTime)}.`
1711
+ );
1712
+ lines.push(
1713
+ out.patterns.length
1714
+ ? `- Notable: ${out.patterns.map((a) => `${a.note} (${day(a.time)})`).join('; ')}.`
1715
+ : '- Notable: no gaps, volume spikes or pivots flagged.'
1716
+ );
1717
+ out.text = lines.join('\n');
1718
+ return out;
1719
+ }
1720
+
1001
1721
  /* ------------------------------------------------------------------ *
1002
1722
  * State serialization (shareable URLs)
1003
1723
  * ------------------------------------------------------------------ */
@@ -1017,7 +1737,9 @@ export function encodeStateQuery(state) {
1017
1737
  if (state.stats) p.set('stats', '1');
1018
1738
  if (state.profile) p.set('profile', '1');
1019
1739
  if (state.annotations) p.set('ann', '1');
1020
- if (state.indicators) p.set('ind', state.indicators.trim().replace(/\s+/g, ','));
1740
+ if (state.volshading === true) p.set('vsh', '1');
1741
+ else if (typeof state.volshading === 'string' && state.volshading) p.set('vsh', state.volshading);
1742
+ if (state.indicators) p.set('ind', splitIndicatorTokens(state.indicators).join(','));
1021
1743
  if (state.view) {
1022
1744
  if (isNum(state.view.from)) p.set('from', String(Math.floor(state.view.from / 1000)));
1023
1745
  if (isNum(state.view.to)) p.set('to', String(Math.floor(state.view.to / 1000)));
@@ -1041,8 +1763,11 @@ export function decodeStateQuery(str) {
1041
1763
  if (p.get('stats') === '1') state.stats = true;
1042
1764
  if (p.get('profile') === '1') state.profile = true;
1043
1765
  if (p.get('ann') === '1') state.annotations = true;
1766
+ const vsh = p.get('vsh');
1767
+ if (vsh === '1') state.volshading = true;
1768
+ else if (vsh) state.volshading = vsh;
1044
1769
  const ind = p.get('ind');
1045
- if (ind) state.indicators = ind.split(',').map((s) => s.trim()).filter(Boolean).join(' ');
1770
+ if (ind) state.indicators = splitIndicatorTokens(ind).join(' ');
1046
1771
  const from = p.get('from');
1047
1772
  const to = p.get('to');
1048
1773
  if (from != null || to != null) {
package/src/hab-chart.js CHANGED
@@ -24,6 +24,8 @@ import {
24
24
  positionPnl, checkAlertCross, computeStats, safeColor,
25
25
  SERIES_TYPES, calcHeikinAshi, buildColumns, computeVolumeProfile,
26
26
  calcRSI, detectAnnotations, priceToFreq,
27
+ calcRealizedVol, volRegimeBands, percentileOfSorted, parseVolShading,
28
+ windowSummary,
27
29
  } from './core.js';
28
30
 
29
31
  /* ------------------------------------------------------------------ *
@@ -36,7 +38,7 @@ const HTMLElementBase = typeof HTMLElement !== 'undefined' ? HTMLElement : class
36
38
 
37
39
  class HabChart extends HTMLElementBase {
38
40
  static get observedAttributes() {
39
- return ['theme', 'type', 'log', 'auto', 'indicators', 'precision', 'label', 'stats', 'profile', 'annotations', 'co-view', 'sonify'];
41
+ return ['theme', 'type', 'log', 'auto', 'indicators', 'precision', 'label', 'stats', 'profile', 'annotations', 'volshading', 'co-view', 'sonify'];
40
42
  }
41
43
 
42
44
  constructor() {
@@ -184,6 +186,7 @@ class HabChart extends HTMLElementBase {
184
186
  this._annotations = false;
185
187
  this._annoKey = '';
186
188
  this._annoList = null;
189
+ this._volshade = null;
187
190
 
188
191
  // cross-tab co-view state
189
192
  this._coviewName = null;
@@ -316,6 +319,10 @@ class HabChart extends HTMLElementBase {
316
319
  this._annotations = val != null && val !== 'false';
317
320
  this._annoKey = '';
318
321
  break;
322
+ case 'volshading':
323
+ this._volshade = val != null && val !== 'false' ? parseVolShading(val) : null;
324
+ this._legendKey = '';
325
+ break;
319
326
  case 'co-view':
320
327
  this._coviewName = val || null;
321
328
  this._setupCoView();
@@ -553,6 +560,41 @@ class HabChart extends HTMLElementBase {
553
560
  return this._canvas.toDataURL('image/png');
554
561
  }
555
562
 
563
+ /**
564
+ * AI-ready summary of the visible window: structured fields plus a
565
+ * ready-to-paste markdown rendering (`text`). Computed locally —
566
+ * nothing leaves the page until the user copies it somewhere.
567
+ * @returns {object|null}
568
+ */
569
+ getDataWindow() {
570
+ const d = this._data;
571
+ if (!d.length || !this._ly) return null;
572
+ const { plotRight } = this._ly;
573
+ const { rightIndex, spacing } = this._view;
574
+ const i0 = Math.max(0, Math.round(rightIndex - plotRight / spacing));
575
+ const i1 = clamp(Math.round(rightIndex), 0, d.length - 1);
576
+ const s = windowSummary(d, i0, i1, { dtMs: this._dt, label: this._label });
577
+ if (!s) return null;
578
+ // snapshot active indicator values at the right edge (scripts show their expression)
579
+ const f = numberFmt(this._prec(d[i1].close));
580
+ const snap = [];
581
+ for (const entry of this._ind.overlays.concat(this._ind.panes)) {
582
+ if (entry.name === 'volume') continue;
583
+ const res = this._indicatorSeries(entry);
584
+ for (const ln of res.lines) {
585
+ const v = ln.values[i1];
586
+ if (!isNum(v)) continue;
587
+ const isScript = entry.name === 'expr' || entry.name === 'pexpr';
588
+ const name = isScript
589
+ ? ln.name || entry.name
590
+ : entry.name + (Object.keys(entry.params).length ? ':' + Object.values(entry.params).join('/') : '');
591
+ snap.push(`${name} = ${f.format(v)}`);
592
+ }
593
+ }
594
+ if (snap.length) s.text += `\n- Indicators: ${snap.join('; ')}.`;
595
+ return s;
596
+ }
597
+
556
598
  /* ------------------------------------------------------------ *
557
599
  * State serialization
558
600
  * ------------------------------------------------------------ */
@@ -575,6 +617,9 @@ class HabChart extends HTMLElementBase {
575
617
  stats: this._stats,
576
618
  profile: this._profile,
577
619
  annotations: this._annotations,
620
+ volshading: this._volshade
621
+ ? `${this._volshade.p1}/${this._volshade.p2}`
622
+ : false,
578
623
  indicators: ind.join(' '),
579
624
  view: range ? { from: range.from, to: range.to } : null,
580
625
  positions: this._positions.map((p) => ({
@@ -599,6 +644,8 @@ class HabChart extends HTMLElementBase {
599
644
  if (typeof state.stats === 'boolean') this.setAttribute('stats', String(state.stats));
600
645
  if (typeof state.profile === 'boolean') this.setAttribute('profile', String(state.profile));
601
646
  if (typeof state.annotations === 'boolean') this.setAttribute('annotations', String(state.annotations));
647
+ if (state.volshading === true) this.setAttribute('volshading', 'true');
648
+ else if (typeof state.volshading === 'string' && state.volshading) this.setAttribute('volshading', state.volshading);
602
649
  if (typeof state.label === 'string') this.setAttribute('label', state.label);
603
650
  if (typeof state.indicators === 'string') {
604
651
  this.setAttribute('indicators', state.indicators);
@@ -889,6 +936,23 @@ class HabChart extends HTMLElementBase {
889
936
  return this._cache.map.__rsi14;
890
937
  }
891
938
 
939
+ /** Volatility-regime data (realized vol + percentile bands), cached per data version. */
940
+ _volShadeCache() {
941
+ if (!this._volshade) return null;
942
+ if (this._cache.v !== this._version) {
943
+ this._cache = { v: this._version, map: {} };
944
+ }
945
+ if (!this._cache.map.__volshade) {
946
+ const closes = this._data.map((b) => b.close);
947
+ const vol = calcRealizedVol(closes, this._volshade.period);
948
+ this._cache.map.__volshade = {
949
+ vol,
950
+ ...volRegimeBands(vol, this._volshade.p1, this._volshade.p2),
951
+ };
952
+ }
953
+ return this._cache.map.__volshade;
954
+ }
955
+
892
956
  /** Compute (and cache per data version) an indicator entry's series. */
893
957
  _indicatorSeries(entry) {
894
958
  if (this._cache.v !== this._version) {
@@ -1288,6 +1352,32 @@ class HabChart extends HTMLElementBase {
1288
1352
  }
1289
1353
  ctx.stroke();
1290
1354
 
1355
+ /* volatility-regime shading (behind everything but the grid) */
1356
+ if (this._volshade) {
1357
+ const vs = this._volShadeCache();
1358
+ if (vs) {
1359
+ const flushRun = (val, a, b) => {
1360
+ if (val !== 0 && val !== 2) return;
1361
+ const xa = clamp(this._xFor(a) - sp * 0.5, 0, plotRight);
1362
+ const xb = clamp(this._xFor(b) + sp * 0.5, 0, plotRight);
1363
+ if (xb <= xa) return;
1364
+ ctx.fillStyle = val === 0 ? hexToRgba(pal.accent, 0.05) : hexToRgba(pal.down, 0.07);
1365
+ ctx.fillRect(xa, main.y0, xb - xa, main.h);
1366
+ };
1367
+ let runVal = -2;
1368
+ let runA = i0;
1369
+ for (let i = i0; i <= i1; i++) {
1370
+ const rv = vs.regimes[i] == null ? -1 : vs.regimes[i];
1371
+ if (rv !== runVal) {
1372
+ flushRun(runVal, runA, i - 1);
1373
+ runVal = rv;
1374
+ runA = i;
1375
+ }
1376
+ }
1377
+ flushRun(runVal, runA, i1);
1378
+ }
1379
+ }
1380
+
1291
1381
  /* position zones (under series) */
1292
1382
  for (const pos of this._positions) {
1293
1383
  const yE = clamp(yOf(pos.entry), main.y0, main.y1);
@@ -1825,21 +1915,21 @@ class HabChart extends HTMLElementBase {
1825
1915
  ctx.fillText(fmtV(g), W - 6, pyOf(g));
1826
1916
  }
1827
1917
 
1828
- // pane label + live values
1918
+ // pane label + live values (script panes show their expression label)
1829
1919
  const hi = this._hover ? clamp(this._hover.index, 0, d.length - 1) : d.length - 1;
1830
1920
  const vals = res.lines
1831
1921
  .map((ln) => (isNum(ln.values[hi]) ? fmtV(ln.values[hi]) : '—'))
1832
1922
  .join(' ');
1923
+ const paneLabel =
1924
+ (entry.name === 'expr' || entry.name === 'pexpr') && res.lines[0] && res.lines[0].name
1925
+ ? res.lines[0].name
1926
+ : `${entry.name.toUpperCase()} ${Object.values(entry.params).join(' ')}`;
1833
1927
  ctx.font = axisFont(600);
1834
1928
  ctx.fillStyle = pal.text;
1835
1929
  ctx.textAlign = 'left';
1836
1930
  ctx.textBaseline = 'top';
1837
1931
  ctx.globalAlpha = 0.9;
1838
- ctx.fillText(
1839
- `${entry.name.toUpperCase()} ${Object.values(entry.params).join(' ')}${vals ? ' ' + vals : ''}`,
1840
- 8,
1841
- pr.y0 + 5
1842
- );
1932
+ ctx.fillText(`${paneLabel}${vals ? ' ' + vals : ''}`, 8, pr.y0 + 5);
1843
1933
  ctx.globalAlpha = 1;
1844
1934
  }
1845
1935
 
@@ -2103,7 +2193,8 @@ class HabChart extends HTMLElementBase {
2103
2193
  const idx = clamp(hoverIdx, 0, d.length - 1);
2104
2194
  const key = [
2105
2195
  idx, this._version, this._type, this._label, this._theme,
2106
- this.getAttribute('indicators'), this._positions.length, this._posVersion || 0,
2196
+ this.getAttribute('indicators'), this.getAttribute('volshading'),
2197
+ this._positions.length, this._posVersion || 0,
2107
2198
  ].join('|');
2108
2199
  if (key === this._legendKey) return;
2109
2200
  this._legendKey = key;
@@ -2143,12 +2234,29 @@ class HabChart extends HTMLElementBase {
2143
2234
  const vals = res.lines
2144
2235
  .map((ln) => (isNum(ln.values[idx]) ? f.format(ln.values[idx]) : '—'))
2145
2236
  .join(' ');
2146
- html +=
2147
- `<div class="row"><span class="ind">` +
2148
- `<i style="background:${dotColor}"></i>${entry.name.toUpperCase()} ${Object.values(entry.params).join(' ')}` +
2149
- `</span><span class="v">${vals}</span></div>`;
2237
+ // script indicators carry their expression in the line name; built-ins show name+params
2238
+ const label =
2239
+ (entry.name === 'expr' || entry.name === 'pexpr') && res.lines[0].name
2240
+ ? esc(res.lines[0].name)
2241
+ : `${entry.name.toUpperCase()} ${Object.values(entry.params).join(' ')}`;
2242
+ html += `<div class="row"><span class="ind"><i style="background:${dotColor}"></i>${label}</span><span class="v">${vals}</span></div>`;
2150
2243
  });
2151
2244
 
2245
+ if (this._volshade) {
2246
+ const vs = this._volShadeCache();
2247
+ const rv = vs ? vs.regimes[idx] : -1;
2248
+ if (vs && rv >= 0) {
2249
+ const pct = percentileOfSorted(vs.sorted, vs.vol[idx]);
2250
+ const name = rv === 0 ? 'calm' : rv === 2 ? 'hot' : 'normal';
2251
+ const dot = rv === 0 ? palNow.accent : rv === 2 ? palNow.down : palNow.text;
2252
+ html +=
2253
+ `<div class="row"><span class="ind">` +
2254
+ `<i style="background:${dot}"></i>VOL ${this._volshade.p1}/${this._volshade.p2} · ${name}` +
2255
+ `${isNum(pct) ? ` · ${pct.toFixed(0)}%ile` : ''}` +
2256
+ `</span></div>`;
2257
+ }
2258
+ }
2259
+
2152
2260
  if (this._annotations && this._annoList) {
2153
2261
  const notes = this._annoList.filter((a) => a.i === idx).map((a) => a.note);
2154
2262
  if (notes.length) {
package/types/core.d.ts CHANGED
@@ -263,6 +263,12 @@ export declare function calcEMASparse(values: Array<number | null>, period: numb
263
263
  export declare function calcRSI(closes: number[], period: number): Array<number | null>;
264
264
  /** Rolling standard deviation (population) over `period`, aligned like SMA. */
265
265
  export declare function calcStdDev(values: any, period: any): any[];
266
+ /** Linear-weighted moving average (most recent bar weighs `period`), aligned like SMA.
267
+ * @param {number[]} values
268
+ * @param {number} period
269
+ * @returns {Array<number|null>}
270
+ */
271
+ export declare function calcWMA(values: number[], period: number): Array<number | null>;
266
272
  /**
267
273
  * Bollinger Bands.
268
274
  * @param {number[]} closes
@@ -505,7 +511,8 @@ export declare const BUILTIN_INDICATORS: Map<string, {
505
511
  }>;
506
512
  /**
507
513
  * Parse an `indicators` attribute string against a registry.
508
- * Token: `name[:param[/param…]][@color]`, plus the `volume` keyword.
514
+ * Token: `name[:param[/param…]][@color]`, the `volume` keyword, and
515
+ * HabScript blobs `expr:{…}` (overlay) / `pexpr:{…}` (separate pane).
509
516
  * @param {string|null|undefined} str
510
517
  * @param {Map<string, IndicatorDef>} registry
511
518
  * @returns {{overlays: IndicatorEntry[], panes: IndicatorEntry[], volume: boolean, unknown: string[]}}
@@ -516,6 +523,46 @@ export declare function parseIndicators(str: string | null | undefined, registry
516
523
  volume: boolean;
517
524
  unknown: string[];
518
525
  };
526
+ /**
527
+ * Split an indicators string into tokens, keeping `expr:{…}` / `pexpr:{…}`
528
+ * blobs atomic — spaces and commas inside the braces are preserved, and an
529
+ * optional `@color` suffix directly after `}` stays attached.
530
+ * Separators are whitespace, `,` and `;`.
531
+ * @param {string|null|undefined} str
532
+ * @returns {string[]}
533
+ */
534
+ export declare function splitIndicatorTokens(str: string | null | undefined): string[];
535
+ /**
536
+ * Compile a HabScript expression. Throws a descriptive error on any syntax
537
+ * or semantic problem — never evaluates strings at runtime.
538
+ * @param {string} src
539
+ * @returns {{src: string, ast: object}}
540
+ */
541
+ export declare function compileScript(src: string): {
542
+ src: string;
543
+ ast: object;
544
+ };
545
+ /**
546
+ * Evaluate a compiled script (or a raw expression string) over bars.
547
+ * @param {{src:string, ast:object}|string} compiled
548
+ * @param {Bar[]} bars
549
+ * @returns {number[]} length `bars.length`; non-finite values become NaN
550
+ */
551
+ export declare function evalScript(compiled: {
552
+ src: string;
553
+ ast: object;
554
+ } | string, bars: Bar[]): number[];
555
+ /**
556
+ * Build an indicator definition from a HabScript expression — used inline by
557
+ * `indicators="expr:{…}"` / `pexpr:{…}"`, or register it under a name:
558
+ * `HabChart.registerIndicator('myspread', scriptIndicator('close - ema(close,21)'))`.
559
+ * @param {string} src
560
+ * @param {{pane?: boolean}} [opts]
561
+ * @returns {IndicatorDef}
562
+ */
563
+ export declare function scriptIndicator(src: string, opts?: {
564
+ pane?: boolean;
565
+ }): IndicatorDef;
519
566
  /**
520
567
  * Unrealized P&L of a position at `price`.
521
568
  * @param {{side?: 'long'|'short', entry: number, qty?: number}} pos
@@ -557,6 +604,66 @@ export declare function computeStats(bars: Bar[], i0: number, i1: number, dtMs:
557
604
  dn: number;
558
605
  avgVolume: number;
559
606
  };
607
+ /**
608
+ * Rolling realized volatility: population stddev of log returns over the
609
+ * last `period` bars (per-bar value, aligned like SMA — null until the
610
+ * window fills).
611
+ * @param {number[]} closes
612
+ * @param {number} [period=20]
613
+ * @returns {Array<number|null>}
614
+ */
615
+ export declare function calcRealizedVol(closes: number[], period?: number): Array<number | null>;
616
+ /**
617
+ * Classify a realized-vol series into regimes by empirical percentile over
618
+ * the whole series: 0 = calm (≤ qLow), 1 = normal, 2 = hot (≥ qHigh),
619
+ * -1 = unknown (null input). A degenerate spread (qHigh ≤ qLow, e.g. a
620
+ * flat series) classifies everything as normal.
621
+ * @param {Array<number|null>} vol
622
+ * @param {number} [qLow=30]
623
+ * @param {number} [qHigh=70]
624
+ * @returns {{regimes:number[], sorted:number[], q1:number, q2:number}}
625
+ */
626
+ export declare function volRegimeBands(vol: Array<number | null>, qLow?: number, qHigh?: number): {
627
+ regimes: number[];
628
+ sorted: number[];
629
+ q1: number;
630
+ q2: number;
631
+ };
632
+ /**
633
+ * Percentile (0–100) of `v` within an ascending `sorted` array.
634
+ * @param {number[]} sorted
635
+ * @param {number} v
636
+ * @returns {number}
637
+ */
638
+ export declare function percentileOfSorted(sorted: number[], v: number): number;
639
+ /**
640
+ * Parse a `volshading` attribute value: `""` / `"true"` → defaults (30/70,
641
+ * period 20); `"30/70"` custom cutoffs; `"30/70/14"` cutoffs + period.
642
+ * Inputs are clamped so qLow always stays at least 2 points below qHigh.
643
+ * @param {string|null|undefined} val
644
+ * @returns {{p1:number, p2:number, period:number}}
645
+ */
646
+ export declare function parseVolShading(val: string | null | undefined): {
647
+ p1: number;
648
+ p2: number;
649
+ period: number;
650
+ };
651
+ export declare const tfLabelOf: (dtMs: any) => string;
652
+ /**
653
+ * Compact, LLM-friendly summary of a bar window: structured fields plus a
654
+ * ready-to-paste markdown rendering (`text`). Built entirely from local
655
+ * data — nothing leaves the page until the user pastes it somewhere.
656
+ *
657
+ * @param {Bar[]} bars full dataset
658
+ * @param {number} i0 first index of the window
659
+ * @param {number} i1 last index of the window
660
+ * @param {{dtMs?: number, label?: string}} [opts] bar spacing (ms) + chart label
661
+ * @returns {object|null} null when the window is empty or out of range
662
+ */
663
+ export declare function windowSummary(bars: Bar[], i0: number, i1: number, opts?: {
664
+ dtMs?: number;
665
+ label?: string;
666
+ }): object | null;
560
667
  /**
561
668
  * Encode a chart state (from getState()) as a compact query string.
562
669
  * View times are encoded in whole seconds.
@@ -93,6 +93,11 @@ declare class HabChart extends HTMLElementBase {
93
93
  i: number;
94
94
  note: string;
95
95
  }[];
96
+ _volshade: {
97
+ p1: number;
98
+ p2: number;
99
+ period: number;
100
+ };
96
101
  _coviewName: any;
97
102
  _coviewCh: BroadcastChannel;
98
103
  _coviewPeer: string;
@@ -228,6 +233,13 @@ declare class HabChart extends HTMLElementBase {
228
233
  }): void;
229
234
  /** Current canvas as a PNG data URL. */
230
235
  exportPNG(): any;
236
+ /**
237
+ * AI-ready summary of the visible window: structured fields plus a
238
+ * ready-to-paste markdown rendering (`text`). Computed locally —
239
+ * nothing leaves the page until the user copies it somewhere.
240
+ * @returns {object|null}
241
+ */
242
+ getDataWindow(): object | null;
231
243
  /**
232
244
  * Serializable snapshot of the chart's configuration and view.
233
245
  * Feed it to setState() (or encodeStateQuery for shareable URLs).
@@ -313,6 +325,8 @@ declare class HabChart extends HTMLElementBase {
313
325
  _renderBars(): any;
314
326
  /** RSI(14) over raw closes, cached per data version (annotation input). */
315
327
  _cachedRSI14(): any;
328
+ /** Volatility-regime data (realized vol + percentile bands), cached per data version. */
329
+ _volShadeCache(): any;
316
330
  /** Compute (and cache per data version) an indicator entry's series. */
317
331
  _indicatorSeries(entry: any): any;
318
332
  /** Resolve a line color: #hex / rgb() / CSS name / palette key ('rsi', 'up', …) / cycle.