@kamuira/stock-analyzer 1.0.5 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +176 -189
- package/analyze.js +57 -918
- package/backtest.js +309 -352
- package/bin/analyze.js +0 -0
- package/bin/backtest.js +0 -0
- package/bin/server.js +0 -0
- package/dev.js +8 -1
- package/indicators.js +538 -0
- package/package.json +9 -3
- package/scoring.js +541 -0
- package/server.js +69 -1532
- package/stock.js +0 -181
package/scoring.js
ADDED
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 评分模块 - 唯一的评分逻辑来源
|
|
3
|
+
*
|
|
4
|
+
* 这个模块取代了原来三处重复的评分代码:
|
|
5
|
+
* - analyze.js analyzeStock()
|
|
6
|
+
* - server.js analyzeStock() + backtestScore()
|
|
7
|
+
* - backtest.js quickScore()
|
|
8
|
+
*
|
|
9
|
+
* 所有调用方都通过 computeScore() 拿到同一份评分结果,
|
|
10
|
+
* 这样回测验证的就是用户实际使用的策略。
|
|
11
|
+
*
|
|
12
|
+
* 这个模块是纯函数,无 IO。大盘环境由调用方注入。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const indicators = require('./indicators');
|
|
16
|
+
const {
|
|
17
|
+
SMA, EMA, MACD, RSI, KDJ, BOLL, ATR, ADX,
|
|
18
|
+
detectDivergence,
|
|
19
|
+
detectPatterns,
|
|
20
|
+
detectMomentumExhaustion,
|
|
21
|
+
calcFibonacci,
|
|
22
|
+
detectGaps,
|
|
23
|
+
} = indicators;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 计算单只股票的综合评分
|
|
27
|
+
*
|
|
28
|
+
* @param {Array} klines K 线数组(至少 60 天)
|
|
29
|
+
* @param {Object} options
|
|
30
|
+
* - marketEnv: { trend: 'bull'|'bear'|'neutral', score: number, signals: string[] }
|
|
31
|
+
* - realtime: 实时行情对象(可选,用于报告输出)
|
|
32
|
+
* @returns 完整分析对象
|
|
33
|
+
*/
|
|
34
|
+
function computeScore(klines, options = {}) {
|
|
35
|
+
const marketEnv = options.marketEnv || { trend: 'neutral', score: 0, signals: ['未提供大盘环境'] };
|
|
36
|
+
const realtime = options.realtime || null;
|
|
37
|
+
|
|
38
|
+
const closes = klines.map(k => k.close);
|
|
39
|
+
const highs = klines.map(k => k.high);
|
|
40
|
+
const lows = klines.map(k => k.low);
|
|
41
|
+
const volumes = klines.map(k => k.volume);
|
|
42
|
+
const n = closes.length;
|
|
43
|
+
const last = n - 1;
|
|
44
|
+
|
|
45
|
+
// ===== 指标计算 =====
|
|
46
|
+
const ma5 = SMA(closes, 5);
|
|
47
|
+
const ma10 = SMA(closes, 10);
|
|
48
|
+
const ma20 = SMA(closes, 20);
|
|
49
|
+
const ma60 = SMA(closes, 60);
|
|
50
|
+
const macd = MACD(closes);
|
|
51
|
+
const rsi = RSI(closes, 14);
|
|
52
|
+
const kdj = KDJ(highs, lows, closes);
|
|
53
|
+
const boll = BOLL(closes);
|
|
54
|
+
const atrArr = ATR(highs, lows, closes);
|
|
55
|
+
const adxData = ADX(highs, lows, closes);
|
|
56
|
+
|
|
57
|
+
// ===== 量能预计算 =====
|
|
58
|
+
const vol5 = volumes.slice(-5).reduce((a, b) => a + b, 0) / 5;
|
|
59
|
+
const vol20 = volumes.slice(-20).reduce((a, b) => a + b, 0) / 20;
|
|
60
|
+
const volRatio = +(vol5 / vol20).toFixed(2);
|
|
61
|
+
const todayVolExpand = volumes[last] > vol20 * 1.2;
|
|
62
|
+
const vol5Expand = vol5 > vol20 * 1.2;
|
|
63
|
+
const vol5Shrink = vol5 < vol20 * 0.7;
|
|
64
|
+
|
|
65
|
+
// ===== 趋势方向预判 =====
|
|
66
|
+
const recent20 = closes.slice(-20);
|
|
67
|
+
const trend20 = (recent20[recent20.length - 1] - recent20[0]) / recent20[0] * 100;
|
|
68
|
+
const recent5 = closes.slice(-5);
|
|
69
|
+
const trend5 = (recent5[recent5.length - 1] - recent5[0]) / recent5[0] * 100;
|
|
70
|
+
const isBullTrend = trend20 > 3;
|
|
71
|
+
const isBearTrend = trend20 < -3;
|
|
72
|
+
|
|
73
|
+
// ===== 连续性辅助 =====
|
|
74
|
+
function countConsecutiveDays(condFn, maxLookback = 10) {
|
|
75
|
+
let count = 0;
|
|
76
|
+
for (let i = last; i >= Math.max(0, last - maxLookback); i--) {
|
|
77
|
+
if (condFn(i)) count++; else break;
|
|
78
|
+
}
|
|
79
|
+
return count;
|
|
80
|
+
}
|
|
81
|
+
function freshnessMultiplier(days) {
|
|
82
|
+
if (days <= 1) return 1.0;
|
|
83
|
+
if (days <= 3) return 0.7;
|
|
84
|
+
if (days <= 5) return 0.4;
|
|
85
|
+
return 0.2;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// --- 均线 + 信号确认 + 连续性 ---
|
|
89
|
+
const maSignals = [];
|
|
90
|
+
let maScore = 0;
|
|
91
|
+
const bullAlign = ma5[last] > ma10[last] && ma10[last] > ma20[last];
|
|
92
|
+
const bearAlign = ma5[last] < ma10[last] && ma10[last] < ma20[last];
|
|
93
|
+
if (bullAlign) {
|
|
94
|
+
const days = countConsecutiveDays(i => ma5[i] > ma10[i] && ma10[i] > ma20[i]);
|
|
95
|
+
const mult = freshnessMultiplier(days);
|
|
96
|
+
maSignals.push(`短期均线多头排列 (MA5>MA10>MA20, 已持续${days}天)`);
|
|
97
|
+
maScore += +(2 * mult).toFixed(1);
|
|
98
|
+
if (days > 5) maSignals.push(' 注意: 多头排列已久,短期回调风险增大');
|
|
99
|
+
} else if (bearAlign) {
|
|
100
|
+
const days = countConsecutiveDays(i => ma5[i] < ma10[i] && ma10[i] < ma20[i]);
|
|
101
|
+
const mult = freshnessMultiplier(days);
|
|
102
|
+
maSignals.push(`短期均线空头排列 (MA5<MA10<MA20, 已持续${days}天)`);
|
|
103
|
+
maScore -= +(2 * mult).toFixed(1);
|
|
104
|
+
if (days > 5) maSignals.push(' 注意: 空头排列已久,可能接近超卖');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (closes[last] > ma20[last]) {
|
|
108
|
+
maSignals.push(`收盘价在20日均线上方 (${closes[last]} > MA20=${ma20[last]})`);
|
|
109
|
+
maScore += 1;
|
|
110
|
+
} else {
|
|
111
|
+
maSignals.push(`收盘价在20日均线下方 (${closes[last]} < MA20=${ma20[last]})`);
|
|
112
|
+
maScore -= 1;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (ma5[last] > ma10[last] && ma5[last - 1] <= ma10[last - 1]) {
|
|
116
|
+
let cc = 0, details = [];
|
|
117
|
+
if (todayVolExpand) { cc++; details.push('量能配合'); }
|
|
118
|
+
if (isBullTrend) { cc++; details.push('趋势向上'); }
|
|
119
|
+
if (closes[last] > ma20[last]) { cc++; details.push('站上MA20'); }
|
|
120
|
+
if (cc >= 2) { maSignals.push(`MA5上穿MA10 (金叉) 确认: ${details.join('+')}`); maScore += 3; }
|
|
121
|
+
else if (cc === 1) { maSignals.push(`MA5上穿MA10 (金叉) 部分确认: ${details.join('+')}`); maScore += 1.5; }
|
|
122
|
+
else { maSignals.push('MA5上穿MA10 (金叉) 无确认,可靠性低'); maScore += 0.5; }
|
|
123
|
+
} else if (ma5[last] < ma10[last] && ma5[last - 1] >= ma10[last - 1]) {
|
|
124
|
+
let cc = 0, details = [];
|
|
125
|
+
if (todayVolExpand) { cc++; details.push('放量下跌'); }
|
|
126
|
+
if (isBearTrend) { cc++; details.push('趋势向下'); }
|
|
127
|
+
if (closes[last] < ma20[last]) { cc++; details.push('跌破MA20'); }
|
|
128
|
+
if (cc >= 2) { maSignals.push(`MA5下穿MA10 (死叉) 确认: ${details.join('+')}`); maScore -= 3; }
|
|
129
|
+
else if (cc === 1) { maSignals.push(`MA5下穿MA10 (死叉) 部分确认: ${details.join('+')}`); maScore -= 1.5; }
|
|
130
|
+
else { maSignals.push('MA5下穿MA10 (死叉) 无确认,可能是假信号'); maScore -= 0.5; }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// --- MACD + 多重确认 ---
|
|
134
|
+
const macdSignals = [];
|
|
135
|
+
let macdScore = 0;
|
|
136
|
+
const macdGoldenCross = macd.dif[last] > macd.dea[last] && macd.dif[last - 1] <= macd.dea[last - 1];
|
|
137
|
+
const macdDeathCross = macd.dif[last] < macd.dea[last] && macd.dif[last - 1] >= macd.dea[last - 1];
|
|
138
|
+
|
|
139
|
+
if (macdGoldenCross) {
|
|
140
|
+
let cc = 0, details = [];
|
|
141
|
+
if (todayVolExpand || vol5Expand) { cc++; details.push('量能放大'); }
|
|
142
|
+
if (isBullTrend) { cc++; details.push('趋势配合'); }
|
|
143
|
+
if (macd.dif[last] > -0.5) { cc++; details.push('接近零轴'); }
|
|
144
|
+
if (cc >= 2) { macdSignals.push(`MACD金叉 确认: ${details.join('+')}`); macdScore += 4; }
|
|
145
|
+
else if (cc === 1) { macdSignals.push(`MACD金叉 部分确认: ${details.join('+')}`); macdScore += 2; }
|
|
146
|
+
else { macdSignals.push('MACD金叉 缺乏确认,信号偏弱'); macdScore += 1; }
|
|
147
|
+
} else if (macdDeathCross) {
|
|
148
|
+
let cc = 0, details = [];
|
|
149
|
+
if (todayVolExpand || vol5Expand) { cc++; details.push('放量下跌'); }
|
|
150
|
+
if (isBearTrend) { cc++; details.push('趋势配合'); }
|
|
151
|
+
if (macd.dif[last] < 0.5) { cc++; details.push('零轴下方'); }
|
|
152
|
+
if (cc >= 2) { macdSignals.push(`MACD死叉 确认: ${details.join('+')}`); macdScore -= 4; }
|
|
153
|
+
else if (cc === 1) { macdSignals.push(`MACD死叉 部分确认: ${details.join('+')}`); macdScore -= 2; }
|
|
154
|
+
else { macdSignals.push('MACD死叉 缺乏确认,可能是假信号'); macdScore -= 1; }
|
|
155
|
+
}
|
|
156
|
+
if (macd.dif[last] > 0 && macd.dea[last] > 0) { macdSignals.push('MACD在零轴上方 (多头市场)'); macdScore += 1; }
|
|
157
|
+
else if (macd.dif[last] < 0 && macd.dea[last] < 0) { macdSignals.push('MACD在零轴下方 (空头市场)'); macdScore -= 1; }
|
|
158
|
+
if (macd.histogram[last] > macd.histogram[last - 1]) { macdSignals.push('MACD柱状线放大 (动能增强)'); macdScore += 1; }
|
|
159
|
+
else { macdSignals.push('MACD柱状线缩小 (动能减弱)'); macdScore -= 1; }
|
|
160
|
+
|
|
161
|
+
// --- RSI + 连续性 ---
|
|
162
|
+
const rsiSignals = [];
|
|
163
|
+
let rsiScore = 0;
|
|
164
|
+
const rsiVal = rsi[last];
|
|
165
|
+
if (rsiVal !== null) {
|
|
166
|
+
if (rsiVal < 30) {
|
|
167
|
+
const days = countConsecutiveDays(i => rsi[i] !== null && rsi[i] < 30);
|
|
168
|
+
rsiSignals.push(`RSI=${rsiVal} 超卖区域 (已${days}天)`);
|
|
169
|
+
rsiScore += days <= 2 ? 2 : 3;
|
|
170
|
+
} else if (rsiVal > 70) {
|
|
171
|
+
const days = countConsecutiveDays(i => rsi[i] !== null && rsi[i] > 70);
|
|
172
|
+
rsiSignals.push(`RSI=${rsiVal} 超买区域 (已${days}天)`);
|
|
173
|
+
rsiScore -= days <= 2 ? 1 : 2;
|
|
174
|
+
if (days >= 3 && vol5Shrink) { rsiSignals.push(' 超买+缩量,见顶概率增大'); rsiScore -= 1; }
|
|
175
|
+
} else if (rsiVal >= 50) { rsiSignals.push(`RSI=${rsiVal} 偏强区域`); rsiScore += 1; }
|
|
176
|
+
else { rsiSignals.push(`RSI=${rsiVal} 偏弱区域`); rsiScore -= 1; }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// --- KDJ + 多重确认 ---
|
|
180
|
+
const kdjSignals = [];
|
|
181
|
+
let kdjScore = 0;
|
|
182
|
+
if (kdj.K[last] !== null) {
|
|
183
|
+
const kdjGolden = kdj.K[last] > kdj.D[last] && kdj.K[last - 1] <= kdj.D[last - 1];
|
|
184
|
+
const kdjDeath = kdj.K[last] < kdj.D[last] && kdj.K[last - 1] >= kdj.D[last - 1];
|
|
185
|
+
if (kdjGolden) {
|
|
186
|
+
const inOversold = kdj.J[last] < 30 || kdj.K[last] < 30;
|
|
187
|
+
if (inOversold && todayVolExpand) { kdjSignals.push('KDJ金叉 超卖区+放量确认,信号强'); kdjScore += 3; }
|
|
188
|
+
else if (inOversold || todayVolExpand) { kdjSignals.push('KDJ金叉 部分确认'); kdjScore += 2; }
|
|
189
|
+
else { kdjSignals.push('KDJ金叉 (中位区,信号一般)'); kdjScore += 1; }
|
|
190
|
+
} else if (kdjDeath) {
|
|
191
|
+
const inOverbought = kdj.J[last] > 70 || kdj.K[last] > 70;
|
|
192
|
+
if (inOverbought && todayVolExpand) { kdjSignals.push('KDJ死叉 超买区+放量确认,信号强'); kdjScore -= 3; }
|
|
193
|
+
else if (inOverbought || todayVolExpand) { kdjSignals.push('KDJ死叉 部分确认'); kdjScore -= 2; }
|
|
194
|
+
else { kdjSignals.push('KDJ死叉 (中位区,信号一般)'); kdjScore -= 1; }
|
|
195
|
+
}
|
|
196
|
+
if (kdj.J[last] < 20) {
|
|
197
|
+
const days = countConsecutiveDays(i => kdj.J[i] !== null && kdj.J[i] < 20);
|
|
198
|
+
kdjSignals.push(`J值=${kdj.J[last]} 超卖 (${days}天)`); kdjScore += days >= 3 ? 2 : 1;
|
|
199
|
+
} else if (kdj.J[last] > 80) {
|
|
200
|
+
const days = countConsecutiveDays(i => kdj.J[i] !== null && kdj.J[i] > 80);
|
|
201
|
+
kdjSignals.push(`J值=${kdj.J[last]} 超买 (${days}天)`); kdjScore -= days >= 3 ? 2 : 1;
|
|
202
|
+
}
|
|
203
|
+
kdjSignals.push(`K=${kdj.K[last]} D=${kdj.D[last]} J=${kdj.J[last]}`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// --- 布林带 + 连续性 ---
|
|
207
|
+
const bollSignals = [];
|
|
208
|
+
let bollScore = 0;
|
|
209
|
+
if (boll.mid[last] !== null) {
|
|
210
|
+
const price = closes[last];
|
|
211
|
+
const bandwidth = ((boll.upper[last] - boll.lower[last]) / boll.mid[last] * 100).toFixed(2);
|
|
212
|
+
if (price >= boll.upper[last]) {
|
|
213
|
+
const days = countConsecutiveDays(i => boll.upper[i] !== null && closes[i] >= boll.upper[i]);
|
|
214
|
+
bollSignals.push(`触及布林带上轨 (${boll.upper[last]}),已${days}天`);
|
|
215
|
+
bollScore -= days >= 3 ? 2 : 1;
|
|
216
|
+
} else if (price <= boll.lower[last]) {
|
|
217
|
+
const days = countConsecutiveDays(i => boll.lower[i] !== null && closes[i] <= boll.lower[i]);
|
|
218
|
+
bollSignals.push(`触及布林带下轨 (${boll.lower[last]}),已${days}天`);
|
|
219
|
+
bollScore += days >= 2 ? 2 : 1;
|
|
220
|
+
if (vol5Shrink) { bollSignals.push(' 缩量触下轨,反弹概率较大'); bollScore += 1; }
|
|
221
|
+
} else if (price > boll.mid[last]) { bollSignals.push('在布林带中轨上方运行'); bollScore += 1; }
|
|
222
|
+
else { bollSignals.push('在布林带中轨下方运行'); bollScore -= 1; }
|
|
223
|
+
bollSignals.push(`带宽=${bandwidth}%${bandwidth < 10 ? ' (收窄,可能变盘)' : ''}`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// --- 量价 + 连续性 ---
|
|
227
|
+
const volSignals = [];
|
|
228
|
+
let volScore = 0;
|
|
229
|
+
if (vol5 > vol20 * 1.5) {
|
|
230
|
+
volSignals.push(`近5日量能显著放大 (量比=${volRatio})`);
|
|
231
|
+
if (closes[last] > closes[last - 5]) {
|
|
232
|
+
const days = countConsecutiveDays(i => volumes[i] > vol20 * 1.2 && i > 0 && closes[i] > closes[i - 1]);
|
|
233
|
+
volSignals.push(`放量上涨 (连续${days}天),多头强势`);
|
|
234
|
+
volScore += days >= 3 ? 3 : 2;
|
|
235
|
+
} else { volSignals.push('放量下跌,注意风险'); volScore -= 2; }
|
|
236
|
+
} else if (vol5 < vol20 * 0.7) {
|
|
237
|
+
volSignals.push(`近5日量能萎缩 (量比=${volRatio})`);
|
|
238
|
+
if (closes[last] > closes[last - 5]) { volSignals.push('缩量上涨,持续性存疑'); }
|
|
239
|
+
else { volSignals.push('缩量回调,抛压减轻'); volScore += 1; }
|
|
240
|
+
} else { volSignals.push(`量能平稳 (量比=${volRatio})`); }
|
|
241
|
+
|
|
242
|
+
// --- 趋势 + 连续性 ---
|
|
243
|
+
const trendSignals = [];
|
|
244
|
+
let trendScore = 0;
|
|
245
|
+
if (trend20 > 5) { trendSignals.push(`20日趋势:上涨 (+${trend20.toFixed(2)}%)`); trendScore += 2; }
|
|
246
|
+
else if (trend20 < -5) { trendSignals.push(`20日趋势:下跌 (${trend20.toFixed(2)}%)`); trendScore -= 2; }
|
|
247
|
+
else { trendSignals.push(`20日趋势:震荡 (${trend20.toFixed(2)}%)`); }
|
|
248
|
+
|
|
249
|
+
const upDays = countConsecutiveDays(i => i > 0 && closes[i] > closes[i - 1]);
|
|
250
|
+
const downDays = countConsecutiveDays(i => i > 0 && closes[i] < closes[i - 1]);
|
|
251
|
+
if (trend5 > 3) {
|
|
252
|
+
trendSignals.push(`5日短期趋势:强势上涨 (+${trend5.toFixed(2)}%, 连涨${upDays}天)`);
|
|
253
|
+
trendScore += upDays <= 3 ? 1 : 0;
|
|
254
|
+
if (upDays >= 5) { trendSignals.push(' 连涨过久,短期回调概率增大'); trendScore -= 1; }
|
|
255
|
+
} else if (trend5 < -3) {
|
|
256
|
+
trendSignals.push(`5日短期趋势:快速下跌 (${trend5.toFixed(2)}%, 连跌${downDays}天)`);
|
|
257
|
+
trendScore -= downDays <= 3 ? 1 : 0;
|
|
258
|
+
if (downDays >= 5) { trendSignals.push(' 连跌过久,超跌反弹概率增大'); trendScore += 1; }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// --- ADX 趋势强度 ---
|
|
262
|
+
const adxSignals = [];
|
|
263
|
+
let adxScore = 0;
|
|
264
|
+
const adxVal = +adxData.adx[last].toFixed(2);
|
|
265
|
+
const plusDI = adxData.plusDI[last];
|
|
266
|
+
const minusDI = adxData.minusDI[last];
|
|
267
|
+
let marketState = 'oscillating';
|
|
268
|
+
if (adxVal >= 25) {
|
|
269
|
+
marketState = 'trending';
|
|
270
|
+
if (plusDI > minusDI) {
|
|
271
|
+
adxSignals.push(`ADX=${adxVal} 强趋势上涨 (+DI=${plusDI.toFixed(1)} > -DI=${minusDI.toFixed(1)})`);
|
|
272
|
+
adxScore += 2;
|
|
273
|
+
} else {
|
|
274
|
+
adxSignals.push(`ADX=${adxVal} 强趋势下跌 (-DI=${minusDI.toFixed(1)} > +DI=${plusDI.toFixed(1)})`);
|
|
275
|
+
adxScore -= 2;
|
|
276
|
+
}
|
|
277
|
+
if (adxVal >= 40) adxSignals.push('趋势极强,顺势操作');
|
|
278
|
+
} else if (adxVal >= 20) {
|
|
279
|
+
adxSignals.push(`ADX=${adxVal} 弱趋势,方向不明确`);
|
|
280
|
+
} else {
|
|
281
|
+
adxSignals.push(`ADX=${adxVal} 震荡市,适合高抛低吸`);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// --- ATR 波动率 + 仓位建议 ---
|
|
285
|
+
const atrVal = +atrArr[last].toFixed(3);
|
|
286
|
+
const atrPct = +(atrVal / closes[last] * 100).toFixed(2);
|
|
287
|
+
const atrSignals = [];
|
|
288
|
+
let positionAdvice = '', suggestedStopLoss = 0;
|
|
289
|
+
if (atrPct > 5) { positionAdvice = '波动极大,建议仓位<=20%'; suggestedStopLoss = +(closes[last] - atrVal * 2).toFixed(2); }
|
|
290
|
+
else if (atrPct > 3) { positionAdvice = '波动较大,建议仓位30-50%'; suggestedStopLoss = +(closes[last] - atrVal * 1.5).toFixed(2); }
|
|
291
|
+
else if (atrPct > 1.5) { positionAdvice = '波动适中,建议仓位50-70%'; suggestedStopLoss = +(closes[last] - atrVal * 1.5).toFixed(2); }
|
|
292
|
+
else { positionAdvice = '波动较小,可加大仓位至80%'; suggestedStopLoss = +(closes[last] - atrVal * 2).toFixed(2); }
|
|
293
|
+
atrSignals.push(`ATR=${atrVal} (${atrPct}%)`);
|
|
294
|
+
atrSignals.push(positionAdvice);
|
|
295
|
+
|
|
296
|
+
// --- 背离 ---
|
|
297
|
+
const macdDivergence = detectDivergence(closes, macd.dif, 20);
|
|
298
|
+
const rsiDivergence = detectDivergence(closes, rsi, 20);
|
|
299
|
+
const divergenceSignals = [];
|
|
300
|
+
let divergenceScore = 0;
|
|
301
|
+
if (macdDivergence.bearish) { divergenceSignals.push('MACD ' + macdDivergence.description); divergenceScore -= 3; }
|
|
302
|
+
if (macdDivergence.bullish) { divergenceSignals.push('MACD ' + macdDivergence.description); divergenceScore += 3; }
|
|
303
|
+
if (rsiDivergence.bearish) { divergenceSignals.push('RSI ' + rsiDivergence.description); divergenceScore -= 2; }
|
|
304
|
+
if (rsiDivergence.bullish) { divergenceSignals.push('RSI ' + rsiDivergence.description); divergenceScore += 2; }
|
|
305
|
+
if (divergenceSignals.length === 0) divergenceSignals.push('未检测到明显背离');
|
|
306
|
+
|
|
307
|
+
// --- 量价背离 ---
|
|
308
|
+
const volDivSignals = [];
|
|
309
|
+
let volDivScore = 0;
|
|
310
|
+
if (closes[last] > closes[last - 5] && vol5 < vol20 * 0.7) { volDivSignals.push('量价背离:价涨量缩,动力不足'); volDivScore -= 2; }
|
|
311
|
+
if (closes[last] < closes[last - 5] && vol5 < vol20 * 0.6) { volDivSignals.push('缩量下跌:抛压衰竭'); volDivScore += 1; }
|
|
312
|
+
if (Math.abs(trend5) < 2 && vol5 > vol20 * 1.3) { volDivSignals.push('横盘放量:关注方向选择'); }
|
|
313
|
+
|
|
314
|
+
// --- 形态 ---
|
|
315
|
+
const patterns = detectPatterns(klines, ma20, boll);
|
|
316
|
+
let patternScore = 0;
|
|
317
|
+
const patternSignals = [];
|
|
318
|
+
for (const p of patterns) { patternSignals.push(`[${p.type === 'bullish' ? '看多' : '看空'}] ${p.name}: ${p.description}`); patternScore += p.weight; }
|
|
319
|
+
if (patternSignals.length === 0) patternSignals.push('未识别到明显形态');
|
|
320
|
+
|
|
321
|
+
// --- 动量衰竭 ---
|
|
322
|
+
const momentum = detectMomentumExhaustion(klines);
|
|
323
|
+
|
|
324
|
+
// --- 斐波那契 ---
|
|
325
|
+
const fib = calcFibonacci(klines, 60);
|
|
326
|
+
const fibSignals = [];
|
|
327
|
+
fibSignals.push(`趋势: ${fib.trend} (高${fib.highPrice} 低${fib.lowPrice})`);
|
|
328
|
+
fibSignals.push(`最近位: ${fib.nearest.name}=${fib.nearest.price}`);
|
|
329
|
+
|
|
330
|
+
// --- 缺口 ---
|
|
331
|
+
const gapAnalysis = detectGaps(klines, 20);
|
|
332
|
+
|
|
333
|
+
// --- 支撑/压力 ---
|
|
334
|
+
const supportResistance = [];
|
|
335
|
+
const high20 = Math.max(...highs.slice(-20));
|
|
336
|
+
const low20 = Math.min(...lows.slice(-20));
|
|
337
|
+
supportResistance.push(`近20日压力位: ${high20}`);
|
|
338
|
+
supportResistance.push(`近20日支撑位: ${low20}`);
|
|
339
|
+
if (ma20[last]) supportResistance.push(`MA20动态支撑/压力: ${ma20[last]}`);
|
|
340
|
+
if (boll.upper[last]) {
|
|
341
|
+
supportResistance.push(`布林上轨压力: ${boll.upper[last]}`);
|
|
342
|
+
supportResistance.push(`布林下轨支撑: ${boll.lower[last]}`);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ==================== 动态加权评分 ====================
|
|
346
|
+
let weightedScore = 0;
|
|
347
|
+
if (marketState === 'trending') {
|
|
348
|
+
weightedScore = maScore * 1.5 + macdScore * 1.3 + adxScore * 1.5
|
|
349
|
+
+ rsiScore * 0.7 + kdjScore * 0.7 + bollScore * 0.8
|
|
350
|
+
+ volScore + trendScore * 1.3 + divergenceScore * 1.2 + volDivScore + patternScore
|
|
351
|
+
+ momentum.score * 1.2 + gapAnalysis.score * 0.8;
|
|
352
|
+
} else {
|
|
353
|
+
weightedScore = maScore * 0.8 + macdScore * 0.8 + adxScore * 0.8
|
|
354
|
+
+ rsiScore * 1.5 + kdjScore * 1.5 + bollScore * 1.5
|
|
355
|
+
+ volScore + trendScore * 0.8 + divergenceScore * 1.3 + volDivScore + patternScore * 1.2
|
|
356
|
+
+ momentum.score * 1.0 + gapAnalysis.score * 1.0;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// 趋势一致性
|
|
360
|
+
const trend60 = ma60[last] !== null ? (closes[last] - closes[Math.max(0, last - 60)]) / closes[Math.max(0, last - 60)] * 100 : 0;
|
|
361
|
+
const shortBull = trend5 > 1, midBull = trend20 > 2, longBull = trend60 > 5;
|
|
362
|
+
const shortBear = trend5 < -1, midBear = trend20 < -2, longBear = trend60 < -5;
|
|
363
|
+
if (shortBull && midBull && longBull) weightedScore += 3;
|
|
364
|
+
else if (shortBear && midBear && longBear) weightedScore -= 3;
|
|
365
|
+
else if ((shortBull && midBear) || (shortBear && midBull)) {
|
|
366
|
+
if (weightedScore !== 0) weightedScore *= 0.8;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ADX 震荡市惩罚
|
|
370
|
+
if (adxVal < 15) weightedScore *= 0.4;
|
|
371
|
+
else if (adxVal < 20 && Math.abs(weightedScore) > 0) weightedScore *= 0.6;
|
|
372
|
+
|
|
373
|
+
// 低波动股降权
|
|
374
|
+
if (atrPct < 1.5 && Math.abs(weightedScore) > 3) weightedScore *= 0.7;
|
|
375
|
+
|
|
376
|
+
// 量能确认加成
|
|
377
|
+
if (weightedScore > 5 && vol5Expand) weightedScore *= 1.15;
|
|
378
|
+
else if (weightedScore < -5 && vol5Expand) weightedScore *= 1.15;
|
|
379
|
+
else if (weightedScore > 5 && vol5Shrink) weightedScore *= 0.8;
|
|
380
|
+
|
|
381
|
+
// 大盘环境修正
|
|
382
|
+
if (marketEnv.trend === 'bull') { weightedScore += 1.5; }
|
|
383
|
+
else if (marketEnv.trend === 'bear') { weightedScore -= 1.5; if (weightedScore > 0) weightedScore *= 0.7; }
|
|
384
|
+
|
|
385
|
+
const totalScore = +weightedScore.toFixed(1);
|
|
386
|
+
|
|
387
|
+
// 风险收益比
|
|
388
|
+
const currentPrice = closes[last];
|
|
389
|
+
const supportLevels = [suggestedStopLoss];
|
|
390
|
+
if (ma20[last] && ma20[last] < currentPrice) supportLevels.push(ma20[last]);
|
|
391
|
+
if (boll.lower[last] && boll.lower[last] < currentPrice) supportLevels.push(boll.lower[last]);
|
|
392
|
+
supportLevels.push(low20);
|
|
393
|
+
const validSupports = supportLevels.filter(s => s < currentPrice).sort((a, b) => b - a);
|
|
394
|
+
const smartStopLoss = validSupports.length > 0 ? +validSupports[0].toFixed(2) : suggestedStopLoss;
|
|
395
|
+
|
|
396
|
+
const resistanceLevels = [];
|
|
397
|
+
if (high20 > currentPrice * 1.02) resistanceLevels.push(high20);
|
|
398
|
+
if (boll.upper[last] && boll.upper[last] > currentPrice * 1.02) resistanceLevels.push(boll.upper[last]);
|
|
399
|
+
const fibLevels = Object.values(fib.levels).filter(v => v > currentPrice * 1.03);
|
|
400
|
+
if (fibLevels.length > 0) resistanceLevels.push(Math.min(...fibLevels));
|
|
401
|
+
resistanceLevels.push(+(currentPrice + atrVal * 2).toFixed(2));
|
|
402
|
+
resistanceLevels.push(+(currentPrice + atrVal * 3).toFixed(2));
|
|
403
|
+
const validResistance = [...new Set(resistanceLevels.filter(r => r > currentPrice))].sort((a, b) => a - b);
|
|
404
|
+
const smartTP1 = validResistance.length > 0 ? +validResistance[0].toFixed(2) : +(currentPrice + atrVal * 2).toFixed(2);
|
|
405
|
+
const smartTP2 = validResistance.length > 1 ? +validResistance[1].toFixed(2) : +(currentPrice + atrVal * 3).toFixed(2);
|
|
406
|
+
|
|
407
|
+
const riskAmt = currentPrice - smartStopLoss;
|
|
408
|
+
const rewardAmt = smartTP1 - currentPrice;
|
|
409
|
+
const riskRewardRatio = riskAmt > 0 ? +(rewardAmt / riskAmt).toFixed(2) : 99;
|
|
410
|
+
|
|
411
|
+
let signal, advice;
|
|
412
|
+
if (totalScore >= 10) { signal = '强烈买入'; advice = '多项指标共振看多,可考虑积极建仓'; }
|
|
413
|
+
else if (totalScore >= 5) { signal = '建议买入'; advice = '技术面偏多,可适量买入或加仓'; }
|
|
414
|
+
else if (totalScore >= 1) { signal = '谨慎买入'; advice = '信号偏多但不强烈,可小仓位试探'; }
|
|
415
|
+
else if (totalScore >= -4) { signal = '观望'; advice = '多空信号交织,建议等待更明确的方向'; }
|
|
416
|
+
else if (totalScore >= -9) { signal = '建议卖出'; advice = '技术面偏空,建议减仓或观望'; }
|
|
417
|
+
else { signal = '强烈卖出'; advice = '多项指标看空,建议清仓回避'; }
|
|
418
|
+
if (riskRewardRatio < 1.0 && totalScore > 0) { advice += ';风险收益比不佳(<1:1),建议等回调再入场'; }
|
|
419
|
+
else if (riskRewardRatio >= 2.0 && totalScore > 0) { advice += ';风险收益比优秀(1:' + riskRewardRatio + '),入场性价比高'; }
|
|
420
|
+
|
|
421
|
+
// === 买入/卖出条件 ===
|
|
422
|
+
const buyConditions = [];
|
|
423
|
+
const sellConditions = [];
|
|
424
|
+
const distToMA20 = ma20[last] ? +((currentPrice - ma20[last]) / currentPrice * 100).toFixed(1) : 0;
|
|
425
|
+
const distToHigh20 = +((high20 - currentPrice) / currentPrice * 100).toFixed(1);
|
|
426
|
+
|
|
427
|
+
if (totalScore >= 5) {
|
|
428
|
+
if (distToMA20 < 3) {
|
|
429
|
+
buyConditions.push(`[立即] 当前价附近(${(currentPrice*0.99).toFixed(2)}~${currentPrice.toFixed(2)})直接买入,MA20(${ma20[last]})支撑`);
|
|
430
|
+
} else {
|
|
431
|
+
const intraSupport = +(currentPrice - atrVal * 0.5).toFixed(2);
|
|
432
|
+
const ma5Val = ma5[last] ? +ma5[last].toFixed(2) : intraSupport;
|
|
433
|
+
buyConditions.push(`[首选] 日内回调至${Math.max(intraSupport, ma5Val)}附近轻仓试探`);
|
|
434
|
+
}
|
|
435
|
+
buyConditions.push(`[稳健] 分批: ${currentPrice.toFixed(2)}(1/3), 回调${(currentPrice*0.98).toFixed(2)}加仓(1/3), ${(currentPrice*0.95).toFixed(2)}补仓(1/3)`);
|
|
436
|
+
} else if (totalScore >= 1) {
|
|
437
|
+
buyConditions.push(`[首选] 回调2-3%至${(currentPrice*0.97).toFixed(2)}附近,出现止跌信号后买入`);
|
|
438
|
+
if (macd.dif[last] < macd.dea[last]) buyConditions.push('[等信号] MACD金叉确认后次日买入');
|
|
439
|
+
} else {
|
|
440
|
+
if (boll.lower[last]) buyConditions.push(`[激进] 跌至布林下轨${boll.lower[last].toFixed(2)}+RSI<30+缩量,轻仓抄底`);
|
|
441
|
+
if (ma20[last] && currentPrice < ma20[last]) buyConditions.push(`[等信号] 放量站回MA20(${ma20[last].toFixed(2)})上方后买入`);
|
|
442
|
+
}
|
|
443
|
+
if (distToHigh20 > 0 && distToHigh20 < 5) {
|
|
444
|
+
buyConditions.push(`[突破] 放量突破${high20.toFixed(2)}时跟进,不追超${(high20*1.03).toFixed(2)}`);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
sellConditions.push(`[止损] ${smartStopLoss} (亏${(riskAmt/currentPrice*100).toFixed(1)}%),跌破即走`);
|
|
448
|
+
if (rsiVal > 75) sellConditions.push(`[止盈] RSI=${rsiVal}超买,冲高回落减半仓`);
|
|
449
|
+
if (ma5[last] && currentPrice > ma5[last]) sellConditions.push(`[减仓] 跌破MA5(${ma5[last].toFixed(2)})且次日不收回`);
|
|
450
|
+
if (ma20[last] && currentPrice > ma20[last]) sellConditions.push(`[清仓] 跌破MA20(${ma20[last].toFixed(2)})且3日不收回`);
|
|
451
|
+
if (smartTP1 > currentPrice) sellConditions.push(`[目标] 到达${smartTP1}附近分批止盈`);
|
|
452
|
+
|
|
453
|
+
return {
|
|
454
|
+
realtime,
|
|
455
|
+
marketState: marketState === 'trending' ? '趋势市' : '震荡市',
|
|
456
|
+
marketEnv,
|
|
457
|
+
buyConditions,
|
|
458
|
+
sellConditions,
|
|
459
|
+
indicators: {
|
|
460
|
+
ma: { score: maScore, signals: maSignals },
|
|
461
|
+
macd: { score: macdScore, signals: macdSignals, values: { dif: macd.dif[last], dea: macd.dea[last], histogram: macd.histogram[last] } },
|
|
462
|
+
rsi: { score: rsiScore, signals: rsiSignals, value: rsiVal },
|
|
463
|
+
kdj: { score: kdjScore, signals: kdjSignals },
|
|
464
|
+
boll: { score: bollScore, signals: bollSignals },
|
|
465
|
+
volume: { score: volScore, signals: volSignals },
|
|
466
|
+
trend: { score: trendScore, signals: trendSignals },
|
|
467
|
+
adx: { score: adxScore, signals: adxSignals },
|
|
468
|
+
atr: { signals: atrSignals },
|
|
469
|
+
divergence: { score: divergenceScore, signals: divergenceSignals },
|
|
470
|
+
volumeDivergence: { score: volDivScore, signals: volDivSignals },
|
|
471
|
+
patterns: { score: patternScore, signals: patternSignals },
|
|
472
|
+
momentum: { score: momentum.score, signals: momentum.signals },
|
|
473
|
+
gaps: { score: gapAnalysis.score, signals: gapAnalysis.signals },
|
|
474
|
+
fibonacci: { signals: fibSignals },
|
|
475
|
+
},
|
|
476
|
+
supportResistance,
|
|
477
|
+
riskReward: {
|
|
478
|
+
stopLoss: smartStopLoss,
|
|
479
|
+
takeProfit1: smartTP1,
|
|
480
|
+
takeProfit2: smartTP2,
|
|
481
|
+
riskRewardRatio,
|
|
482
|
+
positionAdvice,
|
|
483
|
+
},
|
|
484
|
+
summary: {
|
|
485
|
+
totalScore,
|
|
486
|
+
normalizedScore: Math.max(0, Math.min(100, +((totalScore + 20) / 40 * 100).toFixed(0))),
|
|
487
|
+
signal,
|
|
488
|
+
advice,
|
|
489
|
+
marketState: marketState === 'trending' ? '趋势市' : '震荡市',
|
|
490
|
+
marketEnv: marketEnv.trend === 'bull' ? '大盘偏强' : marketEnv.trend === 'bear' ? '大盘偏弱' : '大盘震荡',
|
|
491
|
+
breakdown: `均线(${maScore}) MACD(${macdScore}) RSI(${rsiScore}) KDJ(${kdjScore}) 布林(${bollScore}) 量价(${volScore}) 趋势(${trendScore}) ADX(${adxScore}) 背离(${divergenceScore}) 形态(${patternScore}) 动量(${momentum.score}) 缺口(${gapAnalysis.score}) = ${totalScore}(加权)`,
|
|
492
|
+
},
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* 给回测用的"截至第 idx 天"评分
|
|
498
|
+
* 用 klines.slice(0, idx+1) 作为可见数据,模拟当天分析
|
|
499
|
+
*
|
|
500
|
+
* @param {Array} klines 完整 K 线
|
|
501
|
+
* @param {number} idx "今天"的索引
|
|
502
|
+
* @param {Object} options.marketEnv 可选大盘环境
|
|
503
|
+
* @returns {number} 综合评分(单值)
|
|
504
|
+
*/
|
|
505
|
+
function computeQuickScore(klines, idx, options = {}) {
|
|
506
|
+
if (idx < 60) return 0;
|
|
507
|
+
const visible = klines.slice(0, idx + 1);
|
|
508
|
+
const result = computeScore(visible, { marketEnv: options.marketEnv });
|
|
509
|
+
return result.summary.totalScore;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* 给回测用的完整评分(返回信号类型 + 关键中间值)
|
|
514
|
+
* 比 computeQuickScore 多返回 stopLoss / takeProfit 等,P1 阶段用于止损止盈退出
|
|
515
|
+
*/
|
|
516
|
+
function computeBacktestSnapshot(klines, idx, options = {}) {
|
|
517
|
+
if (idx < 60) return null;
|
|
518
|
+
const visible = klines.slice(0, idx + 1);
|
|
519
|
+
const result = computeScore(visible, { marketEnv: options.marketEnv });
|
|
520
|
+
const highs = visible.map(k => k.high);
|
|
521
|
+
const lows = visible.map(k => k.low);
|
|
522
|
+
const closes = visible.map(k => k.close);
|
|
523
|
+
const atrArr = ATR(highs, lows, closes);
|
|
524
|
+
const atrVal = +(atrArr[atrArr.length - 1] || 0).toFixed(3);
|
|
525
|
+
return {
|
|
526
|
+
score: result.summary.totalScore,
|
|
527
|
+
signal: result.summary.signal,
|
|
528
|
+
stopLoss: result.riskReward.stopLoss,
|
|
529
|
+
takeProfit1: result.riskReward.takeProfit1,
|
|
530
|
+
takeProfit2: result.riskReward.takeProfit2,
|
|
531
|
+
rsi: result.indicators.rsi.value,
|
|
532
|
+
atr: atrVal,
|
|
533
|
+
closePrice: closes[closes.length - 1],
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
module.exports = {
|
|
538
|
+
computeScore,
|
|
539
|
+
computeQuickScore,
|
|
540
|
+
computeBacktestSnapshot,
|
|
541
|
+
};
|