wickchart 0.3.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 = {
@@ -410,6 +411,23 @@ export function calcStdDev(values, period) {
410
411
  return out;
411
412
  }
412
413
 
414
+ /** Linear-weighted moving average (most recent bar weighs `period`), aligned like SMA.
415
+ * @param {number[]} values
416
+ * @param {number} period
417
+ * @returns {Array<number|null>}
418
+ */
419
+ export function calcWMA(values, period) {
420
+ const out = new Array(values.length).fill(null);
421
+ if (period < 1 || values.length < period) return out;
422
+ const denom = (period * (period + 1)) / 2;
423
+ for (let i = period - 1; i < values.length; i++) {
424
+ let sum = 0;
425
+ for (let j = 0; j < period; j++) sum += values[i - j] * (period - j);
426
+ out[i] = sum / denom;
427
+ }
428
+ return out;
429
+ }
430
+
413
431
  /**
414
432
  * Bollinger Bands.
415
433
  * @param {number[]} closes
@@ -849,7 +867,8 @@ export const BUILTIN_INDICATORS = new Map(
849
867
 
850
868
  /**
851
869
  * Parse an `indicators` attribute string against a registry.
852
- * Token: `name[:param[/param…]][@color]`, plus the `volume` keyword.
870
+ * Token: `name[:param[/param…]][@color]`, the `volume` keyword, and
871
+ * WickScript blobs `expr:{…}` (overlay) / `pexpr:{…}` (separate pane).
853
872
  * @param {string|null|undefined} str
854
873
  * @param {Map<string, IndicatorDef>} registry
855
874
  * @returns {{overlays: IndicatorEntry[], panes: IndicatorEntry[], volume: boolean, unknown: string[]}}
@@ -858,8 +877,32 @@ export function parseIndicators(str, registry) {
858
877
  const out = { overlays: [], panes: [], volume: false, unknown: [] };
859
878
  if (str == null || str === '') return out;
860
879
  const seen = new Set();
861
- for (const raw of String(str).split(/[\s,;]+/)) {
862
- if (!raw) continue;
880
+ for (const raw of splitIndicatorTokens(str)) {
881
+ const em = raw.match(/^(p?expr):\{([^{}]*)\}(@\S*)?$/i);
882
+ if (em) {
883
+ const pane = em[1].toLowerCase() === 'pexpr';
884
+ const src = em[2].trim();
885
+ let def;
886
+ try {
887
+ def = scriptIndicator(src, { pane });
888
+ } catch (err) {
889
+ out.unknown.push(em[1] + ':{' + src + '}');
890
+ continue;
891
+ }
892
+ const key = (pane ? 'pexpr' : 'expr') + ':{' + src.toLowerCase() + '}';
893
+ if (seen.has(key)) continue;
894
+ seen.add(key);
895
+ const entry = {
896
+ name: pane ? 'pexpr' : 'expr',
897
+ def,
898
+ params: {},
899
+ color: em[3] ? em[3].slice(1) : null,
900
+ key,
901
+ };
902
+ if (pane) out.panes.push(entry);
903
+ else out.overlays.push(entry);
904
+ continue;
905
+ }
863
906
  const m = raw.match(/^([A-Za-z][A-Za-z0-9_]*)(?::([^@]*))?(@.+)?$/);
864
907
  if (!m) continue;
865
908
  const [, name, paramStr, colorStr] = m;
@@ -897,6 +940,387 @@ export function parseIndicators(str, registry) {
897
940
  return out;
898
941
  }
899
942
 
943
+ /* ------------------------------------------------------------------ *
944
+ * WickScript — safe expression mini-language for custom indicators
945
+ *
946
+ * `expr:{(close - sma(close,20)) / sma(close,20)}` compiles through a
947
+ * hand-written tokenizer + recursive-descent parser (no eval / Function)
948
+ * and evaluates element-wise over the bar series. (Called "HabScript" in
949
+ * 0.x releases.)
950
+ * ------------------------------------------------------------------ */
951
+
952
+ const SCRIPT_MAX_LEN = 512;
953
+ const SCRIPT_MAX_TOKENS = 128;
954
+ const SCRIPT_MAX_DEPTH = 24;
955
+
956
+ /** Series variables available inside expressions. */
957
+ const SCRIPT_VARS = ['open', 'high', 'low', 'close', 'volume', 'hl2', 'hlc3', 'ohlc4'];
958
+
959
+ /**
960
+ * Functions available inside expressions. `scalar` lists argument indexes
961
+ * that must be plain whole-number literals (periods / shifts).
962
+ */
963
+ const SCRIPT_FUNCS = {
964
+ sma: { min: 2, max: 2, scalar: [1] },
965
+ ema: { min: 2, max: 2, scalar: [1] },
966
+ wma: { min: 2, max: 2, scalar: [1] },
967
+ stddev: { min: 2, max: 2, scalar: [1] },
968
+ rsi: { min: 2, max: 2, scalar: [1] },
969
+ hh: { min: 2, max: 2, scalar: [1] },
970
+ ll: { min: 2, max: 2, scalar: [1] },
971
+ prev: { min: 1, max: 2, scalar: [1] },
972
+ change: { min: 1, max: 1 },
973
+ abs: { min: 1, max: 1 },
974
+ sqrt: { min: 1, max: 1 },
975
+ log: { min: 1, max: 1 },
976
+ min: { min: 2, max: 2 },
977
+ max: { min: 2, max: 2 },
978
+ crossup: { min: 2, max: 2 },
979
+ crossdown: { min: 2, max: 2 },
980
+ };
981
+
982
+ const scriptErr = (msg) => new Error('script: ' + msg);
983
+
984
+ /**
985
+ * Split an indicators string into tokens, keeping `expr:{…}` / `pexpr:{…}`
986
+ * blobs atomic — spaces and commas inside the braces are preserved, and an
987
+ * optional `@color` suffix directly after `}` stays attached.
988
+ * Separators are whitespace, `,` and `;`.
989
+ * @param {string|null|undefined} str
990
+ * @returns {string[]}
991
+ */
992
+ export function splitIndicatorTokens(str) {
993
+ const out = [];
994
+ const s = String(str == null ? '' : str);
995
+ let i = 0;
996
+ while (i < s.length) {
997
+ while (i < s.length && /[\s,;]/.test(s[i])) i++;
998
+ if (i >= s.length) break;
999
+ let j = i;
1000
+ if (/^(p?expr):\{/i.test(s.slice(i))) {
1001
+ const end = s.indexOf('}', i);
1002
+ if (end === -1) {
1003
+ out.push(s.slice(i)); // unterminated → caller rejects the token
1004
+ break;
1005
+ }
1006
+ j = end + 1;
1007
+ if (s[j] === '@') {
1008
+ j++;
1009
+ while (j < s.length && !/[\s,;]/.test(s[j])) j++;
1010
+ }
1011
+ } else {
1012
+ while (j < s.length && !/[\s,;]/.test(s[j])) j++;
1013
+ }
1014
+ out.push(s.slice(i, j));
1015
+ i = j;
1016
+ }
1017
+ return out.filter(Boolean);
1018
+ }
1019
+
1020
+ /** Tokenize an expression (numbers, identifiers, operators, `( ) ,`). */
1021
+ function tokenizeScript(src) {
1022
+ if (typeof src !== 'string' || !src.trim()) throw scriptErr('empty expression');
1023
+ if (src.length > SCRIPT_MAX_LEN) throw scriptErr(`expression longer than ${SCRIPT_MAX_LEN} chars`);
1024
+ const toks = [];
1025
+ let i = 0;
1026
+ while (i < src.length) {
1027
+ const c = src[i];
1028
+ if (c === ' ' || c === '\t' || c === '\n' || c === '\r') {
1029
+ i++;
1030
+ continue;
1031
+ }
1032
+ const isDigit = c >= '0' && c <= '9';
1033
+ if (isDigit || (c === '.' && src[i + 1] >= '0' && src[i + 1] <= '9')) {
1034
+ const m = src.slice(i).match(/^\d*\.?\d+/);
1035
+ toks.push({ t: 'num', v: parseFloat(m[0]) });
1036
+ i += m[0].length;
1037
+ continue;
1038
+ }
1039
+ const isAlpha = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c === '_';
1040
+ if (isAlpha) {
1041
+ const m = src.slice(i).match(/^[A-Za-z_][A-Za-z0-9_]*/);
1042
+ toks.push({ t: 'id', v: m[0] });
1043
+ i += m[0].length;
1044
+ continue;
1045
+ }
1046
+ if (c === '+' || c === '-' || c === '*' || c === '/' || c === '%') {
1047
+ toks.push({ t: 'op', v: c });
1048
+ i++;
1049
+ continue;
1050
+ }
1051
+ if (c === '(' || c === ')' || c === ',') {
1052
+ toks.push({ t: c });
1053
+ i++;
1054
+ continue;
1055
+ }
1056
+ throw scriptErr(`unexpected character "${c}"`);
1057
+ }
1058
+ if (!toks.length) throw scriptErr('empty expression');
1059
+ if (toks.length > SCRIPT_MAX_TOKENS) throw scriptErr(`more than ${SCRIPT_MAX_TOKENS} tokens`);
1060
+ return toks;
1061
+ }
1062
+
1063
+ /** Recursive-descent parse into a small AST; validates identifiers, calls and arities. */
1064
+ function parseScript(src) {
1065
+ const toks = tokenizeScript(src);
1066
+ let p = 0;
1067
+ const peek = () => toks[p];
1068
+
1069
+ function parseAdd(depth) {
1070
+ let l = parseMul(depth);
1071
+ while (peek() && peek().t === 'op' && (peek().v === '+' || peek().v === '-')) {
1072
+ const op = toks[p++].v;
1073
+ l = { type: 'bin', op, l, r: parseMul(depth) };
1074
+ }
1075
+ return l;
1076
+ }
1077
+ function parseMul(depth) {
1078
+ let l = parseUnary(depth);
1079
+ while (peek() && peek().t === 'op' && (peek().v === '*' || peek().v === '/' || peek().v === '%')) {
1080
+ const op = toks[p++].v;
1081
+ l = { type: 'bin', op, l, r: parseUnary(depth) };
1082
+ }
1083
+ return l;
1084
+ }
1085
+ function parseUnary(depth) {
1086
+ if (depth > SCRIPT_MAX_DEPTH) throw scriptErr('expression too deeply nested');
1087
+ const t = peek();
1088
+ if (t && t.t === 'op' && (t.v === '-' || t.v === '+')) {
1089
+ p++;
1090
+ const e = parseUnary(depth + 1);
1091
+ return t.v === '-' ? { type: 'neg', e } : e;
1092
+ }
1093
+ return parseAtom(depth + 1);
1094
+ }
1095
+ function parseAtom(depth) {
1096
+ if (depth > SCRIPT_MAX_DEPTH) throw scriptErr('expression too deeply nested');
1097
+ const t = toks[p++];
1098
+ if (!t) throw scriptErr('unexpected end of expression');
1099
+ if (t.t === 'num') return { type: 'num', v: t.v };
1100
+ if (t.t === 'id') {
1101
+ const name = t.v.toLowerCase();
1102
+ if (peek() && peek().t === '(') {
1103
+ p++;
1104
+ const args = [];
1105
+ if (peek() && peek().t !== ')') {
1106
+ args.push(parseAdd(depth));
1107
+ while (peek() && peek().t === ',') {
1108
+ p++;
1109
+ args.push(parseAdd(depth));
1110
+ }
1111
+ }
1112
+ const close = toks[p++];
1113
+ if (!close || close.t !== ')') throw scriptErr(`missing ")" after ${name}(`);
1114
+ return { type: 'call', name, args };
1115
+ }
1116
+ if (!SCRIPT_VARS.includes(name)) throw scriptErr(`unknown identifier "${t.v}"`);
1117
+ return { type: 'var', name };
1118
+ }
1119
+ if (t.t === '(') {
1120
+ const e = parseAdd(depth);
1121
+ const close = toks[p++];
1122
+ if (!close || close.t !== ')') throw scriptErr('missing ")"');
1123
+ return e;
1124
+ }
1125
+ throw scriptErr(`unexpected token "${t.t === 'op' ? t.v : t.t}"`);
1126
+ }
1127
+
1128
+ const ast = parseAdd(0);
1129
+ if (p < toks.length) throw scriptErr('unexpected trailing input');
1130
+ validateScriptNode(ast);
1131
+ return ast;
1132
+ }
1133
+
1134
+ function validateScriptNode(n) {
1135
+ if (!n || n.type === 'num' || n.type === 'var') return;
1136
+ if (n.type === 'neg') return validateScriptNode(n.e);
1137
+ if (n.type === 'bin') {
1138
+ validateScriptNode(n.l);
1139
+ validateScriptNode(n.r);
1140
+ return;
1141
+ }
1142
+ if (n.type === 'call') {
1143
+ const spec = SCRIPT_FUNCS[n.name];
1144
+ if (!spec) throw scriptErr(`unknown function "${n.name}"`);
1145
+ if (n.args.length < spec.min || n.args.length > spec.max) {
1146
+ const want = spec.min === spec.max ? String(spec.min) : `${spec.min}–${spec.max}`;
1147
+ throw scriptErr(`${n.name}() takes ${want} argument${spec.max === 1 ? '' : 's'} (got ${n.args.length})`);
1148
+ }
1149
+ n.args.forEach((a, i) => {
1150
+ if (spec.scalar && spec.scalar.includes(i)) {
1151
+ if (a.type !== 'num' || !Number.isInteger(a.v) || a.v < 1) {
1152
+ throw scriptErr(`${n.name}() argument ${i + 1} must be a whole number ≥ 1`);
1153
+ }
1154
+ }
1155
+ validateScriptNode(a);
1156
+ });
1157
+ }
1158
+ }
1159
+
1160
+ /**
1161
+ * Compile a WickScript expression. Throws a descriptive error on any syntax
1162
+ * or semantic problem — never evaluates strings at runtime.
1163
+ * @param {string} src
1164
+ * @returns {{src: string, ast: object}}
1165
+ */
1166
+ export function compileScript(src) {
1167
+ const s = String(src == null ? '' : src).trim();
1168
+ return { src: s, ast: parseScript(s) };
1169
+ }
1170
+
1171
+ /** null → NaN so sparse calc helpers compose safely inside expressions. */
1172
+ const scriptNum = (x) => (x == null || Number.isFinite(x) ? x : NaN);
1173
+
1174
+ function binOp(op, a, b) {
1175
+ if (a == null || b == null) return NaN;
1176
+ switch (op) {
1177
+ case '+': return a + b;
1178
+ case '-': return a - b;
1179
+ case '*': return a * b;
1180
+ case '/': return a / b;
1181
+ case '%': return a % b;
1182
+ }
1183
+ return NaN;
1184
+ }
1185
+
1186
+ function evalScriptNode(node, vars, n) {
1187
+ switch (node.type) {
1188
+ case 'num':
1189
+ return node.v;
1190
+ case 'var':
1191
+ return vars[node.name];
1192
+ case 'neg': {
1193
+ const e = evalScriptNode(node.e, vars, n);
1194
+ if (!Array.isArray(e)) return -e;
1195
+ return e.map((x) => (x == null ? NaN : -x));
1196
+ }
1197
+ case 'bin': {
1198
+ const l = evalScriptNode(node.l, vars, n);
1199
+ const r = evalScriptNode(node.r, vars, n);
1200
+ if (!Array.isArray(l) && !Array.isArray(r)) return binOp(node.op, l, r);
1201
+ const a = Array.isArray(l) ? l : new Array(n).fill(l);
1202
+ const b = Array.isArray(r) ? r : new Array(n).fill(r);
1203
+ const out = new Array(n);
1204
+ for (let i = 0; i < n; i++) out[i] = binOp(node.op, a[i], b[i]);
1205
+ return out;
1206
+ }
1207
+ case 'call':
1208
+ return evalScriptCall(node, vars, n);
1209
+ }
1210
+ return NaN;
1211
+ }
1212
+
1213
+ function evalScriptCall(node, vars, n) {
1214
+ const { name, args } = node;
1215
+ const s0 = evalScriptNode(args[0], vars, n);
1216
+ const a = Array.isArray(s0) ? s0 : new Array(n).fill(s0);
1217
+ // window functions must not read leading nulls as 0 — NaN them so results stay honest
1218
+ const clean = a.map((x) => (x == null ? NaN : x));
1219
+ const p = args.length > 1 && args[1].type === 'num' ? args[1].v : 1;
1220
+
1221
+ switch (name) {
1222
+ case 'sma': return calcSMA(clean, p);
1223
+ case 'ema': return calcEMA(clean, p);
1224
+ case 'wma': return calcWMA(clean, p);
1225
+ case 'stddev': return calcStdDev(clean, p);
1226
+ case 'rsi': return calcRSI(clean, p);
1227
+ case 'hh':
1228
+ case 'll': {
1229
+ const out = new Array(n).fill(null);
1230
+ for (let i = p - 1; i < n; i++) {
1231
+ let v = clean[i];
1232
+ for (let j = i - p + 1; j <= i; j++) {
1233
+ v = name === 'hh' ? Math.max(v, clean[j]) : Math.min(v, clean[j]);
1234
+ }
1235
+ out[i] = v;
1236
+ }
1237
+ return out;
1238
+ }
1239
+ case 'prev': {
1240
+ const out = new Array(n).fill(null);
1241
+ for (let i = p; i < n; i++) out[i] = a[i - p];
1242
+ return out;
1243
+ }
1244
+ case 'change': {
1245
+ const out = new Array(n).fill(null);
1246
+ for (let i = 1; i < n; i++) out[i] = scriptNum(a[i]) - scriptNum(a[i - 1]);
1247
+ return out;
1248
+ }
1249
+ case 'abs': return clean.map((x) => Math.abs(x));
1250
+ case 'sqrt': return clean.map((x) => (x < 0 ? NaN : Math.sqrt(x)));
1251
+ case 'log': return clean.map((x) => (x <= 0 ? NaN : Math.log(x)));
1252
+ case 'min':
1253
+ case 'max': {
1254
+ const b0 = evalScriptNode(args[1], vars, n);
1255
+ const b = Array.isArray(b0) ? b0 : new Array(n).fill(b0);
1256
+ return a.map((x, i) => (name === 'min' ? Math.min(scriptNum(x), scriptNum(b[i])) : Math.max(scriptNum(x), scriptNum(b[i]))));
1257
+ }
1258
+ case 'crossup':
1259
+ case 'crossdown': {
1260
+ const b0 = evalScriptNode(args[1], vars, n);
1261
+ const b = Array.isArray(b0) ? b0 : new Array(n).fill(b0);
1262
+ const out = new Array(n).fill(0);
1263
+ for (let i = 1; i < n; i++) {
1264
+ const x0 = scriptNum(a[i - 1]);
1265
+ const x1 = scriptNum(a[i]);
1266
+ const y0 = scriptNum(b[i - 1]);
1267
+ const y1 = scriptNum(b[i]);
1268
+ if (Number.isNaN(x0) || Number.isNaN(x1) || Number.isNaN(y0) || Number.isNaN(y1)) continue;
1269
+ out[i] = name === 'crossup' ? (x0 <= y0 && x1 > y1 ? 1 : 0) : (x0 >= y0 && x1 < y1 ? 1 : 0);
1270
+ }
1271
+ return out;
1272
+ }
1273
+ }
1274
+ return new Array(n).fill(NaN);
1275
+ }
1276
+
1277
+ /**
1278
+ * Evaluate a compiled script (or a raw expression string) over bars.
1279
+ * @param {{src:string, ast:object}|string} compiled
1280
+ * @param {Bar[]} bars
1281
+ * @returns {number[]} length `bars.length`; non-finite values become NaN
1282
+ */
1283
+ export function evalScript(compiled, bars) {
1284
+ const c = typeof compiled === 'string' ? compileScript(compiled) : compiled;
1285
+ const n = bars.length;
1286
+ const out = new Array(n).fill(NaN);
1287
+ if (!n) return out;
1288
+ const vars = {
1289
+ open: bars.map((b) => b.open),
1290
+ high: bars.map((b) => b.high),
1291
+ low: bars.map((b) => b.low),
1292
+ close: bars.map((b) => b.close),
1293
+ volume: bars.map((b) => b.volume),
1294
+ hl2: bars.map((b) => (b.high + b.low) / 2),
1295
+ hlc3: bars.map((b) => (b.high + b.low + b.close) / 3),
1296
+ ohlc4: bars.map((b) => (b.open + b.high + b.low + b.close) / 4),
1297
+ };
1298
+ const res = evalScriptNode(c.ast, vars, n);
1299
+ const arr = Array.isArray(res) ? res : new Array(n).fill(res);
1300
+ for (let i = 0; i < n; i++) {
1301
+ const v = arr[i];
1302
+ out[i] = v != null && Number.isFinite(v) ? v : NaN;
1303
+ }
1304
+ return out;
1305
+ }
1306
+
1307
+ /**
1308
+ * Build an indicator definition from a WickScript expression — used inline by
1309
+ * `indicators="expr:{…}"` / `pexpr:{…}"`, or register it under a name:
1310
+ * `HabChart.registerIndicator('myspread', scriptIndicator('close - ema(close,21)'))`.
1311
+ * @param {string} src
1312
+ * @param {{pane?: boolean}} [opts]
1313
+ * @returns {IndicatorDef}
1314
+ */
1315
+ export function scriptIndicator(src, opts = {}) {
1316
+ const compiled = compileScript(src);
1317
+ const label = compiled.src.length > 24 ? compiled.src.slice(0, 23) + '…' : compiled.src;
1318
+ return {
1319
+ kind: opts.pane ? 'pane' : 'overlay',
1320
+ compute: (bars) => ({ lines: [{ name: label, values: evalScript(compiled, bars) }] }),
1321
+ };
1322
+ }
1323
+
900
1324
  /* ------------------------------------------------------------------ *
901
1325
  * Trading overlays
902
1326
  * ------------------------------------------------------------------ */
@@ -998,6 +1422,304 @@ export function computeStats(bars, i0, i1, dtMs) {
998
1422
  };
999
1423
  }
1000
1424
 
1425
+ /* ------------------------------------------------------------------ *
1426
+ * Volatility-regime shading
1427
+ * ------------------------------------------------------------------ */
1428
+
1429
+ /**
1430
+ * Rolling realized volatility: population stddev of log returns over the
1431
+ * last `period` bars (per-bar value, aligned like SMA — null until the
1432
+ * window fills).
1433
+ * @param {number[]} closes
1434
+ * @param {number} [period=20]
1435
+ * @returns {Array<number|null>}
1436
+ */
1437
+ export function calcRealizedVol(closes, period = 20) {
1438
+ const n = closes.length;
1439
+ const out = new Array(n).fill(null);
1440
+ if (period < 2 || n < 2) return out;
1441
+ const rets = new Array(n).fill(0);
1442
+ let sum = 0;
1443
+ let sumSq = 0;
1444
+ let cnt = 0;
1445
+ for (let i = 1; i < n; i++) {
1446
+ const r = closes[i - 1] > 0 && closes[i] > 0 ? Math.log(closes[i] / closes[i - 1]) : NaN;
1447
+ rets[i] = r;
1448
+ if (Number.isFinite(r)) {
1449
+ sum += r;
1450
+ sumSq += r * r;
1451
+ cnt++;
1452
+ }
1453
+ const j = i - period; // return that falls out of the window
1454
+ if (j >= 1 && Number.isFinite(rets[j])) {
1455
+ sum -= rets[j];
1456
+ sumSq -= rets[j] * rets[j];
1457
+ cnt--;
1458
+ }
1459
+ if (i >= period && cnt === period) {
1460
+ const mean = sum / period;
1461
+ out[i] = Math.sqrt(Math.max(0, sumSq / period - mean * mean));
1462
+ }
1463
+ }
1464
+ return out;
1465
+ }
1466
+
1467
+ /**
1468
+ * Classify a realized-vol series into regimes by empirical percentile over
1469
+ * the whole series: 0 = calm (≤ qLow), 1 = normal, 2 = hot (≥ qHigh),
1470
+ * -1 = unknown (null input). A degenerate spread (qHigh ≤ qLow, e.g. a
1471
+ * flat series) classifies everything as normal.
1472
+ * @param {Array<number|null>} vol
1473
+ * @param {number} [qLow=30]
1474
+ * @param {number} [qHigh=70]
1475
+ * @returns {{regimes:number[], sorted:number[], q1:number, q2:number}}
1476
+ */
1477
+ export function volRegimeBands(vol, qLow = 30, qHigh = 70) {
1478
+ const n = vol.length;
1479
+ const regimes = new Array(n).fill(-1);
1480
+ const sorted = [];
1481
+ for (let i = 0; i < n; i++) if (isNum(vol[i])) sorted.push(vol[i]);
1482
+ sorted.sort((a, b) => a - b);
1483
+ const q = (p) => {
1484
+ if (!sorted.length) return NaN;
1485
+ const pos = clamp((p / 100) * (sorted.length - 1), 0, sorted.length - 1);
1486
+ const lo = Math.floor(pos);
1487
+ const hi = Math.ceil(pos);
1488
+ return lo === hi ? sorted[lo] : sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo);
1489
+ };
1490
+ const q1 = q(Math.min(qLow, qHigh));
1491
+ const q2 = q(Math.max(qLow, qHigh));
1492
+ const degenerate = !(q2 > q1);
1493
+ for (let i = 0; i < n; i++) {
1494
+ if (!isNum(vol[i])) continue;
1495
+ regimes[i] = degenerate ? 1 : vol[i] <= q1 ? 0 : vol[i] >= q2 ? 2 : 1;
1496
+ }
1497
+ return { regimes, sorted, q1, q2 };
1498
+ }
1499
+
1500
+ /**
1501
+ * Percentile (0–100) of `v` within an ascending `sorted` array.
1502
+ * @param {number[]} sorted
1503
+ * @param {number} v
1504
+ * @returns {number}
1505
+ */
1506
+ export function percentileOfSorted(sorted, v) {
1507
+ if (!sorted.length || !isNum(v)) return NaN;
1508
+ let lo = 0;
1509
+ let hi = sorted.length;
1510
+ while (lo < hi) {
1511
+ const mid = (lo + hi) >> 1;
1512
+ if (sorted[mid] < v) lo = mid + 1;
1513
+ else hi = mid;
1514
+ }
1515
+ if (sorted.length === 1) return sorted[0] === v ? 50 : sorted[0] < v ? 100 : 0;
1516
+ return clamp((lo / (sorted.length - 1)) * 100, 0, 100);
1517
+ }
1518
+
1519
+ /**
1520
+ * Parse a `volshading` attribute value: `""` / `"true"` → defaults (30/70,
1521
+ * period 20); `"30/70"` custom cutoffs; `"30/70/14"` cutoffs + period.
1522
+ * Inputs are clamped so qLow always stays at least 2 points below qHigh.
1523
+ * @param {string|null|undefined} val
1524
+ * @returns {{p1:number, p2:number, period:number}}
1525
+ */
1526
+ export function parseVolShading(val) {
1527
+ const parts = String(val == null ? '' : val).split('/').map((s) => parseFloat(s));
1528
+ let p1 = isNum(parts[0]) ? clamp(parts[0], 0, 98) : 30;
1529
+ const p2 = isNum(parts[1]) ? clamp(parts[1], 2, 100) : 70;
1530
+ p1 = clamp(p1, 0, p2 - 2);
1531
+ const period = isNum(parts[2]) ? Math.round(clamp(parts[2], 2, 500)) : 20;
1532
+ return { p1, p2, period };
1533
+ }
1534
+
1535
+ /* ------------------------------------------------------------------ *
1536
+ * AI-ready window summary
1537
+ * ------------------------------------------------------------------ */
1538
+
1539
+ /** Least-squares trend of a value sequence: slope per bar + goodness of fit. */
1540
+ function lsTrend(vals) {
1541
+ const n = vals.length;
1542
+ let sx = 0;
1543
+ let sy = 0;
1544
+ let sxx = 0;
1545
+ let sxy = 0;
1546
+ for (let i = 0; i < n; i++) {
1547
+ sx += i;
1548
+ sy += vals[i];
1549
+ sxx += i * i;
1550
+ sxy += i * vals[i];
1551
+ }
1552
+ const denom = n * sxx - sx * sx;
1553
+ if (!denom) return { slope: 0, r2: 0 };
1554
+ const slope = (n * sxy - sx * sy) / denom;
1555
+ const intercept = (sy - slope * sx) / n;
1556
+ const meanY = sy / n;
1557
+ let ssTot = 0;
1558
+ let ssRes = 0;
1559
+ for (let i = 0; i < n; i++) {
1560
+ ssTot += (vals[i] - meanY) * (vals[i] - meanY);
1561
+ ssRes += (vals[i] - (intercept + slope * i)) * (vals[i] - (intercept + slope * i));
1562
+ }
1563
+ return { slope, r2: ssTot ? Math.max(0, 1 - ssRes / ssTot) : 0 };
1564
+ }
1565
+
1566
+ export const tfLabelOf = (dtMs) => {
1567
+ if (!isNum(dtMs) || dtMs <= 0) return '';
1568
+ const s = Math.round(dtMs / 1000);
1569
+ if (s < 60) return s + 's';
1570
+ const m = Math.round(s / 60);
1571
+ if (m < 60) return m + 'm';
1572
+ const h = Math.round(m / 60);
1573
+ if (h < 24) return h + 'h';
1574
+ const d = Math.round(h / 24);
1575
+ if (d < 7) return d + 'd';
1576
+ return Math.round(d / 7) + 'w';
1577
+ };
1578
+
1579
+ /**
1580
+ * Compact, LLM-friendly summary of a bar window: structured fields plus a
1581
+ * ready-to-paste markdown rendering (`text`). Built entirely from local
1582
+ * data — nothing leaves the page until the user pastes it somewhere.
1583
+ *
1584
+ * @param {Bar[]} bars full dataset
1585
+ * @param {number} i0 first index of the window
1586
+ * @param {number} i1 last index of the window
1587
+ * @param {{dtMs?: number, label?: string}} [opts] bar spacing (ms) + chart label
1588
+ * @returns {object|null} null when the window is empty or out of range
1589
+ */
1590
+ export function windowSummary(bars, i0, i1, opts = {}) {
1591
+ const n = i1 - i0 + 1;
1592
+ if (!bars.length || n < 2 || i0 < 0 || i1 >= bars.length) return null;
1593
+ const closes = bars.map((b) => b.close);
1594
+ const stats = computeStats(bars, i0, i1, opts.dtMs || 0);
1595
+
1596
+ let hi = -Infinity;
1597
+ let lo = Infinity;
1598
+ let hiI = i0;
1599
+ let loI = i0;
1600
+ let vMax = -Infinity;
1601
+ let vMaxI = i0;
1602
+ for (let i = i0; i <= i1; i++) {
1603
+ if (bars[i].high > hi) {
1604
+ hi = bars[i].high;
1605
+ hiI = i;
1606
+ }
1607
+ if (bars[i].low < lo) {
1608
+ lo = bars[i].low;
1609
+ loI = i;
1610
+ }
1611
+ if (isNum(bars[i].volume) && bars[i].volume > vMax) {
1612
+ vMax = bars[i].volume;
1613
+ vMaxI = i;
1614
+ }
1615
+ }
1616
+
1617
+ // trend over the window: % drift per bar + fit quality
1618
+ const win = closes.slice(i0, i1 + 1);
1619
+ const t = lsTrend(win);
1620
+ const meanY = win.reduce((a, b) => a + b, 0) / n;
1621
+ const slopePct = meanY ? (t.slope / meanY) * 100 : 0;
1622
+ let trendLabel;
1623
+ if (t.r2 < 0.25) trendLabel = 'range-bound';
1624
+ else if (slopePct >= 0.15) trendLabel = 'strong uptrend';
1625
+ else if (slopePct <= -0.15) trendLabel = 'strong downtrend';
1626
+ else if (slopePct >= 0.05) trendLabel = 'uptrend';
1627
+ else if (slopePct <= -0.05) trendLabel = 'downtrend';
1628
+ else trendLabel = 'mild drift ' + (slopePct >= 0 ? 'up' : 'down');
1629
+
1630
+ // realized-vol percentile of the latest bar within the window itself
1631
+ let volPctile = null;
1632
+ const wvol = calcRealizedVol(win, Math.min(20, Math.max(2, Math.floor(n / 3))));
1633
+ let lastVol = null;
1634
+ for (let i = wvol.length - 1; i >= 0; i--) {
1635
+ if (isNum(wvol[i])) {
1636
+ lastVol = wvol[i];
1637
+ break;
1638
+ }
1639
+ }
1640
+ if (lastVol != null) {
1641
+ const sorted = wvol.filter((x) => isNum(x)).sort((a, b) => a - b);
1642
+ volPctile = Math.round(percentileOfSorted(sorted, lastVol));
1643
+ }
1644
+
1645
+ const sma20 = n >= 20 ? calcSMA(win, 20)[n - 1] : null;
1646
+ const rsi14 = n > 15 ? calcRSI(win, 14)[n - 1] : null;
1647
+
1648
+ // notable events (most recent first, capped)
1649
+ const ann = detectAnnotations(bars, i0, i1, calcRSI(closes, 14))
1650
+ .sort((a, b) => b.i - a.i)
1651
+ .slice(0, 8)
1652
+ .map((a) => ({ time: bars[a.i].time, note: a.note }));
1653
+
1654
+ const f = numberFmt(autoPrecision(closes[i1]));
1655
+ const day = (ms) => new Date(ms).toISOString().slice(0, 10);
1656
+ const from = bars[i0].time;
1657
+ const to = bars[i1].time;
1658
+ const label = opts.label || 'Chart';
1659
+ const tf = tfLabelOf(opts.dtMs);
1660
+ const showTf = tf && !label.includes(tf) ? ` · ${tf}` : '';
1661
+
1662
+ const out = {
1663
+ label,
1664
+ bars: n,
1665
+ from,
1666
+ to,
1667
+ timeframe: tf,
1668
+ open: closes[i0],
1669
+ close: closes[i1],
1670
+ changePct: stats.changePct,
1671
+ high: hi,
1672
+ highTime: bars[hiI].time,
1673
+ low: lo,
1674
+ lowTime: bars[loI].time,
1675
+ maxDDPct: stats.maxDDPct,
1676
+ upBars: stats.up,
1677
+ downBars: stats.dn,
1678
+ avgVolume: stats.avgVolume,
1679
+ maxVolume: vMax,
1680
+ maxVolumeTime: bars[vMaxI].time,
1681
+ annVolPct: stats.annVolPct,
1682
+ volPctile,
1683
+ trend: { slopePctPerBar: slopePct, r2: t.r2, label: trendLabel },
1684
+ sma20: isNum(sma20) ? { value: sma20, priceAbove: closes[i1] >= sma20 } : null,
1685
+ rsi14: isNum(rsi14) ? rsi14 : null,
1686
+ patterns: ann,
1687
+ };
1688
+
1689
+ const lines = [];
1690
+ lines.push(
1691
+ `CHART SUMMARY — ${label}${showTf} · ${n} bars · ${day(from)} → ${day(to)}`
1692
+ );
1693
+ lines.push(
1694
+ `- Close ${f.format(out.close)} (${out.changePct >= 0 ? '+' : ''}${out.changePct.toFixed(2)}% over window). ` +
1695
+ `High ${f.format(hi)} on ${day(out.highTime)}, low ${f.format(lo)} on ${day(out.lowTime)}. ` +
1696
+ `Max drawdown ${out.maxDDPct.toFixed(1)}%.`
1697
+ );
1698
+ lines.push(
1699
+ `- Trend: ${trendLabel} (drift ${slopePct >= 0 ? '+' : ''}${slopePct.toFixed(3)}%/bar, fit r² ${t.r2.toFixed(2)}).` +
1700
+ (out.sma20 ? ` Price ${out.sma20.priceAbove ? 'above' : 'below'} SMA20 (${f.format(out.sma20.value)}).` : '') +
1701
+ (out.rsi14 != null ? ` RSI(14) ${out.rsi14.toFixed(1)}.` : '')
1702
+ );
1703
+ lines.push(
1704
+ `- Volatility: annualized ${out.annVolPct.toFixed(0)}%` +
1705
+ (volPctile != null
1706
+ ? `; latest realized vol at the ${volPctile}th percentile of the window ` +
1707
+ `(${volPctile >= 70 ? 'hot' : volPctile <= 30 ? 'calm' : 'normal'} regime).`
1708
+ : '.')
1709
+ );
1710
+ lines.push(
1711
+ `- Bars: ${out.upBars} up / ${out.downBars} down. Volume avg ${fmtCompact(out.avgVolume)}/bar, ` +
1712
+ `peak ${fmtCompact(vMax)} on ${day(out.maxVolumeTime)}.`
1713
+ );
1714
+ lines.push(
1715
+ out.patterns.length
1716
+ ? `- Notable: ${out.patterns.map((a) => `${a.note} (${day(a.time)})`).join('; ')}.`
1717
+ : '- Notable: no gaps, volume spikes or pivots flagged.'
1718
+ );
1719
+ out.text = lines.join('\n');
1720
+ return out;
1721
+ }
1722
+
1001
1723
  /* ------------------------------------------------------------------ *
1002
1724
  * State serialization (shareable URLs)
1003
1725
  * ------------------------------------------------------------------ */
@@ -1017,7 +1739,9 @@ export function encodeStateQuery(state) {
1017
1739
  if (state.stats) p.set('stats', '1');
1018
1740
  if (state.profile) p.set('profile', '1');
1019
1741
  if (state.annotations) p.set('ann', '1');
1020
- if (state.indicators) p.set('ind', state.indicators.trim().replace(/\s+/g, ','));
1742
+ if (state.volshading === true) p.set('vsh', '1');
1743
+ else if (typeof state.volshading === 'string' && state.volshading) p.set('vsh', state.volshading);
1744
+ if (state.indicators) p.set('ind', splitIndicatorTokens(state.indicators).join(','));
1021
1745
  if (state.view) {
1022
1746
  if (isNum(state.view.from)) p.set('from', String(Math.floor(state.view.from / 1000)));
1023
1747
  if (isNum(state.view.to)) p.set('to', String(Math.floor(state.view.to / 1000)));
@@ -1041,8 +1765,11 @@ export function decodeStateQuery(str) {
1041
1765
  if (p.get('stats') === '1') state.stats = true;
1042
1766
  if (p.get('profile') === '1') state.profile = true;
1043
1767
  if (p.get('ann') === '1') state.annotations = true;
1768
+ const vsh = p.get('vsh');
1769
+ if (vsh === '1') state.volshading = true;
1770
+ else if (vsh) state.volshading = vsh;
1044
1771
  const ind = p.get('ind');
1045
- if (ind) state.indicators = ind.split(',').map((s) => s.trim()).filter(Boolean).join(' ');
1772
+ if (ind) state.indicators = splitIndicatorTokens(ind).join(' ');
1046
1773
  const from = p.get('from');
1047
1774
  const to = p.get('to');
1048
1775
  if (from != null || to != null) {