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/src/wick-chart.js CHANGED
@@ -28,7 +28,10 @@ import {
28
28
  SERIES_TYPES, calcHeikinAshi, buildColumns, computeVolumeProfile,
29
29
  calcRSI, detectAnnotations, priceToFreq,
30
30
  calcRealizedVol, volRegimeBands, percentileOfSorted, parseVolShading,
31
- windowSummary,
31
+ windowSummary, normalizeOverlays, barIndexForTime, resolveOverlayColor,
32
+ compileScript, predicateTrueSeries, scriptAlertStep,
33
+ AI_TOOLS, aiPromptText, applyChartOps,
34
+ calcVolCone, normalizeScenario,
32
35
  } from './core.js';
33
36
 
34
37
  /* ------------------------------------------------------------------ *
@@ -45,7 +48,7 @@ const HTMLElementBase = typeof HTMLElement !== 'undefined' ? HTMLElement : class
45
48
 
46
49
  class WickChart extends HTMLElementBase {
47
50
  static get observedAttributes() {
48
- 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'];
49
52
  }
50
53
 
51
54
  constructor() {
@@ -225,6 +228,12 @@ class WickChart extends HTMLElementBase {
225
228
  this._alerts = [];
226
229
  this._seq = 0;
227
230
 
231
+ // server-side overlays (zones & levels)
232
+ this._overlays = [];
233
+
234
+ // scenario projection (ghost path + vol cone)
235
+ this._scenario = null;
236
+
228
237
  this._onResize = () => this._invalidate();
229
238
  this._onPointerDown = (e) => this._pointerDown(e);
230
239
  this._onPointerMove = (e) => this._pointerMove(e);
@@ -330,6 +339,18 @@ class WickChart extends HTMLElementBase {
330
339
  this._volshade = val != null && val !== 'false' ? parseVolShading(val) : null;
331
340
  this._legendKey = '';
332
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
+ }
333
354
  case 'co-view':
334
355
  this._coviewName = val || null;
335
356
  this._setupCoView();
@@ -629,7 +650,11 @@ class WickChart extends HTMLElementBase {
629
650
  })),
630
651
  alerts: this._alerts
631
652
  .filter((a) => !a.fired)
632
- .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
+ ),
633
658
  };
634
659
  }
635
660
 
@@ -667,14 +692,31 @@ class WickChart extends HTMLElementBase {
667
692
  }
668
693
  if (Array.isArray(state.alerts)) {
669
694
  this._alerts = state.alerts
670
- .filter((a) => a && isNum(a.price))
671
- .map((a) => ({
672
- id: a.id != null ? String(a.id) : 'alert-' + ++this._seq,
673
- price: a.price,
674
- direction: a.direction || 'cross',
675
- once: a.once !== false,
676
- fired: false,
677
- }));
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);
678
720
  }
679
721
  if (state.view && state.view.from != null && state.view.to != null) {
680
722
  if (this._ly && this._data.length > 1) {
@@ -727,22 +769,46 @@ class WickChart extends HTMLElementBase {
727
769
  }
728
770
 
729
771
  /**
730
- * Price alert. Fires `wick:alert` ({id, price, bar}) on an edge crossing
731
- * (plus the deprecated `hab:alert` alias)
732
- * during streaming updates.
733
- * @param {{id?: string, price: number, direction?: 'above'|'below'|'cross',
734
- * once?: boolean}} alert
735
- * @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)
736
783
  */
737
784
  addAlert(alert) {
738
- if (!alert || !isNum(alert.price)) return null;
739
- const a = {
740
- id: alert.id != null ? String(alert.id) : 'alert-' + ++this._seq,
741
- price: alert.price,
742
- direction: alert.direction || 'cross',
743
- once: alert.once !== false,
744
- fired: false,
745
- };
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
+ }
746
812
  const i = this._alerts.findIndex((x) => x.id === a.id);
747
813
  if (i >= 0) this._alerts[i] = a;
748
814
  else this._alerts.push(a);
@@ -760,11 +826,194 @@ class WickChart extends HTMLElementBase {
760
826
  this._invalidate();
761
827
  }
762
828
 
763
- /** 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. */
764
1000
  _checkAlerts(prevClose, bar) {
765
- if (!this._alerts.length || !isNum(prevClose)) return;
1001
+ if (!this._alerts.length) return;
766
1002
  for (const a of [...this._alerts]) {
767
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;
768
1017
  if (checkAlertCross(a, prevClose, bar.close)) {
769
1018
  a.fired = true;
770
1019
  this._fire('alert', { id: a.id, price: a.price, bar });
@@ -773,6 +1022,18 @@ class WickChart extends HTMLElementBase {
773
1022
  }
774
1023
  }
775
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
+
776
1037
  /* reflected properties */
777
1038
  get theme() { return this._theme; }
778
1039
  set theme(v) { this.setAttribute('theme', v); }
@@ -1013,7 +1274,9 @@ class WickChart extends HTMLElementBase {
1013
1274
  _rightMargin() {
1014
1275
  const ly = this._ly;
1015
1276
  const w = ly ? ly.plotRight : 600;
1016
- 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;
1017
1280
  }
1018
1281
 
1019
1282
  _applyFit() {
@@ -1381,6 +1644,146 @@ class WickChart extends HTMLElementBase {
1381
1644
  }
1382
1645
  }
1383
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
+
1384
1787
  /* position zones (under series) */
1385
1788
  for (const pos of this._positions) {
1386
1789
  const yE = clamp(yOf(pos.entry), main.y0, main.y1);
@@ -1724,7 +2127,7 @@ class WickChart extends HTMLElementBase {
1724
2127
  ctx.setLineDash([5, 4]);
1725
2128
  ctx.strokeStyle = pal.overlay[0];
1726
2129
  for (const a of this._alerts) {
1727
- if (a.fired) continue;
2130
+ if (a.fired || !isNum(a.price)) continue; // scripted alerts have no line
1728
2131
  const y = yOf(a.price);
1729
2132
  if (y < main.y0 || y > main.y1) continue;
1730
2133
  ctx.globalAlpha = 0.8;
@@ -2449,8 +2852,10 @@ class WickChart extends HTMLElementBase {
2449
2852
  const dy = e.deltaY * (e.deltaMode === 1 ? 33 : 1);
2450
2853
 
2451
2854
  if (Math.abs(dx) > Math.abs(dy) && !e.ctrlKey) {
2452
- // trackpad horizontal scroll → pan
2453
- 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;
2454
2859
  this._auto = this._atRight();
2455
2860
  this._clampView();
2456
2861
  this._invalidate();