wickchart 0.4.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +274 -48
- package/package.json +29 -12
- package/src/core.js +452 -12
- package/src/feeds.js +1 -1
- package/src/react-core.js +191 -0
- package/src/react.js +21 -0
- package/src/{hab-chart.js → wick-chart.js} +525 -111
- package/src/{hab-feed.js → wick-feed.js} +34 -19
- package/types/core.d.ts +209 -3
- package/types/react-core.d.ts +64 -0
- package/types/react.d.ts +3 -0
- package/types/{hab-chart.d.ts → wick-chart.d.ts} +215 -13
- package/types/{hab-feed.d.ts → wick-feed.d.ts} +8 -4
package/src/core.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/* ==========================================================================
|
|
2
|
-
*
|
|
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 --
|
|
255
|
+
* Themes (every key overridable via --wick-* CSS custom properties;
|
|
256
|
+
* the 0.x --hab-* names still work as fallbacks)
|
|
256
257
|
* ------------------------------------------------------------------ */
|
|
257
258
|
|
|
258
259
|
export const THEMES = {
|
|
@@ -867,7 +868,7 @@ export const BUILTIN_INDICATORS = new Map(
|
|
|
867
868
|
/**
|
|
868
869
|
* Parse an `indicators` attribute string against a registry.
|
|
869
870
|
* Token: `name[:param[/param…]][@color]`, the `volume` keyword, and
|
|
870
|
-
*
|
|
871
|
+
* WickScript blobs `expr:{…}` (overlay) / `pexpr:{…}` (separate pane).
|
|
871
872
|
* @param {string|null|undefined} str
|
|
872
873
|
* @param {Map<string, IndicatorDef>} registry
|
|
873
874
|
* @returns {{overlays: IndicatorEntry[], panes: IndicatorEntry[], volume: boolean, unknown: string[]}}
|
|
@@ -940,11 +941,12 @@ export function parseIndicators(str, registry) {
|
|
|
940
941
|
}
|
|
941
942
|
|
|
942
943
|
/* ------------------------------------------------------------------ *
|
|
943
|
-
*
|
|
944
|
+
* WickScript — safe expression mini-language for custom indicators
|
|
944
945
|
*
|
|
945
946
|
* `expr:{(close - sma(close,20)) / sma(close,20)}` compiles through a
|
|
946
947
|
* hand-written tokenizer + recursive-descent parser (no eval / Function)
|
|
947
|
-
* and evaluates element-wise over the bar series.
|
|
948
|
+
* and evaluates element-wise over the bar series. (Called "HabScript" in
|
|
949
|
+
* 0.x releases.)
|
|
948
950
|
* ------------------------------------------------------------------ */
|
|
949
951
|
|
|
950
952
|
const SCRIPT_MAX_LEN = 512;
|
|
@@ -1041,6 +1043,17 @@ function tokenizeScript(src) {
|
|
|
1041
1043
|
i += m[0].length;
|
|
1042
1044
|
continue;
|
|
1043
1045
|
}
|
|
1046
|
+
const two = src.slice(i, i + 2);
|
|
1047
|
+
if (two === '>=' || two === '<=' || two === '==' || two === '!=') {
|
|
1048
|
+
toks.push({ t: 'op', v: two });
|
|
1049
|
+
i += 2;
|
|
1050
|
+
continue;
|
|
1051
|
+
}
|
|
1052
|
+
if (c === '>' || c === '<') {
|
|
1053
|
+
toks.push({ t: 'op', v: c });
|
|
1054
|
+
i++;
|
|
1055
|
+
continue;
|
|
1056
|
+
}
|
|
1044
1057
|
if (c === '+' || c === '-' || c === '*' || c === '/' || c === '%') {
|
|
1045
1058
|
toks.push({ t: 'op', v: c });
|
|
1046
1059
|
i++;
|
|
@@ -1064,6 +1077,17 @@ function parseScript(src) {
|
|
|
1064
1077
|
let p = 0;
|
|
1065
1078
|
const peek = () => toks[p];
|
|
1066
1079
|
|
|
1080
|
+
const CMP_OPS = ['>', '<', '>=', '<=', '==', '!='];
|
|
1081
|
+
|
|
1082
|
+
/** Comparisons bind loosest (a > b + 1); chains associate left, each 1/0. */
|
|
1083
|
+
function parseCmp(depth) {
|
|
1084
|
+
let l = parseAdd(depth);
|
|
1085
|
+
while (peek() && peek().t === 'op' && CMP_OPS.includes(peek().v)) {
|
|
1086
|
+
const op = toks[p++].v;
|
|
1087
|
+
l = { type: 'bin', op, l, r: parseAdd(depth) };
|
|
1088
|
+
}
|
|
1089
|
+
return l;
|
|
1090
|
+
}
|
|
1067
1091
|
function parseAdd(depth) {
|
|
1068
1092
|
let l = parseMul(depth);
|
|
1069
1093
|
while (peek() && peek().t === 'op' && (peek().v === '+' || peek().v === '-')) {
|
|
@@ -1101,10 +1125,10 @@ function parseScript(src) {
|
|
|
1101
1125
|
p++;
|
|
1102
1126
|
const args = [];
|
|
1103
1127
|
if (peek() && peek().t !== ')') {
|
|
1104
|
-
args.push(
|
|
1128
|
+
args.push(parseCmp(depth));
|
|
1105
1129
|
while (peek() && peek().t === ',') {
|
|
1106
1130
|
p++;
|
|
1107
|
-
args.push(
|
|
1131
|
+
args.push(parseCmp(depth));
|
|
1108
1132
|
}
|
|
1109
1133
|
}
|
|
1110
1134
|
const close = toks[p++];
|
|
@@ -1115,7 +1139,7 @@ function parseScript(src) {
|
|
|
1115
1139
|
return { type: 'var', name };
|
|
1116
1140
|
}
|
|
1117
1141
|
if (t.t === '(') {
|
|
1118
|
-
const e =
|
|
1142
|
+
const e = parseCmp(depth);
|
|
1119
1143
|
const close = toks[p++];
|
|
1120
1144
|
if (!close || close.t !== ')') throw scriptErr('missing ")"');
|
|
1121
1145
|
return e;
|
|
@@ -1123,7 +1147,7 @@ function parseScript(src) {
|
|
|
1123
1147
|
throw scriptErr(`unexpected token "${t.t === 'op' ? t.v : t.t}"`);
|
|
1124
1148
|
}
|
|
1125
1149
|
|
|
1126
|
-
const ast =
|
|
1150
|
+
const ast = parseCmp(0);
|
|
1127
1151
|
if (p < toks.length) throw scriptErr('unexpected trailing input');
|
|
1128
1152
|
validateScriptNode(ast);
|
|
1129
1153
|
return ast;
|
|
@@ -1156,7 +1180,7 @@ function validateScriptNode(n) {
|
|
|
1156
1180
|
}
|
|
1157
1181
|
|
|
1158
1182
|
/**
|
|
1159
|
-
* Compile a
|
|
1183
|
+
* Compile a WickScript expression. Throws a descriptive error on any syntax
|
|
1160
1184
|
* or semantic problem — never evaluates strings at runtime.
|
|
1161
1185
|
* @param {string} src
|
|
1162
1186
|
* @returns {{src: string, ast: object}}
|
|
@@ -1177,6 +1201,20 @@ function binOp(op, a, b) {
|
|
|
1177
1201
|
case '*': return a * b;
|
|
1178
1202
|
case '/': return a / b;
|
|
1179
1203
|
case '%': return a % b;
|
|
1204
|
+
// comparisons yield 1/0; NaN operands stay NaN so warm-up gaps survive
|
|
1205
|
+
case '>':
|
|
1206
|
+
case '<':
|
|
1207
|
+
case '>=':
|
|
1208
|
+
case '<=':
|
|
1209
|
+
case '==':
|
|
1210
|
+
case '!=':
|
|
1211
|
+
if (!Number.isFinite(a) || !Number.isFinite(b)) return NaN;
|
|
1212
|
+
if (op === '>') return a > b ? 1 : 0;
|
|
1213
|
+
if (op === '<') return a < b ? 1 : 0;
|
|
1214
|
+
if (op === '>=') return a >= b ? 1 : 0;
|
|
1215
|
+
if (op === '<=') return a <= b ? 1 : 0;
|
|
1216
|
+
if (op === '==') return a === b ? 1 : 0;
|
|
1217
|
+
return a !== b ? 1 : 0;
|
|
1180
1218
|
}
|
|
1181
1219
|
return NaN;
|
|
1182
1220
|
}
|
|
@@ -1303,7 +1341,7 @@ export function evalScript(compiled, bars) {
|
|
|
1303
1341
|
}
|
|
1304
1342
|
|
|
1305
1343
|
/**
|
|
1306
|
-
* Build an indicator definition from a
|
|
1344
|
+
* Build an indicator definition from a WickScript expression — used inline by
|
|
1307
1345
|
* `indicators="expr:{…}"` / `pexpr:{…}"`, or register it under a name:
|
|
1308
1346
|
* `HabChart.registerIndicator('myspread', scriptIndicator('close - ema(close,21)'))`.
|
|
1309
1347
|
* @param {string} src
|
|
@@ -1352,6 +1390,34 @@ export function checkAlertCross(alert, prevPrice, price) {
|
|
|
1352
1390
|
return (prevPrice <= p && price > p) || (prevPrice >= p && price < p);
|
|
1353
1391
|
}
|
|
1354
1392
|
|
|
1393
|
+
/**
|
|
1394
|
+
* Boolean truth series for a WickScript predicate: any numeric expression
|
|
1395
|
+
* where nonzero & finite counts as true (NaN / 0 / ±Infinity → false).
|
|
1396
|
+
* Powers scripted alerts — `addAlert({ when: 'crossup(close, sma(close,50))' })`.
|
|
1397
|
+
* @param {object|string} compiled compiled predicate (or raw source)
|
|
1398
|
+
* @param {Bar[]} bars
|
|
1399
|
+
* @returns {boolean[]}
|
|
1400
|
+
*/
|
|
1401
|
+
export function predicateTrueSeries(compiled, bars) {
|
|
1402
|
+
const vals = evalScript(compiled, bars);
|
|
1403
|
+
return vals.map((v) => Number.isFinite(v) && v !== 0);
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
/**
|
|
1407
|
+
* Edge-triggered step for a scripted alert. `armed` starts true; a rising
|
|
1408
|
+
* edge (false → true) fires once and disarms; a true → false transition
|
|
1409
|
+
* re-arms, so `once: false` alerts can fire again on the next edge while
|
|
1410
|
+
* `once: true` alerts are removed after their first fire.
|
|
1411
|
+
* @param {boolean} armed
|
|
1412
|
+
* @param {boolean} curTrue
|
|
1413
|
+
* @returns {{ fire: boolean, armed: boolean }}
|
|
1414
|
+
*/
|
|
1415
|
+
export function scriptAlertStep(armed, curTrue) {
|
|
1416
|
+
if (curTrue && armed) return { fire: true, armed: false };
|
|
1417
|
+
if (!curTrue && !armed) return { fire: false, armed: true };
|
|
1418
|
+
return { fire: false, armed };
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1355
1421
|
/* ------------------------------------------------------------------ *
|
|
1356
1422
|
* Visible-range statistics
|
|
1357
1423
|
* ------------------------------------------------------------------ */
|
|
@@ -1526,10 +1592,221 @@ export function parseVolShading(val) {
|
|
|
1526
1592
|
let p1 = isNum(parts[0]) ? clamp(parts[0], 0, 98) : 30;
|
|
1527
1593
|
const p2 = isNum(parts[1]) ? clamp(parts[1], 2, 100) : 70;
|
|
1528
1594
|
p1 = clamp(p1, 0, p2 - 2);
|
|
1529
|
-
const period = isNum(parts[2]) ?
|
|
1595
|
+
const period = isNum(parts[2]) ? clamp(parts[2], 2, 500) : 20;
|
|
1530
1596
|
return { p1, p2, period };
|
|
1531
1597
|
}
|
|
1532
1598
|
|
|
1599
|
+
/* ------------------------------------------------------------------ *
|
|
1600
|
+
* Server-side overlays (zones & levels)
|
|
1601
|
+
* ------------------------------------------------------------------ */
|
|
1602
|
+
|
|
1603
|
+
/** Normalize a timestamp to milliseconds (bar times and query times both
|
|
1604
|
+
* auto-detect seconds — anything below 1e12 is treated as seconds). */
|
|
1605
|
+
const normMs = (t) => (t < 1e12 ? t * 1000 : t);
|
|
1606
|
+
|
|
1607
|
+
/**
|
|
1608
|
+
* Index of the last bar whose time is <= `t` (binary search). Clamps to
|
|
1609
|
+
* [0, n-1]: a time before the first bar → 0, past the last bar → n-1.
|
|
1610
|
+
* Empty bars or a non-numeric time → null.
|
|
1611
|
+
* @param {object[]} bars normalized bar objects
|
|
1612
|
+
* @param {number} t timestamp in ms or s
|
|
1613
|
+
* @returns {number|null}
|
|
1614
|
+
*/
|
|
1615
|
+
export function barIndexForTime(bars, t) {
|
|
1616
|
+
if (!Array.isArray(bars) || !bars.length || !isNum(t)) return null;
|
|
1617
|
+
const scale = normMs(bars[bars.length - 1].time) / bars[bars.length - 1].time;
|
|
1618
|
+
const target = normMs(t);
|
|
1619
|
+
let lo = 0;
|
|
1620
|
+
let hi = bars.length - 1;
|
|
1621
|
+
if (target <= bars[0].time * scale) return 0;
|
|
1622
|
+
if (target >= bars[hi].time * scale) return hi;
|
|
1623
|
+
while (lo < hi) {
|
|
1624
|
+
const mid = (lo + hi + 1) >> 1;
|
|
1625
|
+
if (bars[mid].time * scale <= target) lo = mid;
|
|
1626
|
+
else hi = mid - 1;
|
|
1627
|
+
}
|
|
1628
|
+
return lo;
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
/**
|
|
1632
|
+
* Validate & normalize server-side overlay definitions. Overlays are data
|
|
1633
|
+
* from an API, so invalid entries are silently dropped — never thrown.
|
|
1634
|
+
*
|
|
1635
|
+
* zone: { type:'zone', from?: time|null, to?: time|null, priceFrom, priceTo,
|
|
1636
|
+
* color?, alpha?, border?, label?, id? } — a time×price rectangle.
|
|
1637
|
+
* `from`/`to` omitted (or null) anchor to the left/right chart edge;
|
|
1638
|
+
* a zone with no `to` extends into future space past the last bar.
|
|
1639
|
+
* level: { type:'level', price, from?, to?, color?, width?, dash?, label?, id? }
|
|
1640
|
+
* — a horizontal price line, full width by default.
|
|
1641
|
+
*
|
|
1642
|
+
* Colors go through safeColor(); `alpha` clamps to [0.02, 0.8] (default 0.22).
|
|
1643
|
+
* @param {any} list
|
|
1644
|
+
* @returns {object[]} normalized overlays (possibly empty)
|
|
1645
|
+
*/
|
|
1646
|
+
export function normalizeOverlays(list) {
|
|
1647
|
+
if (!Array.isArray(list)) return [];
|
|
1648
|
+
const out = [];
|
|
1649
|
+
let n = 0;
|
|
1650
|
+
for (const raw of list) {
|
|
1651
|
+
if (!raw || typeof raw !== 'object') continue;
|
|
1652
|
+
let type = null;
|
|
1653
|
+
if (raw.type === 'zone') type = 'zone';
|
|
1654
|
+
else if (raw.type === 'level') type = 'level';
|
|
1655
|
+
if (!type) continue;
|
|
1656
|
+
const id = raw.id != null ? String(raw.id).slice(0, 64) : 'ov-' + ++n;
|
|
1657
|
+
const label = raw.label != null ? String(raw.label).slice(0, 40) : '';
|
|
1658
|
+
// palette keys first ('up' is 2 letters and would fail the generic name check)
|
|
1659
|
+
const rawColor = raw.color != null ? String(raw.color).trim() : '';
|
|
1660
|
+
const color = /^(up|down|accent)$/i.test(rawColor)
|
|
1661
|
+
? rawColor.toLowerCase()
|
|
1662
|
+
: safeColor(rawColor) || null;
|
|
1663
|
+
const from = isNum(raw.from) ? raw.from : null;
|
|
1664
|
+
const to = isNum(raw.to) ? raw.to : null;
|
|
1665
|
+
if (type === 'zone') {
|
|
1666
|
+
if (!isNum(raw.priceFrom) || !isNum(raw.priceTo)) continue;
|
|
1667
|
+
out.push({
|
|
1668
|
+
id,
|
|
1669
|
+
type,
|
|
1670
|
+
from,
|
|
1671
|
+
to,
|
|
1672
|
+
priceFrom: Math.min(raw.priceFrom, raw.priceTo),
|
|
1673
|
+
priceTo: Math.max(raw.priceFrom, raw.priceTo),
|
|
1674
|
+
color,
|
|
1675
|
+
alpha: isNum(raw.alpha) ? clamp(raw.alpha, 0.02, 0.8) : 0.22,
|
|
1676
|
+
border: raw.border !== false,
|
|
1677
|
+
label,
|
|
1678
|
+
});
|
|
1679
|
+
} else {
|
|
1680
|
+
if (!isNum(raw.price)) continue;
|
|
1681
|
+
out.push({
|
|
1682
|
+
id,
|
|
1683
|
+
type,
|
|
1684
|
+
from,
|
|
1685
|
+
to,
|
|
1686
|
+
price: raw.price,
|
|
1687
|
+
color,
|
|
1688
|
+
width: isNum(raw.width) ? clamp(raw.width, 1, 4) : 1,
|
|
1689
|
+
dash: raw.dash === true,
|
|
1690
|
+
label,
|
|
1691
|
+
});
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
return out;
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
/**
|
|
1698
|
+
* Resolve an overlay color against the active palette: 'up'/'down'/'accent'
|
|
1699
|
+
* map to theme colors, anything else passes through safeColor(), and invalid
|
|
1700
|
+
* or missing values fall back to the accent color.
|
|
1701
|
+
* @param {any} raw
|
|
1702
|
+
* @param {object} pal active theme palette
|
|
1703
|
+
* @returns {string} a concrete CSS color
|
|
1704
|
+
*/
|
|
1705
|
+
export function resolveOverlayColor(raw, pal) {
|
|
1706
|
+
if (typeof raw === 'string') {
|
|
1707
|
+
const key = raw.trim().toLowerCase();
|
|
1708
|
+
if (key === 'up' || key === 'down' || key === 'accent') return pal[key];
|
|
1709
|
+
const c = safeColor(raw);
|
|
1710
|
+
if (c) return c;
|
|
1711
|
+
}
|
|
1712
|
+
return pal.accent;
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
/* ------------------------------------------------------------------ *
|
|
1716
|
+
* Scenario mode — ghost paths + volatility cones
|
|
1717
|
+
* ------------------------------------------------------------------ */
|
|
1718
|
+
|
|
1719
|
+
/**
|
|
1720
|
+
* σ-cone projection from realized per-bar volatility: price bands widening
|
|
1721
|
+
* with √h (GBM-style, exp(±z·σ·√h)) over `horizon` future bars.
|
|
1722
|
+
* @param {number} lastClose anchor price (bar 0)
|
|
1723
|
+
* @param {number} volPerBar per-bar stddev of log returns (from calcRealizedVol)
|
|
1724
|
+
* @param {number} horizon future bars (clamped 1–500, default 48)
|
|
1725
|
+
* @param {number[]} [levels] σ multipliers, e.g. [1, 2] (each clamped to 0–5)
|
|
1726
|
+
* @returns {{horizon: number, levels: number[], bands: Record<string, {up: number[], down: number[]}>}}
|
|
1727
|
+
* bands[z].up/.down are arrays indexed by h = 0…horizon ([0] === lastClose)
|
|
1728
|
+
*/
|
|
1729
|
+
export function calcVolCone(lastClose, volPerBar, horizon, levels) {
|
|
1730
|
+
const zs = (Array.isArray(levels) && levels.length ? levels : [1, 2])
|
|
1731
|
+
.map((z) => +z)
|
|
1732
|
+
.filter((z) => Number.isFinite(z) && z > 0 && z <= 5)
|
|
1733
|
+
.sort((a, b) => a - b);
|
|
1734
|
+
const lv = zs.length ? zs : [1];
|
|
1735
|
+
const H = Math.max(1, Math.min(500, Math.round(+horizon || 48)));
|
|
1736
|
+
const c = +lastClose;
|
|
1737
|
+
const v = +volPerBar;
|
|
1738
|
+
const bands = {};
|
|
1739
|
+
const flat = !Number.isFinite(c) || c <= 0 || !Number.isFinite(v) || v < 0;
|
|
1740
|
+
for (const z of lv) {
|
|
1741
|
+
const up = new Array(H + 1);
|
|
1742
|
+
const down = new Array(H + 1);
|
|
1743
|
+
for (let h = 0; h <= H; h++) {
|
|
1744
|
+
if (flat) {
|
|
1745
|
+
up[h] = c || 0;
|
|
1746
|
+
down[h] = c || 0;
|
|
1747
|
+
} else {
|
|
1748
|
+
const k = Math.exp(z * v * Math.sqrt(h));
|
|
1749
|
+
up[h] = c * k;
|
|
1750
|
+
down[h] = c / k;
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
bands[z] = { up, down };
|
|
1754
|
+
}
|
|
1755
|
+
return { horizon: H, levels: lv, bands };
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
/**
|
|
1759
|
+
* Validate a scenario spec: a ghost path of future prices (bars or API data)
|
|
1760
|
+
* plus optional cone settings. Invalid entries are dropped, never thrown.
|
|
1761
|
+
*
|
|
1762
|
+
* { path: [64000, 65500, {price: 68000}], // future bars 1..N
|
|
1763
|
+
* horizon: 48, // alternative/additional: cone-only projection
|
|
1764
|
+
* cone: true, // σ-bands from realized vol (default true)
|
|
1765
|
+
* levels: [1, 2], // σ multipliers (default [1, 2])
|
|
1766
|
+
* color?, label? } // palette keys up|down|accent or safe CSS colors
|
|
1767
|
+
*
|
|
1768
|
+
* @param {any} spec
|
|
1769
|
+
* @returns {null|{path: {h:number, price:number}[], horizon: number,
|
|
1770
|
+
* cone: boolean, levels: number[], color: string|null, label: string}}
|
|
1771
|
+
*/
|
|
1772
|
+
export function normalizeScenario(spec) {
|
|
1773
|
+
if (!spec || typeof spec !== 'object') return null;
|
|
1774
|
+
const rawPath = Array.isArray(spec.path) ? spec.path : null;
|
|
1775
|
+
const path = [];
|
|
1776
|
+
if (rawPath) {
|
|
1777
|
+
for (let i = 0; i < rawPath.length && path.length < 250; i++) {
|
|
1778
|
+
const p = rawPath[i];
|
|
1779
|
+
const price = p && typeof p === 'object' ? +p.price : +p;
|
|
1780
|
+
if (Number.isFinite(price) && price > 0) path.push({ h: path.length + 1, price });
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
const hasHorizon = isNum(spec.horizon) && spec.horizon > 0;
|
|
1784
|
+
if (!path.length && !hasHorizon) return null;
|
|
1785
|
+
const horizon = Math.round(
|
|
1786
|
+
clamp(path.length ? (hasHorizon ? Math.max(path.length, spec.horizon) : path.length) : spec.horizon, 1, 500)
|
|
1787
|
+
);
|
|
1788
|
+
let levels = [1, 2];
|
|
1789
|
+
if (Array.isArray(spec.levels)) {
|
|
1790
|
+
const zs = spec.levels
|
|
1791
|
+
.map((z) => +z)
|
|
1792
|
+
.filter((z) => Number.isFinite(z) && z > 0 && z <= 5)
|
|
1793
|
+
.sort((a, b) => a - b);
|
|
1794
|
+
if (zs.length) levels = zs;
|
|
1795
|
+
}
|
|
1796
|
+
const rawColor = spec.color != null ? String(spec.color).trim() : '';
|
|
1797
|
+
const color = /^(up|down|accent)$/i.test(rawColor)
|
|
1798
|
+
? rawColor.toLowerCase()
|
|
1799
|
+
: safeColor(rawColor) || null;
|
|
1800
|
+
return {
|
|
1801
|
+
path,
|
|
1802
|
+
horizon,
|
|
1803
|
+
cone: spec.cone !== false,
|
|
1804
|
+
levels,
|
|
1805
|
+
color,
|
|
1806
|
+
label: spec.label != null ? String(spec.label).slice(0, 40) : '',
|
|
1807
|
+
};
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1533
1810
|
/* ------------------------------------------------------------------ *
|
|
1534
1811
|
* AI-ready window summary
|
|
1535
1812
|
* ------------------------------------------------------------------ */
|
|
@@ -1778,3 +2055,166 @@ export function decodeStateQuery(str) {
|
|
|
1778
2055
|
}
|
|
1779
2056
|
return state;
|
|
1780
2057
|
}
|
|
2058
|
+
|
|
2059
|
+
/* ------------------------------------------------------------------ *
|
|
2060
|
+
* AI agent interface — the chart as a tool surface
|
|
2061
|
+
* ------------------------------------------------------------------ */
|
|
2062
|
+
|
|
2063
|
+
/**
|
|
2064
|
+
* Tool manifest for LLM/agent control of a chart. Tools map 1:1 onto the
|
|
2065
|
+
* public element API; every op through applyChartOps is validated before it
|
|
2066
|
+
* touches the chart (LLM output is untrusted input).
|
|
2067
|
+
*/
|
|
2068
|
+
export const AI_TOOLS = [
|
|
2069
|
+
{
|
|
2070
|
+
tool: 'get_data_window',
|
|
2071
|
+
description:
|
|
2072
|
+
'Read the visible chart window: OHLC stats, trend (slope + fit), volatility percentile, indicator snapshots, detected patterns. Returns structured fields plus a markdown summary.',
|
|
2073
|
+
args: {},
|
|
2074
|
+
},
|
|
2075
|
+
{
|
|
2076
|
+
tool: 'set_indicators',
|
|
2077
|
+
description:
|
|
2078
|
+
'Replace the indicators. Tokens: sma:20 ema:50 bb:20 vwap rsi:14 macd:12/26/9 volume, @hexcolor suffixes, or WickScript expressions like expr:{close - sma(close,20)} / pexpr:{rsi(close,14)}. Empty string clears all.',
|
|
2079
|
+
args: { indicators: 'string — space/comma-separated tokens' },
|
|
2080
|
+
},
|
|
2081
|
+
{
|
|
2082
|
+
tool: 'set_overlays',
|
|
2083
|
+
description:
|
|
2084
|
+
'Draw zones & levels behind the candles (support/resistance, supply/demand). Zone: {type:"zone", from?, to?, priceFrom, priceTo, color?, alpha?, label?} — omit `to` (or pass null) to extend into future space past the last bar. Level: {type:"level", price, color?, dash?, label?}. Invalid entries are dropped.',
|
|
2085
|
+
args: { overlays: 'array of overlay objects' },
|
|
2086
|
+
},
|
|
2087
|
+
{ tool: 'clear_overlays', description: 'Remove all overlays.', args: {} },
|
|
2088
|
+
{
|
|
2089
|
+
tool: 'add_alert',
|
|
2090
|
+
description:
|
|
2091
|
+
'Price alert {price, direction:"above"|"below"|"cross"} or scripted predicate {when:"<WickScript>"} — e.g. when:"crossup(rsi(close,14), 30)" or when:"volume > sma(volume,20) * 3". Fires wick:alert.',
|
|
2092
|
+
args: {},
|
|
2093
|
+
},
|
|
2094
|
+
{
|
|
2095
|
+
tool: 'set_view',
|
|
2096
|
+
description: 'Set the visible time range (unix seconds or ms).',
|
|
2097
|
+
args: { from: 'timestamp', to: 'timestamp' },
|
|
2098
|
+
},
|
|
2099
|
+
{ tool: 'reset_view', description: 'Fit all loaded data.', args: {} },
|
|
2100
|
+
{
|
|
2101
|
+
tool: 'set_type',
|
|
2102
|
+
description: 'Change the series type.',
|
|
2103
|
+
args: { type: '"candles" | "line" | "area" | "bars" | "hollow" | "heikin"' },
|
|
2104
|
+
},
|
|
2105
|
+
{
|
|
2106
|
+
tool: 'set_volshading',
|
|
2107
|
+
description: 'Volatility-regime background shading (calm/normal/hot percentiles).',
|
|
2108
|
+
args: { enabled: 'boolean', low: 'percentile 0–98 (default 30)', high: 'percentile (default 70)' },
|
|
2109
|
+
},
|
|
2110
|
+
];
|
|
2111
|
+
|
|
2112
|
+
/**
|
|
2113
|
+
* Compact system prompt for agent control: paste into any LLM alongside the
|
|
2114
|
+
* tool manifest. The model answers with a JSON array of {tool, args} ops.
|
|
2115
|
+
* @returns {string}
|
|
2116
|
+
*/
|
|
2117
|
+
export function aiPromptText() {
|
|
2118
|
+
const lines = AI_TOOLS.map(
|
|
2119
|
+
(t) => `- ${t.tool}${Object.keys(t.args).length ? '(' + Object.keys(t.args).join(', ') + ')' : '()'}: ${t.description}`
|
|
2120
|
+
);
|
|
2121
|
+
return [
|
|
2122
|
+
'You are controlling a WickChart financial charting element through tool calls.',
|
|
2123
|
+
'Reply with ONLY a JSON array of operations to apply, each {"tool": name, "args": {...}}.',
|
|
2124
|
+
'Use get_data_window first when you need to see the chart before deciding.',
|
|
2125
|
+
'Available tools:',
|
|
2126
|
+
...lines,
|
|
2127
|
+
].join('\n');
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
const AI_CHART_TYPES = SERIES_TYPES;
|
|
2131
|
+
|
|
2132
|
+
/**
|
|
2133
|
+
* Validate + apply a list of {tool, args} ops (typically LLM output) to a
|
|
2134
|
+
* chart-like target. Ops are whitelisted and their args validated — an op
|
|
2135
|
+
* never throws; it returns {ok: false, error} instead so the agent can
|
|
2136
|
+
* self-correct. Target contract: getDataWindow(), setAttribute(k, v),
|
|
2137
|
+
* setOverlays(list), clearOverlays(), addAlert(a), setVisibleRange(r),
|
|
2138
|
+
* fit(), and (static) _registry() for indicator name checks.
|
|
2139
|
+
* @param {object} target chart element (or test double)
|
|
2140
|
+
* @param {any} ops
|
|
2141
|
+
* @returns {Array<{ok: boolean, tool?: string, result?: any, error?: string}>}
|
|
2142
|
+
*/
|
|
2143
|
+
export function applyChartOps(target, ops) {
|
|
2144
|
+
if (!target) return [{ ok: false, error: 'no target' }];
|
|
2145
|
+
if (!Array.isArray(ops)) return [{ ok: false, error: 'ops must be an array of {tool, args} objects' }];
|
|
2146
|
+
return ops.map((op) => {
|
|
2147
|
+
if (!op || typeof op !== 'object' || Array.isArray(op)) {
|
|
2148
|
+
return { ok: false, error: 'each op must be an object: {tool, args}' };
|
|
2149
|
+
}
|
|
2150
|
+
const tool = String(op.tool || '');
|
|
2151
|
+
const args = op.args && typeof op.args === 'object' && !Array.isArray(op.args) ? op.args : {};
|
|
2152
|
+
const fail = (error) => ({ ok: false, tool, error });
|
|
2153
|
+
try {
|
|
2154
|
+
switch (tool) {
|
|
2155
|
+
case 'get_data_window':
|
|
2156
|
+
return { ok: true, tool, result: target.getDataWindow() };
|
|
2157
|
+
case 'set_indicators': {
|
|
2158
|
+
if (typeof args.indicators !== 'string') return fail('args.indicators must be a string');
|
|
2159
|
+
const reg = target.constructor && target.constructor._registry ? target.constructor._registry() : null;
|
|
2160
|
+
const parsed = parseIndicators(args.indicators, reg);
|
|
2161
|
+
if (parsed.unknown.length) {
|
|
2162
|
+
return fail(`unknown indicators: ${parsed.unknown.join(', ')}`);
|
|
2163
|
+
}
|
|
2164
|
+
target.setAttribute('indicators', args.indicators);
|
|
2165
|
+
return { ok: true, tool, result: { applied: args.indicators || '(cleared)' } };
|
|
2166
|
+
}
|
|
2167
|
+
case 'set_overlays': {
|
|
2168
|
+
if (!Array.isArray(args.overlays)) return fail('args.overlays must be an array');
|
|
2169
|
+
const norm = normalizeOverlays(args.overlays);
|
|
2170
|
+
if (!norm.length) return fail('no valid overlays in args.overlays');
|
|
2171
|
+
const ids = target.setOverlays(args.overlays);
|
|
2172
|
+
return { ok: true, tool, result: { applied: ids.length, dropped: args.overlays.length - ids.length } };
|
|
2173
|
+
}
|
|
2174
|
+
case 'clear_overlays':
|
|
2175
|
+
target.clearOverlays();
|
|
2176
|
+
return { ok: true, tool, result: { cleared: true } };
|
|
2177
|
+
case 'add_alert': {
|
|
2178
|
+
if (!isNum(args.price) && typeof args.when !== 'string') {
|
|
2179
|
+
return fail('args needs either price (number) or when (WickScript string)');
|
|
2180
|
+
}
|
|
2181
|
+
const id = target.addAlert(args);
|
|
2182
|
+
return id ? { ok: true, tool, result: { id } } : fail('invalid alert (bad predicate?)');
|
|
2183
|
+
}
|
|
2184
|
+
case 'set_view': {
|
|
2185
|
+
const r = {};
|
|
2186
|
+
if (isNum(args.from)) r.from = normMs(args.from);
|
|
2187
|
+
if (isNum(args.to)) r.to = normMs(args.to);
|
|
2188
|
+
if (!('from' in r) && !('to' in r)) return fail('args needs from and/or to timestamps');
|
|
2189
|
+
target.setVisibleRange(r);
|
|
2190
|
+
return { ok: true, tool, result: r };
|
|
2191
|
+
}
|
|
2192
|
+
case 'reset_view':
|
|
2193
|
+
target.fit();
|
|
2194
|
+
return { ok: true, tool, result: { reset: true } };
|
|
2195
|
+
case 'set_type': {
|
|
2196
|
+
if (!AI_CHART_TYPES.includes(args.type)) {
|
|
2197
|
+
return fail(`args.type must be one of ${AI_CHART_TYPES.join(' | ')}`);
|
|
2198
|
+
}
|
|
2199
|
+
target.setAttribute('type', args.type);
|
|
2200
|
+
return { ok: true, tool, result: { type: args.type } };
|
|
2201
|
+
}
|
|
2202
|
+
case 'set_volshading': {
|
|
2203
|
+
if (args.enabled === false) {
|
|
2204
|
+
target.setAttribute('volshading', 'false');
|
|
2205
|
+
return { ok: true, tool, result: { enabled: false } };
|
|
2206
|
+
}
|
|
2207
|
+
const p = parseVolShading(
|
|
2208
|
+
isNum(args.low) && isNum(args.high) ? `${args.low}/${args.high}` : ''
|
|
2209
|
+
);
|
|
2210
|
+
target.setAttribute('volshading', `${p.p1}/${p.p2}`);
|
|
2211
|
+
return { ok: true, tool, result: { enabled: true, low: p.p1, high: p.p2 } };
|
|
2212
|
+
}
|
|
2213
|
+
default:
|
|
2214
|
+
return fail(`unknown tool "${tool}"`);
|
|
2215
|
+
}
|
|
2216
|
+
} catch (err) {
|
|
2217
|
+
return fail(err && err.message ? err.message : String(err));
|
|
2218
|
+
}
|
|
2219
|
+
});
|
|
2220
|
+
}
|
package/src/feeds.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/* ==========================================================================
|
|
2
|
-
*
|
|
2
|
+
* WickChart feeds — data-source helpers shared by <wick-feed> and the demo app.
|
|
3
3
|
* Browser module (uses fetch/WebSocket inside functions); importable in Node
|
|
4
4
|
* for unit-testing the pure generators.
|
|
5
5
|
* MIT License.
|