@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/server.js CHANGED
@@ -1,8 +1,10 @@
1
1
  /**
2
- * A股综合分析 Web 服务
3
- *
2
+ * A股/台股综合分析 Web 服务
3
+ *
4
4
  * 启动: node server.js
5
5
  * 访问: http://127.0.0.1:3000
6
+ *
7
+ * P0 改造:评分逻辑统一使用 scoring.js,与 CLI(analyze.js / backtest.js)完全一致
6
8
  */
7
9
 
8
10
  const http = require('http');
@@ -10,1417 +12,16 @@ const https = require('https');
10
12
  const fs = require('fs');
11
13
  const path = require('path');
12
14
 
13
- const PORT = 3000;
14
-
15
- // ==================== 数据获取 ====================
16
-
17
- function fetchRealtime(codes) {
18
- return new Promise((resolve, reject) => {
19
- const codesStr = codes.join(',');
20
- const options = {
21
- hostname: 'hq.sinajs.cn',
22
- path: `/list=${codesStr}`,
23
- headers: {
24
- 'Referer': 'http://finance.sina.com.cn',
25
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
26
- },
27
- };
28
- http.get(options, (res) => {
29
- const chunks = [];
30
- res.on('data', c => chunks.push(c));
31
- res.on('end', () => {
32
- const buf = Buffer.concat(chunks);
33
- const text = new TextDecoder('gbk').decode(buf);
34
- const results = [];
35
- for (const line of text.trim().split('\n')) {
36
- const match = line.match(/var hq_str_(\w+)="(.*)";?/);
37
- if (!match || !match[2]) continue;
38
- const fields = match[2].split(',');
39
- if (fields.length < 32) continue;
40
- const yesterdayClose = parseFloat(fields[2]);
41
- const price = parseFloat(fields[3]);
42
- results.push({
43
- code: match[1],
44
- name: fields[0],
45
- price,
46
- open: parseFloat(fields[1]),
47
- high: parseFloat(fields[4]),
48
- low: parseFloat(fields[5]),
49
- yesterdayClose,
50
- change: +(price - yesterdayClose).toFixed(2),
51
- changePct: +(((price - yesterdayClose) / yesterdayClose) * 100).toFixed(2),
52
- volume: Math.round(parseFloat(fields[8]) / 100),
53
- amount: +(parseFloat(fields[9]) / 10000).toFixed(2),
54
- time: `${fields[30]} ${fields[31]}`,
55
- });
56
- }
57
- resolve(results);
58
- });
59
- }).on('error', reject);
60
- });
61
- }
62
-
63
- function fetchHistory(code, days = 120) {
64
- return new Promise((resolve, reject) => {
65
- const url = `https://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param=${code},day,,,${days},qfq`;
66
- https.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (res) => {
67
- if (res.statusCode === 301 || res.statusCode === 302) {
68
- const client = res.headers.location.startsWith('https') ? https : http;
69
- client.get(res.headers.location, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (res2) => {
70
- collectRes(res2, code, resolve, reject);
71
- }).on('error', reject);
72
- return;
73
- }
74
- collectRes(res, code, resolve, reject);
75
- }).on('error', reject);
76
- });
77
- }
78
-
79
- function collectRes(res, code, resolve, reject) {
80
- const chunks = [];
81
- res.on('data', c => chunks.push(c));
82
- res.on('end', () => {
83
- try {
84
- const json = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
85
- if (!json.data || !json.data[code]) { resolve([]); return; }
86
- const klines = json.data[code].qfqday || json.data[code].day || [];
87
- resolve(klines.map(item => ({
88
- date: item[0],
89
- open: parseFloat(item[1]),
90
- close: parseFloat(item[2]),
91
- high: parseFloat(item[3]),
92
- low: parseFloat(item[4]),
93
- volume: parseInt(item[5]) || 0,
94
- })));
95
- } catch (e) { reject(e); }
96
- });
97
- }
98
-
99
- // ==================== 台股数据获取 ====================
100
-
101
- /** 台股实时行情 (TWSE MIS API) */
102
- function fetchTWRealtime(code) {
103
- return new Promise((resolve, reject) => {
104
- // code 格式: tw2330 -> tse_2330.tw (上市) 或 otc_6547.tw (上柜)
105
- const num = code.replace(/^tw/i, '');
106
- // 先尝试上市(tse),失败再试上柜(otc)
107
- const exCh = `tse_${num}.tw|otc_${num}.tw`;
108
- const url = `/stock/api/getStockInfo.jsp?ex_ch=${exCh}&_=${Date.now()}`;
109
- const options = {
110
- hostname: 'mis.twse.com.tw',
111
- path: url,
112
- headers: {
113
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
114
- 'Accept': 'application/json',
115
- },
116
- };
117
- https.get(options, (res) => {
118
- const chunks = [];
119
- res.on('data', c => chunks.push(c));
120
- res.on('end', () => {
121
- try {
122
- const json = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
123
- if (!json.msgArray || json.msgArray.length === 0) { resolve([]); return; }
124
- const results = [];
125
- for (const item of json.msgArray) {
126
- if (!item.z || item.z === '-') continue; // z=成交价
127
- const price = parseFloat(item.z);
128
- const yesterdayClose = parseFloat(item.y); // y=昨收
129
- results.push({
130
- code: `tw${item.c}`,
131
- name: item.n,
132
- price,
133
- open: parseFloat(item.o) || price,
134
- high: parseFloat(item.h) || price,
135
- low: parseFloat(item.l) || price,
136
- yesterdayClose,
137
- change: +(price - yesterdayClose).toFixed(2),
138
- changePct: yesterdayClose > 0 ? +(((price - yesterdayClose) / yesterdayClose) * 100).toFixed(2) : 0,
139
- volume: Math.round(parseInt(item.v) || 0),
140
- amount: 0,
141
- time: `${item.d} ${item.t || ''}`,
142
- });
143
- }
144
- resolve(results);
145
- } catch (e) { reject(e); }
146
- });
147
- }).on('error', reject);
148
- });
149
- }
150
-
151
- /** 台股历史K线 (Yahoo Finance API) */
152
- function fetchTWHistory(code, days = 120) {
153
- return new Promise((resolve, reject) => {
154
- const num = code.replace(/^tw/i, '');
155
- const symbol = `${num}.TW`;
156
- const period2 = Math.floor(Date.now() / 1000);
157
- const period1 = period2 - days * 24 * 60 * 60 * 1.5; // 多取一些以覆盖非交易日
158
- const url = `/v8/finance/chart/${symbol}?period1=${Math.floor(period1)}&period2=${period2}&interval=1d`;
159
- const options = {
160
- hostname: 'query1.finance.yahoo.com',
161
- path: url,
162
- headers: {
163
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
164
- },
165
- };
166
- https.get(options, (res) => {
167
- if (res.statusCode === 301 || res.statusCode === 302) {
168
- https.get(res.headers.location, { headers: options.headers }, (res2) => {
169
- collectTWHistory(res2, resolve, reject);
170
- }).on('error', reject);
171
- return;
172
- }
173
- collectTWHistory(res, resolve, reject);
174
- }).on('error', reject);
175
- });
176
- }
177
-
178
- function collectTWHistory(res, resolve, reject) {
179
- const chunks = [];
180
- res.on('data', c => chunks.push(c));
181
- res.on('end', () => {
182
- try {
183
- const json = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
184
- const result = json.chart && json.chart.result && json.chart.result[0];
185
- if (!result || !result.timestamp) { resolve([]); return; }
186
- const timestamps = result.timestamp;
187
- const quotes = result.indicators.quote[0];
188
- const klines = [];
189
- for (let i = 0; i < timestamps.length; i++) {
190
- if (quotes.close[i] === null) continue;
191
- const d = new Date(timestamps[i] * 1000);
192
- klines.push({
193
- date: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`,
194
- open: +(quotes.open[i] || 0).toFixed(2),
195
- close: +(quotes.close[i] || 0).toFixed(2),
196
- high: +(quotes.high[i] || 0).toFixed(2),
197
- low: +(quotes.low[i] || 0).toFixed(2),
198
- volume: Math.round((quotes.volume[i] || 0) / 1000), // 转为张
199
- });
200
- }
201
- resolve(klines);
202
- } catch (e) { reject(e); }
203
- });
204
- }
205
-
206
- // ==================== 技术指标 ====================
207
-
208
- function SMA(data, period) {
209
- const result = [];
210
- for (let i = 0; i < data.length; i++) {
211
- if (i < period - 1) { result.push(null); continue; }
212
- let sum = 0;
213
- for (let j = i - period + 1; j <= i; j++) sum += data[j];
214
- result.push(+(sum / period).toFixed(3));
215
- }
216
- return result;
217
- }
218
-
219
- function EMA(data, period) {
220
- const result = [];
221
- const k = 2 / (period + 1);
222
- for (let i = 0; i < data.length; i++) {
223
- if (i === 0) { result.push(data[0]); continue; }
224
- result.push(+(data[i] * k + result[i - 1] * (1 - k)).toFixed(3));
225
- }
226
- return result;
227
- }
228
-
229
- function MACD(closes) {
230
- const ema12 = EMA(closes, 12);
231
- const ema26 = EMA(closes, 26);
232
- const dif = ema12.map((v, i) => +(v - ema26[i]).toFixed(3));
233
- const dea = EMA(dif, 9);
234
- const histogram = dif.map((v, i) => +((v - dea[i]) * 2).toFixed(3));
235
- return { dif, dea, histogram };
236
- }
237
-
238
- function RSI(closes, period = 14) {
239
- const result = [];
240
- for (let i = 0; i < closes.length; i++) {
241
- if (i < period) { result.push(null); continue; }
242
- let gains = 0, losses = 0;
243
- for (let j = i - period + 1; j <= i; j++) {
244
- const diff = closes[j] - closes[j - 1];
245
- if (diff > 0) gains += diff; else losses -= diff;
246
- }
247
- const avgGain = gains / period;
248
- const avgLoss = losses / period;
249
- const rs = avgLoss === 0 ? 100 : avgGain / avgLoss;
250
- result.push(+(100 - 100 / (1 + rs)).toFixed(2));
251
- }
252
- return result;
253
- }
254
-
255
- function KDJ(highs, lows, closes, n = 9) {
256
- const K = [], D = [], J = [];
257
- let prevK = 50, prevD = 50;
258
- for (let i = 0; i < closes.length; i++) {
259
- if (i < n - 1) { K.push(null); D.push(null); J.push(null); continue; }
260
- let highN = -Infinity, lowN = Infinity;
261
- for (let j = i - n + 1; j <= i; j++) {
262
- highN = Math.max(highN, highs[j]);
263
- lowN = Math.min(lowN, lows[j]);
264
- }
265
- const rsv = highN === lowN ? 50 : ((closes[i] - lowN) / (highN - lowN)) * 100;
266
- const k = +(2 / 3 * prevK + 1 / 3 * rsv).toFixed(2);
267
- const d = +(2 / 3 * prevD + 1 / 3 * k).toFixed(2);
268
- const j = +(3 * k - 2 * d).toFixed(2);
269
- K.push(k); D.push(d); J.push(j);
270
- prevK = k; prevD = d;
271
- }
272
- return { K, D, J };
273
- }
274
-
275
- function BOLL(closes, period = 20, multiplier = 2) {
276
- const mid = SMA(closes, period);
277
- const upper = [], lower = [];
278
- for (let i = 0; i < closes.length; i++) {
279
- if (mid[i] === null) { upper.push(null); lower.push(null); continue; }
280
- let sum = 0;
281
- for (let j = i - period + 1; j <= i; j++) sum += (closes[j] - mid[i]) ** 2;
282
- const std = Math.sqrt(sum / period);
283
- upper.push(+(mid[i] + multiplier * std).toFixed(3));
284
- lower.push(+(mid[i] - multiplier * std).toFixed(3));
285
- }
286
- return { upper, mid, lower };
287
- }
288
-
289
- /** ATR 真实波动幅度 */
290
- function ATR(highs, lows, closes, period = 14) {
291
- const tr = [];
292
- for (let i = 0; i < closes.length; i++) {
293
- if (i === 0) { tr.push(highs[i] - lows[i]); continue; }
294
- const hl = highs[i] - lows[i];
295
- const hc = Math.abs(highs[i] - closes[i - 1]);
296
- const lc = Math.abs(lows[i] - closes[i - 1]);
297
- tr.push(Math.max(hl, hc, lc));
298
- }
299
- return EMA(tr, period);
300
- }
301
-
302
- /** ADX 趋势强度指标 */
303
- function ADX(highs, lows, closes, period = 14) {
304
- const plusDM = [], minusDM = [], tr = [];
305
- for (let i = 0; i < closes.length; i++) {
306
- if (i === 0) { plusDM.push(0); minusDM.push(0); tr.push(highs[i] - lows[i]); continue; }
307
- const upMove = highs[i] - highs[i - 1];
308
- const downMove = lows[i - 1] - lows[i];
309
- plusDM.push(upMove > downMove && upMove > 0 ? upMove : 0);
310
- minusDM.push(downMove > upMove && downMove > 0 ? downMove : 0);
311
- const hl = highs[i] - lows[i];
312
- const hc = Math.abs(highs[i] - closes[i - 1]);
313
- const lc = Math.abs(lows[i] - closes[i - 1]);
314
- tr.push(Math.max(hl, hc, lc));
315
- }
316
- const atr = EMA(tr, period);
317
- const smoothPlusDM = EMA(plusDM, period);
318
- const smoothMinusDM = EMA(minusDM, period);
319
-
320
- const plusDI = [], minusDI = [], dx = [];
321
- for (let i = 0; i < closes.length; i++) {
322
- const pdi = atr[i] > 0 ? (smoothPlusDM[i] / atr[i]) * 100 : 0;
323
- const mdi = atr[i] > 0 ? (smoothMinusDM[i] / atr[i]) * 100 : 0;
324
- plusDI.push(+pdi.toFixed(2));
325
- minusDI.push(+mdi.toFixed(2));
326
- const sum = pdi + mdi;
327
- dx.push(sum > 0 ? +((Math.abs(pdi - mdi) / sum) * 100).toFixed(2) : 0);
328
- }
329
- const adx = EMA(dx, period);
330
- return { plusDI, minusDI, adx };
331
- }
332
-
333
- /** 检测背离:价格创新高/低但指标未跟随 */
334
- function detectDivergence(closes, indicator, lookback = 20) {
335
- const n = closes.length;
336
- const last = n - 1;
337
- const result = { bullish: false, bearish: false, description: '' };
338
-
339
- // 寻找近期的两个高点/低点
340
- // 顶背离:价格创新高,指标未创新高
341
- let priceHighs = [], indicatorHighs = [];
342
- let priceLows = [], indicatorLows = [];
343
-
344
- for (let i = Math.max(0, last - lookback); i <= last; i++) {
345
- if (indicator[i] === null) continue;
346
- // 局部高点
347
- if (i > 0 && i < last && closes[i] > closes[i - 1] && closes[i] > closes[i + 1]) {
348
- priceHighs.push({ idx: i, price: closes[i], ind: indicator[i] });
349
- }
350
- // 局部低点
351
- if (i > 0 && i < last && closes[i] < closes[i - 1] && closes[i] < closes[i + 1]) {
352
- priceLows.push({ idx: i, price: closes[i], ind: indicator[i] });
353
- }
354
- }
355
-
356
- // 加入最后一天作为潜在高/低点
357
- if (indicator[last] !== null) {
358
- if (closes[last] >= closes[last - 1]) {
359
- priceHighs.push({ idx: last, price: closes[last], ind: indicator[last] });
360
- }
361
- if (closes[last] <= closes[last - 1]) {
362
- priceLows.push({ idx: last, price: closes[last], ind: indicator[last] });
363
- }
364
- }
365
-
366
- // 顶背离检测
367
- if (priceHighs.length >= 2) {
368
- const recent = priceHighs[priceHighs.length - 1];
369
- const prev = priceHighs[priceHighs.length - 2];
370
- if (recent.price > prev.price && recent.ind < prev.ind) {
371
- result.bearish = true;
372
- result.description = '顶背离:价格创新高但指标未跟随,上涨动能衰竭';
373
- }
374
- }
375
-
376
- // 底背离检测
377
- if (priceLows.length >= 2) {
378
- const recent = priceLows[priceLows.length - 1];
379
- const prev = priceLows[priceLows.length - 2];
380
- if (recent.price < prev.price && recent.ind > prev.ind) {
381
- result.bullish = true;
382
- result.description = '底背离:价格创新低但指标未跟随,下跌动能衰竭';
383
- }
384
- }
385
-
386
- return result;
387
- }
388
-
389
- /** 计算成交量加权均价 VWAP (近N日) */
390
- function calcVWAP(closes, volumes, period = 20) {
391
- const n = closes.length;
392
- let sumPV = 0, sumV = 0;
393
- const start = Math.max(0, n - period);
394
- for (let i = start; i < n; i++) {
395
- sumPV += closes[i] * volumes[i];
396
- sumV += volumes[i];
397
- }
398
- return sumV > 0 ? +(sumPV / sumV).toFixed(3) : closes[n - 1];
399
- }
400
-
401
- /** 识别关键形态 */
402
- function detectPatterns(klines, ma20, boll) {
403
- const n = klines.length;
404
- const last = n - 1;
405
- const patterns = [];
406
-
407
- // 缩量回踩MA20
408
- if (n >= 5 && ma20[last] !== null) {
409
- const vol5Avg = klines.slice(-5).reduce((s, k) => s + k.volume, 0) / 5;
410
- const vol20Avg = klines.slice(-20).reduce((s, k) => s + k.volume, 0) / 20;
411
- const nearMA20 = Math.abs(klines[last].close - ma20[last]) / ma20[last] < 0.02;
412
- const volShrink = vol5Avg < vol20Avg * 0.8;
413
- const priorUptrend = klines[last - 5] && klines[last].close > klines[Math.max(0, last - 20)].close;
414
-
415
- if (nearMA20 && volShrink && priorUptrend) {
416
- patterns.push({ type: 'bullish', name: '缩量回踩MA20', description: '上升趋势中缩量回踩均线支撑,经典买入形态', weight: 3 });
417
- }
418
- }
419
-
420
- // 放量突破前高
421
- if (n >= 20) {
422
- const prevHigh = Math.max(...klines.slice(-21, -1).map(k => k.high));
423
- const todayBreak = klines[last].close > prevHigh;
424
- const vol20Avg = klines.slice(-20).reduce((s, k) => s + k.volume, 0) / 20;
425
- const volExpand = klines[last].volume > vol20Avg * 1.5;
426
-
427
- if (todayBreak && volExpand) {
428
- patterns.push({ type: 'bullish', name: '放量突破前高', description: `突破近20日高点${prevHigh.toFixed(2)},成交量配合`, weight: 3 });
429
- }
430
- }
431
-
432
- // 假突破(突破后快速回落)
433
- if (n >= 3) {
434
- const prevHigh = Math.max(...klines.slice(-22, -2).map(k => k.high));
435
- const dayBefore = klines[last - 1];
436
- const today = klines[last];
437
- if (dayBefore.high > prevHigh && today.close < prevHigh * 0.98) {
438
- patterns.push({ type: 'bearish', name: '假突破回落', description: '昨日突破前高后今日快速回落,多头陷阱', weight: -3 });
439
- }
440
- }
441
-
442
- // 长下影线(锤子线)
443
- if (n >= 1) {
444
- const today = klines[last];
445
- const body = Math.abs(today.close - today.open);
446
- const lowerShadow = Math.min(today.open, today.close) - today.low;
447
- const upperShadow = today.high - Math.max(today.open, today.close);
448
- if (lowerShadow > body * 2 && upperShadow < body * 0.5 && body > 0) {
449
- patterns.push({ type: 'bullish', name: '锤子线', description: '长下影线,下方有较强买盘支撑', weight: 2 });
450
- }
451
- // 射击之星
452
- if (upperShadow > body * 2 && lowerShadow < body * 0.5 && body > 0) {
453
- patterns.push({ type: 'bearish', name: '射击之星', description: '长上影线,上方抛压沉重', weight: -2 });
454
- }
455
- }
456
-
457
- // 连续阳线(三连阳)
458
- if (n >= 3) {
459
- const last3 = klines.slice(-3);
460
- if (last3.every(k => k.close > k.open)) {
461
- const increasing = last3[2].close > last3[1].close && last3[1].close > last3[0].close;
462
- if (increasing) {
463
- patterns.push({ type: 'bullish', name: '三连阳', description: '连续三日收阳且逐步走高,多头力量持续', weight: 2 });
464
- }
465
- }
466
- // 三连阴
467
- if (last3.every(k => k.close < k.open)) {
468
- const decreasing = last3[2].close < last3[1].close && last3[1].close < last3[0].close;
469
- if (decreasing) {
470
- patterns.push({ type: 'bearish', name: '三连阴', description: '连续三日收阴且逐步走低,空头主导', weight: -2 });
471
- }
472
- }
473
- }
474
-
475
- // 布林带收窄后突破
476
- if (boll.upper[last] !== null && boll.upper[last - 5] !== null) {
477
- const bw_now = (boll.upper[last] - boll.lower[last]) / boll.mid[last];
478
- const bw_5ago = (boll.upper[last - 5] - boll.lower[last - 5]) / boll.mid[last - 5];
479
- if (bw_5ago < 0.08 && klines[last].close > boll.upper[last]) {
480
- patterns.push({ type: 'bullish', name: '布林收窄后向上突破', description: '波动率收缩后选择方向向上,可能开启新一轮上涨', weight: 3 });
481
- }
482
- if (bw_5ago < 0.08 && klines[last].close < boll.lower[last]) {
483
- patterns.push({ type: 'bearish', name: '布林收窄后向下突破', description: '波动率收缩后选择方向向下,可能开启下跌', weight: -3 });
484
- }
485
- }
486
-
487
- return patterns;
488
- }
489
-
490
- // ==================== 高级分析函数 ====================
491
-
492
- /** 动量衰竭检测:涨跌幅递减 + 量能递减 */
493
- function detectMomentumExhaustion(klines) {
494
- const n = klines.length;
495
- const last = n - 1;
496
- const result = { bullExhaustion: false, bearExhaustion: false, signals: [], score: 0 };
497
- if (n < 10) return result;
498
-
499
- // 上涨动量衰竭:连续上涨但涨幅递减 + 量能递减
500
- const recentUp = [];
501
- for (let i = last; i >= Math.max(0, last - 9); i--) {
502
- if (klines[i].close > klines[i].open) recentUp.unshift(i);
503
- else break;
504
- }
505
- if (recentUp.length >= 3) {
506
- const changes = recentUp.map(i => (klines[i].close - klines[i].open) / klines[i].open * 100);
507
- const vols = recentUp.map(i => klines[i].volume);
508
- const changeDeclining = changes[changes.length - 1] < changes[0] * 0.6;
509
- const volDeclining = vols[vols.length - 1] < vols[0] * 0.7;
510
- if (changeDeclining && volDeclining) {
511
- result.bullExhaustion = true;
512
- result.signals.push(`上涨动量衰竭:连涨${recentUp.length}天但涨幅递减(${changes[0].toFixed(1)}%→${changes[changes.length-1].toFixed(1)}%)且量能萎缩`);
513
- result.score -= 3;
514
- } else if (changeDeclining || volDeclining) {
515
- result.signals.push(`上涨动量减弱:${changeDeclining ? '涨幅递减' : '量能递减'},关注是否见顶`);
516
- result.score -= 1;
517
- }
518
- }
519
-
520
- // 下跌动量衰竭:连续下跌但跌幅递减 + 量能递减
521
- const recentDown = [];
522
- for (let i = last; i >= Math.max(0, last - 9); i--) {
523
- if (klines[i].close < klines[i].open) recentDown.unshift(i);
524
- else break;
525
- }
526
- if (recentDown.length >= 3) {
527
- const changes = recentDown.map(i => Math.abs((klines[i].close - klines[i].open) / klines[i].open * 100));
528
- const vols = recentDown.map(i => klines[i].volume);
529
- const changeDeclining = changes[changes.length - 1] < changes[0] * 0.6;
530
- const volDeclining = vols[vols.length - 1] < vols[0] * 0.7;
531
- if (changeDeclining && volDeclining) {
532
- result.bearExhaustion = true;
533
- result.signals.push(`下跌动量衰竭:连跌${recentDown.length}天但跌幅递减且缩量,空头力竭`);
534
- result.score += 3;
535
- } else if (changeDeclining || volDeclining) {
536
- result.signals.push(`下跌动量减弱:${changeDeclining ? '跌幅递减' : '量能递减'},可能接近底部`);
537
- result.score += 1;
538
- }
539
- }
540
-
541
- if (result.signals.length === 0) result.signals.push('动量正常,未检测到衰竭信号');
542
- return result;
543
- }
544
-
545
- /** 斐波那契回撤位计算 */
546
- function calcFibonacci(klines, lookback = 60) {
547
- const n = klines.length;
548
- const slice = klines.slice(Math.max(0, n - lookback));
549
- const highs = slice.map(k => k.high);
550
- const lows = slice.map(k => k.low);
551
- const highPrice = Math.max(...highs);
552
- const lowPrice = Math.min(...lows);
553
- const highIdx = highs.indexOf(highPrice);
554
- const lowIdx = lows.indexOf(lowPrice);
555
- const diff = highPrice - lowPrice;
556
-
557
- // 判断当前是从高点回撤还是从低点反弹
558
- const isDowntrend = highIdx < lowIdx; // 先见高后见低 = 下跌趋势
559
- const currentPrice = klines[n - 1].close;
560
-
561
- const levels = {};
562
- if (isDowntrend) {
563
- // 下跌趋势中的反弹目标位
564
- levels['0%(低点)'] = lowPrice;
565
- levels['23.6%回撤'] = +(lowPrice + diff * 0.236).toFixed(2);
566
- levels['38.2%回撤'] = +(lowPrice + diff * 0.382).toFixed(2);
567
- levels['50%回撤'] = +(lowPrice + diff * 0.5).toFixed(2);
568
- levels['61.8%回撤'] = +(lowPrice + diff * 0.618).toFixed(2);
569
- levels['100%(高点)'] = highPrice;
570
- } else {
571
- // 上涨趋势中的回调支撑位
572
- levels['100%(高点)'] = highPrice;
573
- levels['23.6%回撤'] = +(highPrice - diff * 0.236).toFixed(2);
574
- levels['38.2%回撤'] = +(highPrice - diff * 0.382).toFixed(2);
575
- levels['50%回撤'] = +(highPrice - diff * 0.5).toFixed(2);
576
- levels['61.8%回撤'] = +(highPrice - diff * 0.618).toFixed(2);
577
- levels['0%(低点)'] = lowPrice;
578
- }
579
-
580
- // 找到当前价格最接近的斐波那契位
581
- const allLevels = Object.entries(levels).map(([name, price]) => ({ name, price, dist: Math.abs(currentPrice - price) }));
582
- allLevels.sort((a, b) => a.dist - b.dist);
583
- const nearest = allLevels[0];
584
-
585
- return { levels, isDowntrend, highPrice, lowPrice, currentPrice, nearest, trend: isDowntrend ? '下跌回撤' : '上涨回调' };
586
- }
587
-
588
- /** 缺口分析 */
589
- function detectGaps(klines, lookback = 20) {
590
- const n = klines.length;
591
- const last = n - 1;
592
- const gaps = [];
593
-
594
- for (let i = Math.max(1, n - lookback); i <= last; i++) {
595
- const prev = klines[i - 1];
596
- const curr = klines[i];
597
- // 向上跳空:今日最低 > 昨日最高
598
- if (curr.low > prev.high) {
599
- const gapSize = +((curr.low - prev.high) / prev.close * 100).toFixed(2);
600
- gaps.push({ type: 'up', date: curr.date, bottom: prev.high, top: curr.low, size: gapSize, idx: i });
601
- }
602
- // 向下跳空:今日最高 < 昨日最低
603
- if (curr.high < prev.low) {
604
- const gapSize = +((prev.low - curr.high) / prev.close * 100).toFixed(2);
605
- gaps.push({ type: 'down', date: curr.date, bottom: curr.high, top: prev.low, size: gapSize, idx: i });
606
- }
607
- }
608
-
609
- // 分类缺口
610
- const signals = [];
611
- let score = 0;
612
- const vol20 = klines.slice(-20).reduce((s, k) => s + k.volume, 0) / 20;
613
-
614
- for (const gap of gaps) {
615
- const daysAgo = last - gap.idx;
616
- const gapVol = klines[gap.idx].volume;
617
- const isRecent = daysAgo <= 3;
618
-
619
- // 判断缺口是否已回补
620
- let filled = false;
621
- for (let j = gap.idx + 1; j <= last; j++) {
622
- if (gap.type === 'up' && klines[j].low <= gap.bottom) { filled = true; break; }
623
- if (gap.type === 'down' && klines[j].high >= gap.top) { filled = true; break; }
624
- }
625
-
626
- if (filled) continue; // 已回补的缺口不再有效
627
-
628
- if (gap.type === 'up') {
629
- if (gapVol > vol20 * 1.5 && gap.size > 1) {
630
- signals.push(`突破缺口(${gap.date}): ${gap.bottom}→${gap.top} (+${gap.size}%) 放量,支撑有效`);
631
- if (isRecent) score += 2;
632
- } else if (gap.size < 0.5) {
633
- signals.push(`普通上跳缺口(${gap.date}): ${gap.bottom}→${gap.top} 幅度小`);
634
- } else {
635
- signals.push(`上跳缺口(${gap.date}): ${gap.bottom}→${gap.top} (+${gap.size}%) 未回补,下方支撑`);
636
- if (isRecent) score += 1;
637
- }
638
- } else {
639
- if (gapVol > vol20 * 1.5 && gap.size > 1) {
640
- signals.push(`突破缺口(${gap.date}): ${gap.top}→${gap.bottom} (-${gap.size}%) 放量,压力有效`);
641
- if (isRecent) score -= 2;
642
- } else {
643
- signals.push(`下跳缺口(${gap.date}): ${gap.top}→${gap.bottom} (-${gap.size}%) 未回补,上方压力`);
644
- if (isRecent) score -= 1;
645
- }
646
- }
647
- }
648
-
649
- if (signals.length === 0) signals.push('近期无有效缺口');
650
- return { gaps: gaps.filter(g => !g.filled), signals, score };
651
- }
15
+ const {
16
+ fetchRealtime, fetchHistory, fetchTWRealtime, fetchTWHistory,
17
+ getMarketEnvironment,
18
+ } = require('./analyze');
19
+ const { computeScore } = require('./scoring');
20
+ const { backtest: runBacktest } = require('./backtest');
652
21
 
653
- /** 支撑压力强度评估 */
654
- function evaluateSupportResistance(klines, keyLevels) {
655
- const n = klines.length;
656
- const tolerance = 0.015; // 1.5%容差
657
- const results = [];
658
-
659
- for (const level of keyLevels) {
660
- let touchCount = 0;
661
- let totalVolAtTouch = 0;
662
- let bounceCount = 0;
663
- const vol20 = klines.slice(-20).reduce((s, k) => s + k.volume, 0) / 20;
664
-
665
- for (let i = Math.max(0, n - 60); i < n; i++) {
666
- const nearLevel = Math.abs(klines[i].low - level.value) / level.value < tolerance ||
667
- Math.abs(klines[i].high - level.value) / level.value < tolerance;
668
- if (nearLevel) {
669
- touchCount++;
670
- totalVolAtTouch += klines[i].volume;
671
- // 判断是否反弹
672
- if (i < n - 1 && klines[i + 1].close > klines[i].close) bounceCount++;
673
- }
674
- }
675
-
676
- const avgVolAtTouch = touchCount > 0 ? totalVolAtTouch / touchCount : 0;
677
- const volStrength = avgVolAtTouch > vol20 ? '强' : '一般';
678
- let reliability = '弱';
679
- if (touchCount >= 3 && bounceCount >= 2) reliability = '强';
680
- else if (touchCount >= 2) reliability = '中';
681
-
682
- results.push({
683
- ...level,
684
- touchCount,
685
- bounceCount,
686
- volStrength,
687
- reliability,
688
- description: `${level.label}: ${level.value} (触及${touchCount}次, 反弹${bounceCount}次, 量能${volStrength}, 可靠性:${reliability})`
689
- });
690
- }
691
-
692
- return results;
693
- }
694
-
695
- /** 回测用简化评分函数 */
696
- function backtestScore(klines, idx) {
697
- if (idx < 60) return 0;
698
- const slice = klines.slice(0, idx + 1);
699
- const closes = slice.map(k => k.close), highs = slice.map(k => k.high);
700
- const lows = slice.map(k => k.low), volumes = slice.map(k => k.volume);
701
- const n = closes.length, last = n - 1;
702
- const ma5 = SMA(closes, 5), ma10 = SMA(closes, 10), ma20 = SMA(closes, 20), ma60 = SMA(closes, 60);
703
- const macd = MACD(closes), rsi = RSI(closes, 14), adxData = ADX(highs, lows, closes);
704
- let score = 0;
705
- if (ma5[last] > ma10[last] && ma10[last] > ma20[last]) score += 2;
706
- else if (ma5[last] < ma10[last] && ma10[last] < ma20[last]) score -= 2;
707
- if (closes[last] > ma20[last]) score += 1; else score -= 1;
708
- if (ma5[last] > ma10[last] && ma5[last-1] <= ma10[last-1]) score += 2;
709
- else if (ma5[last] < ma10[last] && ma5[last-1] >= ma10[last-1]) score -= 2;
710
- if (macd.dif[last] > macd.dea[last] && macd.dif[last-1] <= macd.dea[last-1]) score += 3;
711
- else if (macd.dif[last] < macd.dea[last] && macd.dif[last-1] >= macd.dea[last-1]) score -= 3;
712
- if (macd.dif[last] > 0) score += 1; else score -= 1;
713
- if (macd.histogram[last] > macd.histogram[last-1]) score += 1; else score -= 1;
714
- const rv = rsi[last];
715
- if (rv !== null) { if (rv < 30) score += 2; else if (rv > 70) score -= 2; else if (rv >= 50) score += 1; else score -= 1; }
716
- const av = adxData.adx[last];
717
- if (av >= 25) { if (adxData.plusDI[last] > adxData.minusDI[last]) score += 2; else score -= 2; }
718
- const vol5 = volumes.slice(-5).reduce((a,b)=>a+b,0)/5;
719
- const vol20 = volumes.slice(-20).reduce((a,b)=>a+b,0)/20;
720
- const vol5Expand = vol5 > vol20 * 1.2, vol5Shrink = vol5 < vol20 * 0.7;
721
- if (vol5 > vol20 * 1.5) { if (closes[last] > closes[last-5]) score += 2; else score -= 2; }
722
- const t5 = (closes[last]-closes[Math.max(0,last-5)])/closes[Math.max(0,last-5)]*100;
723
- const t20 = (closes[last]-closes[Math.max(0,last-20)])/closes[Math.max(0,last-20)]*100;
724
- const t60 = ma60[last]!==null ? (closes[last]-closes[Math.max(0,last-60)])/closes[Math.max(0,last-60)]*100 : 0;
725
- if (t20 > 5) score += 2; else if (t20 < -5) score -= 2;
726
- const sB=t5>1,mB=t20>2,lB=t60>5,sR=t5<-1,mR=t20<-2,lR=t60<-5;
727
- if (sB&&mB&&lB) score += 3; else if (sR&&mR&&lR) score -= 3;
728
- else if ((sB&&mR)||(sR&&mB)) score = Math.round(score*0.8);
729
- if (av < 20) score = Math.round(score*0.6); else if (av < 15) score = Math.round(score*0.4);
730
- if (score > 5 && vol5Expand) score = Math.round(score*1.15);
731
- else if (score > 5 && vol5Shrink) score = Math.round(score*0.8);
732
- return score;
733
- }
734
-
735
- /** 获取大盘(上证指数)趋势状态 */
736
- async function getMarketEnvironment() {
737
- try {
738
- const klines = await fetchHistory('sh000001', 30);
739
- if (klines.length < 20) return { trend: 'neutral', score: 0, signals: ['大盘数据不足'] };
740
- const closes = klines.map(k => k.close);
741
- const n = closes.length;
742
- const ma5 = SMA(closes, 5);
743
- const ma20 = SMA(closes, 20);
744
- const trend20 = (closes[n - 1] - closes[Math.max(0, n - 20)]) / closes[Math.max(0, n - 20)] * 100;
745
-
746
- let trend = 'neutral', score = 0;
747
- const signals = [];
748
-
749
- if (ma5[n - 1] > ma20[n - 1] && trend20 > 2) {
750
- trend = 'bull';
751
- score = 1;
752
- signals.push(`上证指数偏强: 20日涨${trend20.toFixed(1)}%, MA5>MA20`);
753
- } else if (ma5[n - 1] < ma20[n - 1] && trend20 < -2) {
754
- trend = 'bear';
755
- score = -1;
756
- signals.push(`上证指数偏弱: 20日跌${trend20.toFixed(1)}%, MA5<MA20`);
757
- } else {
758
- signals.push(`上证指数震荡: 20日变化${trend20.toFixed(1)}%`);
759
- }
760
-
761
- return { trend, score, signals, indexPrice: closes[n - 1] };
762
- } catch (e) {
763
- return { trend: 'neutral', score: 0, signals: ['获取大盘数据失败'] };
764
- }
765
- }
766
-
767
- // ==================== 分析逻辑 ====================
768
-
769
- async function analyzeStock(klines, realtime) {
770
- const closes = klines.map(k => k.close);
771
- const highs = klines.map(k => k.high);
772
- const lows = klines.map(k => k.low);
773
- const volumes = klines.map(k => k.volume);
774
- const n = closes.length;
775
- const last = n - 1;
776
-
777
- // 获取大盘环境
778
- const marketEnv = await getMarketEnvironment();
779
-
780
- const ma5 = SMA(closes, 5);
781
- const ma10 = SMA(closes, 10);
782
- const ma20 = SMA(closes, 20);
783
- const ma60 = SMA(closes, 60);
784
- const macd = MACD(closes);
785
- const rsi = RSI(closes, 14);
786
- const kdj = KDJ(highs, lows, closes);
787
- const boll = BOLL(closes);
788
-
789
- // ===== 量能预计算(供后续信号确认使用)=====
790
- const vol5 = volumes.slice(-5).reduce((a, b) => a + b, 0) / 5;
791
- const vol20 = volumes.slice(-20).reduce((a, b) => a + b, 0) / 20;
792
- const volRatio = +(vol5 / vol20).toFixed(2);
793
- const todayVolExpand = volumes[last] > vol20 * 1.2; // 今日放量
794
- const vol5Expand = vol5 > vol20 * 1.2; // 近5日放量
795
- const vol5Shrink = vol5 < vol20 * 0.7; // 近5日缩量
796
-
797
- // ===== 趋势方向预判(供信号确认)=====
798
- const recent20 = closes.slice(-20);
799
- const trend20 = (recent20[recent20.length - 1] - recent20[0]) / recent20[0] * 100;
800
- const recent5 = closes.slice(-5);
801
- const trend5 = (recent5[recent5.length - 1] - recent5[0]) / recent5[0] * 100;
802
- const isBullTrend = trend20 > 3;
803
- const isBearTrend = trend20 < -3;
804
-
805
- // ===== 连续性辅助:计算信号已持续天数 =====
806
- function countConsecutiveDays(condFn, maxLookback = 10) {
807
- let count = 0;
808
- for (let i = last; i >= Math.max(0, last - maxLookback); i--) {
809
- if (condFn(i)) count++;
810
- else break;
811
- }
812
- return count;
813
- }
814
-
815
- // 信号新鲜度衰减:持续越久分数越低(第1天满分,之后逐步衰减)
816
- function freshnessMultiplier(days) {
817
- if (days <= 1) return 1.0; // 刚出现,满分
818
- if (days <= 3) return 0.7; // 持续2-3天,7折
819
- if (days <= 5) return 0.4; // 持续4-5天,4折(可能已经走了一段)
820
- return 0.2; // 超过5天,信号老化严重
821
- }
822
-
823
- // 均线
824
- const maSignals = [];
825
- let maScore = 0;
826
-
827
- // 多头/空头排列 + 连续性判断
828
- const bullAlign = ma5[last] > ma10[last] && ma10[last] > ma20[last];
829
- const bearAlign = ma5[last] < ma10[last] && ma10[last] < ma20[last];
830
- if (bullAlign) {
831
- const days = countConsecutiveDays(i => ma5[i] > ma10[i] && ma10[i] > ma20[i]);
832
- const mult = freshnessMultiplier(days);
833
- maSignals.push(`短期均线多头排列 (MA5>MA10>MA20, 已持续${days}天)`);
834
- maScore += +(2 * mult).toFixed(1);
835
- if (days > 5) maSignals.push(' ⚠ 多头排列已久,注意短期回调风险');
836
- } else if (bearAlign) {
837
- const days = countConsecutiveDays(i => ma5[i] < ma10[i] && ma10[i] < ma20[i]);
838
- const mult = freshnessMultiplier(days);
839
- maSignals.push(`短期均线空头排列 (MA5<MA10<MA20, 已持续${days}天)`);
840
- maScore -= +(2 * mult).toFixed(1);
841
- if (days > 5) maSignals.push(' ⚠ 空头排列已久,可能接近超卖');
842
- }
843
-
844
- if (closes[last] > ma20[last]) {
845
- maSignals.push(`收盘价在20日均线上方 (${closes[last]} > MA20=${ma20[last]})`);
846
- maScore += 1;
847
- } else {
848
- maSignals.push(`收盘价在20日均线下方 (${closes[last]} < MA20=${ma20[last]})`);
849
- maScore -= 1;
850
- }
851
-
852
- // MA金叉/死叉 + 多重确认
853
- if (ma5[last] > ma10[last] && ma5[last - 1] <= ma10[last - 1]) {
854
- let confirmCount = 0;
855
- let confirmDetails = [];
856
- if (todayVolExpand) { confirmCount++; confirmDetails.push('量能配合'); }
857
- if (isBullTrend) { confirmCount++; confirmDetails.push('趋势向上'); }
858
- if (closes[last] > ma20[last]) { confirmCount++; confirmDetails.push('站上MA20'); }
859
- if (confirmCount >= 2) {
860
- maSignals.push(`MA5上穿MA10 (金叉) ✓ 确认: ${confirmDetails.join('+')}`);
861
- maScore += 3;
862
- } else if (confirmCount === 1) {
863
- maSignals.push(`MA5上穿MA10 (金叉) △ 部分确认: ${confirmDetails.join('+')}`);
864
- maScore += 1.5;
865
- } else {
866
- maSignals.push('MA5上穿MA10 (金叉) ✗ 无量能/趋势确认,可靠性低');
867
- maScore += 0.5;
868
- }
869
- } else if (ma5[last] < ma10[last] && ma5[last - 1] >= ma10[last - 1]) {
870
- let confirmCount = 0;
871
- let confirmDetails = [];
872
- if (todayVolExpand) { confirmCount++; confirmDetails.push('放量下跌'); }
873
- if (isBearTrend) { confirmCount++; confirmDetails.push('趋势向下'); }
874
- if (closes[last] < ma20[last]) { confirmCount++; confirmDetails.push('跌破MA20'); }
875
- if (confirmCount >= 2) {
876
- maSignals.push(`MA5下穿MA10 (死叉) ✓ 确认: ${confirmDetails.join('+')}`);
877
- maScore -= 3;
878
- } else if (confirmCount === 1) {
879
- maSignals.push(`MA5下穿MA10 (死叉) △ 部分确认: ${confirmDetails.join('+')}`);
880
- maScore -= 1.5;
881
- } else {
882
- maSignals.push('MA5下穿MA10 (死叉) ✗ 无确认,可能是震荡假信号');
883
- maScore -= 0.5;
884
- }
885
- }
886
-
887
- // MACD + 多重确认 + 连续性
888
- const macdSignals = [];
889
- let macdScore = 0;
890
- const macdGoldenCross = macd.dif[last] > macd.dea[last] && macd.dif[last - 1] <= macd.dea[last - 1];
891
- const macdDeathCross = macd.dif[last] < macd.dea[last] && macd.dif[last - 1] >= macd.dea[last - 1];
892
-
893
- if (macdGoldenCross) {
894
- let confirmCount = 0;
895
- let details = [];
896
- if (todayVolExpand || vol5Expand) { confirmCount++; details.push('量能放大'); }
897
- if (isBullTrend) { confirmCount++; details.push('趋势配合'); }
898
- if (macd.dif[last] > -0.5) { confirmCount++; details.push('接近零轴'); } // 零轴附近金叉更强
899
- if (confirmCount >= 2) {
900
- macdSignals.push(`MACD金叉 ✓ 确认: ${details.join('+')}`);
901
- macdScore += 4;
902
- } else if (confirmCount === 1) {
903
- macdSignals.push(`MACD金叉 △ 部分确认: ${details.join('+')}`);
904
- macdScore += 2;
905
- } else {
906
- macdSignals.push('MACD金叉 ✗ 缺乏确认,信号偏弱');
907
- macdScore += 1;
908
- }
909
- } else if (macdDeathCross) {
910
- let confirmCount = 0;
911
- let details = [];
912
- if (todayVolExpand || vol5Expand) { confirmCount++; details.push('放量下跌'); }
913
- if (isBearTrend) { confirmCount++; details.push('趋势配合'); }
914
- if (macd.dif[last] < 0.5) { confirmCount++; details.push('零轴下方'); }
915
- if (confirmCount >= 2) {
916
- macdSignals.push(`MACD死叉 ✓ 确认: ${details.join('+')}`);
917
- macdScore -= 4;
918
- } else if (confirmCount === 1) {
919
- macdSignals.push(`MACD死叉 △ 部分确认: ${details.join('+')}`);
920
- macdScore -= 2;
921
- } else {
922
- macdSignals.push('MACD死叉 ✗ 缺乏确认,可能是假信号');
923
- macdScore -= 1;
924
- }
925
- }
926
-
927
- if (macd.dif[last] > 0 && macd.dea[last] > 0) {
928
- macdSignals.push('MACD在零轴上方 (多头市场)'); macdScore += 1;
929
- } else if (macd.dif[last] < 0 && macd.dea[last] < 0) {
930
- macdSignals.push('MACD在零轴下方 (空头市场)'); macdScore -= 1;
931
- }
932
- if (macd.histogram[last] > macd.histogram[last - 1]) {
933
- macdSignals.push('MACD柱状线放大 (动能增强)'); macdScore += 1;
934
- } else {
935
- macdSignals.push('MACD柱状线缩小 (动能减弱)'); macdScore -= 1;
936
- }
937
-
938
- // RSI + 连续性
939
- const rsiSignals = [];
940
- let rsiScore = 0;
941
- const rsiVal = rsi[last];
942
- if (rsiVal !== null) {
943
- if (rsiVal < 30) {
944
- const days = countConsecutiveDays(i => rsi[i] !== null && rsi[i] < 30);
945
- rsiSignals.push(`RSI=${rsiVal} 超卖区域 (已${days}天)`);
946
- rsiScore += days <= 2 ? 2 : 3; // 超卖越久反弹概率越大
947
- } else if (rsiVal > 70) {
948
- const days = countConsecutiveDays(i => rsi[i] !== null && rsi[i] > 70);
949
- rsiSignals.push(`RSI=${rsiVal} 超买区域 (已${days}天)`);
950
- // 超买初期可能还在涨,持续超买才危险
951
- rsiScore -= days <= 2 ? 1 : 2;
952
- if (days >= 3 && vol5Shrink) {
953
- rsiSignals.push(' ⚠ 超买+缩量,见顶概率增大');
954
- rsiScore -= 1;
955
- }
956
- } else if (rsiVal >= 50) { rsiSignals.push(`RSI=${rsiVal} 偏强区域`); rsiScore += 1; }
957
- else { rsiSignals.push(`RSI=${rsiVal} 偏弱区域`); rsiScore -= 1; }
958
- }
959
-
960
- // KDJ + 多重确认 + 连续性
961
- const kdjSignals = [];
962
- let kdjScore = 0;
963
- if (kdj.K[last] !== null) {
964
- const kdjGolden = kdj.K[last] > kdj.D[last] && kdj.K[last - 1] <= kdj.D[last - 1];
965
- const kdjDeath = kdj.K[last] < kdj.D[last] && kdj.K[last - 1] >= kdj.D[last - 1];
966
-
967
- if (kdjGolden) {
968
- const inOversold = kdj.J[last] < 30 || kdj.K[last] < 30;
969
- if (inOversold && todayVolExpand) {
970
- kdjSignals.push('KDJ金叉 ✓ 超卖区+放量确认,信号强');
971
- kdjScore += 3;
972
- } else if (inOversold || todayVolExpand) {
973
- kdjSignals.push('KDJ金叉 △ 部分确认');
974
- kdjScore += 2;
975
- } else {
976
- kdjSignals.push('KDJ金叉 (中位区,信号一般)');
977
- kdjScore += 1;
978
- }
979
- } else if (kdjDeath) {
980
- const inOverbought = kdj.J[last] > 70 || kdj.K[last] > 70;
981
- if (inOverbought && todayVolExpand) {
982
- kdjSignals.push('KDJ死叉 ✓ 超买区+放量确认,信号强');
983
- kdjScore -= 3;
984
- } else if (inOverbought || todayVolExpand) {
985
- kdjSignals.push('KDJ死叉 △ 部分确认');
986
- kdjScore -= 2;
987
- } else {
988
- kdjSignals.push('KDJ死叉 (中位区,信号一般)');
989
- kdjScore -= 1;
990
- }
991
- }
992
-
993
- if (kdj.J[last] < 20) {
994
- const days = countConsecutiveDays(i => kdj.J[i] !== null && kdj.J[i] < 20);
995
- kdjSignals.push(`J值=${kdj.J[last]} 超卖 (${days}天)`);
996
- kdjScore += days >= 3 ? 2 : 1;
997
- } else if (kdj.J[last] > 80) {
998
- const days = countConsecutiveDays(i => kdj.J[i] !== null && kdj.J[i] > 80);
999
- kdjSignals.push(`J值=${kdj.J[last]} 超买 (${days}天)`);
1000
- kdjScore -= days >= 3 ? 2 : 1;
1001
- }
1002
- kdjSignals.push(`K=${kdj.K[last]} D=${kdj.D[last]} J=${kdj.J[last]}`);
1003
- }
1004
-
1005
- // 布林带
1006
- const bollSignals = [];
1007
- let bollScore = 0;
1008
- if (boll.mid[last] !== null) {
1009
- const price = closes[last];
1010
- const bandwidth = ((boll.upper[last] - boll.lower[last]) / boll.mid[last] * 100).toFixed(2);
1011
- if (price >= boll.upper[last]) {
1012
- const days = countConsecutiveDays(i => closes[i] >= boll.upper[i]);
1013
- bollSignals.push(`触及布林带上轨 (${boll.upper[last]}),已${days}天`);
1014
- bollScore -= days >= 3 ? 2 : 1; // 持续触轨风险更大
1015
- } else if (price <= boll.lower[last]) {
1016
- const days = countConsecutiveDays(i => closes[i] <= boll.lower[i]);
1017
- bollSignals.push(`触及布林带下轨 (${boll.lower[last]}),已${days}天`);
1018
- bollScore += days >= 2 ? 2 : 1;
1019
- if (vol5Shrink) { bollSignals.push(' 缩量触下轨,反弹概率较大'); bollScore += 1; }
1020
- } else if (price > boll.mid[last]) { bollSignals.push('在布林带中轨上方运行'); bollScore += 1; }
1021
- else { bollSignals.push('在布林带中轨下方运行'); bollScore -= 1; }
1022
- bollSignals.push(`带宽=${bandwidth}%${bandwidth < 10 ? ' (收窄,可能变盘)' : ''}`);
1023
- }
1024
-
1025
- // 量价(保留原逻辑,增加连续性)
1026
- const volSignals = [];
1027
- let volScore = 0;
1028
- if (vol5 > vol20 * 1.5) {
1029
- volSignals.push(`近5日量能显著放大 (量比=${volRatio})`);
1030
- if (closes[last] > closes[last - 5]) {
1031
- const days = countConsecutiveDays(i => volumes[i] > vol20 * 1.2 && closes[i] > closes[i - 1]);
1032
- volSignals.push(`放量上涨 (连续${days}天),多头强势`);
1033
- volScore += days >= 3 ? 3 : 2;
1034
- } else {
1035
- volSignals.push('放量下跌,注意风险');
1036
- volScore -= 2;
1037
- }
1038
- } else if (vol5 < vol20 * 0.7) {
1039
- volSignals.push(`近5日量能萎缩 (量比=${volRatio})`);
1040
- if (closes[last] > closes[last - 5]) { volSignals.push('缩量上涨,持续性存疑'); }
1041
- else { volSignals.push('缩量回调,抛压减轻'); volScore += 1; }
1042
- } else {
1043
- volSignals.push(`量能平稳 (量比=${volRatio})`);
1044
- }
1045
-
1046
- // 趋势
1047
- const trendSignals = [];
1048
- let trendScore = 0;
1049
- if (trend20 > 5) { trendSignals.push(`20日趋势:上涨 (+${trend20.toFixed(2)}%)`); trendScore += 2; }
1050
- else if (trend20 < -5) { trendSignals.push(`20日趋势:下跌 (${trend20.toFixed(2)}%)`); trendScore -= 2; }
1051
- else { trendSignals.push(`20日趋势:震荡 (${trend20.toFixed(2)}%)`); }
1052
-
1053
- // 5日趋势 + 连续性:连涨/连跌天数
1054
- const upDays = countConsecutiveDays(i => i > 0 && closes[i] > closes[i - 1]);
1055
- const downDays = countConsecutiveDays(i => i > 0 && closes[i] < closes[i - 1]);
1056
- if (trend5 > 3) {
1057
- trendSignals.push(`5日短期趋势:强势上涨 (+${trend5.toFixed(2)}%, 连涨${upDays}天)`);
1058
- trendScore += upDays <= 3 ? 1 : 0; // 连涨超过3天不再加分,追高风险
1059
- if (upDays >= 5) { trendSignals.push(' ⚠ 连涨过久,短期回调概率增大'); trendScore -= 1; }
1060
- } else if (trend5 < -3) {
1061
- trendSignals.push(`5日短期趋势:快速下跌 (${trend5.toFixed(2)}%, 连跌${downDays}天)`);
1062
- trendScore -= downDays <= 3 ? 1 : 0;
1063
- if (downDays >= 5) { trendSignals.push(' ⚠ 连跌过久,超跌反弹概率增大'); trendScore += 1; }
1064
- }
1065
-
1066
- // --- ADX 趋势强度 ---
1067
- const adxData = ADX(highs, lows, closes);
1068
- const adxSignals = [];
1069
- let adxScore = 0;
1070
- const adxVal = +adxData.adx[last].toFixed(2);
1071
- const plusDI = adxData.plusDI[last];
1072
- const minusDI = adxData.minusDI[last];
1073
- let marketState = 'oscillating'; // trending / oscillating
1074
-
1075
- if (adxVal >= 25) {
1076
- marketState = 'trending';
1077
- if (plusDI > minusDI) {
1078
- adxSignals.push(`ADX=${adxVal} 强趋势上涨 (+DI=${plusDI.toFixed(1)} > -DI=${minusDI.toFixed(1)})`);
1079
- adxScore += 2;
1080
- } else {
1081
- adxSignals.push(`ADX=${adxVal} 强趋势下跌 (-DI=${minusDI.toFixed(1)} > +DI=${plusDI.toFixed(1)})`);
1082
- adxScore -= 2;
1083
- }
1084
- if (adxVal >= 40) {
1085
- adxSignals.push('趋势极强,顺势操作');
1086
- }
1087
- } else if (adxVal >= 20) {
1088
- adxSignals.push(`ADX=${adxVal} 弱趋势,方向不明确`);
1089
- } else {
1090
- marketState = 'oscillating';
1091
- adxSignals.push(`ADX=${adxVal} 震荡市,适合高抛低吸`);
1092
- }
1093
-
1094
- // --- ATR 波动率 + 仓位建议 ---
1095
- const atrData = ATR(highs, lows, closes);
1096
- const atrVal = +atrData[last].toFixed(3);
1097
- const atrPct = +(atrVal / closes[last] * 100).toFixed(2);
1098
- const atrSignals = [];
1099
-
1100
- // 仓位建议:波动越大仓位越小
1101
- let positionAdvice = '';
1102
- let suggestedStopLoss = 0;
1103
- if (atrPct > 5) {
1104
- positionAdvice = '波动极大,建议仓位不超过20%';
1105
- suggestedStopLoss = +(closes[last] - atrVal * 2).toFixed(2);
1106
- } else if (atrPct > 3) {
1107
- positionAdvice = '波动较大,建议仓位30-50%';
1108
- suggestedStopLoss = +(closes[last] - atrVal * 1.5).toFixed(2);
1109
- } else if (atrPct > 1.5) {
1110
- positionAdvice = '波动适中,建议仓位50-70%';
1111
- suggestedStopLoss = +(closes[last] - atrVal * 1.5).toFixed(2);
1112
- } else {
1113
- positionAdvice = '波动较小,可适当加大仓位至80%';
1114
- suggestedStopLoss = +(closes[last] - atrVal * 2).toFixed(2);
1115
- }
1116
- atrSignals.push(`ATR=${atrVal} (${atrPct}%)`);
1117
- atrSignals.push(positionAdvice);
1118
- atrSignals.push(`建议止损位: ${suggestedStopLoss} (基于ATR)`);
1119
-
1120
- // --- MACD 背离检测 ---
1121
- const macdDivergence = detectDivergence(closes, macd.dif, 20);
1122
- const divergenceSignals = [];
1123
- let divergenceScore = 0;
1124
- if (macdDivergence.bearish) {
1125
- divergenceSignals.push('MACD ' + macdDivergence.description);
1126
- divergenceScore -= 3;
1127
- }
1128
- if (macdDivergence.bullish) {
1129
- divergenceSignals.push('MACD ' + macdDivergence.description);
1130
- divergenceScore += 3;
1131
- }
1132
-
1133
- // --- RSI 背离检测 ---
1134
- const rsiDivergence = detectDivergence(closes, rsi, 20);
1135
- if (rsiDivergence.bearish) {
1136
- divergenceSignals.push('RSI ' + rsiDivergence.description);
1137
- divergenceScore -= 2;
1138
- }
1139
- if (rsiDivergence.bullish) {
1140
- divergenceSignals.push('RSI ' + rsiDivergence.description);
1141
- divergenceScore += 2;
1142
- }
1143
- if (divergenceSignals.length === 0) {
1144
- divergenceSignals.push('未检测到明显背离');
1145
- }
1146
-
1147
- // --- 量价背离检测 ---
1148
- const volDivSignals = [];
1149
- let volDivScore = 0;
1150
- // 价格上涨但量能萎缩 = 量价背离(看空)
1151
- if (closes[last] > closes[last - 5] && vol5 < vol20 * 0.7) {
1152
- volDivSignals.push('量价背离:价格上涨但量能萎缩,上涨动力不足');
1153
- volDivScore -= 2;
1154
- }
1155
- // 价格下跌但量能萎缩 = 惜售(看多)
1156
- if (closes[last] < closes[last - 5] && vol5 < vol20 * 0.6) {
1157
- volDivSignals.push('缩量下跌:抛压衰竭,可能见底');
1158
- volDivScore += 1;
1159
- }
1160
- // 价格横盘但量能放大 = 蓄势
1161
- if (Math.abs(trend5) < 2 && vol5 > vol20 * 1.3) {
1162
- volDivSignals.push('横盘放量:主力可能在吸筹或出货,关注方向选择');
1163
- }
1164
-
1165
- // --- 形态识别 ---
1166
- const patterns = detectPatterns(klines, ma20, boll);
1167
- let patternScore = 0;
1168
- const patternSignals = [];
1169
- for (const p of patterns) {
1170
- patternSignals.push(`[${p.type === 'bullish' ? '看多' : '看空'}] ${p.name}: ${p.description}`);
1171
- patternScore += p.weight;
1172
- }
1173
- if (patternSignals.length === 0) {
1174
- patternSignals.push('未识别到明显形态');
1175
- }
1176
-
1177
- // --- VWAP ---
1178
- const vwap = calcVWAP(closes, volumes, 20);
1179
-
1180
- // --- 动量衰竭检测 ---
1181
- const momentum = detectMomentumExhaustion(klines);
1182
-
1183
- // --- 斐波那契回撤 ---
1184
- const fib = calcFibonacci(klines, 60);
1185
- const fibSignals = [];
1186
- fibSignals.push(`当前趋势: ${fib.trend} (高点${fib.highPrice} → 低点${fib.lowPrice})`);
1187
- fibSignals.push(`最近斐波那契位: ${fib.nearest.name} = ${fib.nearest.price} (距当前价${((fib.nearest.price - fib.currentPrice) / fib.currentPrice * 100).toFixed(1)}%)`);
1188
-
1189
- // --- 缺口分析 ---
1190
- const gapAnalysis = detectGaps(klines, 20);
1191
-
1192
- // --- 支撑压力强度评估 ---
1193
- const supportResistance = [];
1194
- const high20 = Math.max(...highs.slice(-20));
1195
- const low20 = Math.min(...lows.slice(-20));
1196
- supportResistance.push({ label: '近20日压力位', value: high20 });
1197
- supportResistance.push({ label: '近20日支撑位', value: low20 });
1198
- if (ma20[last]) supportResistance.push({ label: 'MA20动态支撑/压力', value: ma20[last] });
1199
- if (boll.upper[last]) {
1200
- supportResistance.push({ label: '布林上轨压力', value: boll.upper[last] });
1201
- supportResistance.push({ label: '布林下轨支撑', value: boll.lower[last] });
1202
- }
1203
- // VWAP 作为支撑/压力
1204
- supportResistance.push({ label: 'VWAP(20日)', value: vwap });
1205
-
1206
- const srStrength = evaluateSupportResistance(klines, supportResistance);
1207
-
1208
- // ==================== 动态加权评分 ====================
1209
- // 趋势市:加大趋势类指标权重(MA、MACD、ADX)
1210
- // 震荡市:加大震荡类指标权重(RSI、KDJ、布林带)
1211
- let weightedScore = 0;
1212
- if (marketState === 'trending') {
1213
- weightedScore = maScore * 1.5 + macdScore * 1.3 + adxScore * 1.5
1214
- + rsiScore * 0.7 + kdjScore * 0.7 + bollScore * 0.8
1215
- + volScore * 1.0 + trendScore * 1.3
1216
- + divergenceScore * 1.2 + volDivScore + patternScore * 1.0
1217
- + momentum.score * 1.2 + gapAnalysis.score * 0.8;
1218
- } else {
1219
- weightedScore = maScore * 0.8 + macdScore * 0.8 + adxScore * 0.8
1220
- + rsiScore * 1.5 + kdjScore * 1.5 + bollScore * 1.5
1221
- + volScore * 1.0 + trendScore * 0.8
1222
- + divergenceScore * 1.3 + volDivScore + patternScore * 1.2
1223
- + momentum.score * 1.0 + gapAnalysis.score * 1.0;
1224
- }
1225
-
1226
- // === 优化1: 趋势一致性奖惩 ===
1227
- // 短期(5日)、中期(20日)、长期(60日)方向一致时加分,矛盾时减分
1228
- const trend60 = ma60[last] !== null ? (closes[last] - closes[Math.max(0, last - 60)]) / closes[Math.max(0, last - 60)] * 100 : 0;
1229
- const shortBull = trend5 > 1, midBull = trend20 > 2, longBull = trend60 > 5;
1230
- const shortBear = trend5 < -1, midBear = trend20 < -2, longBear = trend60 < -5;
1231
-
1232
- if (shortBull && midBull && longBull) {
1233
- weightedScore += 3; // 三周期共振看多,强加分
1234
- } else if (shortBear && midBear && longBear) {
1235
- weightedScore -= 3; // 三周期共振看空
1236
- } else if ((shortBull && midBear) || (shortBear && midBull)) {
1237
- // 短期和中期矛盾,信号可靠性降低
1238
- if (weightedScore > 0) weightedScore *= 0.8;
1239
- else if (weightedScore < 0) weightedScore *= 0.8;
1240
- }
1241
-
1242
- // === 优化2: ADX<20 震荡市惩罚 ===
1243
- // 回测显示震荡市信号胜率接近50%,不可靠
1244
- if (adxVal < 15) {
1245
- weightedScore *= 0.4; // 极度震荡,信号几乎无效
1246
- } else if (adxVal < 20 && Math.abs(weightedScore) > 0) {
1247
- weightedScore *= 0.6; // 震荡市所有信号打6折
1248
- }
1249
-
1250
- // === 优化3: 波动率过滤 ===
1251
- // 低波动股(ATR<1.5%)技术分析效果差,信号降权
1252
- if (atrPct < 1.5 && Math.abs(weightedScore) > 3) {
1253
- weightedScore *= 0.7;
1254
- }
1255
-
1256
- // === 优化4: 量能确认加成 ===
1257
- // 有量能配合的信号更可靠
1258
- if (weightedScore > 5 && vol5Expand) {
1259
- weightedScore *= 1.15; // 放量看多,加成15%
1260
- } else if (weightedScore < -5 && vol5Expand) {
1261
- weightedScore *= 1.15; // 放量看空,加成15%
1262
- } else if (weightedScore > 5 && vol5Shrink) {
1263
- weightedScore *= 0.8; // 缩量看多,打折
1264
- }
1265
-
1266
- // 大盘环境修正
1267
- if (marketEnv.trend === 'bull') {
1268
- weightedScore += 1.5;
1269
- } else if (marketEnv.trend === 'bear') {
1270
- weightedScore -= 1.5;
1271
- if (weightedScore > 0) weightedScore *= 0.7;
1272
- }
1273
-
1274
- const totalScore = +weightedScore.toFixed(1);
1275
-
1276
- // 风险收益比计算 - 基于实际支撑/压力位
1277
- // 止损:取最近的下方支撑位(MA20、布林下轨、近20日低点中最近的)
1278
- const currentPrice = closes[last];
1279
- const supportLevels = [suggestedStopLoss]; // ATR止损作为兜底
1280
- if (ma20[last] && ma20[last] < currentPrice) supportLevels.push(ma20[last]);
1281
- if (boll.lower[last] && boll.lower[last] < currentPrice) supportLevels.push(boll.lower[last]);
1282
- supportLevels.push(low20);
1283
- // 选最近的支撑位作为止损(距离最小但在下方)
1284
- const validSupports = supportLevels.filter(s => s < currentPrice).sort((a, b) => b - a);
1285
- const smartStopLoss = validSupports.length > 0 ? +validSupports[0].toFixed(2) : suggestedStopLoss;
1286
-
1287
- // 止盈:取上方有意义的压力位(排除距离太近<2%的)
1288
- const resistanceLevels = [];
1289
- if (high20 > currentPrice * 1.02) resistanceLevels.push(high20);
1290
- if (boll.upper[last] && boll.upper[last] > currentPrice * 1.02) resistanceLevels.push(boll.upper[last]);
1291
- // 斐波那契位作为目标
1292
- const fibLevels = Object.values(fib.levels).filter(v => v > currentPrice * 1.03);
1293
- if (fibLevels.length > 0) resistanceLevels.push(Math.min(...fibLevels));
1294
- // ATR目标作为补充(始终有效)
1295
- resistanceLevels.push(+(currentPrice + atrVal * 2).toFixed(2));
1296
- resistanceLevels.push(+(currentPrice + atrVal * 3).toFixed(2));
1297
- const validResistance = [...new Set(resistanceLevels.filter(r => r > currentPrice))].sort((a, b) => a - b);
1298
- const smartTP1 = validResistance.length > 0 ? +validResistance[0].toFixed(2) : +(currentPrice + atrVal * 2).toFixed(2);
1299
- const smartTP2 = validResistance.length > 1 ? +validResistance[1].toFixed(2) : +(currentPrice + atrVal * 3).toFixed(2);
1300
-
1301
- const riskAmt = currentPrice - smartStopLoss;
1302
- const rewardAmt = smartTP1 - currentPrice;
1303
- const riskRewardRatio = riskAmt > 0 ? +(rewardAmt / riskAmt).toFixed(2) : 99;
1304
-
1305
- const riskReward = {
1306
- currentPrice,
1307
- stopLoss: smartStopLoss,
1308
- takeProfit1: smartTP1,
1309
- takeProfit2: smartTP2,
1310
- riskRewardRatio,
1311
- riskPct: +(riskAmt / currentPrice * 100).toFixed(2),
1312
- reward1Pct: +(rewardAmt / currentPrice * 100).toFixed(2),
1313
- reward2Pct: +((smartTP2 - currentPrice) / currentPrice * 100).toFixed(2),
1314
- };
1315
-
1316
- let signal, signalClass, advice;
1317
- if (totalScore >= 10) { signal = '强烈买入'; signalClass = 'strong-buy'; advice = '多项指标共振看多,可考虑积极建仓'; }
1318
- else if (totalScore >= 5) { signal = '建议买入'; signalClass = 'buy'; advice = '技术面偏多,可适量买入或加仓'; }
1319
- else if (totalScore >= 1) { signal = '谨慎买入'; signalClass = 'weak-buy'; advice = '信号偏多但不强烈,可小仓位试探'; }
1320
- else if (totalScore >= -4) { signal = '观望'; signalClass = 'neutral'; advice = '多空信号交织,建议等待更明确的方向'; }
1321
- else if (totalScore >= -9) { signal = '建议卖出'; signalClass = 'sell'; advice = '技术面偏空,建议减仓或观望'; }
1322
- else { signal = '强烈卖出'; signalClass = 'strong-sell'; advice = '多项指标看空,建议清仓回避'; }
1323
-
1324
- // 根据风险收益比调整建议(只有真正偏低时才提示)
1325
- if (riskRewardRatio < 1.0 && totalScore > 0) {
1326
- advice += ';风险收益比不佳(<1:1),建议等回调后再入场';
1327
- } else if (riskRewardRatio >= 2.0 && totalScore > 0) {
1328
- advice += ';风险收益比优秀(1:' + riskRewardRatio + '),入场性价比高';
1329
- }
1330
-
1331
- // === 买入/卖出条件 - 基于当前价格位置给出可操作建议 ===
1332
- const buyConditions = [];
1333
- const sellConditions = [];
1334
- const distToMA20 = ma20[last] ? +((currentPrice - ma20[last]) / currentPrice * 100).toFixed(1) : 0;
1335
- const distToHigh20 = +((high20 - currentPrice) / currentPrice * 100).toFixed(1);
1336
- const distToBollUpper = boll.upper[last] ? +((boll.upper[last] - currentPrice) / currentPrice * 100).toFixed(1) : 0;
1337
-
1338
- if (totalScore >= 5) {
1339
- if (distToMA20 < 3) {
1340
- buyConditions.push({ condition: `当前价附近(${(currentPrice * 0.99).toFixed(2)}~${currentPrice.toFixed(2)})直接买入,MA20(${ma20[last]})支撑`, priority: '立即' });
1341
- } else {
1342
- const intraSupport = +(currentPrice - atrVal * 0.5).toFixed(2);
1343
- const ma5Support = ma5[last] ? +ma5[last].toFixed(2) : intraSupport;
1344
- buyConditions.push({ condition: `日内回调至${Math.max(intraSupport, ma5Support)}附近(MA5或半个ATR)轻仓试探`, priority: '首选' });
1345
- }
1346
- const batch1 = +(currentPrice * 0.98).toFixed(2);
1347
- const batch2 = +(currentPrice * 0.95).toFixed(2);
1348
- buyConditions.push({ condition: `分批建仓: ${currentPrice.toFixed(2)}(1/3仓), 回调${batch1}加仓(1/3), 再跌${batch2}补仓(1/3)`, priority: '稳健' });
1349
- } else if (totalScore >= 1) {
1350
- const entryPrice = +(currentPrice * 0.97).toFixed(2);
1351
- buyConditions.push({ condition: `回调2-3%至${entryPrice}附近,出现止跌信号(下影线/缩量)后买入`, priority: '首选' });
1352
- if (macd.dif[last] < macd.dea[last]) {
1353
- buyConditions.push({ condition: 'MACD金叉当日收盘确认后次日买入', priority: '等信号' });
1354
- }
1355
- } else {
1356
- if (boll.lower[last]) {
1357
- buyConditions.push({ condition: `跌至布林下轨${boll.lower[last].toFixed(2)}附近+RSI<30+缩量,可轻仓抄底`, priority: '激进' });
1358
- }
1359
- if (ma20[last] && currentPrice < ma20[last]) {
1360
- buyConditions.push({ condition: `放量站回MA20(${ma20[last].toFixed(2)})上方后买入`, priority: '等信号' });
1361
- }
1362
- }
1363
- if (distToHigh20 > 0 && distToHigh20 < 5) {
1364
- buyConditions.push({ condition: `放量(>昨日1.5倍)突破${high20.toFixed(2)}时跟进,不追超${(high20 * 1.03).toFixed(2)}`, priority: '突破' });
1365
- }
1366
-
1367
- sellConditions.push({ condition: `止损位: ${smartStopLoss} (亏损${riskReward.riskPct}%),跌破即走`, priority: '止损' });
1368
- if (rsiVal > 75) {
1369
- sellConditions.push({ condition: `RSI已${rsiVal}超买,冲高回落减半仓`, priority: '止盈' });
1370
- } else if (totalScore >= 5 && distToBollUpper < 1) {
1371
- sellConditions.push({ condition: '已触及布林上轨,短线可减仓1/3', priority: '止盈' });
1372
- }
1373
- if (ma20[last] && currentPrice > ma20[last]) {
1374
- sellConditions.push({ condition: `跌破MA5(${ma5[last] ? ma5[last].toFixed(2) : '-'})且次日不收回,先减仓`, priority: '减仓' });
1375
- sellConditions.push({ condition: `跌破MA20(${ma20[last].toFixed(2)})且3日不收回,清仓`, priority: '清仓' });
1376
- } else {
1377
- sellConditions.push({ condition: `继续下破${(currentPrice * 0.95).toFixed(2)}(再跌5%),止损离场`, priority: '止损' });
1378
- }
1379
- if (smartTP1 > currentPrice) {
1380
- sellConditions.push({ condition: `到达目标位${smartTP1}附近,分批止盈`, priority: '目标' });
1381
- }
1382
-
1383
- return {
1384
- realtime,
1385
- marketState: marketState === 'trending' ? '趋势市' : '震荡市',
1386
- marketEnv: { trend: marketEnv.trend, signals: marketEnv.signals, indexPrice: marketEnv.indexPrice },
1387
- indicators: {
1388
- ma: { score: maScore, signals: maSignals },
1389
- macd: { score: macdScore, signals: macdSignals, values: { dif: macd.dif[last], dea: macd.dea[last], histogram: macd.histogram[last] } },
1390
- rsi: { score: rsiScore, signals: rsiSignals, value: rsiVal },
1391
- kdj: { score: kdjScore, signals: kdjSignals, values: { K: kdj.K[last], D: kdj.D[last], J: kdj.J[last] } },
1392
- boll: { score: bollScore, signals: bollSignals, values: { upper: boll.upper[last], mid: boll.mid[last], lower: boll.lower[last] } },
1393
- volume: { score: volScore, signals: volSignals },
1394
- trend: { score: trendScore, signals: trendSignals },
1395
- adx: { score: adxScore, signals: adxSignals, values: { adx: adxVal, plusDI, minusDI } },
1396
- atr: { signals: atrSignals, values: { atr: atrVal, atrPct } },
1397
- divergence: { score: divergenceScore, signals: divergenceSignals },
1398
- volumeDivergence: { score: volDivScore, signals: volDivSignals },
1399
- patterns: { score: patternScore, signals: patternSignals },
1400
- momentum: { score: momentum.score, signals: momentum.signals },
1401
- gaps: { score: gapAnalysis.score, signals: gapAnalysis.signals },
1402
- fibonacci: { signals: fibSignals, levels: fib.levels },
1403
- },
1404
- supportResistance: srStrength,
1405
- riskReward,
1406
- buyConditions,
1407
- sellConditions,
1408
- summary: {
1409
- totalScore,
1410
- normalizedScore: Math.max(0, Math.min(100, +((totalScore + 20) / 40 * 100).toFixed(0))),
1411
- signal,
1412
- signalClass,
1413
- advice,
1414
- marketState: marketState === 'trending' ? '趋势市' : '震荡市',
1415
- marketEnv: marketEnv.trend === 'bull' ? '大盘偏强' : marketEnv.trend === 'bear' ? '大盘偏弱' : '大盘震荡',
1416
- positionAdvice,
1417
- breakdown: { ma: maScore, macd: macdScore, rsi: rsiScore, kdj: kdjScore, boll: bollScore, volume: volScore, trend: trendScore, adx: adxScore, divergence: divergenceScore, pattern: patternScore, volDiv: volDivScore, momentum: momentum.score, gaps: gapAnalysis.score },
1418
- },
1419
- klines: klines.slice(-30),
1420
- };
1421
- }
22
+ const PORT = 3000;
1422
23
 
1423
- // ==================== 股票搜索(名称/拼音 -> 代码) ====================
24
+ // ==================== 股票搜索(中文/拼音) ====================
1424
25
 
1425
26
  function searchStock(keyword) {
1426
27
  return new Promise((resolve, reject) => {
@@ -1445,71 +46,35 @@ function collectSearch(res, resolve, reject) {
1445
46
  res.on('end', () => {
1446
47
  try {
1447
48
  const text = Buffer.concat(chunks).toString('utf-8');
1448
- // 格式: v_hint="sh~600089~特变电工~tbdg~GP-A^..."
1449
49
  const match = text.match(/v_hint="(.*)"/);
1450
50
  if (!match || !match[1]) { resolve([]); return; }
1451
-
1452
51
  const items = match[1].split('^').filter(Boolean);
1453
52
  const results = [];
1454
53
  for (const item of items) {
1455
54
  const parts = item.split('~');
1456
55
  if (parts.length < 5) continue;
1457
- const market = parts[0]; // sh/sz
1458
- const num = parts[1]; // 600089
1459
- const name = parts[2]; // 特变电工
1460
- const type = parts[4]; // GP-A, GP-B, ZS, FJ, etc.
1461
-
1462
- // 只保留A股股票(GP-A)
1463
- if (type === 'GP-A') {
1464
- results.push({ code: `${market}${num}`, name, type });
1465
- }
56
+ const market = parts[0];
57
+ const num = parts[1];
58
+ const name = parts[2];
59
+ const type = parts[4];
60
+ if (type === 'GP-A') results.push({ code: `${market}${num}`, name, type });
1466
61
  }
1467
62
  resolve(results);
1468
63
  } catch (e) { reject(e); }
1469
64
  });
1470
65
  }
1471
66
 
1472
- /**
1473
- * 解析用户输入,支持:
1474
- * - 股票代码: sz002049, sh600089
1475
- * - 纯数字: 002049, 600089
1476
- * - 中文名称: 特变电工
1477
- * - 拼音缩写: tbdg
1478
- */
1479
67
  async function resolveStockCode(input) {
1480
68
  input = input.trim();
1481
-
1482
- // 台股:tw前缀
1483
- if (/^tw\d{4,6}$/i.test(input)) {
1484
- return input.toLowerCase();
1485
- }
1486
-
1487
- // 台股:纯4位数字(台股代码通常4位)
1488
- if (/^\d{4}$/.test(input)) {
1489
- return 'tw' + input;
1490
- }
1491
-
1492
- // A股:已经是标准代码格式
1493
- if (/^(sh|sz)\d{6}$/i.test(input)) {
1494
- return input.toLowerCase();
1495
- }
1496
-
1497
- // A股:纯6位数字,自动补前缀
1498
- if (/^\d{6}$/.test(input)) {
1499
- if (input.startsWith('6')) return 'sh' + input;
1500
- return 'sz' + input;
1501
- }
1502
-
1503
- // 其他情况(中文名、拼音等),走搜索
69
+ if (/^tw\d{4,6}$/i.test(input)) return input.toLowerCase();
70
+ if (/^\d{4}$/.test(input)) return 'tw' + input;
71
+ if (/^(sh|sz)\d{6}$/i.test(input)) return input.toLowerCase();
72
+ if (/^\d{6}$/.test(input)) return (input.startsWith('6') ? 'sh' : 'sz') + input;
1504
73
  const results = await searchStock(input);
1505
- if (results.length > 0) {
1506
- return results[0].code; // 返回第一个匹配的A股
1507
- }
1508
-
1509
- return null;
74
+ return results.length > 0 ? results[0].code : null;
1510
75
  }
1511
76
 
1512
- // ==================== HTTP 服务器 ====================
77
+ // ==================== HTTP 服务 ====================
1513
78
 
1514
79
  const server = http.createServer(async (req, res) => {
1515
80
  const url = new URL(req.url, `http://localhost:${PORT}`);
@@ -1533,7 +98,7 @@ const server = http.createServer(async (req, res) => {
1533
98
  return;
1534
99
  }
1535
100
 
1536
- // API: 回测
101
+ // API: 回测 — 使用与实盘一致的评分(scoring.computeQuickScore)
1537
102
  if (url.pathname === '/api/backtest') {
1538
103
  const input = url.searchParams.get('code');
1539
104
  if (!input) {
@@ -1552,36 +117,25 @@ const server = http.createServer(async (req, res) => {
1552
117
  const klines = isTW ? await fetchTWHistory(code, 500) : await fetchHistory(code, 500);
1553
118
  if (klines.length < 80) {
1554
119
  res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
1555
- res.end(JSON.stringify({ error: `${code} 历史数据不足,无法回测` }));
120
+ res.end(JSON.stringify({ error: `${code} 历史数据不足,无法回测` }));
1556
121
  return;
1557
122
  }
1558
123
 
1559
- // 简化版回测
1560
- const n = klines.length;
1561
- const holdDays = [3, 5, 10];
1562
- const results = {};
1563
- for (const days of holdDays) {
1564
- results[days] = { buyWins: 0, buyLosses: 0, buyReturns: [], sellWins: 0, sellLosses: 0, sellReturns: [] };
1565
- }
1566
- let buySignals = 0, sellSignals = 0, totalDays = 0;
1567
-
1568
- for (let i = 60; i < n; i++) {
1569
- const score = backtestScore(klines, i);
1570
- totalDays++;
1571
- const isBuy = score >= 5, isSell = score <= -5;
1572
- if (!isBuy && !isSell) continue;
1573
- if (isBuy) buySignals++;
1574
- if (isSell) sellSignals++;
1575
- for (const days of holdDays) {
1576
- const exitIdx = Math.min(i + days, n - 1);
1577
- if (exitIdx <= i) continue;
1578
- const ret = +((klines[exitIdx].close - klines[i].close) / klines[i].close * 100).toFixed(2);
1579
- if (isBuy) { results[days].buyReturns.push(ret); if (ret > 0) results[days].buyWins++; else results[days].buyLosses++; }
1580
- if (isSell) { const sr = -ret; results[days].sellReturns.push(sr); if (sr > 0) results[days].sellWins++; else results[days].sellLosses++; }
1581
- }
1582
- }
124
+ // 拉一次上证指数用于大盘环境过滤
125
+ let indexKlines = null;
126
+ try {
127
+ indexKlines = await fetchHistory('sh000001', 500);
128
+ if (indexKlines.length < 30) indexKlines = null;
129
+ } catch (e) { /* 忽略,降级为无大盘过滤 */ }
130
+
131
+ const trailing = url.searchParams.get('trailing') === '1';
132
+ const trailingATR = parseFloat(url.searchParams.get('trailing_atr')) || 2.5;
133
+ const useTP1 = url.searchParams.get('no_tp1') !== '1';
134
+ const btResult = runBacktest(klines, {
135
+ startIdx: 60, maxHoldDays: 30, indexKlines,
136
+ trailing, trailingATR, useTP1,
137
+ });
1583
138
 
1584
- // 获取股票名称
1585
139
  let name = code;
1586
140
  if (!isTW) {
1587
141
  const rtArr = await fetchRealtime([code]);
@@ -1591,31 +145,25 @@ const server = http.createServer(async (req, res) => {
1591
145
  if (rtArr.length > 0) name = rtArr[0].name;
1592
146
  }
1593
147
 
1594
- // 汇总
1595
- const summary = {};
1596
- for (const days of holdDays) {
1597
- const r = results[days];
1598
- const total = r.buyWins + r.buyLosses;
1599
- summary[days] = {
1600
- buyTotal: total,
1601
- winRate: total > 0 ? +((r.buyWins / total) * 100).toFixed(1) : 0,
1602
- avgReturn: total > 0 ? +(r.buyReturns.reduce((a, b) => a + b, 0) / total).toFixed(2) : 0,
1603
- maxWin: r.buyReturns.length > 0 ? Math.max(...r.buyReturns) : 0,
1604
- maxLoss: r.buyReturns.length > 0 ? Math.min(...r.buyReturns) : 0,
1605
- };
1606
- }
1607
-
148
+ // 评级
1608
149
  let grade = '较差';
1609
- const s3 = summary[3], s5 = summary[5], s10 = summary[10];
1610
- // 取三个周期中最佳表现来评级
1611
- const bestWinRate = Math.max(s3.winRate, s5.winRate, s10.winRate);
1612
- const bestAvgReturn = Math.max(s3.avgReturn, s5.avgReturn, s10.avgReturn);
1613
- if (bestWinRate >= 65 && bestAvgReturn > 2) grade = '优秀';
1614
- else if (bestWinRate >= 58 && bestAvgReturn > 1) grade = '良好';
1615
- else if (bestWinRate >= 55 || (bestWinRate >= 50 && bestAvgReturn > 0.5)) grade = '一般';
150
+ if (btResult.long) {
151
+ const { winRate, avgReturn } = btResult.long;
152
+ if (winRate >= 60 && avgReturn > 3) grade = '优秀';
153
+ else if (winRate >= 55 && avgReturn > 1.5) grade = '良好';
154
+ else if (winRate >= 50 || avgReturn > 0) grade = '一般';
155
+ }
1616
156
 
1617
157
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
1618
- res.end(JSON.stringify({ code, name, totalDays, buySignals, sellSignals, summary, grade, dataRange: `${klines[0].date} ~ ${klines[n-1].date}` }));
158
+ res.end(JSON.stringify({
159
+ code, name,
160
+ engine: 'event-driven-v1',
161
+ dataRange: btResult.dataRange,
162
+ long: btResult.long,
163
+ short: btResult.short,
164
+ trades: btResult.trades.slice(-20), // 最近20笔
165
+ grade,
166
+ }));
1619
167
  } catch (e) {
1620
168
  res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
1621
169
  res.end(JSON.stringify({ error: `回测出错: ${e.message}` }));
@@ -1631,44 +179,31 @@ const server = http.createServer(async (req, res) => {
1631
179
  res.end(JSON.stringify({ error: '请提供股票代码或名称' }));
1632
180
  return;
1633
181
  }
1634
-
1635
182
  try {
1636
- // 解析用户输入(支持名称、拼音、代码)
1637
183
  const code = await resolveStockCode(input);
1638
184
  if (!code) {
1639
185
  res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
1640
- res.end(JSON.stringify({ error: `未找到"${input}"对应的股票,请检查输入(A股6位/台股4位)` }));
186
+ res.end(JSON.stringify({ error: `未找到"${input}"对应的股票,请检查输入(A股6位/台股4位)` }));
1641
187
  return;
1642
188
  }
1643
-
1644
- // 根据市场选择数据源
1645
189
  const isTW = code.startsWith('tw');
1646
- let realtimeArr, klines;
1647
- if (isTW) {
1648
- [realtimeArr, klines] = await Promise.all([
1649
- fetchTWRealtime(code),
1650
- fetchTWHistory(code, 120),
1651
- ]);
1652
- } else {
1653
- [realtimeArr, klines] = await Promise.all([
1654
- fetchRealtime([code]),
1655
- fetchHistory(code, 120),
1656
- ]);
1657
- }
190
+ const [realtimeArr, klines] = isTW
191
+ ? await Promise.all([fetchTWRealtime(code), fetchTWHistory(code, 120)])
192
+ : await Promise.all([fetchRealtime([code]), fetchHistory(code, 120)]);
1658
193
 
1659
194
  if (!realtimeArr.length) {
1660
195
  res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
1661
196
  res.end(JSON.stringify({ error: `未找到股票 ${code} 的实时数据` }));
1662
197
  return;
1663
198
  }
1664
-
1665
199
  if (klines.length < 30) {
1666
200
  res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
1667
- res.end(JSON.stringify({ error: `${code} 历史数据不足,无法进行有效分析` }));
201
+ res.end(JSON.stringify({ error: `${code} 历史数据不足,无法进行有效分析` }));
1668
202
  return;
1669
203
  }
1670
204
 
1671
- const analysis = await analyzeStock(klines, realtimeArr[0]);
205
+ const marketEnv = await getMarketEnvironment();
206
+ const analysis = computeScore(klines, { marketEnv, realtime: realtimeArr[0] });
1672
207
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
1673
208
  res.end(JSON.stringify(analysis));
1674
209
  } catch (e) {
@@ -1691,7 +226,6 @@ const server = http.createServer(async (req, res) => {
1691
226
  res.end('Not Found');
1692
227
  });
1693
228
 
1694
- // 自动打开浏览器
1695
229
  function openBrowser(url) {
1696
230
  const { exec } = require('child_process');
1697
231
  const platform = process.platform;
@@ -1700,18 +234,17 @@ function openBrowser(url) {
1700
234
  else exec(`xdg-open ${url}`);
1701
235
  }
1702
236
 
1703
- // 端口占用自动重试
1704
237
  function startServer(port) {
1705
238
  server.listen(port, () => {
1706
239
  console.log(`\n${'='.repeat(50)}`);
1707
- console.log(` A股综合分析工具`);
240
+ console.log(` A股/台股综合分析工具`);
1708
241
  console.log(` 打开浏览器访问: http://127.0.0.1:${port}`);
1709
242
  console.log(`${'='.repeat(50)}\n`);
1710
243
  openBrowser(`http://127.0.0.1:${port}`);
1711
244
  });
1712
245
  server.on('error', (err) => {
1713
246
  if (err.code === 'EADDRINUSE') {
1714
- console.log(`端口 ${port} 被占用,尝试 ${port + 1}...`);
247
+ console.log(`端口 ${port} 被占用,尝试 ${port + 1}...`);
1715
248
  startServer(port + 1);
1716
249
  } else {
1717
250
  console.error('服务启动失败:', err.message);
@@ -1719,4 +252,8 @@ function startServer(port) {
1719
252
  });
1720
253
  }
1721
254
 
1722
- startServer(PORT);
255
+ if (require.main === module) {
256
+ startServer(PORT);
257
+ }
258
+
259
+ module.exports = { server, startServer, searchStock, resolveStockCode };