wickchart 1.0.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 +200 -1
- package/package.json +20 -3
- package/src/core.js +443 -5
- package/src/react-core.js +191 -0
- package/src/react.js +21 -0
- package/src/wick-chart.js +436 -31
- package/types/core.d.ts +206 -0
- package/types/react-core.d.ts +64 -0
- package/types/react.d.ts +3 -0
- package/types/wick-chart.d.ts +125 -8
package/src/core.js
CHANGED
|
@@ -1043,6 +1043,17 @@ function tokenizeScript(src) {
|
|
|
1043
1043
|
i += m[0].length;
|
|
1044
1044
|
continue;
|
|
1045
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
|
+
}
|
|
1046
1057
|
if (c === '+' || c === '-' || c === '*' || c === '/' || c === '%') {
|
|
1047
1058
|
toks.push({ t: 'op', v: c });
|
|
1048
1059
|
i++;
|
|
@@ -1066,6 +1077,17 @@ function parseScript(src) {
|
|
|
1066
1077
|
let p = 0;
|
|
1067
1078
|
const peek = () => toks[p];
|
|
1068
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
|
+
}
|
|
1069
1091
|
function parseAdd(depth) {
|
|
1070
1092
|
let l = parseMul(depth);
|
|
1071
1093
|
while (peek() && peek().t === 'op' && (peek().v === '+' || peek().v === '-')) {
|
|
@@ -1103,10 +1125,10 @@ function parseScript(src) {
|
|
|
1103
1125
|
p++;
|
|
1104
1126
|
const args = [];
|
|
1105
1127
|
if (peek() && peek().t !== ')') {
|
|
1106
|
-
args.push(
|
|
1128
|
+
args.push(parseCmp(depth));
|
|
1107
1129
|
while (peek() && peek().t === ',') {
|
|
1108
1130
|
p++;
|
|
1109
|
-
args.push(
|
|
1131
|
+
args.push(parseCmp(depth));
|
|
1110
1132
|
}
|
|
1111
1133
|
}
|
|
1112
1134
|
const close = toks[p++];
|
|
@@ -1117,7 +1139,7 @@ function parseScript(src) {
|
|
|
1117
1139
|
return { type: 'var', name };
|
|
1118
1140
|
}
|
|
1119
1141
|
if (t.t === '(') {
|
|
1120
|
-
const e =
|
|
1142
|
+
const e = parseCmp(depth);
|
|
1121
1143
|
const close = toks[p++];
|
|
1122
1144
|
if (!close || close.t !== ')') throw scriptErr('missing ")"');
|
|
1123
1145
|
return e;
|
|
@@ -1125,7 +1147,7 @@ function parseScript(src) {
|
|
|
1125
1147
|
throw scriptErr(`unexpected token "${t.t === 'op' ? t.v : t.t}"`);
|
|
1126
1148
|
}
|
|
1127
1149
|
|
|
1128
|
-
const ast =
|
|
1150
|
+
const ast = parseCmp(0);
|
|
1129
1151
|
if (p < toks.length) throw scriptErr('unexpected trailing input');
|
|
1130
1152
|
validateScriptNode(ast);
|
|
1131
1153
|
return ast;
|
|
@@ -1179,6 +1201,20 @@ function binOp(op, a, b) {
|
|
|
1179
1201
|
case '*': return a * b;
|
|
1180
1202
|
case '/': return a / b;
|
|
1181
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;
|
|
1182
1218
|
}
|
|
1183
1219
|
return NaN;
|
|
1184
1220
|
}
|
|
@@ -1354,6 +1390,34 @@ export function checkAlertCross(alert, prevPrice, price) {
|
|
|
1354
1390
|
return (prevPrice <= p && price > p) || (prevPrice >= p && price < p);
|
|
1355
1391
|
}
|
|
1356
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
|
+
|
|
1357
1421
|
/* ------------------------------------------------------------------ *
|
|
1358
1422
|
* Visible-range statistics
|
|
1359
1423
|
* ------------------------------------------------------------------ */
|
|
@@ -1528,10 +1592,221 @@ export function parseVolShading(val) {
|
|
|
1528
1592
|
let p1 = isNum(parts[0]) ? clamp(parts[0], 0, 98) : 30;
|
|
1529
1593
|
const p2 = isNum(parts[1]) ? clamp(parts[1], 2, 100) : 70;
|
|
1530
1594
|
p1 = clamp(p1, 0, p2 - 2);
|
|
1531
|
-
const period = isNum(parts[2]) ?
|
|
1595
|
+
const period = isNum(parts[2]) ? clamp(parts[2], 2, 500) : 20;
|
|
1532
1596
|
return { p1, p2, period };
|
|
1533
1597
|
}
|
|
1534
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
|
+
|
|
1535
1810
|
/* ------------------------------------------------------------------ *
|
|
1536
1811
|
* AI-ready window summary
|
|
1537
1812
|
* ------------------------------------------------------------------ */
|
|
@@ -1780,3 +2055,166 @@ export function decodeStateQuery(str) {
|
|
|
1780
2055
|
}
|
|
1781
2056
|
return state;
|
|
1782
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
|
+
}
|