wickchart-signals 0.1.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.
Files changed (4) hide show
  1. package/README.md +43 -0
  2. package/core.mjs +80 -0
  3. package/package.json +39 -0
  4. package/signals.mjs +218 -0
package/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # wickchart-signals
2
+
3
+ Candlestick pattern badges for [wickchart](https://github.com/benyblack/wickchart),
4
+ as an opt-in plugin layer — the core stays pattern-free. Zero dependencies.
5
+
6
+ Detected patterns (v1):
7
+
8
+ - **Engulfing** (`E`) — bullish / bearish: the body swallows the opposite-colored
9
+ previous body and is strictly bigger.
10
+ - **Pin bar** (`P`) — bullish (hammer) / bearish (shooting star): wick ≥ 2× body,
11
+ opposite wick ≤ body.
12
+ - **Inside bar** (`IB`) — neutral: high and low inside the previous bar's range.
13
+
14
+ Badges are letter chips above/below the bar (direction-colored: green up,
15
+ red down, gray neutral) with a **crosshair hover explanation** — hover a
16
+ badged bar and the plugin draws what it is ("Bullish engulfing") and fires
17
+ `wick:signals`, so you can surface it in your own UI. The hover bridge
18
+ listens to the chart's own crosshair events: badges never claim a pointer
19
+ gesture, pan/zoom/measure stay untouched.
20
+
21
+ ```js
22
+ npm install wickchart wickchart-signals // the plugin is a separate package
23
+
24
+ import 'wickchart'; // the chart itself
25
+ import { attachSignals } from 'wickchart-signals';
26
+
27
+ const chart = document.querySelector('wick-chart');
28
+ const signals = attachSignals(chart);
29
+
30
+ signals.setKinds(['engulfing', 'pinbar']); // subset (default: all three)
31
+ signals.setLabels(false); // hover explanations off
32
+ signals.count; // signals in the current dataset
33
+ signals.detach();
34
+
35
+ chart.addEventListener('wick:signals', (e) => {
36
+ status.textContent = e.detail ? e.detail.label : '';
37
+ });
38
+ ```
39
+
40
+ Detection is O(n), cached per dataset and kind subset, so panning and
41
+ zooming are pure repaints.
42
+
43
+ Peer dependency: wickchart ≥ 1.4.0 (the plugin layer API).
package/core.mjs ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * wickchart-signals — pure pattern detection: candlestick signals as plain
3
+ * data. No DOM, no canvas; everything is unit-testable data in / data out.
4
+ *
5
+ * Detected kinds (v1): bullish/bearish engulfing, bullish/bearish pin bar
6
+ * (hammer / shooting star), inside bar. Each signal: { i, kind, dir } —
7
+ * `dir` is 'bull' | 'bear' | null (inside bars are neutral).
8
+ */
9
+
10
+ export const KINDS = ['engulfing', 'pinbar', 'inside'];
11
+
12
+ export const KIND_INFO = {
13
+ engulfing: { letter: 'E', name: 'engulfing' },
14
+ pinbar: { letter: 'P', name: 'pin bar' },
15
+ inside: { letter: 'IB', name: 'inside bar' },
16
+ };
17
+
18
+ const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
19
+
20
+ /** Candle anatomy: { bull, body, range, upper, lower } (null when invalid). */
21
+ export function anatomy(b) {
22
+ if (!b || !isNum(b.open) || !isNum(b.close) || !isNum(b.high) || !isNum(b.low)) return null;
23
+ const body = Math.abs(b.close - b.open);
24
+ const range = b.high - b.low;
25
+ if (range <= 0) return null;
26
+ return {
27
+ bull: b.close >= b.open,
28
+ body,
29
+ range,
30
+ upper: b.high - Math.max(b.open, b.close),
31
+ lower: Math.min(b.open, b.close) - b.low,
32
+ top: Math.max(b.open, b.close),
33
+ bottom: Math.min(b.open, b.close),
34
+ };
35
+ }
36
+
37
+ function engulfing(prev, cur) {
38
+ if (cur.bull === prev.bull) return null; // engulfing needs opposite colors
39
+ if (cur.body <= prev.body) return null; // the current body must be strictly bigger
40
+ if (cur.top >= prev.top && cur.bottom <= prev.bottom) return cur.bull ? 'bull' : 'bear';
41
+ return null;
42
+ }
43
+
44
+ function pinbar(a) {
45
+ if (a.body <= 0) return null;
46
+ if (a.lower >= 2 * a.body && a.upper <= a.body) return 'bull'; // hammer
47
+ if (a.upper >= 2 * a.body && a.lower <= a.body) return 'bear'; // shooting star
48
+ return null;
49
+ }
50
+
51
+ /**
52
+ * Detect signals over a full bar array. O(n); cache the result per dataset.
53
+ * @param {Array<{time,open,high,low,close}>} bars
54
+ * @param {string[]} [kinds] subset of KINDS to detect (default: all)
55
+ * @returns {Array<{i: number, kind: string, dir: 'bull'|'bear'|null}>}
56
+ */
57
+ export function detectSignals(bars, kinds) {
58
+ if (!Array.isArray(bars) || bars.length < 2) return [];
59
+ const want = Array.isArray(kinds) && kinds.length ? new Set(kinds.filter((k) => KINDS.includes(k))) : new Set(KINDS);
60
+ const out = [];
61
+ for (let i = 1; i < bars.length; i++) {
62
+ const cur = anatomy(bars[i]);
63
+ const prev = anatomy(bars[i - 1]);
64
+ if (!cur || !prev) continue;
65
+ if (want.has('engulfing')) {
66
+ const dir = engulfing(prev, cur);
67
+ if (dir) out.push({ i, kind: 'engulfing', dir });
68
+ }
69
+ if (want.has('pinbar')) {
70
+ const dir = pinbar(cur);
71
+ if (dir) out.push({ i, kind: 'pinbar', dir });
72
+ }
73
+ if (want.has('inside')) {
74
+ if (bars[i].high <= bars[i - 1].high && bars[i].low >= bars[i - 1].low) {
75
+ out.push({ i, kind: 'inside', dir: null });
76
+ }
77
+ }
78
+ }
79
+ return out;
80
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "wickchart-signals",
3
+ "version": "0.1.0",
4
+ "description": "Candlestick pattern badges for wickchart — bullish/bearish engulfing, pin bars and inside bars as letter chips with crosshair hover explanations. Opt-in plugin, zero dependencies.",
5
+ "type": "module",
6
+ "main": "signals.mjs",
7
+ "module": "signals.mjs",
8
+ "exports": {
9
+ ".": "./signals.mjs",
10
+ "./core": "./core.mjs"
11
+ },
12
+ "files": [
13
+ "signals.mjs",
14
+ "core.mjs",
15
+ "README.md"
16
+ ],
17
+ "keywords": [
18
+ "chart",
19
+ "charting",
20
+ "patterns",
21
+ "signals",
22
+ "engulfing",
23
+ "pinbar",
24
+ "candlestick",
25
+ "trading",
26
+ "web-component",
27
+ "wickchart",
28
+ "canvas"
29
+ ],
30
+ "license": "MIT",
31
+ "peerDependencies": {
32
+ "wickchart": ">=1.4.0"
33
+ },
34
+ "peerDependenciesMeta": {
35
+ "wickchart": {
36
+ "optional": true
37
+ }
38
+ }
39
+ }
package/signals.mjs ADDED
@@ -0,0 +1,218 @@
1
+ /**
2
+ * wickchart-signals — candlestick pattern badges as a wickchart plugin
3
+ * layer: bullish/bearish engulfing, pin bars and inside bars drawn as small
4
+ * letter chips above/below the bar, with a crosshair hover explanation —
5
+ * same passive-hover pattern as wickchart-sessions (the layer never claims
6
+ * a pointer gesture). Detection lives in core.mjs and is cached per dataset.
7
+ *
8
+ * import { attachSignals } from 'wickchart-signals';
9
+ * const signals = attachSignals(chart);
10
+ * signals.setKinds(['engulfing', 'pinbar']); // subset (default: all)
11
+ * signals.setLabels(false); // hover explanations off
12
+ * signals.detach();
13
+ *
14
+ * Events on the chart element:
15
+ * wick:signals { detail: { index, time, signals, label } } — the pattern
16
+ * under the crosshair changed (null when the crosshair left a signal)
17
+ */
18
+
19
+ import { detectSignals, KINDS, KIND_INFO } from './core.mjs';
20
+
21
+ const FONT = '600 9px ui-sans-serif, system-ui, sans-serif';
22
+ const OFFSET = 14; // px between the bar extreme and the chip center
23
+
24
+ const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
25
+
26
+ const normKinds = (k) =>
27
+ Array.isArray(k) && k.length ? k.filter((x) => KINDS.includes(x)) : KINDS.slice();
28
+
29
+ export function attachSignals(chart, opts = {}) {
30
+ return new SignalsLayer(chart, opts);
31
+ }
32
+
33
+ export class SignalsLayer {
34
+ constructor(chart, opts = {}) {
35
+ if (!chart || typeof chart.addLayer !== 'function') {
36
+ throw new TypeError('attachSignals(chart): the chart element is required');
37
+ }
38
+ this._chart = chart;
39
+ this._kinds = normKinds(opts.kinds);
40
+ this._labels = opts.labels !== false;
41
+ this._signals = [];
42
+ this._datasetLen = 0;
43
+ this._cacheKey = null;
44
+ this._hover = null; // { index, label }
45
+ this._layer = { id: 'wick-signals', draw: (api) => this._render(api) };
46
+ chart.addLayer(this._layer);
47
+ this._onCross = (e) => this._cross(e.detail);
48
+ chart.addEventListener('wick:crosshair', this._onCross);
49
+ }
50
+
51
+ /* ---------------- public API ---------------- */
52
+
53
+ setKinds(kinds) {
54
+ this._kinds = normKinds(kinds);
55
+ this._cacheKey = null; // re-detect with the new subset
56
+ this._redraw();
57
+ return this;
58
+ }
59
+
60
+ get kinds() {
61
+ return this._kinds.slice();
62
+ }
63
+
64
+ /** Hover explanation chips near the badges (default on). */
65
+ setLabels(on) {
66
+ this._labels = on !== false;
67
+ this._redraw();
68
+ return this;
69
+ }
70
+
71
+ get labels() {
72
+ return this._labels;
73
+ }
74
+
75
+ /** Signal count of the last detection pass. */
76
+ get count() {
77
+ return this._signals.length;
78
+ }
79
+
80
+ detach() {
81
+ this._chart.removeEventListener('wick:crosshair', this._onCross);
82
+ try {
83
+ this._chart.removeLayer('wick-signals');
84
+ } catch (_) {}
85
+ this._chart = null;
86
+ }
87
+
88
+ /* ---------------- internals ---------------- */
89
+
90
+ _redraw() {
91
+ if (this._chart && typeof this._chart.requestDraw === 'function') this._chart.requestDraw();
92
+ }
93
+
94
+ _cross(detail) {
95
+ if (!this._chart) return;
96
+ let index = detail && isNum(detail.index) ? detail.index : null;
97
+ if (index != null && (index < 0 || index >= this._datasetLen)) index = null;
98
+ let hit = null;
99
+ if (index != null) {
100
+ const onBar = this._signals.filter((s) => s.i === index);
101
+ if (onBar.length) {
102
+ hit = {
103
+ index,
104
+ label: onBar.map((s) => this._name(s)).join(' · '),
105
+ };
106
+ }
107
+ }
108
+ const changed = (hit && hit.index) !== (this._hover && this._hover.index);
109
+ this._hover = hit;
110
+ if (changed) {
111
+ this._redraw();
112
+ this._chart.dispatchEvent(
113
+ new CustomEvent('wick:signals', {
114
+ detail: hit
115
+ ? {
116
+ index: hit.index,
117
+ time: this._chart.data[hit.index] ? this._chart.data[hit.index].time : null,
118
+ signals: this._signals.filter((s) => s.i === hit.index),
119
+ label: hit.label,
120
+ }
121
+ : null,
122
+ })
123
+ );
124
+ }
125
+ }
126
+
127
+ _name(s) {
128
+ const info = KIND_INFO[s.kind] || { name: s.kind };
129
+ return s.dir ? `${s.dir === 'bull' ? 'Bullish' : 'Bearish'} ${info.name}` : info.name;
130
+ }
131
+ /* ---------------- render ---------------- */
132
+
133
+ _render(api) {
134
+ const { ctx, layout: ly, palette: pal, data } = api;
135
+ if (!data.length) {
136
+ this._signals = [];
137
+ this._datasetLen = 0;
138
+ return;
139
+ }
140
+ // O(n) detection once per (dataset, kinds) — cached otherwise
141
+ const key = data.length + ':' + data[data.length - 1].time + ':' + this._kinds.join(',');
142
+ if (this._cacheKey !== key) {
143
+ this._cacheKey = key;
144
+ this._signals = detectSignals(data, this._kinds);
145
+ this._datasetLen = data.length;
146
+ }
147
+
148
+ const t0 = api.xToTime(0);
149
+ const t1 = api.xToTime(ly.plotRight);
150
+ if (!isNum(t0) || !isNum(t1) || t1 <= t0) return;
151
+
152
+ ctx.save();
153
+ ctx.beginPath();
154
+ ctx.rect(0, ly.main.y0, ly.plotRight + 1, ly.main.h);
155
+ ctx.clip();
156
+ ctx.font = FONT;
157
+ ctx.textAlign = 'center';
158
+ ctx.textBaseline = 'middle';
159
+
160
+ let lastBarSignals = null;
161
+ for (const s of this._signals) {
162
+ const bar = data[s.i];
163
+ if (!bar || bar.time < t0 || bar.time > t1) continue;
164
+ const x = api.timeToX(bar.time);
165
+ const y =
166
+ s.dir === 'bear'
167
+ ? api.priceToY(bar.high) - OFFSET
168
+ : s.dir === 'bull'
169
+ ? api.priceToY(bar.low) + OFFSET
170
+ : api.priceToY(bar.high) - OFFSET; // neutral (inside bar) sits above
171
+ if (!isNum(x) || !isNum(y)) continue;
172
+ const color = s.dir === 'bull' ? pal.up : s.dir === 'bear' ? pal.down : pal.text;
173
+ this._chip(ctx, x, y, KIND_INFO[s.kind] ? KIND_INFO[s.kind].letter : '?', color, pal.bg);
174
+ if (this._hover && this._hover.index === s.i) {
175
+ if (!lastBarSignals) lastBarSignals = { x, y, texts: [] };
176
+ lastBarSignals.texts.push(this._name(s));
177
+ }
178
+ }
179
+
180
+ if (this._labels && lastBarSignals) {
181
+ this._tooltip(ctx, lastBarSignals, pal);
182
+ }
183
+ ctx.restore();
184
+ }
185
+
186
+ /** Letter chip: small filled disc with a 1-color halo of the direction. */
187
+ _chip(ctx, x, y, letter, color, bg) {
188
+ const r = letter.length > 1 ? 7 : 5.5;
189
+ ctx.globalAlpha = 0.95;
190
+ ctx.beginPath();
191
+ ctx.arc(x, y, r, 0, Math.PI * 2);
192
+ ctx.fillStyle = color;
193
+ ctx.fill();
194
+ ctx.lineWidth = 1.5;
195
+ ctx.strokeStyle = bg;
196
+ ctx.stroke();
197
+ ctx.fillStyle = bg;
198
+ ctx.fillText(letter, x, y + 0.5);
199
+ ctx.globalAlpha = 1;
200
+ }
201
+
202
+ /** Hover explanation near the badges (bg-haloed text, stacked lines). */
203
+ _tooltip(ctx, box, pal) {
204
+ ctx.font = FONT;
205
+ ctx.textAlign = 'left';
206
+ ctx.textBaseline = 'middle';
207
+ let y = box.y - (box.texts.length - 1) * 11;
208
+ for (const text of box.texts) {
209
+ ctx.globalAlpha = 1;
210
+ ctx.strokeStyle = pal.bg;
211
+ ctx.lineWidth = 3;
212
+ ctx.strokeText(text, box.x + 10, y);
213
+ ctx.fillStyle = pal.text;
214
+ ctx.fillText(text, box.x + 10, y);
215
+ y += 11;
216
+ }
217
+ }
218
+ }