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.
@@ -1,16 +1,19 @@
1
1
  /* ==========================================================================
2
- * <hab-chart> — a modern, dependency-free financial charting web component.
2
+ * <wick-chart> — a modern, dependency-free financial charting web component.
3
3
  *
4
- * <hab-chart label="BTC · 1h" type="candles" indicators="sma:20 volume">
5
- * </hab-chart>
4
+ * <wick-chart label="BTC · 1h" type="candles" indicators="sma:20 volume">
5
+ * </wick-chart>
6
6
  * <script type="module">
7
- * const chart = document.querySelector('hab-chart');
7
+ * const chart = document.querySelector('wick-chart');
8
8
  * chart.setData(bars); // [{ time, open, high, low, close, volume }]
9
9
  * chart.update(bar); // streaming update / append
10
10
  * </script>
11
11
  *
12
12
  * Zero dependencies. Canvas-rendered. Framework-agnostic (works in React,
13
- * Vue, Svelte, plain HTML). Themeable with --hab-* CSS custom properties.
13
+ * Vue, plain HTML). Themeable with --wick-* CSS custom properties.
14
+ *
15
+ * The 0.x names (<hab-chart>, --hab-*, hab:* events) still work as
16
+ * deprecated aliases — see the README migration notes.
14
17
  *
15
18
  * MIT License.
16
19
  * ========================================================================== */
@@ -25,20 +28,27 @@ import {
25
28
  SERIES_TYPES, calcHeikinAshi, buildColumns, computeVolumeProfile,
26
29
  calcRSI, detectAnnotations, priceToFreq,
27
30
  calcRealizedVol, volRegimeBands, percentileOfSorted, parseVolShading,
28
- windowSummary,
31
+ windowSummary, normalizeOverlays, barIndexForTime, resolveOverlayColor,
32
+ compileScript, predicateTrueSeries, scriptAlertStep,
33
+ AI_TOOLS, aiPromptText, applyChartOps,
34
+ calcVolCone, normalizeScenario,
29
35
  } from './core.js';
30
36
 
31
37
  /* ------------------------------------------------------------------ *
32
- * <hab-chart>
38
+ * <wick-chart>
33
39
  * ------------------------------------------------------------------ */
34
40
 
41
+ /** Indicator registry — module scope so registrations are shared by
42
+ * <wick-chart> and the deprecated <hab-chart> alias element. */
43
+ const REGISTRY = new Map(BUILTIN_INDICATORS);
44
+
35
45
  /* SSR safety: importing this module under Node (Next.js/Nuxt server render)
36
46
  * must not throw — the element simply registers only in browsers. */
37
47
  const HTMLElementBase = typeof HTMLElement !== 'undefined' ? HTMLElement : class {};
38
48
 
39
- class HabChart extends HTMLElementBase {
49
+ class WickChart extends HTMLElementBase {
40
50
  static get observedAttributes() {
41
- return ['theme', 'type', 'log', 'auto', 'indicators', 'precision', 'label', 'stats', 'profile', 'annotations', 'volshading', 'co-view', 'sonify'];
51
+ return ['theme', 'type', 'log', 'auto', 'indicators', 'precision', 'label', 'stats', 'profile', 'annotations', 'volshading', 'overlays', 'co-view', 'sonify'];
42
52
  }
43
53
 
44
54
  constructor() {
@@ -55,7 +65,7 @@ class HabChart extends HTMLElementBase {
55
65
  contain: content;
56
66
  }
57
67
  :host(:focus-visible) {
58
- outline: 2px solid var(--hab-accent, #4c8dff);
68
+ outline: 2px solid var(--wick-accent, var(--hab-accent, #4c8dff));
59
69
  outline-offset: -2px;
60
70
  }
61
71
  .wrap { position: absolute; inset: 0; overflow: hidden; }
@@ -78,26 +88,26 @@ class HabChart extends HTMLElementBase {
78
88
  }
79
89
  .legend .row { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
80
90
  .legend .sym {
81
- color: var(--hab-text-strong, #e6edf3);
91
+ color: var(--wick-text-strong, var(--hab-text-strong, #e6edf3));
82
92
  font-weight: 700;
83
93
  font-size: 13px;
84
94
  letter-spacing: 0.02em;
85
95
  }
86
96
  .legend .kv { display: inline-flex; gap: 5px; align-items: baseline; white-space: nowrap; }
87
- .legend .k { color: var(--hab-text, #8b949e); font-size: 11px; }
88
- .legend .v { color: var(--hab-text-strong, #e6edf3); font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; }
97
+ .legend .k { color: var(--wick-text, var(--hab-text, #8b949e)); font-size: 11px; }
98
+ .legend .v { color: var(--wick-text-strong, var(--hab-text-strong, #e6edf3)); font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; }
89
99
  .legend .pct { font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; }
90
- .legend .up { color: var(--hab-up, #16c784); }
91
- .legend .dn { color: var(--hab-down, #ea3943); }
100
+ .legend .up { color: var(--wick-up, var(--hab-up, #16c784)); }
101
+ .legend .dn { color: var(--wick-down, var(--hab-down, #ea3943)); }
92
102
  .legend .ind {
93
103
  display: inline-flex; align-items: center; gap: 6px;
94
- color: var(--hab-text, #8b949e); font-size: 11.5px; white-space: nowrap;
104
+ color: var(--wick-text, var(--hab-text, #8b949e)); font-size: 11.5px; white-space: nowrap;
95
105
  }
96
106
  .legend .ind i { width: 8px; height: 2.5px; border-radius: 2px; display: inline-block; }
97
107
  .legend .ind .v { font-size: 12px; }
98
108
  .legend .insight {
99
- color: var(--hab-accent, #4c8dff);
100
- background: var(--hab-chip, rgba(127, 137, 153, 0.12));
109
+ color: var(--wick-accent, var(--hab-accent, #4c8dff));
110
+ background: var(--wick-chip, var(--hab-chip, rgba(127, 137, 153, 0.12)));
101
111
  border-radius: 6px;
102
112
  padding: 1px 8px;
103
113
  font-size: 11.5px;
@@ -106,7 +116,7 @@ class HabChart extends HTMLElementBase {
106
116
  .nodata {
107
117
  position: absolute; inset: 0;
108
118
  display: flex; align-items: center; justify-content: center;
109
- color: var(--hab-text, #8b949e);
119
+ color: var(--wick-text, var(--hab-text, #8b949e));
110
120
  font: 500 13px ${FONT_STACK};
111
121
  pointer-events: none;
112
122
  }
@@ -119,25 +129,25 @@ class HabChart extends HTMLElementBase {
119
129
  }
120
130
  .hud .pos {
121
131
  display: inline-flex; gap: 9px; align-items: baseline; white-space: nowrap;
122
- background: var(--hab-chip, rgba(127, 137, 153, 0.12));
123
- border: 1px solid var(--hab-border, rgba(148, 163, 184, 0.2));
132
+ background: var(--wick-chip, var(--hab-chip, rgba(127, 137, 153, 0.12)));
133
+ border: 1px solid var(--wick-border, var(--hab-border, rgba(148, 163, 184, 0.2)));
124
134
  border-radius: 7px;
125
135
  padding: 3px 9px;
126
136
  }
127
137
  .hud .statsrow {
128
138
  display: inline-flex; gap: 12px; white-space: nowrap;
129
- background: var(--hab-chip, rgba(127, 137, 153, 0.12));
130
- border: 1px solid var(--hab-border, rgba(148, 163, 184, 0.2));
139
+ background: var(--wick-chip, var(--hab-chip, rgba(127, 137, 153, 0.12)));
140
+ border: 1px solid var(--wick-border, var(--hab-border, rgba(148, 163, 184, 0.2)));
131
141
  border-radius: 7px;
132
142
  padding: 3px 10px;
133
- color: var(--hab-text, #8b949e);
143
+ color: var(--wick-text, var(--hab-text, #8b949e));
134
144
  font-variant-numeric: tabular-nums;
135
145
  }
136
- .hud .statsrow b { color: var(--hab-text-strong, #e6edf3); font-weight: 600; }
137
- .hud .k { color: var(--hab-text, #8b949e); font-weight: 500; }
138
- .hud .v { color: var(--hab-text-strong, #e6edf3); font-variant-numeric: tabular-nums; }
139
- .hud .up { color: var(--hab-up, #16c784); }
140
- .hud .dn { color: var(--hab-down, #ea3943); }
146
+ .hud .statsrow b { color: var(--wick-text-strong, var(--hab-text-strong, #e6edf3)); font-weight: 600; }
147
+ .hud .k { color: var(--wick-text, var(--hab-text, #8b949e)); font-weight: 500; }
148
+ .hud .v { color: var(--wick-text-strong, var(--hab-text-strong, #e6edf3)); font-variant-numeric: tabular-nums; }
149
+ .hud .up { color: var(--wick-up, var(--hab-up, #16c784)); }
150
+ .hud .dn { color: var(--wick-down, var(--hab-down, #ea3943)); }
141
151
  </style>
142
152
  <div class="wrap" part="wrap">
143
153
  <canvas part="canvas" role="img"></canvas>
@@ -218,6 +228,12 @@ class HabChart extends HTMLElementBase {
218
228
  this._alerts = [];
219
229
  this._seq = 0;
220
230
 
231
+ // server-side overlays (zones & levels)
232
+ this._overlays = [];
233
+
234
+ // scenario projection (ghost path + vol cone)
235
+ this._scenario = null;
236
+
221
237
  this._onResize = () => this._invalidate();
222
238
  this._onPointerDown = (e) => this._pointerDown(e);
223
239
  this._onPointerMove = (e) => this._pointerMove(e);
@@ -305,7 +321,7 @@ class HabChart extends HTMLElementBase {
305
321
  this._label = val || '';
306
322
  break;
307
323
  case 'indicators':
308
- this._ind = parseIndicators(val, HabChart._registry());
324
+ this._ind = parseIndicators(val, WickChart._registry());
309
325
  break;
310
326
  case 'stats':
311
327
  this._stats = val != null && val !== 'false';
@@ -323,6 +339,18 @@ class HabChart extends HTMLElementBase {
323
339
  this._volshade = val != null && val !== 'false' ? parseVolShading(val) : null;
324
340
  this._legendKey = '';
325
341
  break;
342
+ case 'overlays': {
343
+ let ovs = [];
344
+ if (val != null && val !== '') {
345
+ try {
346
+ ovs = normalizeOverlays(JSON.parse(val));
347
+ } catch (err) {
348
+ ovs = [];
349
+ }
350
+ }
351
+ this._overlays = ovs;
352
+ break;
353
+ }
326
354
  case 'co-view':
327
355
  this._coviewName = val || null;
328
356
  this._setupCoView();
@@ -339,20 +367,15 @@ class HabChart extends HTMLElementBase {
339
367
  * Indicator registry
340
368
  * ------------------------------------------------------------ */
341
369
 
342
- static _registryMap = null;
343
-
344
- /** Lazily-built registry, seeded with the built-in indicators. */
370
+ /** Indicator registry (module scope — shared with the legacy alias tag). */
345
371
  static _registry() {
346
- if (!HabChart._registryMap) {
347
- HabChart._registryMap = new Map(BUILTIN_INDICATORS);
348
- }
349
- return HabChart._registryMap;
372
+ return REGISTRY;
350
373
  }
351
374
 
352
375
  /**
353
376
  * Register a custom indicator.
354
377
  *
355
- * HabChart.registerIndicator('vwap', {
378
+ * WickChart.registerIndicator('vwap', {
356
379
  * kind: 'overlay', // or 'pane'
357
380
  * params: { period: 20 }, // defaults; settable via name:period
358
381
  * compute(bars, params) { // bars: normalized {time,o,h,l,c,v}
@@ -373,7 +396,7 @@ class HabChart extends HTMLElementBase {
373
396
  if (!def || typeof def.compute !== 'function') {
374
397
  throw new Error('registerIndicator: def.compute must be a function');
375
398
  }
376
- HabChart._registry().set(name.toLowerCase(), {
399
+ REGISTRY.set(name.toLowerCase(), {
377
400
  kind: def.kind === 'pane' ? 'pane' : 'overlay',
378
401
  ...def,
379
402
  });
@@ -403,7 +426,7 @@ class HabChart extends HTMLElementBase {
403
426
  }
404
427
  const norm = [];
405
428
  for (const b of bars) {
406
- const nb = HabChart._normBar(b);
429
+ const nb = WickChart._normBar(b);
407
430
  if (nb) norm.push(nb);
408
431
  }
409
432
  // skip the O(n log n) sort when already ascending (typical for feeds)
@@ -432,7 +455,7 @@ class HabChart extends HTMLElementBase {
432
455
  * @param {import('./core.js').Bar} bar
433
456
  */
434
457
  update(bar) {
435
- const b = HabChart._normBar(bar);
458
+ const b = WickChart._normBar(bar);
436
459
  if (!b) return;
437
460
  const d = this._data;
438
461
  const last = d[d.length - 1];
@@ -492,7 +515,7 @@ class HabChart extends HTMLElementBase {
492
515
  }
493
516
  const older = [];
494
517
  for (const b of bars) {
495
- const nb = HabChart._normBar(b);
518
+ const nb = WickChart._normBar(b);
496
519
  if (nb) older.push(nb);
497
520
  }
498
521
  const { bars: merged, added } = mergeOlderData(this._data, older);
@@ -540,14 +563,14 @@ class HabChart extends HTMLElementBase {
540
563
  setVisibleRange(range) {
541
564
  const d = this._data;
542
565
  if (!d.length || !range || !this._ly) return;
543
- const from = HabChart._timeToMs(range.from);
544
- const to = HabChart._timeToMs(range.to);
545
- let i0 = HabChart._indexForTime(d, from);
546
- let i1 = HabChart._indexForTime(d, to);
566
+ const from = WickChart._timeToMs(range.from);
567
+ const to = WickChart._timeToMs(range.to);
568
+ let i0 = WickChart._indexForTime(d, from);
569
+ let i1 = WickChart._indexForTime(d, to);
547
570
  if (i0 > i1) [i0, i1] = [i1, i0];
548
571
  if (i1 - i0 < 2) return;
549
572
  const { plotRight } = this._ly;
550
- this._view.spacing = clamp(plotRight / (i1 - i0), this._minSpacing(), HabChart._MAX_SP);
573
+ this._view.spacing = clamp(plotRight / (i1 - i0), this._minSpacing(), WickChart._MAX_SP);
551
574
  this._view.rightIndex = i1;
552
575
  this._auto = false;
553
576
  this._clampView();
@@ -627,7 +650,11 @@ class HabChart extends HTMLElementBase {
627
650
  })),
628
651
  alerts: this._alerts
629
652
  .filter((a) => !a.fired)
630
- .map((a) => ({ id: a.id, price: a.price, direction: a.direction, once: a.once })),
653
+ .map((a) =>
654
+ a.when != null
655
+ ? { id: a.id, when: a.when, once: a.once }
656
+ : { id: a.id, price: a.price, direction: a.direction, once: a.once }
657
+ ),
631
658
  };
632
659
  }
633
660
 
@@ -665,14 +692,31 @@ class HabChart extends HTMLElementBase {
665
692
  }
666
693
  if (Array.isArray(state.alerts)) {
667
694
  this._alerts = state.alerts
668
- .filter((a) => a && isNum(a.price))
669
- .map((a) => ({
670
- id: a.id != null ? String(a.id) : 'alert-' + ++this._seq,
671
- price: a.price,
672
- direction: a.direction || 'cross',
673
- once: a.once !== false,
674
- fired: false,
675
- }));
695
+ .filter((a) => a && (isNum(a.price) || typeof a.when === 'string'))
696
+ .map((a) => {
697
+ if (typeof a.when === 'string' && a.when.trim()) {
698
+ try {
699
+ return {
700
+ id: a.id != null ? String(a.id) : 'alert-' + ++this._seq,
701
+ when: a.when.trim(),
702
+ compiled: compileScript(a.when),
703
+ once: a.once !== false,
704
+ fired: false,
705
+ armed: true,
706
+ };
707
+ } catch (err) {
708
+ return null;
709
+ }
710
+ }
711
+ return {
712
+ id: a.id != null ? String(a.id) : 'alert-' + ++this._seq,
713
+ price: a.price,
714
+ direction: a.direction || 'cross',
715
+ once: a.once !== false,
716
+ fired: false,
717
+ };
718
+ })
719
+ .filter(Boolean);
676
720
  }
677
721
  if (state.view && state.view.from != null && state.view.to != null) {
678
722
  if (this._ly && this._data.length > 1) {
@@ -725,21 +769,46 @@ class HabChart extends HTMLElementBase {
725
769
  }
726
770
 
727
771
  /**
728
- * Price alert. Fires `hab:alert` ({id, price, bar}) on an edge crossing
729
- * during streaming updates.
730
- * @param {{id?: string, price: number, direction?: 'above'|'below'|'cross',
731
- * once?: boolean}} alert
732
- * @returns {string|null} the alert id
772
+ * Price or scripted alert. Price alerts fire `wick:alert`
773
+ * ({id, price, bar}) on an edge crossing; scripted alerts evaluate a
774
+ * WickScript predicate (`when`) on every streamed bar and fire on its
775
+ * false→true edge — e.g. `when: 'crossup(rsi(close,14), 30)'` or
776
+ * `when: 'volume > sma(volume,20) * 3'`. Scripted events carry the
777
+ * triggering close as `price` plus the `when` source (deprecated
778
+ * `hab:alert` alias still dispatched).
779
+ * @param {{id?: string, price?: number, direction?: 'above'|'below'|'cross',
780
+ * when?: string, once?: boolean}} alert
781
+ * @returns {string|null} the alert id (null when no valid price/when,
782
+ * or the predicate fails to compile)
733
783
  */
734
784
  addAlert(alert) {
735
- if (!alert || !isNum(alert.price)) return null;
736
- const a = {
737
- id: alert.id != null ? String(alert.id) : 'alert-' + ++this._seq,
738
- price: alert.price,
739
- direction: alert.direction || 'cross',
740
- once: alert.once !== false,
741
- fired: false,
742
- };
785
+ if (!alert) return null;
786
+ let a;
787
+ if (typeof alert.when === 'string' && alert.when.trim()) {
788
+ let compiled;
789
+ try {
790
+ compiled = compileScript(alert.when);
791
+ } catch (err) {
792
+ return null;
793
+ }
794
+ a = {
795
+ id: alert.id != null ? String(alert.id) : 'alert-' + ++this._seq,
796
+ when: alert.when.trim(),
797
+ compiled,
798
+ once: alert.once !== false,
799
+ fired: false,
800
+ armed: true,
801
+ };
802
+ } else {
803
+ if (!isNum(alert.price)) return null;
804
+ a = {
805
+ id: alert.id != null ? String(alert.id) : 'alert-' + ++this._seq,
806
+ price: alert.price,
807
+ direction: alert.direction || 'cross',
808
+ once: alert.once !== false,
809
+ fired: false,
810
+ };
811
+ }
743
812
  const i = this._alerts.findIndex((x) => x.id === a.id);
744
813
  if (i >= 0) this._alerts[i] = a;
745
814
  else this._alerts.push(a);
@@ -757,21 +826,214 @@ class HabChart extends HTMLElementBase {
757
826
  this._invalidate();
758
827
  }
759
828
 
760
- /** Check alerts against an incoming bar (prev close → new close). */
829
+ /**
830
+ * Server-side overlays: zones & levels anchored in time × price — e.g.
831
+ * supply/demand zones from an analysis API. Zones with no `to` extend
832
+ * into future space past the last bar, like TradingView drawings.
833
+ *
834
+ * zone: { type:'zone', from?:ms, to?:ms|null, priceFrom, priceTo,
835
+ * color?, alpha?, border?, label?, id? }
836
+ * level: { type:'level', price, from?, to?, color?, width?, dash?,
837
+ * label?, id? }
838
+ *
839
+ * Invalid entries are dropped, never thrown. Colors accept hex/rgb()/CSS
840
+ * names plus the palette keys 'up' | 'down' | 'accent'.
841
+ * @param {object[]} list
842
+ * @returns {string[]} applied overlay ids
843
+ */
844
+ setOverlays(list) {
845
+ this._overlays = normalizeOverlays(list);
846
+ this._invalidate();
847
+ return this._overlays.map((o) => o.id);
848
+ }
849
+
850
+ /** @returns {object[]} a copy of the current overlays */
851
+ get overlays() {
852
+ return this._overlays.map((o) => ({ ...o }));
853
+ }
854
+
855
+ /**
856
+ * Add or replace (upsert, by id) a single overlay.
857
+ * @returns {string|null} the overlay id, or null if invalid
858
+ */
859
+ addOverlay(ov) {
860
+ const norm = normalizeOverlays([ov]);
861
+ if (!norm.length) return null;
862
+ const one = norm[0];
863
+ const i = this._overlays.findIndex((x) => x.id === one.id);
864
+ if (i >= 0) this._overlays[i] = one;
865
+ else this._overlays.push(one);
866
+ this._invalidate();
867
+ return one.id;
868
+ }
869
+
870
+ removeOverlay(id) {
871
+ this._overlays = this._overlays.filter((o) => o.id !== String(id));
872
+ this._invalidate();
873
+ }
874
+
875
+ clearOverlays() {
876
+ this._overlays = [];
877
+ this._invalidate();
878
+ }
879
+
880
+ /**
881
+ * Scenario projection into future space: a ghost path of future prices
882
+ * plus optional σ-bands (vol cone) from realized volatility.
883
+ *
884
+ * chart.setScenario({ path: [64000, 65500, 68000], label: 'bull case' });
885
+ * chart.setScenario({ horizon: 48, cone: true }); // cone-only
886
+ *
887
+ * The path is an array of prices (or {price} objects) for future bars
888
+ * 1..N; horizon defaults to the path length (1–500). `cone` (default
889
+ * true) draws ±levels·σ bands widening with √h from the current realized
890
+ * vol; `color` accepts up|down|accent or safe CSS colors. Setting a
891
+ * scenario reserves future space on the right; analysis data — excluded
892
+ * from getState/setState.
893
+ * @param {object} spec
894
+ * @returns {object|null} the normalized scenario, or null when invalid
895
+ */
896
+ setScenario(spec) {
897
+ this._scenario = normalizeScenario(spec);
898
+ this._invalidate();
899
+ return this._scenario;
900
+ }
901
+
902
+ clearScenario() {
903
+ this._scenario = null;
904
+ this._invalidate();
905
+ }
906
+
907
+ /** @returns {object|null} a copy of the active scenario */
908
+ get scenario() {
909
+ if (!this._scenario) return null;
910
+ return { ...this._scenario, path: this._scenario.path.map((p) => ({ ...p })) };
911
+ }
912
+
913
+ /** σ-cone for the active scenario, cached per data version. */
914
+ _scenarioConeCache() {
915
+ if (!this._scenario || !this._data.length) return null;
916
+ if (this._cache.v !== this._version) {
917
+ this._cache = { v: this._version, map: {} };
918
+ }
919
+ if (!this._cache.map.__scenario) {
920
+ const d = this._data;
921
+ const vol = calcRealizedVol(d.map((b) => b.close), 20);
922
+ let v = NaN;
923
+ for (let i = vol.length - 1; i >= 0; i--) {
924
+ if (Number.isFinite(vol[i])) { v = vol[i]; break; }
925
+ }
926
+ this._cache.map.__scenario = calcVolCone(
927
+ d[d.length - 1].close,
928
+ v,
929
+ this._scenario.horizon,
930
+ this._scenario.levels
931
+ );
932
+ }
933
+ return this._cache.map.__scenario;
934
+ }
935
+
936
+ /* ------------------------------------------------------------ *
937
+ * AI agent interface — the chart as a tool surface
938
+ * ------------------------------------------------------------ */
939
+
940
+ /** Tool manifest for LLM control — JSON-safe copy of AI_TOOLS. */
941
+ aiTools() {
942
+ return JSON.parse(JSON.stringify(AI_TOOLS));
943
+ }
944
+
945
+ /** System prompt for agent control — paste into any LLM alongside aiTools(). */
946
+ aiPrompt() {
947
+ return aiPromptText();
948
+ }
949
+
950
+ /** Grounding context for a model: current state + visible-window summary. */
951
+ aiContext() {
952
+ return { state: this.getState(), window: this.getDataWindow() };
953
+ }
954
+
955
+ /**
956
+ * Apply a list of {tool, args} ops (typically LLM output) through the
957
+ * validated dispatcher in core. Never throws — each op resolves
958
+ * {ok, tool, result} or {ok: false, tool, error} so an agent can
959
+ * self-correct.
960
+ * @param {any} ops
961
+ * @returns {Array<object>}
962
+ */
963
+ applyAI(ops) {
964
+ return applyChartOps(this, ops);
965
+ }
966
+
967
+ /**
968
+ * Ask an AI to operate the chart. Builds the payload {system,
969
+ * instruction, chart, tools}; with a `run` async function (your model
970
+ * call — the chart itself never touches the network), applies the
971
+ * returned ops and resolves {payload, ops, results}. Without `run`,
972
+ * returns the payload for manual wiring — send it anywhere, then call
973
+ * chart.applyAI(ops) with the model's answer.
974
+ *
975
+ * const { results } = await chart.ask('add RSI and mark the demand zone', {
976
+ * run: async (payload) => (await callMyLLM(payload)).ops,
977
+ * });
978
+ *
979
+ * @param {string} instruction natural-language request
980
+ * @param {{run?: (payload: object) => Promise<any>}} [opts]
981
+ */
982
+ async ask(instruction, opts = {}) {
983
+ const payload = {
984
+ system: aiPromptText(),
985
+ instruction: String(instruction == null ? '' : instruction),
986
+ chart: this.aiContext(),
987
+ tools: this.aiTools(),
988
+ };
989
+ if (typeof opts.run !== 'function') {
990
+ return { payload, ops: null, results: null };
991
+ }
992
+ const ops = await opts.run(payload);
993
+ const results = this.applyAI(ops);
994
+ return { payload, ops, results };
995
+ }
996
+
997
+ /** Check alerts against an incoming bar (prev close → new close).
998
+ * Scripted (`when`) alerts evaluate their predicate series, cached per
999
+ * data version, and fire on the false→true edge. */
761
1000
  _checkAlerts(prevClose, bar) {
762
- if (!this._alerts.length || !isNum(prevClose)) return;
1001
+ if (!this._alerts.length) return;
763
1002
  for (const a of [...this._alerts]) {
764
1003
  if (a.fired) continue;
1004
+ if (a.when != null) {
1005
+ const series = this._predicateCache(a);
1006
+ const curTrue = series.length ? series[series.length - 1] : false;
1007
+ const step = scriptAlertStep(a.armed, curTrue);
1008
+ a.armed = step.armed;
1009
+ if (step.fire) {
1010
+ a.fired = true;
1011
+ this._fire('alert', { id: a.id, price: bar.close, when: a.when, bar });
1012
+ if (a.once) this._alerts = this._alerts.filter((x) => x !== a);
1013
+ }
1014
+ continue;
1015
+ }
1016
+ if (!isNum(prevClose)) continue;
765
1017
  if (checkAlertCross(a, prevClose, bar.close)) {
766
1018
  a.fired = true;
767
- this.dispatchEvent(
768
- new CustomEvent('hab:alert', { detail: { id: a.id, price: a.price, bar } })
769
- );
1019
+ this._fire('alert', { id: a.id, price: a.price, bar });
770
1020
  if (a.once) this._alerts = this._alerts.filter((x) => x !== a);
771
1021
  }
772
1022
  }
773
1023
  }
774
1024
 
1025
+ /** Cached boolean series for a scripted alert's predicate (per data version). */
1026
+ _predicateCache(alert) {
1027
+ if (this._cache.v !== this._version) {
1028
+ this._cache = { v: this._version, map: {} };
1029
+ }
1030
+ const k = 'pred:' + alert.when;
1031
+ if (!this._cache.map[k]) {
1032
+ this._cache.map[k] = predicateTrueSeries(alert.compiled, this._data);
1033
+ }
1034
+ return this._cache.map[k];
1035
+ }
1036
+
775
1037
  /* reflected properties */
776
1038
  get theme() { return this._theme; }
777
1039
  set theme(v) { this.setAttribute('theme', v); }
@@ -808,7 +1070,7 @@ class HabChart extends HTMLElementBase {
808
1070
  if (!b) return null;
809
1071
  const t = b.time != null ? b.time : b.t;
810
1072
  if (!isNum(t)) return null;
811
- const time = HabChart._timeToMs(t);
1073
+ const time = WickChart._timeToMs(t);
812
1074
  const close = isNum(b.close) ? b.close : isNum(b.value) ? b.value : NaN;
813
1075
  if (!isNum(close)) return null;
814
1076
  const open = isNum(b.open) ? b.open : close;
@@ -887,8 +1149,9 @@ class HabChart extends HTMLElementBase {
887
1149
  if (this._pal && this._palKey === this._theme) return this._pal;
888
1150
  const base = THEMES[this._theme] || THEMES.dark;
889
1151
  const cs = getComputedStyle(this);
1152
+ // --wick-* is canonical; --hab-* still honored as the 0.x fallback
890
1153
  const get = (name, fallback) => {
891
- const v = cs.getPropertyValue('--hab-' + name).trim();
1154
+ const v = cs.getPropertyValue('--wick-' + name).trim() || cs.getPropertyValue('--hab-' + name).trim();
892
1155
  return v || fallback;
893
1156
  };
894
1157
  const pal = {};
@@ -899,7 +1162,8 @@ class HabChart extends HTMLElementBase {
899
1162
  o.push(get('overlay-' + i, base.overlay[i]));
900
1163
  }
901
1164
  // allow single overlay color
902
- const single = cs.getPropertyValue('--hab-overlay').trim();
1165
+ const single =
1166
+ cs.getPropertyValue('--wick-overlay').trim() || cs.getPropertyValue('--hab-overlay').trim();
903
1167
  pal.overlay = single ? base.overlay.map(() => single) : o;
904
1168
  } else {
905
1169
  pal[k] = get(k.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()), base[k]);
@@ -1010,7 +1274,9 @@ class HabChart extends HTMLElementBase {
1010
1274
  _rightMargin() {
1011
1275
  const ly = this._ly;
1012
1276
  const w = ly ? ly.plotRight : 600;
1013
- return Math.max(3, (w / this._view.spacing) * 0.06);
1277
+ const base = Math.max(3, (w / this._view.spacing) * 0.06);
1278
+ // a scenario projection reserves future space so the cone stays visible
1279
+ return this._scenario ? Math.max(base, this._scenario.horizon + 3) : base;
1014
1280
  }
1015
1281
 
1016
1282
  _applyFit() {
@@ -1018,7 +1284,7 @@ class HabChart extends HTMLElementBase {
1018
1284
  if (!d.length || !this._ly) return;
1019
1285
  const { plotRight } = this._ly;
1020
1286
  const target = Math.min(d.length, 150);
1021
- this._view.spacing = clamp(plotRight / target, this._minSpacing(), HabChart._MAX_SP);
1287
+ this._view.spacing = clamp(plotRight / target, this._minSpacing(), WickChart._MAX_SP);
1022
1288
  this._view.rightIndex = d.length - 1 + this._rightMargin();
1023
1289
  }
1024
1290
 
@@ -1027,7 +1293,7 @@ class HabChart extends HTMLElementBase {
1027
1293
  const ly = this._ly;
1028
1294
  if (!d.length || !ly) return;
1029
1295
  const v = this._view;
1030
- v.spacing = clamp(v.spacing, this._minSpacing(), HabChart._MAX_SP);
1296
+ v.spacing = clamp(v.spacing, this._minSpacing(), WickChart._MAX_SP);
1031
1297
  const visible = ly.plotRight / v.spacing;
1032
1298
  const maxRight = d.length - 1 + Math.max(6, visible * 0.5);
1033
1299
  const minRight = Math.min(2, d.length - 1);
@@ -1378,6 +1644,146 @@ class HabChart extends HTMLElementBase {
1378
1644
  }
1379
1645
  }
1380
1646
 
1647
+ /* server-side overlays: zones & levels (above regime shading, under series).
1648
+ * Zones with `to == null` extend into future space past the last bar. */
1649
+ if (this._overlays.length) {
1650
+ const d = this._data;
1651
+ const idxFor = (t, fallback) => (t == null ? fallback : barIndexForTime(d, t));
1652
+ for (const ov of this._overlays) {
1653
+ const col = resolveOverlayColor(ov.color, pal);
1654
+ if (ov.type === 'zone') {
1655
+ const iA = Math.max(0, idxFor(ov.from, 0));
1656
+ const iB = ov.to == null ? null : Math.max(0, idxFor(ov.to, d.length - 1));
1657
+ const zx0 = clamp(this._xFor(iA) - sp * 0.5, 0, plotRight);
1658
+ const zx1 = iB == null ? plotRight : clamp(this._xFor(iB) + sp * 0.5, 0, plotRight);
1659
+ const zyT = clamp(yOf(ov.priceTo), main.y0, main.y1);
1660
+ const zyB = clamp(yOf(ov.priceFrom), main.y0, main.y1);
1661
+ if (zx1 - zx0 < 1 || zyB - zyT < 1) continue;
1662
+ ctx.save();
1663
+ ctx.globalAlpha = ov.alpha;
1664
+ ctx.fillStyle = col;
1665
+ ctx.fillRect(zx0, zyT, zx1 - zx0, zyB - zyT);
1666
+ if (ov.border) {
1667
+ ctx.globalAlpha = Math.min(1, ov.alpha + 0.4);
1668
+ ctx.lineWidth = 1;
1669
+ ctx.strokeStyle = col;
1670
+ ctx.strokeRect(
1671
+ Math.round(zx0) + 0.5, Math.round(zyT) + 0.5,
1672
+ Math.max(2, Math.round(zx1 - zx0) - 1), Math.max(2, Math.round(zyB - zyT) - 1)
1673
+ );
1674
+ }
1675
+ if (ov.label) {
1676
+ ctx.globalAlpha = 0.95;
1677
+ ctx.font = pillFont();
1678
+ ctx.fillStyle = col;
1679
+ ctx.textAlign = 'left';
1680
+ ctx.textBaseline = 'top';
1681
+ ctx.fillText(ov.label, zx0 + 6, Math.max(main.y0, zyT) + 4);
1682
+ }
1683
+ ctx.restore();
1684
+ } else {
1685
+ const lx0 = clamp(this._xFor(Math.max(0, idxFor(ov.from, 0))) - sp * 0.5, 0, plotRight);
1686
+ const lx1 =
1687
+ ov.to == null
1688
+ ? plotRight
1689
+ : clamp(this._xFor(Math.max(0, idxFor(ov.to, d.length - 1))) + sp * 0.5, 0, plotRight);
1690
+ const ly = Math.round(yOf(ov.price)) + 0.5;
1691
+ if (lx1 - lx0 < 1 || ly < main.y0 || ly > main.y1) continue;
1692
+ ctx.save();
1693
+ ctx.strokeStyle = col;
1694
+ ctx.lineWidth = ov.width;
1695
+ if (ov.dash) ctx.setLineDash([5, 4]);
1696
+ ctx.beginPath();
1697
+ ctx.moveTo(lx0, ly);
1698
+ ctx.lineTo(lx1, ly);
1699
+ ctx.stroke();
1700
+ if (ov.label) {
1701
+ ctx.font = pillFont();
1702
+ ctx.fillStyle = col;
1703
+ ctx.textAlign = 'right';
1704
+ ctx.textBaseline = 'bottom';
1705
+ ctx.fillText(ov.label, Math.min(lx1, plotRight) - 6, ly - 2);
1706
+ }
1707
+ ctx.restore();
1708
+ }
1709
+ }
1710
+ }
1711
+
1712
+ /* scenario projection: ghost path + σ-cone in future space (clipped to
1713
+ * the main plot so out-of-range bands never bleed into the axis) */
1714
+ if (this._scenario && d.length) {
1715
+ const sc = this._scenario;
1716
+ const col = resolveOverlayColor(sc.color, pal);
1717
+ const baseIdx = d.length - 1;
1718
+ const xAt = (h) => this._xFor(baseIdx + h);
1719
+ ctx.save();
1720
+ ctx.beginPath();
1721
+ ctx.rect(0, main.y0, plotRight, main.h);
1722
+ ctx.clip();
1723
+ if (sc.cone) {
1724
+ const cone = this._scenarioConeCache();
1725
+ if (cone) {
1726
+ for (let li = cone.levels.length - 1; li >= 0; li--) {
1727
+ const b = cone.bands[cone.levels[li]];
1728
+ ctx.globalAlpha = li === 0 ? 0.1 : 0.05;
1729
+ ctx.fillStyle = col;
1730
+ ctx.beginPath();
1731
+ ctx.moveTo(xAt(0), yOf(b.up[0]));
1732
+ for (let h = 1; h <= cone.horizon; h++) ctx.lineTo(xAt(h), yOf(b.up[h]));
1733
+ for (let h = cone.horizon; h >= 0; h--) ctx.lineTo(xAt(h), yOf(b.down[h]));
1734
+ ctx.closePath();
1735
+ ctx.fill();
1736
+ }
1737
+ const inner = cone.bands[cone.levels[0]];
1738
+ ctx.globalAlpha = 0.4;
1739
+ ctx.strokeStyle = col;
1740
+ ctx.lineWidth = 1;
1741
+ ctx.setLineDash([4, 4]);
1742
+ for (const arr of [inner.up, inner.down]) {
1743
+ ctx.beginPath();
1744
+ ctx.moveTo(xAt(0), yOf(arr[0]));
1745
+ for (let h = 1; h <= cone.horizon; h++) ctx.lineTo(xAt(h), yOf(arr[h]));
1746
+ ctx.stroke();
1747
+ }
1748
+ ctx.setLineDash([]);
1749
+ }
1750
+ }
1751
+ if (sc.path.length) {
1752
+ ctx.globalAlpha = 0.9;
1753
+ ctx.strokeStyle = col;
1754
+ ctx.lineWidth = 1.5;
1755
+ ctx.setLineDash([6, 4]);
1756
+ ctx.beginPath();
1757
+ ctx.moveTo(xAt(0), yOf(d[baseIdx].close));
1758
+ for (const p of sc.path) ctx.lineTo(xAt(p.h), yOf(p.price));
1759
+ ctx.stroke();
1760
+ ctx.setLineDash([]);
1761
+ ctx.fillStyle = col;
1762
+ for (const p of sc.path) {
1763
+ const y = yOf(p.price);
1764
+ if (y >= main.y0 && y <= main.y1) {
1765
+ ctx.beginPath();
1766
+ ctx.arc(xAt(p.h), y, 2.5, 0, Math.PI * 2);
1767
+ ctx.fill();
1768
+ }
1769
+ }
1770
+ if (sc.label) {
1771
+ const p = sc.path[sc.path.length - 1];
1772
+ ctx.font = pillFont();
1773
+ ctx.globalAlpha = 0.95;
1774
+ ctx.fillStyle = col;
1775
+ ctx.textAlign = 'left';
1776
+ ctx.textBaseline = 'middle';
1777
+ ctx.fillText(
1778
+ sc.label,
1779
+ Math.min(xAt(p.h) + 8, plotRight - 4),
1780
+ clamp(yOf(p.price), main.y0 + 8, main.y1 - 8)
1781
+ );
1782
+ }
1783
+ }
1784
+ ctx.restore();
1785
+ }
1786
+
1381
1787
  /* position zones (under series) */
1382
1788
  for (const pos of this._positions) {
1383
1789
  const yE = clamp(yOf(pos.entry), main.y0, main.y1);
@@ -1457,9 +1863,7 @@ class HabChart extends HTMLElementBase {
1457
1863
  this._annoList = detectAnnotations(this._data, i0, i1, this._cachedRSI14());
1458
1864
  this._annoKey = akey;
1459
1865
  this._legendKey = ''; // legend may now show insights at the hovered bar
1460
- this.dispatchEvent(
1461
- new CustomEvent('hab:annotations', { detail: { annotations: this._annoList } })
1462
- );
1866
+ this._fire('annotations', { annotations: this._annoList });
1463
1867
  }
1464
1868
  const A = this._annoList;
1465
1869
  if (A.length) {
@@ -1723,7 +2127,7 @@ class HabChart extends HTMLElementBase {
1723
2127
  ctx.setLineDash([5, 4]);
1724
2128
  ctx.strokeStyle = pal.overlay[0];
1725
2129
  for (const a of this._alerts) {
1726
- if (a.fired) continue;
2130
+ if (a.fired || !isNum(a.price)) continue; // scripted alerts have no line
1727
2131
  const y = yOf(a.price);
1728
2132
  if (y < main.y0 || y > main.y1) continue;
1729
2133
  ctx.globalAlpha = 0.8;
@@ -2353,7 +2757,7 @@ class HabChart extends HTMLElementBase {
2353
2757
  const s = clamp(
2354
2758
  (this._pinch.spacing * dist) / this._pinch.dist,
2355
2759
  this._minSpacing(),
2356
- HabChart._MAX_SP
2760
+ WickChart._MAX_SP
2357
2761
  );
2358
2762
  this._view.spacing = s;
2359
2763
  this._view.rightIndex = this._pinch.idxAtMid + (ly.plotRight - mid.x) / s;
@@ -2406,15 +2810,11 @@ class HabChart extends HTMLElementBase {
2406
2810
  const d = this._data;
2407
2811
  const barA = d[clamp(m.iA, 0, d.length - 1)];
2408
2812
  const barB = d[clamp(m.iB, 0, d.length - 1)];
2409
- this.dispatchEvent(
2410
- new CustomEvent('hab:measure', {
2411
- detail: {
2412
- from: { index: m.iA, time: barA.time, price: m.pA },
2413
- to: { index: m.iB, time: barB.time, price: m.pB },
2414
- bars: Math.abs(m.iB - m.iA),
2415
- },
2416
- })
2417
- );
2813
+ this._fire('measure', {
2814
+ from: { index: m.iA, time: barA.time, price: m.pA },
2815
+ to: { index: m.iB, time: barB.time, price: m.pB },
2816
+ bars: Math.abs(m.iB - m.iA),
2817
+ });
2418
2818
  }
2419
2819
  } else if (this._pan && had && !this._pan.moved && this._data.length) {
2420
2820
  if (this._measure) {
@@ -2426,11 +2826,7 @@ class HabChart extends HTMLElementBase {
2426
2826
  const pt = this._localPoint(e);
2427
2827
  const idx = clamp(Math.round(this._indexForX(pt.x)), 0, this._data.length - 1);
2428
2828
  const price = this._yToPrice(pt.y);
2429
- this.dispatchEvent(
2430
- new CustomEvent('hab:select', {
2431
- detail: { index: idx, bar: this._data[idx], price },
2432
- })
2433
- );
2829
+ this._fire('select', { index: idx, bar: this._data[idx], price });
2434
2830
  }
2435
2831
  }
2436
2832
  this._pan = null;
@@ -2456,8 +2852,10 @@ class HabChart extends HTMLElementBase {
2456
2852
  const dy = e.deltaY * (e.deltaMode === 1 ? 33 : 1);
2457
2853
 
2458
2854
  if (Math.abs(dx) > Math.abs(dy) && !e.ctrlKey) {
2459
- // trackpad horizontal scroll → pan
2460
- this._view.rightIndex -= dx / this._view.spacing;
2855
+ // trackpad horizontal scroll → pan. Wheel deltas are viewport-relative:
2856
+ // deltaX>0 means "scroll right", i.e. reveal newer bars. On natural-scroll
2857
+ // trackpads this makes the content follow the fingers, matching drag.
2858
+ this._view.rightIndex += dx / this._view.spacing;
2461
2859
  this._auto = this._atRight();
2462
2860
  this._clampView();
2463
2861
  this._invalidate();
@@ -2467,7 +2865,7 @@ class HabChart extends HTMLElementBase {
2467
2865
 
2468
2866
  const factor = Math.exp(-dy * (e.ctrlKey ? 0.008 : 0.0016));
2469
2867
  const oldSp = this._view.spacing;
2470
- const newSp = clamp(oldSp * factor, this._minSpacing(), HabChart._MAX_SP);
2868
+ const newSp = clamp(oldSp * factor, this._minSpacing(), WickChart._MAX_SP);
2471
2869
  if (newSp === oldSp) return;
2472
2870
  const idxAtCursor = this._indexForX(pt.x);
2473
2871
  this._view.spacing = newSp;
@@ -2504,12 +2902,12 @@ class HabChart extends HTMLElementBase {
2504
2902
  this._invalidate();
2505
2903
  this._emitRange();
2506
2904
  } else if (key === '+' || key === '=') {
2507
- this._view.spacing = clamp(this._view.spacing * 1.25, this._minSpacing(), HabChart._MAX_SP);
2905
+ this._view.spacing = clamp(this._view.spacing * 1.25, this._minSpacing(), WickChart._MAX_SP);
2508
2906
  this._clampView();
2509
2907
  this._invalidate();
2510
2908
  this._emitRange();
2511
2909
  } else if (key === '-' || key === '_') {
2512
- this._view.spacing = clamp(this._view.spacing / 1.25, this._minSpacing(), HabChart._MAX_SP);
2910
+ this._view.spacing = clamp(this._view.spacing / 1.25, this._minSpacing(), WickChart._MAX_SP);
2513
2911
  this._clampView();
2514
2912
  this._invalidate();
2515
2913
  this._emitRange();
@@ -2636,7 +3034,7 @@ class HabChart extends HTMLElementBase {
2636
3034
  price: this._yToPrice(hover.y),
2637
3035
  };
2638
3036
  }
2639
- this.dispatchEvent(new CustomEvent('hab:crosshair', { detail }));
3037
+ this._fire('crosshair', detail);
2640
3038
 
2641
3039
  // co-view: share the pointer with peer charts (leave events bypass throttle)
2642
3040
  if (this._coviewCh) {
@@ -2678,7 +3076,7 @@ class HabChart extends HTMLElementBase {
2678
3076
  if (!name || !this._connected || typeof BroadcastChannel === 'undefined') return;
2679
3077
  if (!this._coviewPeer) this._coviewPeer = 'p' + Math.random().toString(36).slice(2, 8);
2680
3078
  try {
2681
- const ch = new BroadcastChannel('hab-co-view:' + name);
3079
+ const ch = new BroadcastChannel('wick-co-view:' + name);
2682
3080
  ch.onmessage = (ev) => this._onCoMessage(ev.data);
2683
3081
  this._coviewCh = ch;
2684
3082
  } catch (_) {}
@@ -2703,7 +3101,7 @@ class HabChart extends HTMLElementBase {
2703
3101
  }
2704
3102
  if (!isNum(m.time) || !this._data.length) return;
2705
3103
  this._ghost = {
2706
- index: HabChart._indexForTime(this._data, m.time),
3104
+ index: WickChart._indexForTime(this._data, m.time),
2707
3105
  yFrac: isNum(m.yFrac) ? clamp(m.yFrac, 0, 1) : null,
2708
3106
  at: Date.now(),
2709
3107
  };
@@ -2715,15 +3113,31 @@ class HabChart extends HTMLElementBase {
2715
3113
  this._invalidate();
2716
3114
  }
2717
3115
 
3116
+ /** Dispatch `wick:name` (canonical) plus the deprecated `hab:name` alias,
3117
+ * so 0.x listeners keep working until 2.0. */
3118
+ _fire(name, detail) {
3119
+ this.dispatchEvent(new CustomEvent('wick:' + name, { detail }));
3120
+ this.dispatchEvent(new CustomEvent('hab:' + name, { detail }));
3121
+ }
3122
+
2718
3123
  _emitRange() {
2719
3124
  const r = this.getVisibleRange();
2720
3125
  if (!r) return;
2721
- this.dispatchEvent(new CustomEvent('hab:range', { detail: r }));
3126
+ this._fire('range', r);
2722
3127
  }
2723
3128
  }
2724
3129
 
2725
- if (typeof customElements !== 'undefined' && !customElements.get('hab-chart')) {
2726
- customElements.define('hab-chart', HabChart);
3130
+ if (typeof customElements !== 'undefined') {
3131
+ if (!customElements.get('wick-chart')) {
3132
+ customElements.define('wick-chart', WickChart);
3133
+ }
3134
+ // 0.x alias: same element under its old tag name (deprecated, removed in 2.0)
3135
+ if (!customElements.get('hab-chart')) {
3136
+ /** @deprecated use <wick-chart> */
3137
+ class HabChart extends WickChart {}
3138
+ customElements.define('hab-chart', HabChart);
3139
+ }
2727
3140
  }
2728
3141
 
2729
- export default HabChart;
3142
+ export default WickChart;
3143
+ export { WickChart };