@kamuira/stock-analyzer 1.0.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/analyze.js ADDED
@@ -0,0 +1,1222 @@
1
+ /**
2
+ * A股/台股综合分析工具
3
+ *
4
+ * 技术指标:MA均线、MACD、RSI、KDJ、布林带
5
+ * 量价分析:放量突破、缩量回调、量价背离
6
+ * 趋势分析:趋势方向、支撑位、压力位
7
+ *
8
+ * 用法:node analyze.js [sz002049|sh603893|tw2330|2330|all]
9
+ */
10
+
11
+ const http = require('http');
12
+ const https = require('https');
13
+
14
+ const WATCH_LIST = {
15
+ 'sz002049': '紫光国微',
16
+ 'sh603893': '瑞芯微',
17
+ 'sz300750': '宁德时代',
18
+ 'sz300274': '阳光电源',
19
+ 'sh603698': '航天工程',
20
+ 'sh601138': '工业富联',
21
+ 'sh600011': '华能国际',
22
+ 'sh601600': '中国铝业',
23
+ 'sz002138': '顺络电子',
24
+ 'sh603986': '兆易创新',
25
+ 'sz002716': '湖南白银',
26
+ 'sh603256': '宏和科技',
27
+ 'sz001309': '德明利',
28
+ 'sh601899': '紫金矿业',
29
+ 'sz000426': '兴业银锡',
30
+ 'sz002428': '云南锗业',
31
+ 'sh600259': '中稀有色',
32
+ 'sh600362': '江西铜业',
33
+ 'sh600206': '有研新材',
34
+ 'sh600111': '北方稀土',
35
+ 'sh601318': '中国平安',
36
+ 'sh601066': '中信建投',
37
+ 'tw2330': '台积电',
38
+ 'tw2317': '鸿海',
39
+ 'tw2454': '联发科',
40
+ };
41
+
42
+ // ==================== 数据获取 ====================
43
+
44
+ function fetchRealtime(codes) {
45
+ return new Promise((resolve, reject) => {
46
+ const codesStr = codes.join(',');
47
+ const options = {
48
+ hostname: 'hq.sinajs.cn',
49
+ path: `/list=${codesStr}`,
50
+ headers: {
51
+ 'Referer': 'http://finance.sina.com.cn',
52
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
53
+ },
54
+ };
55
+ http.get(options, (res) => {
56
+ const chunks = [];
57
+ res.on('data', c => chunks.push(c));
58
+ res.on('end', () => {
59
+ const buf = Buffer.concat(chunks);
60
+ const text = new TextDecoder('gbk').decode(buf);
61
+ const results = [];
62
+ for (const line of text.trim().split('\n')) {
63
+ const match = line.match(/var hq_str_(\w+)="(.*)";?/);
64
+ if (!match || !match[2]) continue;
65
+ const fields = match[2].split(',');
66
+ if (fields.length < 32) continue;
67
+ const yesterdayClose = parseFloat(fields[2]);
68
+ const price = parseFloat(fields[3]);
69
+ results.push({
70
+ code: match[1],
71
+ name: fields[0],
72
+ price,
73
+ open: parseFloat(fields[1]),
74
+ high: parseFloat(fields[4]),
75
+ low: parseFloat(fields[5]),
76
+ yesterdayClose,
77
+ change: +(price - yesterdayClose).toFixed(2),
78
+ changePct: +(((price - yesterdayClose) / yesterdayClose) * 100).toFixed(2),
79
+ volume: Math.round(parseFloat(fields[8]) / 100),
80
+ amount: +(parseFloat(fields[9]) / 10000).toFixed(2),
81
+ time: `${fields[30]} ${fields[31]}`,
82
+ });
83
+ }
84
+ resolve(results);
85
+ });
86
+ }).on('error', reject);
87
+ });
88
+ }
89
+
90
+ function fetchHistory(code, days = 120) {
91
+ return new Promise((resolve, reject) => {
92
+ const url = `https://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param=${code},day,,,${days},qfq`;
93
+ https.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (res) => {
94
+ if (res.statusCode === 301 || res.statusCode === 302) {
95
+ const client = res.headers.location.startsWith('https') ? https : http;
96
+ client.get(res.headers.location, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (res2) => {
97
+ collect(res2, code, resolve, reject);
98
+ }).on('error', reject);
99
+ return;
100
+ }
101
+ collect(res, code, resolve, reject);
102
+ }).on('error', reject);
103
+ });
104
+ }
105
+
106
+ function collect(res, code, resolve, reject) {
107
+ const chunks = [];
108
+ res.on('data', c => chunks.push(c));
109
+ res.on('end', () => {
110
+ try {
111
+ const json = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
112
+ if (!json.data || !json.data[code]) { resolve([]); return; }
113
+ const klines = json.data[code].qfqday || json.data[code].day || [];
114
+ resolve(klines.map(item => ({
115
+ date: item[0],
116
+ open: parseFloat(item[1]),
117
+ close: parseFloat(item[2]),
118
+ high: parseFloat(item[3]),
119
+ low: parseFloat(item[4]),
120
+ volume: parseInt(item[5]) || 0,
121
+ })));
122
+ } catch (e) { reject(e); }
123
+ });
124
+ }
125
+
126
+ // ==================== 台股数据获取 ====================
127
+
128
+ function fetchTWRealtime(code) {
129
+ return new Promise((resolve, reject) => {
130
+ const num = code.replace(/^tw/i, '');
131
+ const exCh = `tse_${num}.tw|otc_${num}.tw`;
132
+ const url = `/stock/api/getStockInfo.jsp?ex_ch=${exCh}&_=${Date.now()}`;
133
+ https.get({ hostname: 'mis.twse.com.tw', path: url, headers: { 'User-Agent': 'Mozilla/5.0' } }, (res) => {
134
+ const chunks = [];
135
+ res.on('data', c => chunks.push(c));
136
+ res.on('end', () => {
137
+ try {
138
+ const json = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
139
+ if (!json.msgArray || json.msgArray.length === 0) { resolve([]); return; }
140
+ const results = [];
141
+ for (const item of json.msgArray) {
142
+ if (!item.z || item.z === '-') continue;
143
+ const price = parseFloat(item.z);
144
+ const yesterdayClose = parseFloat(item.y);
145
+ results.push({
146
+ code: `tw${item.c}`, name: item.n, price,
147
+ open: parseFloat(item.o) || price, high: parseFloat(item.h) || price,
148
+ low: parseFloat(item.l) || price, yesterdayClose,
149
+ change: +(price - yesterdayClose).toFixed(2),
150
+ changePct: +(((price - yesterdayClose) / yesterdayClose) * 100).toFixed(2),
151
+ volume: Math.round(parseInt(item.v) || 0), amount: 0,
152
+ time: `${item.d} ${item.t || ''}`,
153
+ });
154
+ }
155
+ resolve(results);
156
+ } catch (e) { reject(e); }
157
+ });
158
+ }).on('error', reject);
159
+ });
160
+ }
161
+
162
+ function fetchTWHistory(code, days = 120) {
163
+ return new Promise((resolve, reject) => {
164
+ const num = code.replace(/^tw/i, '');
165
+ const symbol = `${num}.TW`;
166
+ const period2 = Math.floor(Date.now() / 1000);
167
+ const period1 = period2 - Math.floor(days * 24 * 60 * 60 * 1.5);
168
+ const url = `/v8/finance/chart/${symbol}?period1=${period1}&period2=${period2}&interval=1d`;
169
+ https.get({ hostname: 'query1.finance.yahoo.com', path: url, headers: { 'User-Agent': 'Mozilla/5.0' } }, (res) => {
170
+ if (res.statusCode === 301 || res.statusCode === 302) {
171
+ https.get(res.headers.location, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (res2) => {
172
+ collectTWHist(res2, resolve, reject);
173
+ }).on('error', reject);
174
+ return;
175
+ }
176
+ collectTWHist(res, resolve, reject);
177
+ }).on('error', reject);
178
+ });
179
+ }
180
+
181
+ function collectTWHist(res, resolve, reject) {
182
+ const chunks = [];
183
+ res.on('data', c => chunks.push(c));
184
+ res.on('end', () => {
185
+ try {
186
+ const json = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
187
+ const result = json.chart && json.chart.result && json.chart.result[0];
188
+ if (!result || !result.timestamp) { resolve([]); return; }
189
+ const ts = result.timestamp, q = result.indicators.quote[0];
190
+ const klines = [];
191
+ for (let i = 0; i < ts.length; i++) {
192
+ if (q.close[i] === null) continue;
193
+ const d = new Date(ts[i] * 1000);
194
+ klines.push({
195
+ date: `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`,
196
+ open: +(q.open[i]||0).toFixed(2), close: +(q.close[i]||0).toFixed(2),
197
+ high: +(q.high[i]||0).toFixed(2), low: +(q.low[i]||0).toFixed(2),
198
+ volume: Math.round((q.volume[i]||0)/1000),
199
+ });
200
+ }
201
+ resolve(klines);
202
+ } catch (e) { reject(e); }
203
+ });
204
+ }
205
+
206
+ // ==================== 技术指标计算 ====================
207
+
208
+ /** 简单移动平均线 */
209
+ function SMA(data, period) {
210
+ const result = [];
211
+ for (let i = 0; i < data.length; i++) {
212
+ if (i < period - 1) { result.push(null); continue; }
213
+ let sum = 0;
214
+ for (let j = i - period + 1; j <= i; j++) sum += data[j];
215
+ result.push(+(sum / period).toFixed(3));
216
+ }
217
+ return result;
218
+ }
219
+
220
+ /** 指数移动平均线 */
221
+ function EMA(data, period) {
222
+ const result = [];
223
+ const k = 2 / (period + 1);
224
+ for (let i = 0; i < data.length; i++) {
225
+ if (i === 0) { result.push(data[0]); continue; }
226
+ result.push(+(data[i] * k + result[i - 1] * (1 - k)).toFixed(3));
227
+ }
228
+ return result;
229
+ }
230
+
231
+ /** MACD指标 (12, 26, 9) */
232
+ function MACD(closes) {
233
+ const ema12 = EMA(closes, 12);
234
+ const ema26 = EMA(closes, 26);
235
+ const dif = ema12.map((v, i) => +(v - ema26[i]).toFixed(3));
236
+ const dea = EMA(dif, 9);
237
+ const histogram = dif.map((v, i) => +((v - dea[i]) * 2).toFixed(3));
238
+ return { dif, dea, histogram };
239
+ }
240
+
241
+ /** RSI指标 */
242
+ function RSI(closes, period = 14) {
243
+ const result = [];
244
+ for (let i = 0; i < closes.length; i++) {
245
+ if (i < period) { result.push(null); continue; }
246
+ let gains = 0, losses = 0;
247
+ for (let j = i - period + 1; j <= i; j++) {
248
+ const diff = closes[j] - closes[j - 1];
249
+ if (diff > 0) gains += diff;
250
+ else losses -= diff;
251
+ }
252
+ const avgGain = gains / period;
253
+ const avgLoss = losses / period;
254
+ const rs = avgLoss === 0 ? 100 : avgGain / avgLoss;
255
+ result.push(+(100 - 100 / (1 + rs)).toFixed(2));
256
+ }
257
+ return result;
258
+ }
259
+
260
+ /** KDJ指标 (9, 3, 3) */
261
+ function KDJ(highs, lows, closes, n = 9) {
262
+ const K = [], D = [], J = [];
263
+ let prevK = 50, prevD = 50;
264
+ for (let i = 0; i < closes.length; i++) {
265
+ if (i < n - 1) { K.push(null); D.push(null); J.push(null); continue; }
266
+ let highN = -Infinity, lowN = Infinity;
267
+ for (let j = i - n + 1; j <= i; j++) {
268
+ highN = Math.max(highN, highs[j]);
269
+ lowN = Math.min(lowN, lows[j]);
270
+ }
271
+ const rsv = highN === lowN ? 50 : ((closes[i] - lowN) / (highN - lowN)) * 100;
272
+ const k = +(2 / 3 * prevK + 1 / 3 * rsv).toFixed(2);
273
+ const d = +(2 / 3 * prevD + 1 / 3 * k).toFixed(2);
274
+ const j = +(3 * k - 2 * d).toFixed(2);
275
+ K.push(k); D.push(d); J.push(j);
276
+ prevK = k; prevD = d;
277
+ }
278
+ return { K, D, J };
279
+ }
280
+
281
+ /** 布林带 (20, 2) */
282
+ function BOLL(closes, period = 20, multiplier = 2) {
283
+ const mid = SMA(closes, period);
284
+ const upper = [], lower = [];
285
+ for (let i = 0; i < closes.length; i++) {
286
+ if (mid[i] === null) { upper.push(null); lower.push(null); continue; }
287
+ let sum = 0;
288
+ for (let j = i - period + 1; j <= i; j++) sum += (closes[j] - mid[i]) ** 2;
289
+ const std = Math.sqrt(sum / period);
290
+ upper.push(+(mid[i] + multiplier * std).toFixed(3));
291
+ lower.push(+(mid[i] - multiplier * std).toFixed(3));
292
+ }
293
+ return { upper, mid, lower };
294
+ }
295
+
296
+ // ==================== 高级指标 ====================
297
+
298
+ /** ATR 真实波动幅度 */
299
+ function ATR(highs, lows, closes, period = 14) {
300
+ const tr = [];
301
+ for (let i = 0; i < closes.length; i++) {
302
+ if (i === 0) { tr.push(highs[i] - lows[i]); continue; }
303
+ const hl = highs[i] - lows[i];
304
+ const hc = Math.abs(highs[i] - closes[i - 1]);
305
+ const lc = Math.abs(lows[i] - closes[i - 1]);
306
+ tr.push(Math.max(hl, hc, lc));
307
+ }
308
+ return EMA(tr, period);
309
+ }
310
+
311
+ /** ADX 趋势强度指标 */
312
+ function ADX(highs, lows, closes, period = 14) {
313
+ const plusDM = [], minusDM = [], tr = [];
314
+ for (let i = 0; i < closes.length; i++) {
315
+ if (i === 0) { plusDM.push(0); minusDM.push(0); tr.push(highs[i] - lows[i]); continue; }
316
+ const upMove = highs[i] - highs[i - 1];
317
+ const downMove = lows[i - 1] - lows[i];
318
+ plusDM.push(upMove > downMove && upMove > 0 ? upMove : 0);
319
+ minusDM.push(downMove > upMove && downMove > 0 ? downMove : 0);
320
+ const hl = highs[i] - lows[i];
321
+ const hc = Math.abs(highs[i] - closes[i - 1]);
322
+ const lc = Math.abs(lows[i] - closes[i - 1]);
323
+ tr.push(Math.max(hl, hc, lc));
324
+ }
325
+ const atr = EMA(tr, period);
326
+ const smoothPlusDM = EMA(plusDM, period);
327
+ const smoothMinusDM = EMA(minusDM, period);
328
+ const plusDI = [], minusDI = [], dx = [];
329
+ for (let i = 0; i < closes.length; i++) {
330
+ const pdi = atr[i] > 0 ? (smoothPlusDM[i] / atr[i]) * 100 : 0;
331
+ const mdi = atr[i] > 0 ? (smoothMinusDM[i] / atr[i]) * 100 : 0;
332
+ plusDI.push(+pdi.toFixed(2));
333
+ minusDI.push(+mdi.toFixed(2));
334
+ const sum = pdi + mdi;
335
+ dx.push(sum > 0 ? +((Math.abs(pdi - mdi) / sum) * 100).toFixed(2) : 0);
336
+ }
337
+ const adx = EMA(dx, period);
338
+ return { plusDI, minusDI, adx };
339
+ }
340
+
341
+ /** 检测背离 */
342
+ function detectDivergence(closes, indicator, lookback = 20) {
343
+ const n = closes.length;
344
+ const last = n - 1;
345
+ const result = { bullish: false, bearish: false, description: '' };
346
+ let priceHighs = [], priceLows = [];
347
+ for (let i = Math.max(0, last - lookback); i <= last; i++) {
348
+ if (indicator[i] === null) continue;
349
+ if (i > 0 && i < last && closes[i] > closes[i - 1] && closes[i] > closes[i + 1]) {
350
+ priceHighs.push({ idx: i, price: closes[i], ind: indicator[i] });
351
+ }
352
+ if (i > 0 && i < last && closes[i] < closes[i - 1] && closes[i] < closes[i + 1]) {
353
+ priceLows.push({ idx: i, price: closes[i], ind: indicator[i] });
354
+ }
355
+ }
356
+ if (indicator[last] !== null) {
357
+ if (closes[last] >= closes[last - 1]) priceHighs.push({ idx: last, price: closes[last], ind: indicator[last] });
358
+ if (closes[last] <= closes[last - 1]) priceLows.push({ idx: last, price: closes[last], ind: indicator[last] });
359
+ }
360
+ if (priceHighs.length >= 2) {
361
+ const recent = priceHighs[priceHighs.length - 1];
362
+ const prev = priceHighs[priceHighs.length - 2];
363
+ if (recent.price > prev.price && recent.ind < prev.ind) {
364
+ result.bearish = true;
365
+ result.description = '顶背离:价格创新高但指标未跟随,上涨动能衰竭';
366
+ }
367
+ }
368
+ if (priceLows.length >= 2) {
369
+ const recent = priceLows[priceLows.length - 1];
370
+ const prev = priceLows[priceLows.length - 2];
371
+ if (recent.price < prev.price && recent.ind > prev.ind) {
372
+ result.bullish = true;
373
+ result.description = '底背离:价格创新低但指标未跟随,下跌动能衰竭';
374
+ }
375
+ }
376
+ return result;
377
+ }
378
+
379
+ /** VWAP */
380
+ function calcVWAP(closes, volumes, period = 20) {
381
+ const n = closes.length;
382
+ let sumPV = 0, sumV = 0;
383
+ const start = Math.max(0, n - period);
384
+ for (let i = start; i < n; i++) { sumPV += closes[i] * volumes[i]; sumV += volumes[i]; }
385
+ return sumV > 0 ? +(sumPV / sumV).toFixed(3) : closes[n - 1];
386
+ }
387
+
388
+ /** 形态识别 */
389
+ function detectPatterns(klines, ma20, boll) {
390
+ const n = klines.length;
391
+ const last = n - 1;
392
+ const patterns = [];
393
+ if (n >= 5 && ma20[last] !== null) {
394
+ const vol5Avg = klines.slice(-5).reduce((s, k) => s + k.volume, 0) / 5;
395
+ const vol20Avg = klines.slice(-20).reduce((s, k) => s + k.volume, 0) / 20;
396
+ const nearMA20 = Math.abs(klines[last].close - ma20[last]) / ma20[last] < 0.02;
397
+ const volShrink = vol5Avg < vol20Avg * 0.8;
398
+ const priorUptrend = klines[last].close > klines[Math.max(0, last - 20)].close;
399
+ if (nearMA20 && volShrink && priorUptrend) {
400
+ patterns.push({ type: 'bullish', name: '缩量回踩MA20', description: '上升趋势中缩量回踩均线支撑', weight: 3 });
401
+ }
402
+ }
403
+ if (n >= 20) {
404
+ const prevHigh = Math.max(...klines.slice(-21, -1).map(k => k.high));
405
+ const todayBreak = klines[last].close > prevHigh;
406
+ const vol20Avg = klines.slice(-20).reduce((s, k) => s + k.volume, 0) / 20;
407
+ const volExpand = klines[last].volume > vol20Avg * 1.5;
408
+ if (todayBreak && volExpand) {
409
+ patterns.push({ type: 'bullish', name: '放量突破前高', description: `突破近20日高点${prevHigh.toFixed(2)}`, weight: 3 });
410
+ }
411
+ }
412
+ if (n >= 3) {
413
+ const prevHigh = Math.max(...klines.slice(-22, -2).map(k => k.high));
414
+ const dayBefore = klines[last - 1];
415
+ const today = klines[last];
416
+ if (dayBefore.high > prevHigh && today.close < prevHigh * 0.98) {
417
+ patterns.push({ type: 'bearish', name: '假突破回落', description: '突破前高后快速回落,多头陷阱', weight: -3 });
418
+ }
419
+ }
420
+ if (n >= 1) {
421
+ const today = klines[last];
422
+ const body = Math.abs(today.close - today.open);
423
+ const lowerShadow = Math.min(today.open, today.close) - today.low;
424
+ const upperShadow = today.high - Math.max(today.open, today.close);
425
+ if (lowerShadow > body * 2 && upperShadow < body * 0.5 && body > 0) {
426
+ patterns.push({ type: 'bullish', name: '锤子线', description: '长下影线,下方有买盘支撑', weight: 2 });
427
+ }
428
+ if (upperShadow > body * 2 && lowerShadow < body * 0.5 && body > 0) {
429
+ patterns.push({ type: 'bearish', name: '射击之星', description: '长上影线,上方抛压沉重', weight: -2 });
430
+ }
431
+ }
432
+ if (n >= 3) {
433
+ const last3 = klines.slice(-3);
434
+ if (last3.every(k => k.close > k.open) && last3[2].close > last3[1].close && last3[1].close > last3[0].close) {
435
+ patterns.push({ type: 'bullish', name: '三连阳', description: '连续三日收阳逐步走高', weight: 2 });
436
+ }
437
+ if (last3.every(k => k.close < k.open) && last3[2].close < last3[1].close && last3[1].close < last3[0].close) {
438
+ patterns.push({ type: 'bearish', name: '三连阴', description: '连续三日收阴逐步走低', weight: -2 });
439
+ }
440
+ }
441
+ if (boll.upper[last] !== null && boll.upper[last - 5] !== null) {
442
+ const bw_5ago = (boll.upper[last - 5] - boll.lower[last - 5]) / boll.mid[last - 5];
443
+ if (bw_5ago < 0.08 && klines[last].close > boll.upper[last]) {
444
+ patterns.push({ type: 'bullish', name: '布林收窄后向上突破', description: '波动率收缩后向上选择方向', weight: 3 });
445
+ }
446
+ if (bw_5ago < 0.08 && klines[last].close < boll.lower[last]) {
447
+ patterns.push({ type: 'bearish', name: '布林收窄后向下突破', description: '波动率收缩后向下选择方向', weight: -3 });
448
+ }
449
+ }
450
+ return patterns;
451
+ }
452
+
453
+ /** 动量衰竭检测 */
454
+ function detectMomentumExhaustion(klines) {
455
+ const n = klines.length;
456
+ const last = n - 1;
457
+ const result = { bullExhaustion: false, bearExhaustion: false, signals: [], score: 0 };
458
+ if (n < 10) return result;
459
+ const recentUp = [];
460
+ for (let i = last; i >= Math.max(0, last - 9); i--) {
461
+ if (klines[i].close > klines[i].open) recentUp.unshift(i); else break;
462
+ }
463
+ if (recentUp.length >= 3) {
464
+ const changes = recentUp.map(i => (klines[i].close - klines[i].open) / klines[i].open * 100);
465
+ const vols = recentUp.map(i => klines[i].volume);
466
+ const changeDeclining = changes[changes.length - 1] < changes[0] * 0.6;
467
+ const volDeclining = vols[vols.length - 1] < vols[0] * 0.7;
468
+ if (changeDeclining && volDeclining) { result.bullExhaustion = true; result.signals.push(`上涨动量衰竭:连涨${recentUp.length}天但涨幅+量能递减`); result.score -= 3; }
469
+ else if (changeDeclining || volDeclining) { result.signals.push(`上涨动量减弱:${changeDeclining ? '涨幅递减' : '量能递减'}`); result.score -= 1; }
470
+ }
471
+ const recentDown = [];
472
+ for (let i = last; i >= Math.max(0, last - 9); i--) {
473
+ if (klines[i].close < klines[i].open) recentDown.unshift(i); else break;
474
+ }
475
+ if (recentDown.length >= 3) {
476
+ const changes = recentDown.map(i => Math.abs((klines[i].close - klines[i].open) / klines[i].open * 100));
477
+ const vols = recentDown.map(i => klines[i].volume);
478
+ const changeDeclining = changes[changes.length - 1] < changes[0] * 0.6;
479
+ const volDeclining = vols[vols.length - 1] < vols[0] * 0.7;
480
+ if (changeDeclining && volDeclining) { result.bearExhaustion = true; result.signals.push(`下跌动量衰竭:连跌${recentDown.length}天但跌幅+量能递减`); result.score += 3; }
481
+ else if (changeDeclining || volDeclining) { result.signals.push(`下跌动量减弱:${changeDeclining ? '跌幅递减' : '量能递减'}`); result.score += 1; }
482
+ }
483
+ if (result.signals.length === 0) result.signals.push('动量正常');
484
+ return result;
485
+ }
486
+
487
+ /** 斐波那契回撤位 */
488
+ function calcFibonacci(klines, lookback = 60) {
489
+ const n = klines.length;
490
+ const slice = klines.slice(Math.max(0, n - lookback));
491
+ const highs = slice.map(k => k.high);
492
+ const lows = slice.map(k => k.low);
493
+ const highPrice = Math.max(...highs);
494
+ const lowPrice = Math.min(...lows);
495
+ const highIdx = highs.indexOf(highPrice);
496
+ const lowIdx = lows.indexOf(lowPrice);
497
+ const diff = highPrice - lowPrice;
498
+ const isDowntrend = highIdx < lowIdx;
499
+ const currentPrice = klines[n - 1].close;
500
+ const levels = {};
501
+ if (isDowntrend) {
502
+ levels['0%(低点)'] = lowPrice;
503
+ levels['23.6%'] = +(lowPrice + diff * 0.236).toFixed(2);
504
+ levels['38.2%'] = +(lowPrice + diff * 0.382).toFixed(2);
505
+ levels['50%'] = +(lowPrice + diff * 0.5).toFixed(2);
506
+ levels['61.8%'] = +(lowPrice + diff * 0.618).toFixed(2);
507
+ levels['100%(高点)'] = highPrice;
508
+ } else {
509
+ levels['100%(高点)'] = highPrice;
510
+ levels['23.6%'] = +(highPrice - diff * 0.236).toFixed(2);
511
+ levels['38.2%'] = +(highPrice - diff * 0.382).toFixed(2);
512
+ levels['50%'] = +(highPrice - diff * 0.5).toFixed(2);
513
+ levels['61.8%'] = +(highPrice - diff * 0.618).toFixed(2);
514
+ levels['0%(低点)'] = lowPrice;
515
+ }
516
+ const allLevels = Object.entries(levels).map(([name, price]) => ({ name, price, dist: Math.abs(currentPrice - price) }));
517
+ allLevels.sort((a, b) => a.dist - b.dist);
518
+ return { levels, isDowntrend, highPrice, lowPrice, currentPrice, nearest: allLevels[0], trend: isDowntrend ? '下跌回撤' : '上涨回调' };
519
+ }
520
+
521
+ /** 缺口分析 */
522
+ function detectGaps(klines, lookback = 20) {
523
+ const n = klines.length;
524
+ const last = n - 1;
525
+ const gaps = [];
526
+ for (let i = Math.max(1, n - lookback); i <= last; i++) {
527
+ const prev = klines[i - 1], curr = klines[i];
528
+ if (curr.low > prev.high) gaps.push({ type: 'up', date: curr.date, bottom: prev.high, top: curr.low, size: +((curr.low - prev.high) / prev.close * 100).toFixed(2), idx: i });
529
+ if (curr.high < prev.low) gaps.push({ type: 'down', date: curr.date, bottom: curr.high, top: prev.low, size: +((prev.low - curr.high) / prev.close * 100).toFixed(2), idx: i });
530
+ }
531
+ const signals = [];
532
+ let score = 0;
533
+ const vol20 = klines.slice(-20).reduce((s, k) => s + k.volume, 0) / 20;
534
+ for (const gap of gaps) {
535
+ let filled = false;
536
+ for (let j = gap.idx + 1; j <= last; j++) {
537
+ if (gap.type === 'up' && klines[j].low <= gap.bottom) { filled = true; break; }
538
+ if (gap.type === 'down' && klines[j].high >= gap.top) { filled = true; break; }
539
+ }
540
+ if (filled) continue;
541
+ const isRecent = (last - gap.idx) <= 3;
542
+ if (gap.type === 'up') {
543
+ signals.push(`上跳缺口(${gap.date}): ${gap.bottom}→${gap.top} (+${gap.size}%) 未回补`);
544
+ if (isRecent && klines[gap.idx].volume > vol20 * 1.5) score += 2;
545
+ else if (isRecent) score += 1;
546
+ } else {
547
+ signals.push(`下跳缺口(${gap.date}): ${gap.top}→${gap.bottom} (-${gap.size}%) 未回补`);
548
+ if (isRecent && klines[gap.idx].volume > vol20 * 1.5) score -= 2;
549
+ else if (isRecent) score -= 1;
550
+ }
551
+ }
552
+ if (signals.length === 0) signals.push('近期无有效缺口');
553
+ return { signals, score };
554
+ }
555
+
556
+ /** 获取大盘趋势 */
557
+ async function getMarketEnvironment() {
558
+ try {
559
+ const klines = await fetchHistory('sh000001', 30);
560
+ if (klines.length < 20) return { trend: 'neutral', score: 0, signals: ['大盘数据不足'] };
561
+ const closes = klines.map(k => k.close);
562
+ const n = closes.length;
563
+ const ma5 = SMA(closes, 5);
564
+ const ma20 = SMA(closes, 20);
565
+ const trend20 = (closes[n - 1] - closes[Math.max(0, n - 20)]) / closes[Math.max(0, n - 20)] * 100;
566
+ let trend = 'neutral', score = 0;
567
+ const signals = [];
568
+ if (ma5[n - 1] > ma20[n - 1] && trend20 > 2) { trend = 'bull'; score = 1; signals.push(`上证偏强: 20日涨${trend20.toFixed(1)}%`); }
569
+ else if (ma5[n - 1] < ma20[n - 1] && trend20 < -2) { trend = 'bear'; score = -1; signals.push(`上证偏弱: 20日跌${trend20.toFixed(1)}%`); }
570
+ else { signals.push(`上证震荡: 20日${trend20.toFixed(1)}%`); }
571
+ return { trend, score, signals };
572
+ } catch (e) { return { trend: 'neutral', score: 0, signals: ['获取大盘数据失败'] }; }
573
+ }
574
+
575
+ // ==================== 分析逻辑 ====================
576
+
577
+ async function analyzeStock(klines, realtime) {
578
+ const closes = klines.map(k => k.close);
579
+ const highs = klines.map(k => k.high);
580
+ const lows = klines.map(k => k.low);
581
+ const volumes = klines.map(k => k.volume);
582
+ const n = closes.length;
583
+ const last = n - 1;
584
+
585
+ // 获取大盘环境
586
+ const marketEnv = await getMarketEnvironment();
587
+
588
+ // --- 均线分析 ---
589
+ const ma5 = SMA(closes, 5);
590
+ const ma10 = SMA(closes, 10);
591
+ const ma20 = SMA(closes, 20);
592
+ const ma60 = SMA(closes, 60);
593
+ const macd = MACD(closes);
594
+ const rsi = RSI(closes, 14);
595
+ const kdj = KDJ(highs, lows, closes);
596
+ const boll = BOLL(closes);
597
+
598
+ // ===== 量能预计算 =====
599
+ const vol5 = volumes.slice(-5).reduce((a, b) => a + b, 0) / 5;
600
+ const vol20 = volumes.slice(-20).reduce((a, b) => a + b, 0) / 20;
601
+ const volRatio = +(vol5 / vol20).toFixed(2);
602
+ const todayVolExpand = volumes[last] > vol20 * 1.2;
603
+ const vol5Expand = vol5 > vol20 * 1.2;
604
+ const vol5Shrink = vol5 < vol20 * 0.7;
605
+
606
+ // ===== 趋势方向预判 =====
607
+ const recent20 = closes.slice(-20);
608
+ const trend20 = (recent20[recent20.length - 1] - recent20[0]) / recent20[0] * 100;
609
+ const recent5 = closes.slice(-5);
610
+ const trend5 = (recent5[recent5.length - 1] - recent5[0]) / recent5[0] * 100;
611
+ const isBullTrend = trend20 > 3;
612
+ const isBearTrend = trend20 < -3;
613
+
614
+ // ===== 连续性辅助 =====
615
+ function countConsecutiveDays(condFn, maxLookback = 10) {
616
+ let count = 0;
617
+ for (let i = last; i >= Math.max(0, last - maxLookback); i--) {
618
+ if (condFn(i)) count++; else break;
619
+ }
620
+ return count;
621
+ }
622
+ function freshnessMultiplier(days) {
623
+ if (days <= 1) return 1.0;
624
+ if (days <= 3) return 0.7;
625
+ if (days <= 5) return 0.4;
626
+ return 0.2;
627
+ }
628
+
629
+ // --- 均线 + 信号确认 + 连续性 ---
630
+ const maSignals = [];
631
+ let maScore = 0;
632
+
633
+ const bullAlign = ma5[last] > ma10[last] && ma10[last] > ma20[last];
634
+ const bearAlign = ma5[last] < ma10[last] && ma10[last] < ma20[last];
635
+ if (bullAlign) {
636
+ const days = countConsecutiveDays(i => ma5[i] > ma10[i] && ma10[i] > ma20[i]);
637
+ const mult = freshnessMultiplier(days);
638
+ maSignals.push(`短期均线多头排列 (MA5>MA10>MA20, 已持续${days}天)`);
639
+ maScore += +(2 * mult).toFixed(1);
640
+ if (days > 5) maSignals.push(' 注意: 多头排列已久,短期回调风险增大');
641
+ } else if (bearAlign) {
642
+ const days = countConsecutiveDays(i => ma5[i] < ma10[i] && ma10[i] < ma20[i]);
643
+ const mult = freshnessMultiplier(days);
644
+ maSignals.push(`短期均线空头排列 (MA5<MA10<MA20, 已持续${days}天)`);
645
+ maScore -= +(2 * mult).toFixed(1);
646
+ if (days > 5) maSignals.push(' 注意: 空头排列已久,可能接近超卖');
647
+ }
648
+
649
+ if (closes[last] > ma20[last]) {
650
+ maSignals.push(`收盘价在20日均线上方 (${closes[last]} > MA20=${ma20[last]})`);
651
+ maScore += 1;
652
+ } else {
653
+ maSignals.push(`收盘价在20日均线下方 (${closes[last]} < MA20=${ma20[last]})`);
654
+ maScore -= 1;
655
+ }
656
+
657
+ if (ma5[last] > ma10[last] && ma5[last - 1] <= ma10[last - 1]) {
658
+ let cc = 0, details = [];
659
+ if (todayVolExpand) { cc++; details.push('量能配合'); }
660
+ if (isBullTrend) { cc++; details.push('趋势向上'); }
661
+ if (closes[last] > ma20[last]) { cc++; details.push('站上MA20'); }
662
+ if (cc >= 2) { maSignals.push(`MA5上穿MA10 (金叉) 确认: ${details.join('+')}`); maScore += 3; }
663
+ else if (cc === 1) { maSignals.push(`MA5上穿MA10 (金叉) 部分确认: ${details.join('+')}`); maScore += 1.5; }
664
+ else { maSignals.push('MA5上穿MA10 (金叉) 无确认,可靠性低'); maScore += 0.5; }
665
+ } else if (ma5[last] < ma10[last] && ma5[last - 1] >= ma10[last - 1]) {
666
+ let cc = 0, details = [];
667
+ if (todayVolExpand) { cc++; details.push('放量下跌'); }
668
+ if (isBearTrend) { cc++; details.push('趋势向下'); }
669
+ if (closes[last] < ma20[last]) { cc++; details.push('跌破MA20'); }
670
+ if (cc >= 2) { maSignals.push(`MA5下穿MA10 (死叉) 确认: ${details.join('+')}`); maScore -= 3; }
671
+ else if (cc === 1) { maSignals.push(`MA5下穿MA10 (死叉) 部分确认: ${details.join('+')}`); maScore -= 1.5; }
672
+ else { maSignals.push('MA5下穿MA10 (死叉) 无确认,可能是假信号'); maScore -= 0.5; }
673
+ }
674
+
675
+ // --- MACD + 多重确认 ---
676
+ const macdSignals = [];
677
+ let macdScore = 0;
678
+ const macdGoldenCross = macd.dif[last] > macd.dea[last] && macd.dif[last - 1] <= macd.dea[last - 1];
679
+ const macdDeathCross = macd.dif[last] < macd.dea[last] && macd.dif[last - 1] >= macd.dea[last - 1];
680
+
681
+ if (macdGoldenCross) {
682
+ let cc = 0, details = [];
683
+ if (todayVolExpand || vol5Expand) { cc++; details.push('量能放大'); }
684
+ if (isBullTrend) { cc++; details.push('趋势配合'); }
685
+ if (macd.dif[last] > -0.5) { cc++; details.push('接近零轴'); }
686
+ if (cc >= 2) { macdSignals.push(`MACD金叉 确认: ${details.join('+')}`); macdScore += 4; }
687
+ else if (cc === 1) { macdSignals.push(`MACD金叉 部分确认: ${details.join('+')}`); macdScore += 2; }
688
+ else { macdSignals.push('MACD金叉 缺乏确认,信号偏弱'); macdScore += 1; }
689
+ } else if (macdDeathCross) {
690
+ let cc = 0, details = [];
691
+ if (todayVolExpand || vol5Expand) { cc++; details.push('放量下跌'); }
692
+ if (isBearTrend) { cc++; details.push('趋势配合'); }
693
+ if (macd.dif[last] < 0.5) { cc++; details.push('零轴下方'); }
694
+ if (cc >= 2) { macdSignals.push(`MACD死叉 确认: ${details.join('+')}`); macdScore -= 4; }
695
+ else if (cc === 1) { macdSignals.push(`MACD死叉 部分确认: ${details.join('+')}`); macdScore -= 2; }
696
+ else { macdSignals.push('MACD死叉 缺乏确认,可能是假信号'); macdScore -= 1; }
697
+ }
698
+ if (macd.dif[last] > 0 && macd.dea[last] > 0) { macdSignals.push('MACD在零轴上方 (多头市场)'); macdScore += 1; }
699
+ else if (macd.dif[last] < 0 && macd.dea[last] < 0) { macdSignals.push('MACD在零轴下方 (空头市场)'); macdScore -= 1; }
700
+ if (macd.histogram[last] > macd.histogram[last - 1]) { macdSignals.push('MACD柱状线放大 (动能增强)'); macdScore += 1; }
701
+ else { macdSignals.push('MACD柱状线缩小 (动能减弱)'); macdScore -= 1; }
702
+
703
+ // --- RSI + 连续性 ---
704
+ const rsiSignals = [];
705
+ let rsiScore = 0;
706
+ const rsiVal = rsi[last];
707
+ if (rsiVal !== null) {
708
+ if (rsiVal < 30) {
709
+ const days = countConsecutiveDays(i => rsi[i] !== null && rsi[i] < 30);
710
+ rsiSignals.push(`RSI=${rsiVal} 超卖区域 (已${days}天)`);
711
+ rsiScore += days <= 2 ? 2 : 3;
712
+ } else if (rsiVal > 70) {
713
+ const days = countConsecutiveDays(i => rsi[i] !== null && rsi[i] > 70);
714
+ rsiSignals.push(`RSI=${rsiVal} 超买区域 (已${days}天)`);
715
+ rsiScore -= days <= 2 ? 1 : 2;
716
+ if (days >= 3 && vol5Shrink) { rsiSignals.push(' 超买+缩量,见顶概率增大'); rsiScore -= 1; }
717
+ } else if (rsiVal >= 50) { rsiSignals.push(`RSI=${rsiVal} 偏强区域`); rsiScore += 1; }
718
+ else { rsiSignals.push(`RSI=${rsiVal} 偏弱区域`); rsiScore -= 1; }
719
+ }
720
+
721
+ // --- KDJ + 多重确认 ---
722
+ const kdjSignals = [];
723
+ let kdjScore = 0;
724
+ if (kdj.K[last] !== null) {
725
+ const kdjGolden = kdj.K[last] > kdj.D[last] && kdj.K[last - 1] <= kdj.D[last - 1];
726
+ const kdjDeath = kdj.K[last] < kdj.D[last] && kdj.K[last - 1] >= kdj.D[last - 1];
727
+ if (kdjGolden) {
728
+ const inOversold = kdj.J[last] < 30 || kdj.K[last] < 30;
729
+ if (inOversold && todayVolExpand) { kdjSignals.push('KDJ金叉 超卖区+放量确认,信号强'); kdjScore += 3; }
730
+ else if (inOversold || todayVolExpand) { kdjSignals.push('KDJ金叉 部分确认'); kdjScore += 2; }
731
+ else { kdjSignals.push('KDJ金叉 (中位区,信号一般)'); kdjScore += 1; }
732
+ } else if (kdjDeath) {
733
+ const inOverbought = kdj.J[last] > 70 || kdj.K[last] > 70;
734
+ if (inOverbought && todayVolExpand) { kdjSignals.push('KDJ死叉 超买区+放量确认,信号强'); kdjScore -= 3; }
735
+ else if (inOverbought || todayVolExpand) { kdjSignals.push('KDJ死叉 部分确认'); kdjScore -= 2; }
736
+ else { kdjSignals.push('KDJ死叉 (中位区,信号一般)'); kdjScore -= 1; }
737
+ }
738
+ if (kdj.J[last] < 20) {
739
+ const days = countConsecutiveDays(i => kdj.J[i] !== null && kdj.J[i] < 20);
740
+ kdjSignals.push(`J值=${kdj.J[last]} 超卖 (${days}天)`); kdjScore += days >= 3 ? 2 : 1;
741
+ } else if (kdj.J[last] > 80) {
742
+ const days = countConsecutiveDays(i => kdj.J[i] !== null && kdj.J[i] > 80);
743
+ kdjSignals.push(`J值=${kdj.J[last]} 超买 (${days}天)`); kdjScore -= days >= 3 ? 2 : 1;
744
+ }
745
+ kdjSignals.push(`K=${kdj.K[last]} D=${kdj.D[last]} J=${kdj.J[last]}`);
746
+ }
747
+
748
+ // --- 布林带 + 连续性 ---
749
+ const bollSignals = [];
750
+ let bollScore = 0;
751
+ if (boll.mid[last] !== null) {
752
+ const price = closes[last];
753
+ const bandwidth = ((boll.upper[last] - boll.lower[last]) / boll.mid[last] * 100).toFixed(2);
754
+ if (price >= boll.upper[last]) {
755
+ const days = countConsecutiveDays(i => closes[i] >= boll.upper[i]);
756
+ bollSignals.push(`触及布林带上轨 (${boll.upper[last]}),已${days}天`);
757
+ bollScore -= days >= 3 ? 2 : 1;
758
+ } else if (price <= boll.lower[last]) {
759
+ const days = countConsecutiveDays(i => closes[i] <= boll.lower[i]);
760
+ bollSignals.push(`触及布林带下轨 (${boll.lower[last]}),已${days}天`);
761
+ bollScore += days >= 2 ? 2 : 1;
762
+ if (vol5Shrink) { bollSignals.push(' 缩量触下轨,反弹概率较大'); bollScore += 1; }
763
+ } else if (price > boll.mid[last]) { bollSignals.push('在布林带中轨上方运行'); bollScore += 1; }
764
+ else { bollSignals.push('在布林带中轨下方运行'); bollScore -= 1; }
765
+ bollSignals.push(`带宽=${bandwidth}%${bandwidth < 10 ? ' (收窄,可能变盘)' : ''}`);
766
+ }
767
+
768
+ // --- 量价 + 连续性 ---
769
+ const volSignals = [];
770
+ let volScore = 0;
771
+ if (vol5 > vol20 * 1.5) {
772
+ volSignals.push(`近5日量能显著放大 (量比=${volRatio})`);
773
+ if (closes[last] > closes[last - 5]) {
774
+ const days = countConsecutiveDays(i => volumes[i] > vol20 * 1.2 && i > 0 && closes[i] > closes[i - 1]);
775
+ volSignals.push(`放量上涨 (连续${days}天),多头强势`);
776
+ volScore += days >= 3 ? 3 : 2;
777
+ } else { volSignals.push('放量下跌,注意风险'); volScore -= 2; }
778
+ } else if (vol5 < vol20 * 0.7) {
779
+ volSignals.push(`近5日量能萎缩 (量比=${volRatio})`);
780
+ if (closes[last] > closes[last - 5]) { volSignals.push('缩量上涨,持续性存疑'); }
781
+ else { volSignals.push('缩量回调,抛压减轻'); volScore += 1; }
782
+ } else { volSignals.push(`量能平稳 (量比=${volRatio})`); }
783
+
784
+ // --- 趋势 + 连续性 ---
785
+ const trendSignals = [];
786
+ let trendScore = 0;
787
+ if (trend20 > 5) { trendSignals.push(`20日趋势:上涨 (+${trend20.toFixed(2)}%)`); trendScore += 2; }
788
+ else if (trend20 < -5) { trendSignals.push(`20日趋势:下跌 (${trend20.toFixed(2)}%)`); trendScore -= 2; }
789
+ else { trendSignals.push(`20日趋势:震荡 (${trend20.toFixed(2)}%)`); }
790
+
791
+ const upDays = countConsecutiveDays(i => i > 0 && closes[i] > closes[i - 1]);
792
+ const downDays = countConsecutiveDays(i => i > 0 && closes[i] < closes[i - 1]);
793
+ if (trend5 > 3) {
794
+ trendSignals.push(`5日短期趋势:强势上涨 (+${trend5.toFixed(2)}%, 连涨${upDays}天)`);
795
+ trendScore += upDays <= 3 ? 1 : 0;
796
+ if (upDays >= 5) { trendSignals.push(' 连涨过久,短期回调概率增大'); trendScore -= 1; }
797
+ } else if (trend5 < -3) {
798
+ trendSignals.push(`5日短期趋势:快速下跌 (${trend5.toFixed(2)}%, 连跌${downDays}天)`);
799
+ trendScore -= downDays <= 3 ? 1 : 0;
800
+ if (downDays >= 5) { trendSignals.push(' 连跌过久,超跌反弹概率增大'); trendScore += 1; }
801
+ }
802
+
803
+ // --- ADX 趋势强度 ---
804
+ const adxData = ADX(highs, lows, closes);
805
+ const adxSignals = [];
806
+ let adxScore = 0;
807
+ const adxVal = +adxData.adx[last].toFixed(2);
808
+ const plusDI = adxData.plusDI[last];
809
+ const minusDI = adxData.minusDI[last];
810
+ let marketState = 'oscillating';
811
+
812
+ if (adxVal >= 25) {
813
+ marketState = 'trending';
814
+ if (plusDI > minusDI) {
815
+ adxSignals.push(`ADX=${adxVal} 强趋势上涨 (+DI=${plusDI.toFixed(1)} > -DI=${minusDI.toFixed(1)})`);
816
+ adxScore += 2;
817
+ } else {
818
+ adxSignals.push(`ADX=${adxVal} 强趋势下跌 (-DI=${minusDI.toFixed(1)} > +DI=${plusDI.toFixed(1)})`);
819
+ adxScore -= 2;
820
+ }
821
+ if (adxVal >= 40) adxSignals.push('趋势极强,顺势操作');
822
+ } else if (adxVal >= 20) {
823
+ adxSignals.push(`ADX=${adxVal} 弱趋势,方向不明确`);
824
+ } else {
825
+ adxSignals.push(`ADX=${adxVal} 震荡市,适合高抛低吸`);
826
+ }
827
+
828
+ // --- ATR 波动率 + 仓位建议 ---
829
+ const atrData = ATR(highs, lows, closes);
830
+ const atrVal = +atrData[last].toFixed(3);
831
+ const atrPct = +(atrVal / closes[last] * 100).toFixed(2);
832
+ const atrSignals = [];
833
+ let positionAdvice = '', suggestedStopLoss = 0;
834
+ if (atrPct > 5) { positionAdvice = '波动极大,建议仓位<=20%'; suggestedStopLoss = +(closes[last] - atrVal * 2).toFixed(2); }
835
+ else if (atrPct > 3) { positionAdvice = '波动较大,建议仓位30-50%'; suggestedStopLoss = +(closes[last] - atrVal * 1.5).toFixed(2); }
836
+ else if (atrPct > 1.5) { positionAdvice = '波动适中,建议仓位50-70%'; suggestedStopLoss = +(closes[last] - atrVal * 1.5).toFixed(2); }
837
+ else { positionAdvice = '波动较小,可加大仓位至80%'; suggestedStopLoss = +(closes[last] - atrVal * 2).toFixed(2); }
838
+ atrSignals.push(`ATR=${atrVal} (${atrPct}%)`);
839
+ atrSignals.push(positionAdvice);
840
+
841
+ // --- 背离检测 ---
842
+ const macdDivergence = detectDivergence(closes, macd.dif, 20);
843
+ const rsiDivergence = detectDivergence(closes, rsi, 20);
844
+ const divergenceSignals = [];
845
+ let divergenceScore = 0;
846
+ if (macdDivergence.bearish) { divergenceSignals.push('MACD ' + macdDivergence.description); divergenceScore -= 3; }
847
+ if (macdDivergence.bullish) { divergenceSignals.push('MACD ' + macdDivergence.description); divergenceScore += 3; }
848
+ if (rsiDivergence.bearish) { divergenceSignals.push('RSI ' + rsiDivergence.description); divergenceScore -= 2; }
849
+ if (rsiDivergence.bullish) { divergenceSignals.push('RSI ' + rsiDivergence.description); divergenceScore += 2; }
850
+ if (divergenceSignals.length === 0) divergenceSignals.push('未检测到明显背离');
851
+
852
+ // --- 量价背离 ---
853
+ const volDivSignals = [];
854
+ let volDivScore = 0;
855
+ if (closes[last] > closes[last - 5] && vol5 < vol20 * 0.7) { volDivSignals.push('量价背离:价涨量缩,动力不足'); volDivScore -= 2; }
856
+ if (closes[last] < closes[last - 5] && vol5 < vol20 * 0.6) { volDivSignals.push('缩量下跌:抛压衰竭'); volDivScore += 1; }
857
+ if (Math.abs(trend5) < 2 && vol5 > vol20 * 1.3) { volDivSignals.push('横盘放量:关注方向选择'); }
858
+
859
+ // --- 形态识别 ---
860
+ const patterns = detectPatterns(klines, ma20, boll);
861
+ let patternScore = 0;
862
+ const patternSignals = [];
863
+ for (const p of patterns) { patternSignals.push(`[${p.type === 'bullish' ? '看多' : '看空'}] ${p.name}: ${p.description}`); patternScore += p.weight; }
864
+ if (patternSignals.length === 0) patternSignals.push('未识别到明显形态');
865
+
866
+ // --- 动量衰竭 ---
867
+ const momentum = detectMomentumExhaustion(klines);
868
+
869
+ // --- 斐波那契 ---
870
+ const fib = calcFibonacci(klines, 60);
871
+ const fibSignals = [];
872
+ fibSignals.push(`趋势: ${fib.trend} (高${fib.highPrice} 低${fib.lowPrice})`);
873
+ fibSignals.push(`最近位: ${fib.nearest.name}=${fib.nearest.price}`);
874
+
875
+ // --- 缺口分析 ---
876
+ const gapAnalysis = detectGaps(klines, 20);
877
+
878
+ // --- 支撑压力位 ---
879
+ const supportResistance = [];
880
+ const high20 = Math.max(...highs.slice(-20));
881
+ const low20 = Math.min(...lows.slice(-20));
882
+ supportResistance.push(`近20日压力位: ${high20}`);
883
+ supportResistance.push(`近20日支撑位: ${low20}`);
884
+ if (ma20[last]) supportResistance.push(`MA20动态支撑/压力: ${ma20[last]}`);
885
+ if (boll.upper[last]) {
886
+ supportResistance.push(`布林上轨压力: ${boll.upper[last]}`);
887
+ supportResistance.push(`布林下轨支撑: ${boll.lower[last]}`);
888
+ }
889
+
890
+ // ==================== 动态加权评分 ====================
891
+ let weightedScore = 0;
892
+ if (marketState === 'trending') {
893
+ weightedScore = maScore * 1.5 + macdScore * 1.3 + adxScore * 1.5
894
+ + rsiScore * 0.7 + kdjScore * 0.7 + bollScore * 0.8
895
+ + volScore + trendScore * 1.3 + divergenceScore * 1.2 + volDivScore + patternScore
896
+ + momentum.score * 1.2 + gapAnalysis.score * 0.8;
897
+ } else {
898
+ weightedScore = maScore * 0.8 + macdScore * 0.8 + adxScore * 0.8
899
+ + rsiScore * 1.5 + kdjScore * 1.5 + bollScore * 1.5
900
+ + volScore + trendScore * 0.8 + divergenceScore * 1.3 + volDivScore + patternScore * 1.2
901
+ + momentum.score * 1.0 + gapAnalysis.score * 1.0;
902
+ }
903
+
904
+ // 趋势一致性奖惩
905
+ const trend60 = ma60[last] !== null ? (closes[last] - closes[Math.max(0, last - 60)]) / closes[Math.max(0, last - 60)] * 100 : 0;
906
+ const shortBull = trend5 > 1, midBull = trend20 > 2, longBull = trend60 > 5;
907
+ const shortBear = trend5 < -1, midBear = trend20 < -2, longBear = trend60 < -5;
908
+ if (shortBull && midBull && longBull) weightedScore += 3;
909
+ else if (shortBear && midBear && longBear) weightedScore -= 3;
910
+ else if ((shortBull && midBear) || (shortBear && midBull)) {
911
+ if (weightedScore > 0) weightedScore *= 0.8;
912
+ else if (weightedScore < 0) weightedScore *= 0.8;
913
+ }
914
+
915
+ // ADX<20 震荡市惩罚
916
+ if (adxVal < 15) weightedScore *= 0.4;
917
+ else if (adxVal < 20 && Math.abs(weightedScore) > 0) weightedScore *= 0.6;
918
+
919
+ // 低波动股降权
920
+ if (atrPct < 1.5 && Math.abs(weightedScore) > 3) weightedScore *= 0.7;
921
+
922
+ // 量能确认加成
923
+ if (weightedScore > 5 && vol5Expand) weightedScore *= 1.15;
924
+ else if (weightedScore < -5 && vol5Expand) weightedScore *= 1.15;
925
+ else if (weightedScore > 5 && vol5Shrink) weightedScore *= 0.8;
926
+
927
+ // 大盘环境修正
928
+ if (marketEnv.trend === 'bull') { weightedScore += 1.5; }
929
+ else if (marketEnv.trend === 'bear') { weightedScore -= 1.5; if (weightedScore > 0) weightedScore *= 0.7; }
930
+
931
+ const totalScore = +weightedScore.toFixed(1);
932
+
933
+ // 风险收益比 - 基于实际支撑/压力位
934
+ const currentPrice = closes[last];
935
+ const supportLevels = [suggestedStopLoss];
936
+ if (ma20[last] && ma20[last] < currentPrice) supportLevels.push(ma20[last]);
937
+ if (boll.lower[last] && boll.lower[last] < currentPrice) supportLevels.push(boll.lower[last]);
938
+ supportLevels.push(low20);
939
+ const validSupports = supportLevels.filter(s => s < currentPrice).sort((a, b) => b - a);
940
+ const smartStopLoss = validSupports.length > 0 ? +validSupports[0].toFixed(2) : suggestedStopLoss;
941
+
942
+ const resistanceLevels = [];
943
+ if (high20 > currentPrice * 1.02) resistanceLevels.push(high20);
944
+ if (boll.upper[last] && boll.upper[last] > currentPrice * 1.02) resistanceLevels.push(boll.upper[last]);
945
+ const fibLevels = Object.values(fib.levels).filter(v => v > currentPrice * 1.03);
946
+ if (fibLevels.length > 0) resistanceLevels.push(Math.min(...fibLevels));
947
+ resistanceLevels.push(+(currentPrice + atrVal * 2).toFixed(2));
948
+ resistanceLevels.push(+(currentPrice + atrVal * 3).toFixed(2));
949
+ const validResistance = [...new Set(resistanceLevels.filter(r => r > currentPrice))].sort((a, b) => a - b);
950
+ const smartTP1 = validResistance.length > 0 ? +validResistance[0].toFixed(2) : +(currentPrice + atrVal * 2).toFixed(2);
951
+ const smartTP2 = validResistance.length > 1 ? +validResistance[1].toFixed(2) : +(currentPrice + atrVal * 3).toFixed(2);
952
+
953
+ const riskAmt = currentPrice - smartStopLoss;
954
+ const rewardAmt = smartTP1 - currentPrice;
955
+ const riskRewardRatio = riskAmt > 0 ? +(rewardAmt / riskAmt).toFixed(2) : 99;
956
+
957
+ let signal, advice;
958
+ if (totalScore >= 10) { signal = '强烈买入'; advice = '多项指标共振看多,可考虑积极建仓'; }
959
+ else if (totalScore >= 5) { signal = '建议买入'; advice = '技术面偏多,可适量买入或加仓'; }
960
+ else if (totalScore >= 1) { signal = '谨慎买入'; advice = '信号偏多但不强烈,可小仓位试探'; }
961
+ else if (totalScore >= -4) { signal = '观望'; advice = '多空信号交织,建议等待更明确的方向'; }
962
+ else if (totalScore >= -9) { signal = '建议卖出'; advice = '技术面偏空,建议减仓或观望'; }
963
+ else { signal = '强烈卖出'; advice = '多项指标看空,建议清仓回避'; }
964
+
965
+ if (riskRewardRatio < 1.0 && totalScore > 0) { advice += ';风险收益比不佳(<1:1),建议等回调再入场'; }
966
+ else if (riskRewardRatio >= 2.0 && totalScore > 0) { advice += ';风险收益比优秀(1:' + riskRewardRatio + '),入场性价比高'; }
967
+
968
+ // === 买入/卖出条件 ===
969
+ const buyConditions = [];
970
+ const sellConditions = [];
971
+ const distToMA20 = ma20[last] ? +((currentPrice - ma20[last]) / currentPrice * 100).toFixed(1) : 0;
972
+ const distToHigh20 = +((high20 - currentPrice) / currentPrice * 100).toFixed(1);
973
+
974
+ if (totalScore >= 5) {
975
+ if (distToMA20 < 3) {
976
+ buyConditions.push(`[立即] 当前价附近(${(currentPrice*0.99).toFixed(2)}~${currentPrice.toFixed(2)})直接买入,MA20(${ma20[last]})支撑`);
977
+ } else {
978
+ const intraSupport = +(currentPrice - atrVal * 0.5).toFixed(2);
979
+ const ma5Val = ma5[last] ? +ma5[last].toFixed(2) : intraSupport;
980
+ buyConditions.push(`[首选] 日内回调至${Math.max(intraSupport, ma5Val)}附近轻仓试探`);
981
+ }
982
+ buyConditions.push(`[稳健] 分批: ${currentPrice.toFixed(2)}(1/3), 回调${(currentPrice*0.98).toFixed(2)}加仓(1/3), ${(currentPrice*0.95).toFixed(2)}补仓(1/3)`);
983
+ } else if (totalScore >= 1) {
984
+ buyConditions.push(`[首选] 回调2-3%至${(currentPrice*0.97).toFixed(2)}附近,出现止跌信号后买入`);
985
+ if (macd.dif[last] < macd.dea[last]) buyConditions.push('[等信号] MACD金叉确认后次日买入');
986
+ } else {
987
+ if (boll.lower[last]) buyConditions.push(`[激进] 跌至布林下轨${boll.lower[last].toFixed(2)}+RSI<30+缩量,轻仓抄底`);
988
+ if (ma20[last] && currentPrice < ma20[last]) buyConditions.push(`[等信号] 放量站回MA20(${ma20[last].toFixed(2)})上方后买入`);
989
+ }
990
+ if (distToHigh20 > 0 && distToHigh20 < 5) {
991
+ buyConditions.push(`[突破] 放量突破${high20.toFixed(2)}时跟进,不追超${(high20*1.03).toFixed(2)}`);
992
+ }
993
+
994
+ sellConditions.push(`[止损] ${smartStopLoss} (亏${(riskAmt/currentPrice*100).toFixed(1)}%),跌破即走`);
995
+ if (rsiVal > 75) sellConditions.push(`[止盈] RSI=${rsiVal}超买,冲高回落减半仓`);
996
+ if (ma5[last] && currentPrice > ma5[last]) sellConditions.push(`[减仓] 跌破MA5(${ma5[last].toFixed(2)})且次日不收回`);
997
+ if (ma20[last] && currentPrice > ma20[last]) sellConditions.push(`[清仓] 跌破MA20(${ma20[last].toFixed(2)})且3日不收回`);
998
+ if (smartTP1 > currentPrice) sellConditions.push(`[目标] 到达${smartTP1}附近分批止盈`);
999
+
1000
+ return {
1001
+ realtime,
1002
+ marketState: marketState === 'trending' ? '趋势市' : '震荡市',
1003
+ marketEnv,
1004
+ buyConditions,
1005
+ sellConditions,
1006
+ indicators: {
1007
+ ma: { score: maScore, signals: maSignals },
1008
+ macd: { score: macdScore, signals: macdSignals, values: { dif: macd.dif[last], dea: macd.dea[last], histogram: macd.histogram[last] } },
1009
+ rsi: { score: rsiScore, signals: rsiSignals, value: rsiVal },
1010
+ kdj: { score: kdjScore, signals: kdjSignals },
1011
+ boll: { score: bollScore, signals: bollSignals },
1012
+ volume: { score: volScore, signals: volSignals },
1013
+ trend: { score: trendScore, signals: trendSignals },
1014
+ adx: { score: adxScore, signals: adxSignals },
1015
+ atr: { signals: atrSignals },
1016
+ divergence: { score: divergenceScore, signals: divergenceSignals },
1017
+ volumeDivergence: { score: volDivScore, signals: volDivSignals },
1018
+ patterns: { score: patternScore, signals: patternSignals },
1019
+ momentum: { score: momentum.score, signals: momentum.signals },
1020
+ gaps: { score: gapAnalysis.score, signals: gapAnalysis.signals },
1021
+ fibonacci: { signals: fibSignals },
1022
+ },
1023
+ supportResistance,
1024
+ riskReward: {
1025
+ stopLoss: smartStopLoss,
1026
+ takeProfit1: smartTP1,
1027
+ takeProfit2: smartTP2,
1028
+ riskRewardRatio,
1029
+ positionAdvice,
1030
+ },
1031
+ summary: {
1032
+ totalScore,
1033
+ normalizedScore: Math.max(0, Math.min(100, +((totalScore + 20) / 40 * 100).toFixed(0))),
1034
+ signal,
1035
+ advice,
1036
+ marketState: marketState === 'trending' ? '趋势市' : '震荡市',
1037
+ marketEnv: marketEnv.trend === 'bull' ? '大盘偏强' : marketEnv.trend === 'bear' ? '大盘偏弱' : '大盘震荡',
1038
+ breakdown: `均线(${maScore}) MACD(${macdScore}) RSI(${rsiScore}) KDJ(${kdjScore}) 布林(${bollScore}) 量价(${volScore}) 趋势(${trendScore}) ADX(${adxScore}) 背离(${divergenceScore}) 形态(${patternScore}) 动量(${momentum.score}) 缺口(${gapAnalysis.score}) = ${totalScore}(加权)`,
1039
+ },
1040
+ };
1041
+ }
1042
+
1043
+ // ==================== 输出格式化 ====================
1044
+
1045
+ function formatReport(analysis) {
1046
+ const { realtime: rt, indicators: ind, supportResistance: sr, summary, riskReward: rr, marketState, marketEnv, buyConditions, sellConditions } = analysis;
1047
+ const lines = [];
1048
+
1049
+ lines.push('═'.repeat(60));
1050
+ lines.push(` ${rt.name} (${rt.code}) 综合分析报告`);
1051
+ lines.push(` 当前价: ${rt.price} 涨跌: ${rt.change > 0 ? '+' : ''}${rt.change} (${rt.changePct > 0 ? '+' : ''}${rt.changePct}%)`);
1052
+ lines.push(` 时间: ${rt.time} 市场状态: ${marketState} ${summary.marketEnv}`);
1053
+ lines.push('═'.repeat(60));
1054
+
1055
+ lines.push('');
1056
+ lines.push(`【综合信号】 >>> ${summary.signal} <<<`);
1057
+ lines.push(` 综合评分: ${summary.totalScore} 分 (标准化: ${summary.normalizedScore}/100)`);
1058
+ lines.push(` 评分构成: ${summary.breakdown}`);
1059
+ lines.push(` 建议: ${summary.advice}`);
1060
+
1061
+ lines.push('');
1062
+ lines.push('─'.repeat(60));
1063
+ lines.push('【大盘环境】');
1064
+ marketEnv.signals.forEach(s => lines.push(' · ' + s));
1065
+
1066
+ lines.push('');
1067
+ lines.push('【均线系统】 得分: ' + ind.ma.score);
1068
+ ind.ma.signals.forEach(s => lines.push(' · ' + s));
1069
+
1070
+ lines.push('');
1071
+ lines.push('【MACD】 得分: ' + ind.macd.score);
1072
+ lines.push(` DIF=${ind.macd.values.dif} DEA=${ind.macd.values.dea} 柱=${ind.macd.values.histogram}`);
1073
+ ind.macd.signals.forEach(s => lines.push(' · ' + s));
1074
+
1075
+ lines.push('');
1076
+ lines.push('【RSI】 得分: ' + ind.rsi.score);
1077
+ ind.rsi.signals.forEach(s => lines.push(' · ' + s));
1078
+
1079
+ lines.push('');
1080
+ lines.push('【KDJ】 得分: ' + ind.kdj.score);
1081
+ ind.kdj.signals.forEach(s => lines.push(' · ' + s));
1082
+
1083
+ lines.push('');
1084
+ lines.push('【布林带】 得分: ' + ind.boll.score);
1085
+ ind.boll.signals.forEach(s => lines.push(' · ' + s));
1086
+
1087
+ lines.push('');
1088
+ lines.push('【量价关系】 得分: ' + ind.volume.score);
1089
+ ind.volume.signals.forEach(s => lines.push(' · ' + s));
1090
+
1091
+ lines.push('');
1092
+ lines.push('【趋势分析】 得分: ' + ind.trend.score);
1093
+ ind.trend.signals.forEach(s => lines.push(' · ' + s));
1094
+
1095
+ lines.push('');
1096
+ lines.push('【ADX趋势强度】 得分: ' + ind.adx.score);
1097
+ ind.adx.signals.forEach(s => lines.push(' · ' + s));
1098
+
1099
+ lines.push('');
1100
+ lines.push('【ATR波动率】');
1101
+ ind.atr.signals.forEach(s => lines.push(' · ' + s));
1102
+
1103
+ lines.push('');
1104
+ lines.push('【背离检测】 得分: ' + ind.divergence.score);
1105
+ ind.divergence.signals.forEach(s => lines.push(' · ' + s));
1106
+ if (ind.volumeDivergence.signals.length > 0) {
1107
+ ind.volumeDivergence.signals.forEach(s => lines.push(' · ' + s));
1108
+ }
1109
+
1110
+ lines.push('');
1111
+ lines.push('【形态识别】 得分: ' + ind.patterns.score);
1112
+ ind.patterns.signals.forEach(s => lines.push(' · ' + s));
1113
+
1114
+ lines.push('');
1115
+ lines.push('【动量分析】 得分: ' + ind.momentum.score);
1116
+ ind.momentum.signals.forEach(s => lines.push(' · ' + s));
1117
+
1118
+ lines.push('');
1119
+ lines.push('【缺口分析】 得分: ' + ind.gaps.score);
1120
+ ind.gaps.signals.forEach(s => lines.push(' · ' + s));
1121
+
1122
+ lines.push('');
1123
+ lines.push('【斐波那契】');
1124
+ ind.fibonacci.signals.forEach(s => lines.push(' · ' + s));
1125
+
1126
+ lines.push('');
1127
+ lines.push('─'.repeat(60));
1128
+ lines.push('【买入条件】');
1129
+ if (buyConditions && buyConditions.length > 0) {
1130
+ buyConditions.forEach(s => lines.push(' · ' + s));
1131
+ } else {
1132
+ lines.push(' · 暂无明确买入信号');
1133
+ }
1134
+
1135
+ lines.push('');
1136
+ lines.push('【卖出/止损条件】');
1137
+ if (sellConditions && sellConditions.length > 0) {
1138
+ sellConditions.forEach(s => lines.push(' · ' + s));
1139
+ } else {
1140
+ lines.push(' · 暂无');
1141
+ }
1142
+
1143
+ lines.push('');
1144
+ lines.push('─'.repeat(60));
1145
+ lines.push('【支撑/压力位】');
1146
+ sr.forEach(s => lines.push(' · ' + s));
1147
+
1148
+ lines.push('');
1149
+ lines.push('【风险收益比】');
1150
+ lines.push(` · 止损位: ${rr.stopLoss} | 止盈1: ${rr.takeProfit1} | 止盈2: ${rr.takeProfit2}`);
1151
+ lines.push(` · 风险收益比: 1:${rr.riskRewardRatio}`);
1152
+ lines.push(` · ${rr.positionAdvice}`);
1153
+
1154
+ lines.push('');
1155
+ lines.push('═'.repeat(60));
1156
+ lines.push(' 免责声明: 以上分析仅基于技术面,不构成投资建议。');
1157
+ lines.push(' 投资有风险,入市需谨慎。');
1158
+ lines.push('═'.repeat(60));
1159
+
1160
+ return lines.join('\n');
1161
+ }
1162
+
1163
+ // ==================== 主程序 ====================
1164
+
1165
+ function resolveCode(input) {
1166
+ input = input.trim();
1167
+ if (/^tw\d{4,6}$/i.test(input)) return input.toLowerCase();
1168
+ if (/^\d{4}$/.test(input)) return 'tw' + input;
1169
+ if (/^(sh|sz)\d{6}$/i.test(input)) return input.toLowerCase();
1170
+ if (/^\d{6}$/.test(input)) return (input.startsWith('6') ? 'sh' : 'sz') + input;
1171
+ return input;
1172
+ }
1173
+
1174
+ async function main() {
1175
+ const arg = process.argv[2] || 'all';
1176
+ let codes;
1177
+ if (arg === 'all') {
1178
+ codes = Object.keys(WATCH_LIST);
1179
+ } else {
1180
+ codes = [resolveCode(arg)];
1181
+ }
1182
+
1183
+ console.log(`\n正在获取数据并分析...\n`);
1184
+
1185
+ for (const code of codes) {
1186
+ try {
1187
+ const isTW = code.startsWith('tw');
1188
+ let realtimeArr, klines;
1189
+
1190
+ if (isTW) {
1191
+ [realtimeArr, klines] = await Promise.all([
1192
+ fetchTWRealtime(code),
1193
+ fetchTWHistory(code, 120),
1194
+ ]);
1195
+ } else {
1196
+ [realtimeArr, klines] = await Promise.all([
1197
+ fetchRealtime([code]),
1198
+ fetchHistory(code, 120),
1199
+ ]);
1200
+ }
1201
+
1202
+ const rt = realtimeArr.find(r => r.code === code) || realtimeArr[0];
1203
+ if (!rt) {
1204
+ console.log(`未找到 ${code} 的实时数据,跳过`);
1205
+ continue;
1206
+ }
1207
+
1208
+ if (klines.length < 60) {
1209
+ console.log(`${code} 历史数据不足60天 (仅${klines.length}天),跳过`);
1210
+ continue;
1211
+ }
1212
+
1213
+ const analysis = await analyzeStock(klines, rt);
1214
+ console.log(formatReport(analysis));
1215
+ console.log('');
1216
+ } catch (e) {
1217
+ console.log(`${code} 分析出错: ${e.message}`);
1218
+ }
1219
+ }
1220
+ }
1221
+
1222
+ main().catch(e => console.error('分析出错:', e.message));