@kamuira/stock-analyzer 1.0.4 → 1.2.1
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 +172 -189
- package/analyze.js +57 -918
- package/backtest.js +309 -352
- package/bin/analyze.js +0 -0
- package/bin/backtest.js +0 -0
- package/bin/server.js +0 -0
- package/dev.js +8 -1
- package/indicators.js +538 -0
- package/package.json +4 -6
- package/scoring.js +541 -0
- package/server.js +69 -1532
- package/stock.js +0 -181
package/bin/analyze.js
CHANGED
|
File without changes
|
package/bin/backtest.js
CHANGED
|
File without changes
|
package/bin/server.js
CHANGED
|
File without changes
|
package/dev.js
CHANGED
|
@@ -7,7 +7,14 @@ const fs = require('fs');
|
|
|
7
7
|
const path = require('path');
|
|
8
8
|
|
|
9
9
|
const SERVER_FILE = path.join(__dirname, 'server.js');
|
|
10
|
-
const WATCH_FILES = [
|
|
10
|
+
const WATCH_FILES = [
|
|
11
|
+
SERVER_FILE,
|
|
12
|
+
path.join(__dirname, 'analyze.js'),
|
|
13
|
+
path.join(__dirname, 'backtest.js'),
|
|
14
|
+
path.join(__dirname, 'scoring.js'),
|
|
15
|
+
path.join(__dirname, 'indicators.js'),
|
|
16
|
+
path.join(__dirname, 'index.html'),
|
|
17
|
+
];
|
|
11
18
|
let child = null;
|
|
12
19
|
let restarting = false;
|
|
13
20
|
|
package/indicators.js
ADDED
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 技术指标共享模块
|
|
3
|
+
*
|
|
4
|
+
* 注意:
|
|
5
|
+
* - EMA 使用前 period 日 SMA 作为种子(标准做法,前期值更准确)
|
|
6
|
+
* - RSI 使用 Wilder 平滑(与同花顺/通达信/TradingView 一致)
|
|
7
|
+
* - ADX 使用 Wilder smoothing(标准 Welles Wilder 算法)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ==================== 基础工具 ====================
|
|
11
|
+
|
|
12
|
+
function round(v, n = 3) {
|
|
13
|
+
if (v === null || v === undefined || Number.isNaN(v)) return null;
|
|
14
|
+
return +v.toFixed(n);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** 简单移动平均 */
|
|
18
|
+
function SMA(data, period) {
|
|
19
|
+
const result = [];
|
|
20
|
+
for (let i = 0; i < data.length; i++) {
|
|
21
|
+
if (i < period - 1) { result.push(null); continue; }
|
|
22
|
+
let sum = 0;
|
|
23
|
+
for (let j = i - period + 1; j <= i; j++) sum += data[j];
|
|
24
|
+
result.push(round(sum / period));
|
|
25
|
+
}
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 指数移动平均
|
|
31
|
+
* 前 period-1 个值返回 null;第 period 个值用 SMA 作种子;之后用 EMA 递推
|
|
32
|
+
*/
|
|
33
|
+
function EMA(data, period) {
|
|
34
|
+
const result = new Array(data.length).fill(null);
|
|
35
|
+
if (data.length < period) return result;
|
|
36
|
+
const k = 2 / (period + 1);
|
|
37
|
+
let seed = 0;
|
|
38
|
+
for (let i = 0; i < period; i++) seed += data[i];
|
|
39
|
+
seed = seed / period;
|
|
40
|
+
result[period - 1] = round(seed);
|
|
41
|
+
for (let i = period; i < data.length; i++) {
|
|
42
|
+
result[i] = round(data[i] * k + result[i - 1] * (1 - k));
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Wilder 平滑(用于 RSI / ADX)
|
|
49
|
+
* 第一个值是前 period 日的简单平均,之后:val[i] = (val[i-1] * (period-1) + data[i]) / period
|
|
50
|
+
*/
|
|
51
|
+
function wilderSmooth(data, period) {
|
|
52
|
+
const result = new Array(data.length).fill(null);
|
|
53
|
+
if (data.length < period) return result;
|
|
54
|
+
let seed = 0;
|
|
55
|
+
for (let i = 0; i < period; i++) seed += data[i];
|
|
56
|
+
result[period - 1] = seed / period;
|
|
57
|
+
for (let i = period; i < data.length; i++) {
|
|
58
|
+
result[i] = (result[i - 1] * (period - 1) + data[i]) / period;
|
|
59
|
+
}
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ==================== MACD ====================
|
|
64
|
+
|
|
65
|
+
/** MACD(12, 26, 9) — 由于早期 EMA 为 null,前 25 项 dif 也为 null */
|
|
66
|
+
function MACD(closes) {
|
|
67
|
+
const ema12 = EMA(closes, 12);
|
|
68
|
+
const ema26 = EMA(closes, 26);
|
|
69
|
+
const dif = ema12.map((v, i) => (v === null || ema26[i] === null) ? null : round(v - ema26[i]));
|
|
70
|
+
// 对 dif 做 EMA9 时需要跳过 null 起点
|
|
71
|
+
const dea = emaWithSkip(dif, 9);
|
|
72
|
+
const histogram = dif.map((v, i) => (v === null || dea[i] === null) ? null : round((v - dea[i]) * 2));
|
|
73
|
+
return { dif, dea, histogram };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** EMA 计算时跳过开头的 null(用于 MACD 的 DEA 计算) */
|
|
77
|
+
function emaWithSkip(data, period) {
|
|
78
|
+
const result = new Array(data.length).fill(null);
|
|
79
|
+
// 找到第一个非 null 索引
|
|
80
|
+
let start = 0;
|
|
81
|
+
while (start < data.length && data[start] === null) start++;
|
|
82
|
+
if (data.length - start < period) return result;
|
|
83
|
+
const k = 2 / (period + 1);
|
|
84
|
+
let seed = 0;
|
|
85
|
+
for (let i = start; i < start + period; i++) seed += data[i];
|
|
86
|
+
seed = seed / period;
|
|
87
|
+
const seedIdx = start + period - 1;
|
|
88
|
+
result[seedIdx] = round(seed);
|
|
89
|
+
for (let i = seedIdx + 1; i < data.length; i++) {
|
|
90
|
+
result[i] = round(data[i] * k + result[i - 1] * (1 - k));
|
|
91
|
+
}
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ==================== RSI(Wilder) ====================
|
|
96
|
+
|
|
97
|
+
function RSI(closes, period = 14) {
|
|
98
|
+
const result = new Array(closes.length).fill(null);
|
|
99
|
+
if (closes.length <= period) return result;
|
|
100
|
+
|
|
101
|
+
const gains = [], losses = [];
|
|
102
|
+
for (let i = 1; i < closes.length; i++) {
|
|
103
|
+
const diff = closes[i] - closes[i - 1];
|
|
104
|
+
gains.push(diff > 0 ? diff : 0);
|
|
105
|
+
losses.push(diff < 0 ? -diff : 0);
|
|
106
|
+
}
|
|
107
|
+
// gains/losses 长度 = closes.length - 1, gains[i] 对应 closes[i+1]
|
|
108
|
+
|
|
109
|
+
// 第一个 RSI 出现在 closes 索引 period 处:用 gains[0..period-1] 的简单平均
|
|
110
|
+
let avgGain = 0, avgLoss = 0;
|
|
111
|
+
for (let i = 0; i < period; i++) { avgGain += gains[i]; avgLoss += losses[i]; }
|
|
112
|
+
avgGain /= period; avgLoss /= period;
|
|
113
|
+
let rs = avgLoss === 0 ? Infinity : avgGain / avgLoss;
|
|
114
|
+
result[period] = avgLoss === 0 ? 100 : round(100 - 100 / (1 + rs), 2);
|
|
115
|
+
|
|
116
|
+
for (let i = period + 1; i < closes.length; i++) {
|
|
117
|
+
const gIdx = i - 1;
|
|
118
|
+
avgGain = (avgGain * (period - 1) + gains[gIdx]) / period;
|
|
119
|
+
avgLoss = (avgLoss * (period - 1) + losses[gIdx]) / period;
|
|
120
|
+
rs = avgLoss === 0 ? Infinity : avgGain / avgLoss;
|
|
121
|
+
result[i] = avgLoss === 0 ? 100 : round(100 - 100 / (1 + rs), 2);
|
|
122
|
+
}
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ==================== KDJ ====================
|
|
127
|
+
|
|
128
|
+
function KDJ(highs, lows, closes, n = 9) {
|
|
129
|
+
const K = [], D = [], J = [];
|
|
130
|
+
let prevK = 50, prevD = 50;
|
|
131
|
+
for (let i = 0; i < closes.length; i++) {
|
|
132
|
+
if (i < n - 1) { K.push(null); D.push(null); J.push(null); continue; }
|
|
133
|
+
let highN = -Infinity, lowN = Infinity;
|
|
134
|
+
for (let j = i - n + 1; j <= i; j++) {
|
|
135
|
+
highN = Math.max(highN, highs[j]);
|
|
136
|
+
lowN = Math.min(lowN, lows[j]);
|
|
137
|
+
}
|
|
138
|
+
const rsv = highN === lowN ? 50 : ((closes[i] - lowN) / (highN - lowN)) * 100;
|
|
139
|
+
const k = +(2 / 3 * prevK + 1 / 3 * rsv).toFixed(2);
|
|
140
|
+
const d = +(2 / 3 * prevD + 1 / 3 * k).toFixed(2);
|
|
141
|
+
const j = +(3 * k - 2 * d).toFixed(2);
|
|
142
|
+
K.push(k); D.push(d); J.push(j);
|
|
143
|
+
prevK = k; prevD = d;
|
|
144
|
+
}
|
|
145
|
+
return { K, D, J };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ==================== 布林带 ====================
|
|
149
|
+
|
|
150
|
+
function BOLL(closes, period = 20, multiplier = 2) {
|
|
151
|
+
const mid = SMA(closes, period);
|
|
152
|
+
const upper = [], lower = [];
|
|
153
|
+
for (let i = 0; i < closes.length; i++) {
|
|
154
|
+
if (mid[i] === null) { upper.push(null); lower.push(null); continue; }
|
|
155
|
+
let sum = 0;
|
|
156
|
+
for (let j = i - period + 1; j <= i; j++) sum += (closes[j] - mid[i]) ** 2;
|
|
157
|
+
const std = Math.sqrt(sum / period);
|
|
158
|
+
upper.push(round(mid[i] + multiplier * std));
|
|
159
|
+
lower.push(round(mid[i] - multiplier * std));
|
|
160
|
+
}
|
|
161
|
+
return { upper, mid, lower };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ==================== ATR(Wilder) ====================
|
|
165
|
+
|
|
166
|
+
function calcTR(highs, lows, closes) {
|
|
167
|
+
const tr = [];
|
|
168
|
+
for (let i = 0; i < closes.length; i++) {
|
|
169
|
+
if (i === 0) { tr.push(highs[i] - lows[i]); continue; }
|
|
170
|
+
const hl = highs[i] - lows[i];
|
|
171
|
+
const hc = Math.abs(highs[i] - closes[i - 1]);
|
|
172
|
+
const lc = Math.abs(lows[i] - closes[i - 1]);
|
|
173
|
+
tr.push(Math.max(hl, hc, lc));
|
|
174
|
+
}
|
|
175
|
+
return tr;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function ATR(highs, lows, closes, period = 14) {
|
|
179
|
+
const tr = calcTR(highs, lows, closes);
|
|
180
|
+
const atr = wilderSmooth(tr, period);
|
|
181
|
+
// 早期 null 用 TR 兜底,避免下游空指针
|
|
182
|
+
return atr.map((v, i) => v === null ? round(tr[i] || 0) : round(v));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ==================== ADX(Wilder) ====================
|
|
186
|
+
|
|
187
|
+
function ADX(highs, lows, closes, period = 14) {
|
|
188
|
+
const n = closes.length;
|
|
189
|
+
const tr = calcTR(highs, lows, closes);
|
|
190
|
+
const plusDM = [0], minusDM = [0];
|
|
191
|
+
for (let i = 1; i < n; i++) {
|
|
192
|
+
const upMove = highs[i] - highs[i - 1];
|
|
193
|
+
const downMove = lows[i - 1] - lows[i];
|
|
194
|
+
plusDM.push(upMove > downMove && upMove > 0 ? upMove : 0);
|
|
195
|
+
minusDM.push(downMove > upMove && downMove > 0 ? downMove : 0);
|
|
196
|
+
}
|
|
197
|
+
const sTR = wilderSmooth(tr, period);
|
|
198
|
+
const sPDM = wilderSmooth(plusDM, period);
|
|
199
|
+
const sMDM = wilderSmooth(minusDM, period);
|
|
200
|
+
const plusDI = new Array(n).fill(0);
|
|
201
|
+
const minusDI = new Array(n).fill(0);
|
|
202
|
+
const dx = new Array(n).fill(0);
|
|
203
|
+
for (let i = 0; i < n; i++) {
|
|
204
|
+
if (sTR[i] === null || sTR[i] === 0) continue;
|
|
205
|
+
plusDI[i] = (sPDM[i] / sTR[i]) * 100;
|
|
206
|
+
minusDI[i] = (sMDM[i] / sTR[i]) * 100;
|
|
207
|
+
const sum = plusDI[i] + minusDI[i];
|
|
208
|
+
dx[i] = sum > 0 ? (Math.abs(plusDI[i] - minusDI[i]) / sum) * 100 : 0;
|
|
209
|
+
}
|
|
210
|
+
// ADX = DX 的 Wilder 平滑;首个 ADX 出现在 2*period-1
|
|
211
|
+
const adx = wilderSmooth(dx, period);
|
|
212
|
+
return {
|
|
213
|
+
plusDI: plusDI.map(v => +v.toFixed(2)),
|
|
214
|
+
minusDI: minusDI.map(v => +v.toFixed(2)),
|
|
215
|
+
adx: adx.map(v => v === null ? 0 : +v.toFixed(2)),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ==================== VWAP ====================
|
|
220
|
+
|
|
221
|
+
function calcVWAP(closes, volumes, period = 20) {
|
|
222
|
+
const n = closes.length;
|
|
223
|
+
let sumPV = 0, sumV = 0;
|
|
224
|
+
const start = Math.max(0, n - period);
|
|
225
|
+
for (let i = start; i < n; i++) { sumPV += closes[i] * volumes[i]; sumV += volumes[i]; }
|
|
226
|
+
return sumV > 0 ? +(sumPV / sumV).toFixed(3) : closes[n - 1];
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ==================== ZigZag + 背离 ====================
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* ZigZag swing 检测
|
|
233
|
+
* 从价格序列中提取局部高/低点,过滤掉小于 threshold 比例的小波动
|
|
234
|
+
*
|
|
235
|
+
* @param {number[]} prices 价格序列(收盘价)
|
|
236
|
+
* @param {number} threshold 反转阈值(默认 3%);从极值反向走超过这个比例才确认为 swing
|
|
237
|
+
* @returns swings 数组,每项:{ idx, price, type: 'high'|'low' }
|
|
238
|
+
* 注意:最后一个 swing 是"未确认的当前进行中极值",可能被后续行情推翻
|
|
239
|
+
*/
|
|
240
|
+
function zigzag(prices, threshold = 0.03) {
|
|
241
|
+
const n = prices.length;
|
|
242
|
+
if (n < 2) return [];
|
|
243
|
+
const swings = [];
|
|
244
|
+
let dir = null;
|
|
245
|
+
let extremeIdx = 0;
|
|
246
|
+
let extremePrice = prices[0];
|
|
247
|
+
|
|
248
|
+
for (let i = 1; i < n; i++) {
|
|
249
|
+
const p = prices[i];
|
|
250
|
+
if (dir === null) {
|
|
251
|
+
if (p > extremePrice * (1 + threshold)) {
|
|
252
|
+
swings.push({ idx: extremeIdx, price: extremePrice, type: 'low' });
|
|
253
|
+
dir = 'up'; extremeIdx = i; extremePrice = p;
|
|
254
|
+
} else if (p < extremePrice * (1 - threshold)) {
|
|
255
|
+
swings.push({ idx: extremeIdx, price: extremePrice, type: 'high' });
|
|
256
|
+
dir = 'down'; extremeIdx = i; extremePrice = p;
|
|
257
|
+
} else if (p > extremePrice) {
|
|
258
|
+
extremeIdx = i; extremePrice = p;
|
|
259
|
+
} else if (p < extremePrice) {
|
|
260
|
+
extremeIdx = i; extremePrice = p;
|
|
261
|
+
}
|
|
262
|
+
} else if (dir === 'up') {
|
|
263
|
+
if (p > extremePrice) { extremeIdx = i; extremePrice = p; }
|
|
264
|
+
else if (p < extremePrice * (1 - threshold)) {
|
|
265
|
+
swings.push({ idx: extremeIdx, price: extremePrice, type: 'high' });
|
|
266
|
+
dir = 'down'; extremeIdx = i; extremePrice = p;
|
|
267
|
+
}
|
|
268
|
+
} else { // down
|
|
269
|
+
if (p < extremePrice) { extremeIdx = i; extremePrice = p; }
|
|
270
|
+
else if (p > extremePrice * (1 + threshold)) {
|
|
271
|
+
swings.push({ idx: extremeIdx, price: extremePrice, type: 'low' });
|
|
272
|
+
dir = 'up'; extremeIdx = i; extremePrice = p;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// 收尾:最后一段的进行中极值(未被反向确认)
|
|
277
|
+
swings.push({
|
|
278
|
+
idx: extremeIdx, price: extremePrice,
|
|
279
|
+
type: dir === 'up' ? 'high' : (dir === 'down' ? 'low' : 'high'),
|
|
280
|
+
unconfirmed: true,
|
|
281
|
+
});
|
|
282
|
+
return swings;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* 价格与指标背离(基于 ZigZag swing)
|
|
287
|
+
*
|
|
288
|
+
* 顶背离:最近两个 swing high 中,price 创新高但 indicator 没创新高 → bearish
|
|
289
|
+
* 底背离:最近两个 swing low 中,price 创新低但 indicator 没创新低 → bullish
|
|
290
|
+
*
|
|
291
|
+
* @param {number[]} closes
|
|
292
|
+
* @param {number[]} indicator 指标序列(同长度,允许 null)
|
|
293
|
+
* @param {number} lookback 仅在最近 lookback 个 K 线内寻找 swing(默认 40,需大于 ZigZag 周期才有意义)
|
|
294
|
+
* @param {number} threshold ZigZag 反转阈值(默认 3%)
|
|
295
|
+
*/
|
|
296
|
+
function detectDivergence(closes, indicator, lookback = 40, threshold = 0.03) {
|
|
297
|
+
const result = { bullish: false, bearish: false, description: '' };
|
|
298
|
+
const n = closes.length;
|
|
299
|
+
if (n < 5) return result;
|
|
300
|
+
|
|
301
|
+
const start = Math.max(0, n - lookback);
|
|
302
|
+
const slice = closes.slice(start);
|
|
303
|
+
const sliceSwings = zigzag(slice, threshold);
|
|
304
|
+
const swings = sliceSwings
|
|
305
|
+
.map(s => ({ ...s, idx: s.idx + start }))
|
|
306
|
+
.filter(s => indicator[s.idx] !== null && indicator[s.idx] !== undefined);
|
|
307
|
+
|
|
308
|
+
const highs = swings.filter(s => s.type === 'high');
|
|
309
|
+
const lows = swings.filter(s => s.type === 'low');
|
|
310
|
+
|
|
311
|
+
if (highs.length >= 2) {
|
|
312
|
+
const recent = highs[highs.length - 1];
|
|
313
|
+
const prev = highs[highs.length - 2];
|
|
314
|
+
if (recent.price > prev.price && indicator[recent.idx] < indicator[prev.idx]) {
|
|
315
|
+
result.bearish = true;
|
|
316
|
+
result.description = `顶背离:${prev.idx}日价${prev.price.toFixed(2)}→${recent.idx}日新高${recent.price.toFixed(2)},指标${indicator[prev.idx].toFixed(2)}→${indicator[recent.idx].toFixed(2)}未跟随`;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (lows.length >= 2) {
|
|
320
|
+
const recent = lows[lows.length - 1];
|
|
321
|
+
const prev = lows[lows.length - 2];
|
|
322
|
+
if (recent.price < prev.price && indicator[recent.idx] > indicator[prev.idx]) {
|
|
323
|
+
result.bullish = true;
|
|
324
|
+
result.description = `底背离:${prev.idx}日价${prev.price.toFixed(2)}→${recent.idx}日新低${recent.price.toFixed(2)},指标${indicator[prev.idx].toFixed(2)}→${indicator[recent.idx].toFixed(2)}未跟随`;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return result;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ==================== 形态识别 ====================
|
|
331
|
+
|
|
332
|
+
function detectPatterns(klines, ma20, boll) {
|
|
333
|
+
const n = klines.length;
|
|
334
|
+
const last = n - 1;
|
|
335
|
+
const patterns = [];
|
|
336
|
+
|
|
337
|
+
if (n >= 5 && ma20[last] !== null) {
|
|
338
|
+
const vol5Avg = klines.slice(-5).reduce((s, k) => s + k.volume, 0) / 5;
|
|
339
|
+
const vol20Avg = klines.slice(-20).reduce((s, k) => s + k.volume, 0) / 20;
|
|
340
|
+
const nearMA20 = Math.abs(klines[last].close - ma20[last]) / ma20[last] < 0.02;
|
|
341
|
+
const volShrink = vol5Avg < vol20Avg * 0.8;
|
|
342
|
+
const priorUptrend = klines[last].close > klines[Math.max(0, last - 20)].close;
|
|
343
|
+
if (nearMA20 && volShrink && priorUptrend) {
|
|
344
|
+
patterns.push({ type: 'bullish', name: '缩量回踩MA20', description: '上升趋势中缩量回踩均线支撑', weight: 3 });
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (n >= 20) {
|
|
348
|
+
const prevHigh = Math.max(...klines.slice(-21, -1).map(k => k.high));
|
|
349
|
+
const todayBreak = klines[last].close > prevHigh;
|
|
350
|
+
const vol20Avg = klines.slice(-20).reduce((s, k) => s + k.volume, 0) / 20;
|
|
351
|
+
const volExpand = klines[last].volume > vol20Avg * 1.5;
|
|
352
|
+
if (todayBreak && volExpand) {
|
|
353
|
+
patterns.push({ type: 'bullish', name: '放量突破前高', description: `突破近20日高点${prevHigh.toFixed(2)}`, weight: 3 });
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (n >= 3) {
|
|
357
|
+
const prevHigh = Math.max(...klines.slice(-22, -2).map(k => k.high));
|
|
358
|
+
const dayBefore = klines[last - 1];
|
|
359
|
+
const today = klines[last];
|
|
360
|
+
if (dayBefore.high > prevHigh && today.close < prevHigh * 0.98) {
|
|
361
|
+
patterns.push({ type: 'bearish', name: '假突破回落', description: '突破前高后快速回落,多头陷阱', weight: -3 });
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (n >= 1) {
|
|
365
|
+
const today = klines[last];
|
|
366
|
+
const body = Math.abs(today.close - today.open);
|
|
367
|
+
const lowerShadow = Math.min(today.open, today.close) - today.low;
|
|
368
|
+
const upperShadow = today.high - Math.max(today.open, today.close);
|
|
369
|
+
if (lowerShadow > body * 2 && upperShadow < body * 0.5 && body > 0) {
|
|
370
|
+
patterns.push({ type: 'bullish', name: '锤子线', description: '长下影线,下方有买盘支撑', weight: 2 });
|
|
371
|
+
}
|
|
372
|
+
if (upperShadow > body * 2 && lowerShadow < body * 0.5 && body > 0) {
|
|
373
|
+
patterns.push({ type: 'bearish', name: '射击之星', description: '长上影线,上方抛压沉重', weight: -2 });
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (n >= 3) {
|
|
377
|
+
const last3 = klines.slice(-3);
|
|
378
|
+
if (last3.every(k => k.close > k.open) && last3[2].close > last3[1].close && last3[1].close > last3[0].close) {
|
|
379
|
+
patterns.push({ type: 'bullish', name: '三连阳', description: '连续三日收阳逐步走高', weight: 2 });
|
|
380
|
+
}
|
|
381
|
+
if (last3.every(k => k.close < k.open) && last3[2].close < last3[1].close && last3[1].close < last3[0].close) {
|
|
382
|
+
patterns.push({ type: 'bearish', name: '三连阴', description: '连续三日收阴逐步走低', weight: -2 });
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (boll.upper[last] !== null && last >= 5 && boll.upper[last - 5] !== null) {
|
|
386
|
+
const bw_5ago = (boll.upper[last - 5] - boll.lower[last - 5]) / boll.mid[last - 5];
|
|
387
|
+
if (bw_5ago < 0.08 && klines[last].close > boll.upper[last]) {
|
|
388
|
+
patterns.push({ type: 'bullish', name: '布林收窄后向上突破', description: '波动率收缩后向上选择方向', weight: 3 });
|
|
389
|
+
}
|
|
390
|
+
if (bw_5ago < 0.08 && klines[last].close < boll.lower[last]) {
|
|
391
|
+
patterns.push({ type: 'bearish', name: '布林收窄后向下突破', description: '波动率收缩后向下选择方向', weight: -3 });
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return patterns;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ==================== 动量衰竭 ====================
|
|
398
|
+
|
|
399
|
+
function detectMomentumExhaustion(klines) {
|
|
400
|
+
const n = klines.length;
|
|
401
|
+
const last = n - 1;
|
|
402
|
+
const result = { bullExhaustion: false, bearExhaustion: false, signals: [], score: 0 };
|
|
403
|
+
if (n < 10) return result;
|
|
404
|
+
const recentUp = [];
|
|
405
|
+
for (let i = last; i >= Math.max(0, last - 9); i--) {
|
|
406
|
+
if (klines[i].close > klines[i].open) recentUp.unshift(i); else break;
|
|
407
|
+
}
|
|
408
|
+
if (recentUp.length >= 3) {
|
|
409
|
+
const changes = recentUp.map(i => (klines[i].close - klines[i].open) / klines[i].open * 100);
|
|
410
|
+
const vols = recentUp.map(i => klines[i].volume);
|
|
411
|
+
const changeDeclining = changes[changes.length - 1] < changes[0] * 0.6;
|
|
412
|
+
const volDeclining = vols[vols.length - 1] < vols[0] * 0.7;
|
|
413
|
+
if (changeDeclining && volDeclining) { result.bullExhaustion = true; result.signals.push(`上涨动量衰竭:连涨${recentUp.length}天但涨幅+量能递减`); result.score -= 3; }
|
|
414
|
+
else if (changeDeclining || volDeclining) { result.signals.push(`上涨动量减弱:${changeDeclining ? '涨幅递减' : '量能递减'}`); result.score -= 1; }
|
|
415
|
+
}
|
|
416
|
+
const recentDown = [];
|
|
417
|
+
for (let i = last; i >= Math.max(0, last - 9); i--) {
|
|
418
|
+
if (klines[i].close < klines[i].open) recentDown.unshift(i); else break;
|
|
419
|
+
}
|
|
420
|
+
if (recentDown.length >= 3) {
|
|
421
|
+
const changes = recentDown.map(i => Math.abs((klines[i].close - klines[i].open) / klines[i].open * 100));
|
|
422
|
+
const vols = recentDown.map(i => klines[i].volume);
|
|
423
|
+
const changeDeclining = changes[changes.length - 1] < changes[0] * 0.6;
|
|
424
|
+
const volDeclining = vols[vols.length - 1] < vols[0] * 0.7;
|
|
425
|
+
if (changeDeclining && volDeclining) { result.bearExhaustion = true; result.signals.push(`下跌动量衰竭:连跌${recentDown.length}天但跌幅+量能递减`); result.score += 3; }
|
|
426
|
+
else if (changeDeclining || volDeclining) { result.signals.push(`下跌动量减弱:${changeDeclining ? '跌幅递减' : '量能递减'}`); result.score += 1; }
|
|
427
|
+
}
|
|
428
|
+
if (result.signals.length === 0) result.signals.push('动量正常');
|
|
429
|
+
return result;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ==================== 斐波那契 ====================
|
|
433
|
+
|
|
434
|
+
function calcFibonacci(klines, lookback = 60) {
|
|
435
|
+
const n = klines.length;
|
|
436
|
+
const slice = klines.slice(Math.max(0, n - lookback));
|
|
437
|
+
const highs = slice.map(k => k.high);
|
|
438
|
+
const lows = slice.map(k => k.low);
|
|
439
|
+
const highPrice = Math.max(...highs);
|
|
440
|
+
const lowPrice = Math.min(...lows);
|
|
441
|
+
const highIdx = highs.indexOf(highPrice);
|
|
442
|
+
const lowIdx = lows.indexOf(lowPrice);
|
|
443
|
+
const diff = highPrice - lowPrice;
|
|
444
|
+
const isDowntrend = highIdx < lowIdx;
|
|
445
|
+
const currentPrice = klines[n - 1].close;
|
|
446
|
+
const levels = {};
|
|
447
|
+
if (isDowntrend) {
|
|
448
|
+
levels['0%(低点)'] = lowPrice;
|
|
449
|
+
levels['23.6%'] = +(lowPrice + diff * 0.236).toFixed(2);
|
|
450
|
+
levels['38.2%'] = +(lowPrice + diff * 0.382).toFixed(2);
|
|
451
|
+
levels['50%'] = +(lowPrice + diff * 0.5).toFixed(2);
|
|
452
|
+
levels['61.8%'] = +(lowPrice + diff * 0.618).toFixed(2);
|
|
453
|
+
levels['100%(高点)'] = highPrice;
|
|
454
|
+
} else {
|
|
455
|
+
levels['100%(高点)'] = highPrice;
|
|
456
|
+
levels['23.6%'] = +(highPrice - diff * 0.236).toFixed(2);
|
|
457
|
+
levels['38.2%'] = +(highPrice - diff * 0.382).toFixed(2);
|
|
458
|
+
levels['50%'] = +(highPrice - diff * 0.5).toFixed(2);
|
|
459
|
+
levels['61.8%'] = +(highPrice - diff * 0.618).toFixed(2);
|
|
460
|
+
levels['0%(低点)'] = lowPrice;
|
|
461
|
+
}
|
|
462
|
+
const allLevels = Object.entries(levels).map(([name, price]) => ({ name, price, dist: Math.abs(currentPrice - price) }));
|
|
463
|
+
allLevels.sort((a, b) => a.dist - b.dist);
|
|
464
|
+
return { levels, isDowntrend, highPrice, lowPrice, currentPrice, nearest: allLevels[0], trend: isDowntrend ? '下跌回撤' : '上涨回调' };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ==================== 缺口检测 ====================
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* 缺口分析(只保留"有效缺口")
|
|
471
|
+
*
|
|
472
|
+
* 过滤条件(P1 修复):
|
|
473
|
+
* - 缺口幅度 > MIN_GAP_PCT(默认 0.5%) — 排除普通跳空开盘
|
|
474
|
+
* - 当日量能 > 1.5 × 20日均量 — 排除无量跳空(可能是除权或异常)
|
|
475
|
+
*
|
|
476
|
+
* 这两个条件大幅减少了"满屏假缺口"的问题。
|
|
477
|
+
*/
|
|
478
|
+
function detectGaps(klines, lookback = 20, options = {}) {
|
|
479
|
+
const { minGapPct = 0.5, volMultiple = 1.5 } = options;
|
|
480
|
+
const n = klines.length;
|
|
481
|
+
const last = n - 1;
|
|
482
|
+
const vol20 = klines.slice(-20).reduce((s, k) => s + k.volume, 0) / 20;
|
|
483
|
+
|
|
484
|
+
const gaps = [];
|
|
485
|
+
for (let i = Math.max(1, n - lookback); i <= last; i++) {
|
|
486
|
+
const prev = klines[i - 1], curr = klines[i];
|
|
487
|
+
const isVolValid = curr.volume > vol20 * volMultiple;
|
|
488
|
+
if (curr.low > prev.high) {
|
|
489
|
+
const sizePct = (curr.low - prev.high) / prev.close * 100;
|
|
490
|
+
if (sizePct >= minGapPct && isVolValid) {
|
|
491
|
+
gaps.push({ type: 'up', date: curr.date, bottom: prev.high, top: curr.low, size: +sizePct.toFixed(2), idx: i });
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
if (curr.high < prev.low) {
|
|
495
|
+
const sizePct = (prev.low - curr.high) / prev.close * 100;
|
|
496
|
+
if (sizePct >= minGapPct && isVolValid) {
|
|
497
|
+
gaps.push({ type: 'down', date: curr.date, bottom: curr.high, top: prev.low, size: +sizePct.toFixed(2), idx: i });
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const signals = [];
|
|
503
|
+
let score = 0;
|
|
504
|
+
for (const gap of gaps) {
|
|
505
|
+
let filled = false;
|
|
506
|
+
for (let j = gap.idx + 1; j <= last; j++) {
|
|
507
|
+
if (gap.type === 'up' && klines[j].low <= gap.bottom) { filled = true; break; }
|
|
508
|
+
if (gap.type === 'down' && klines[j].high >= gap.top) { filled = true; break; }
|
|
509
|
+
}
|
|
510
|
+
if (filled) continue;
|
|
511
|
+
const isRecent = (last - gap.idx) <= 3;
|
|
512
|
+
if (gap.type === 'up') {
|
|
513
|
+
signals.push(`上跳缺口(${gap.date}): ${gap.bottom}→${gap.top} (+${gap.size}%) 未回补`);
|
|
514
|
+
// 有效缺口本身已经过量能滤镜,所以最近的直接给满分
|
|
515
|
+
if (isRecent) score += 2;
|
|
516
|
+
else score += 1;
|
|
517
|
+
} else {
|
|
518
|
+
signals.push(`下跳缺口(${gap.date}): ${gap.top}→${gap.bottom} (-${gap.size}%) 未回补`);
|
|
519
|
+
if (isRecent) score -= 2;
|
|
520
|
+
else score -= 1;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
if (signals.length === 0) signals.push('近期无有效缺口');
|
|
524
|
+
return { signals, score };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
module.exports = {
|
|
528
|
+
SMA, EMA, MACD, RSI, KDJ, BOLL, ATR, ADX,
|
|
529
|
+
calcVWAP,
|
|
530
|
+
zigzag,
|
|
531
|
+
detectDivergence,
|
|
532
|
+
detectPatterns,
|
|
533
|
+
detectMomentumExhaustion,
|
|
534
|
+
calcFibonacci,
|
|
535
|
+
detectGaps,
|
|
536
|
+
// 工具
|
|
537
|
+
wilderSmooth,
|
|
538
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kamuira/stock-analyzer",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.1",
|
|
4
|
+
"preferGlobal": true,
|
|
4
5
|
"description": "A股/台股综合技术分析工具 - 支持实时分析、回测验证、买卖建议",
|
|
5
6
|
"main": "server.js",
|
|
6
7
|
"bin": {
|
|
@@ -27,17 +28,14 @@
|
|
|
27
28
|
],
|
|
28
29
|
"author": "guiwzh",
|
|
29
30
|
"license": "MIT",
|
|
30
|
-
"repository": {
|
|
31
|
-
"type": "git",
|
|
32
|
-
"url": ""
|
|
33
|
-
},
|
|
34
31
|
"files": [
|
|
35
32
|
"bin/",
|
|
36
33
|
"server.js",
|
|
37
34
|
"analyze.js",
|
|
38
35
|
"backtest.js",
|
|
39
36
|
"dev.js",
|
|
40
|
-
"
|
|
37
|
+
"indicators.js",
|
|
38
|
+
"scoring.js",
|
|
41
39
|
"index.html",
|
|
42
40
|
"README.md"
|
|
43
41
|
],
|