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 +108 -0
- package/package.json +1 -1
- package/src/core.js +730 -5
- package/src/hab-chart.js +120 -12
- package/types/core.d.ts +108 -1
- package/types/hab-chart.d.ts +14 -0
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
|
+
"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]`,
|
|
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
|
|
862
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
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]`,
|
|
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.
|
package/types/hab-chart.d.ts
CHANGED
|
@@ -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.
|