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.
- package/README.md +43 -0
- package/core.mjs +80 -0
- package/package.json +39 -0
- 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
|
+
}
|