@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/analyze.js CHANGED
@@ -1,15 +1,16 @@
1
1
  /**
2
- * A股/台股综合分析工具
3
- *
4
- * 技术指标:MA均线、MACD、RSI、KDJ、布林带
5
- * 量价分析:放量突破、缩量回调、量价背离
6
- * 趋势分析:趋势方向、支撑位、压力位
7
- *
8
- * 用法:node analyze.js [sz002049|sh603893|tw2330|2330|all]
2
+ * A股/台股综合分析工具(CLI)
3
+ *
4
+ * 评分逻辑统一在 scoring.js,指标统一在 indicators.js
5
+ * 此文件只负责:数据获取 + 大盘环境 + 报告输出
6
+ *
7
+ * 用法:node analyze.js [sz002049|sh603893|tw2330|2330|all]
9
8
  */
10
9
 
11
10
  const http = require('http');
12
11
  const https = require('https');
12
+ const { SMA } = require('./indicators');
13
+ const { computeScore } = require('./scoring');
13
14
 
14
15
  const WATCH_LIST = {
15
16
  'sz002049': '紫光国微',
@@ -75,7 +76,7 @@ function fetchRealtime(codes) {
75
76
  low: parseFloat(fields[5]),
76
77
  yesterdayClose,
77
78
  change: +(price - yesterdayClose).toFixed(2),
78
- changePct: +(((price - yesterdayClose) / yesterdayClose) * 100).toFixed(2),
79
+ changePct: yesterdayClose > 0 ? +(((price - yesterdayClose) / yesterdayClose) * 100).toFixed(2) : 0,
79
80
  volume: Math.round(parseFloat(fields[8]) / 100),
80
81
  amount: +(parseFloat(fields[9]) / 10000).toFixed(2),
81
82
  time: `${fields[30]} ${fields[31]}`,
@@ -123,7 +124,7 @@ function collect(res, code, resolve, reject) {
123
124
  });
124
125
  }
125
126
 
126
- // ==================== 台股数据获取 ====================
127
+ // ==================== 台股数据 ====================
127
128
 
128
129
  function fetchTWRealtime(code) {
129
130
  return new Promise((resolve, reject) => {
@@ -147,7 +148,7 @@ function fetchTWRealtime(code) {
147
148
  open: parseFloat(item.o) || price, high: parseFloat(item.h) || price,
148
149
  low: parseFloat(item.l) || price, yesterdayClose,
149
150
  change: +(price - yesterdayClose).toFixed(2),
150
- changePct: +(((price - yesterdayClose) / yesterdayClose) * 100).toFixed(2),
151
+ changePct: yesterdayClose > 0 ? +(((price - yesterdayClose) / yesterdayClose) * 100).toFixed(2) : 0,
151
152
  volume: Math.round(parseInt(item.v) || 0), amount: 0,
152
153
  time: `${item.d} ${item.t || ''}`,
153
154
  });
@@ -203,357 +204,8 @@ function collectTWHist(res, resolve, reject) {
203
204
  });
204
205
  }
205
206
 
206
- // ==================== 技术指标计算 ====================
207
+ // ==================== 大盘环境 ====================
207
208
 
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
209
  async function getMarketEnvironment() {
558
210
  try {
559
211
  const klines = await fetchHistory('sh000001', 30);
@@ -572,475 +224,7 @@ async function getMarketEnvironment() {
572
224
  } catch (e) { return { trend: 'neutral', score: 0, signals: ['获取大盘数据失败'] }; }
573
225
  }
574
226
 
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
- // ==================== 输出格式化 ====================
227
+ // ==================== 报告输出 ====================
1044
228
 
1045
229
  function formatReport(analysis) {
1046
230
  const { realtime: rt, indicators: ind, supportResistance: sr, summary, riskReward: rr, marketState, marketEnv, buyConditions, sellConditions } = analysis;
@@ -1063,62 +247,35 @@ function formatReport(analysis) {
1063
247
  lines.push('【大盘环境】');
1064
248
  marketEnv.signals.forEach(s => lines.push(' · ' + s));
1065
249
 
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));
250
+ const sections = [
251
+ ['均线系统', ind.ma],
252
+ ['MACD', ind.macd],
253
+ ['RSI', ind.rsi],
254
+ ['KDJ', ind.kdj],
255
+ ['布林带', ind.boll],
256
+ ['量价关系', ind.volume],
257
+ ['趋势分析', ind.trend],
258
+ ['ADX趋势强度', ind.adx],
259
+ ['背离检测', ind.divergence],
260
+ ['形态识别', ind.patterns],
261
+ ['动量分析', ind.momentum],
262
+ ['缺口分析', ind.gaps],
263
+ ];
264
+ for (const [name, sec] of sections) {
265
+ lines.push('');
266
+ lines.push(`【${name}】 得分: ${sec.score !== undefined ? sec.score : ''}`);
267
+ if (name === 'MACD' && sec.values) lines.push(` DIF=${sec.values.dif} DEA=${sec.values.dea} 柱=${sec.values.histogram}`);
268
+ sec.signals.forEach(s => lines.push(' · ' + s));
269
+ }
1098
270
 
1099
271
  lines.push('');
1100
272
  lines.push('【ATR波动率】');
1101
273
  ind.atr.signals.forEach(s => lines.push(' · ' + s));
1102
274
 
1103
- lines.push('');
1104
- lines.push('【背离检测】 得分: ' + ind.divergence.score);
1105
- ind.divergence.signals.forEach(s => lines.push(' · ' + s));
1106
275
  if (ind.volumeDivergence.signals.length > 0) {
1107
276
  ind.volumeDivergence.signals.forEach(s => lines.push(' · ' + s));
1108
277
  }
1109
278
 
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
279
  lines.push('');
1123
280
  lines.push('【斐波那契】');
1124
281
  ind.fibonacci.signals.forEach(s => lines.push(' · ' + s));
@@ -1126,19 +283,13 @@ function formatReport(analysis) {
1126
283
  lines.push('');
1127
284
  lines.push('─'.repeat(60));
1128
285
  lines.push('【买入条件】');
1129
- if (buyConditions && buyConditions.length > 0) {
1130
- buyConditions.forEach(s => lines.push(' · ' + s));
1131
- } else {
1132
- lines.push(' · 暂无明确买入信号');
1133
- }
286
+ if (buyConditions && buyConditions.length > 0) buyConditions.forEach(s => lines.push(' · ' + s));
287
+ else lines.push(' · 暂无明确买入信号');
1134
288
 
1135
289
  lines.push('');
1136
290
  lines.push('【卖出/止损条件】');
1137
- if (sellConditions && sellConditions.length > 0) {
1138
- sellConditions.forEach(s => lines.push(' · ' + s));
1139
- } else {
1140
- lines.push(' · 暂无');
1141
- }
291
+ if (sellConditions && sellConditions.length > 0) sellConditions.forEach(s => lines.push(' · ' + s));
292
+ else lines.push(' · 暂无');
1142
293
 
1143
294
  lines.push('');
1144
295
  lines.push('─'.repeat(60));
@@ -1153,8 +304,8 @@ function formatReport(analysis) {
1153
304
 
1154
305
  lines.push('');
1155
306
  lines.push('═'.repeat(60));
1156
- lines.push(' 免责声明: 以上分析仅基于技术面,不构成投资建议。');
1157
- lines.push(' 投资有风险,入市需谨慎。');
307
+ lines.push(' 免责声明: 以上分析仅基于技术面,不构成投资建议。');
308
+ lines.push(' 投资有风险,入市需谨慎。');
1158
309
  lines.push('═'.repeat(60));
1159
310
 
1160
311
  return lines.join('\n');
@@ -1173,44 +324,25 @@ function resolveCode(input) {
1173
324
 
1174
325
  async function main() {
1175
326
  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
- }
327
+ const codes = arg === 'all' ? Object.keys(WATCH_LIST) : [resolveCode(arg)];
1182
328
 
1183
329
  console.log(`\n正在获取数据并分析...\n`);
1184
330
 
331
+ // 拿一次大盘环境就够了(同一批分析共享)
332
+ const marketEnv = await getMarketEnvironment();
333
+
1185
334
  for (const code of codes) {
1186
335
  try {
1187
336
  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
- }
337
+ const [realtimeArr, klines] = isTW
338
+ ? await Promise.all([fetchTWRealtime(code), fetchTWHistory(code, 120)])
339
+ : await Promise.all([fetchRealtime([code]), fetchHistory(code, 120)]);
1201
340
 
1202
341
  const rt = realtimeArr.find(r => r.code === code) || realtimeArr[0];
1203
- if (!rt) {
1204
- console.log(`未找到 ${code} 的实时数据,跳过`);
1205
- continue;
1206
- }
342
+ if (!rt) { console.log(`未找到 ${code} 的实时数据,跳过`); continue; }
343
+ if (klines.length < 60) { console.log(`${code} 历史数据不足60天 (仅${klines.length}天),跳过`); continue; }
1207
344
 
1208
- if (klines.length < 60) {
1209
- console.log(`${code} 历史数据不足60天 (仅${klines.length}天),跳过`);
1210
- continue;
1211
- }
1212
-
1213
- const analysis = await analyzeStock(klines, rt);
345
+ const analysis = computeScore(klines, { marketEnv, realtime: rt });
1214
346
  console.log(formatReport(analysis));
1215
347
  console.log('');
1216
348
  } catch (e) {
@@ -1219,4 +351,11 @@ async function main() {
1219
351
  }
1220
352
  }
1221
353
 
1222
- main().catch(e => console.error('分析出错:', e.message));
354
+ if (require.main === module) {
355
+ main().catch(e => console.error('分析出错:', e.message));
356
+ }
357
+
358
+ module.exports = {
359
+ fetchRealtime, fetchHistory, fetchTWRealtime, fetchTWHistory,
360
+ getMarketEnvironment, resolveCode, formatReport, WATCH_LIST,
361
+ };