@kamuira/stock-analyzer 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/analyze.js +1222 -0
- package/backtest.js +423 -0
- package/bin/analyze.js +4 -0
- package/bin/backtest.js +4 -0
- package/bin/server.js +4 -0
- package/dev.js +50 -0
- package/index.html +503 -0
- package/package.json +47 -0
- package/readme.md +166 -0
- package/server.js +1699 -0
- package/stock.js +181 -0
package/backtest.js
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 回测验证工具
|
|
3
|
+
* 用历史数据验证分析方法论的有效性
|
|
4
|
+
*
|
|
5
|
+
* 用法: node backtest.js [sz002049|sh601138|2330|all]
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const http = require('http');
|
|
9
|
+
const https = require('https');
|
|
10
|
+
|
|
11
|
+
// ==================== 数据获取(复用) ====================
|
|
12
|
+
|
|
13
|
+
function fetchHistory(code, days = 250) {
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
if (code.startsWith('tw')) {
|
|
16
|
+
fetchTWHistory(code, days).then(resolve).catch(reject);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const url = `https://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param=${code},day,,,${days},qfq`;
|
|
20
|
+
https.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (res) => {
|
|
21
|
+
if (res.statusCode === 301 || res.statusCode === 302) {
|
|
22
|
+
const client = res.headers.location.startsWith('https') ? https : http;
|
|
23
|
+
client.get(res.headers.location, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (res2) => {
|
|
24
|
+
collectRes(res2, code, resolve, reject);
|
|
25
|
+
}).on('error', reject);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
collectRes(res, code, resolve, reject);
|
|
29
|
+
}).on('error', reject);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function collectRes(res, code, resolve, reject) {
|
|
34
|
+
const chunks = [];
|
|
35
|
+
res.on('data', c => chunks.push(c));
|
|
36
|
+
res.on('end', () => {
|
|
37
|
+
try {
|
|
38
|
+
const json = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
|
|
39
|
+
if (!json.data || !json.data[code]) { resolve([]); return; }
|
|
40
|
+
const klines = json.data[code].qfqday || json.data[code].day || [];
|
|
41
|
+
resolve(klines.map(item => ({
|
|
42
|
+
date: item[0], open: parseFloat(item[1]), close: parseFloat(item[2]),
|
|
43
|
+
high: parseFloat(item[3]), low: parseFloat(item[4]), volume: parseInt(item[5]) || 0,
|
|
44
|
+
})));
|
|
45
|
+
} catch (e) { reject(e); }
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function fetchTWHistory(code, days = 250) {
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
const num = code.replace(/^tw/i, '');
|
|
52
|
+
const symbol = `${num}.TW`;
|
|
53
|
+
const period2 = Math.floor(Date.now() / 1000);
|
|
54
|
+
const period1 = period2 - Math.floor(days * 24 * 60 * 60 * 1.5);
|
|
55
|
+
const url = `/v8/finance/chart/${symbol}?period1=${period1}&period2=${period2}&interval=1d`;
|
|
56
|
+
https.get({ hostname: 'query1.finance.yahoo.com', path: url, headers: { 'User-Agent': 'Mozilla/5.0' } }, (res) => {
|
|
57
|
+
if (res.statusCode === 301 || res.statusCode === 302) {
|
|
58
|
+
https.get(res.headers.location, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (res2) => {
|
|
59
|
+
collectTW(res2, resolve, reject);
|
|
60
|
+
}).on('error', reject);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
collectTW(res, resolve, reject);
|
|
64
|
+
}).on('error', reject);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function collectTW(res, resolve, reject) {
|
|
69
|
+
const chunks = [];
|
|
70
|
+
res.on('data', c => chunks.push(c));
|
|
71
|
+
res.on('end', () => {
|
|
72
|
+
try {
|
|
73
|
+
const json = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
|
|
74
|
+
const result = json.chart && json.chart.result && json.chart.result[0];
|
|
75
|
+
if (!result || !result.timestamp) { resolve([]); return; }
|
|
76
|
+
const ts = result.timestamp, q = result.indicators.quote[0];
|
|
77
|
+
const klines = [];
|
|
78
|
+
for (let i = 0; i < ts.length; i++) {
|
|
79
|
+
if (q.close[i] === null) continue;
|
|
80
|
+
const d = new Date(ts[i] * 1000);
|
|
81
|
+
klines.push({
|
|
82
|
+
date: `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`,
|
|
83
|
+
open: +(q.open[i]||0).toFixed(2), close: +(q.close[i]||0).toFixed(2),
|
|
84
|
+
high: +(q.high[i]||0).toFixed(2), low: +(q.low[i]||0).toFixed(2),
|
|
85
|
+
volume: Math.round((q.volume[i]||0)/1000),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
resolve(klines);
|
|
89
|
+
} catch (e) { reject(e); }
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ==================== 简化版技术分析(用于回测) ====================
|
|
94
|
+
|
|
95
|
+
function SMA(data, period) {
|
|
96
|
+
const result = [];
|
|
97
|
+
for (let i = 0; i < data.length; i++) {
|
|
98
|
+
if (i < period - 1) { result.push(null); continue; }
|
|
99
|
+
let sum = 0;
|
|
100
|
+
for (let j = i - period + 1; j <= i; j++) sum += data[j];
|
|
101
|
+
result.push(+(sum / period).toFixed(3));
|
|
102
|
+
}
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function EMA(data, period) {
|
|
107
|
+
const result = [];
|
|
108
|
+
const k = 2 / (period + 1);
|
|
109
|
+
for (let i = 0; i < data.length; i++) {
|
|
110
|
+
if (i === 0) { result.push(data[0]); continue; }
|
|
111
|
+
result.push(+(data[i] * k + result[i - 1] * (1 - k)).toFixed(3));
|
|
112
|
+
}
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function MACD(closes) {
|
|
117
|
+
const ema12 = EMA(closes, 12);
|
|
118
|
+
const ema26 = EMA(closes, 26);
|
|
119
|
+
const dif = ema12.map((v, i) => +(v - ema26[i]).toFixed(3));
|
|
120
|
+
const dea = EMA(dif, 9);
|
|
121
|
+
const histogram = dif.map((v, i) => +((v - dea[i]) * 2).toFixed(3));
|
|
122
|
+
return { dif, dea, histogram };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function RSI(closes, period = 14) {
|
|
126
|
+
const result = [];
|
|
127
|
+
for (let i = 0; i < closes.length; i++) {
|
|
128
|
+
if (i < period) { result.push(null); continue; }
|
|
129
|
+
let gains = 0, losses = 0;
|
|
130
|
+
for (let j = i - period + 1; j <= i; j++) {
|
|
131
|
+
const diff = closes[j] - closes[j - 1];
|
|
132
|
+
if (diff > 0) gains += diff; else losses -= diff;
|
|
133
|
+
}
|
|
134
|
+
const avgGain = gains / period, avgLoss = losses / period;
|
|
135
|
+
const rs = avgLoss === 0 ? 100 : avgGain / avgLoss;
|
|
136
|
+
result.push(+(100 - 100 / (1 + rs)).toFixed(2));
|
|
137
|
+
}
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function ADX(highs, lows, closes, period = 14) {
|
|
142
|
+
const plusDM = [], minusDM = [], tr = [];
|
|
143
|
+
for (let i = 0; i < closes.length; i++) {
|
|
144
|
+
if (i === 0) { plusDM.push(0); minusDM.push(0); tr.push(highs[i] - lows[i]); continue; }
|
|
145
|
+
const upMove = highs[i] - highs[i - 1], downMove = lows[i - 1] - lows[i];
|
|
146
|
+
plusDM.push(upMove > downMove && upMove > 0 ? upMove : 0);
|
|
147
|
+
minusDM.push(downMove > upMove && downMove > 0 ? downMove : 0);
|
|
148
|
+
tr.push(Math.max(highs[i] - lows[i], Math.abs(highs[i] - closes[i-1]), Math.abs(lows[i] - closes[i-1])));
|
|
149
|
+
}
|
|
150
|
+
const atr = EMA(tr, period);
|
|
151
|
+
const sPDM = EMA(plusDM, period), sMDM = EMA(minusDM, period);
|
|
152
|
+
const plusDI = [], minusDI = [], dx = [];
|
|
153
|
+
for (let i = 0; i < closes.length; i++) {
|
|
154
|
+
const pdi = atr[i] > 0 ? (sPDM[i] / atr[i]) * 100 : 0;
|
|
155
|
+
const mdi = atr[i] > 0 ? (sMDM[i] / atr[i]) * 100 : 0;
|
|
156
|
+
plusDI.push(pdi); minusDI.push(mdi);
|
|
157
|
+
const sum = pdi + mdi;
|
|
158
|
+
dx.push(sum > 0 ? (Math.abs(pdi - mdi) / sum) * 100 : 0);
|
|
159
|
+
}
|
|
160
|
+
return { plusDI, minusDI, adx: EMA(dx, period) };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 简化版评分:返回当日综合得分(优化版) */
|
|
164
|
+
function quickScore(klines, idx) {
|
|
165
|
+
if (idx < 60) return 0;
|
|
166
|
+
const slice = klines.slice(0, idx + 1);
|
|
167
|
+
const closes = slice.map(k => k.close);
|
|
168
|
+
const highs = slice.map(k => k.high);
|
|
169
|
+
const lows = slice.map(k => k.low);
|
|
170
|
+
const volumes = slice.map(k => k.volume);
|
|
171
|
+
const n = closes.length;
|
|
172
|
+
const last = n - 1;
|
|
173
|
+
|
|
174
|
+
const ma5 = SMA(closes, 5);
|
|
175
|
+
const ma10 = SMA(closes, 10);
|
|
176
|
+
const ma20 = SMA(closes, 20);
|
|
177
|
+
const ma60 = SMA(closes, 60);
|
|
178
|
+
const macd = MACD(closes);
|
|
179
|
+
const rsi = RSI(closes, 14);
|
|
180
|
+
const adxData = ADX(highs, lows, closes);
|
|
181
|
+
|
|
182
|
+
let score = 0;
|
|
183
|
+
|
|
184
|
+
// 均线
|
|
185
|
+
if (ma5[last] > ma10[last] && ma10[last] > ma20[last]) score += 2;
|
|
186
|
+
else if (ma5[last] < ma10[last] && ma10[last] < ma20[last]) score -= 2;
|
|
187
|
+
if (closes[last] > ma20[last]) score += 1; else score -= 1;
|
|
188
|
+
if (ma5[last] > ma10[last] && ma5[last - 1] <= ma10[last - 1]) score += 2;
|
|
189
|
+
else if (ma5[last] < ma10[last] && ma5[last - 1] >= ma10[last - 1]) score -= 2;
|
|
190
|
+
|
|
191
|
+
// MACD
|
|
192
|
+
if (macd.dif[last] > macd.dea[last] && macd.dif[last - 1] <= macd.dea[last - 1]) score += 3;
|
|
193
|
+
else if (macd.dif[last] < macd.dea[last] && macd.dif[last - 1] >= macd.dea[last - 1]) score -= 3;
|
|
194
|
+
if (macd.dif[last] > 0) score += 1; else score -= 1;
|
|
195
|
+
if (macd.histogram[last] > macd.histogram[last - 1]) score += 1; else score -= 1;
|
|
196
|
+
|
|
197
|
+
// RSI
|
|
198
|
+
const rsiVal = rsi[last];
|
|
199
|
+
if (rsiVal !== null) {
|
|
200
|
+
if (rsiVal < 30) score += 2;
|
|
201
|
+
else if (rsiVal > 70) score -= 2;
|
|
202
|
+
else if (rsiVal >= 50) score += 1;
|
|
203
|
+
else score -= 1;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ADX
|
|
207
|
+
const adxVal = adxData.adx[last];
|
|
208
|
+
if (adxVal >= 25) {
|
|
209
|
+
if (adxData.plusDI[last] > adxData.minusDI[last]) score += 2; else score -= 2;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 量价
|
|
213
|
+
const vol5 = volumes.slice(-5).reduce((a, b) => a + b, 0) / 5;
|
|
214
|
+
const vol20 = volumes.slice(-20).reduce((a, b) => a + b, 0) / 20;
|
|
215
|
+
const vol5Expand = vol5 > vol20 * 1.2;
|
|
216
|
+
const vol5Shrink = vol5 < vol20 * 0.7;
|
|
217
|
+
if (vol5 > vol20 * 1.5) {
|
|
218
|
+
if (closes[last] > closes[last - 5]) score += 2; else score -= 2;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// 趋势
|
|
222
|
+
const trend5 = (closes[last] - closes[Math.max(0, last - 5)]) / closes[Math.max(0, last - 5)] * 100;
|
|
223
|
+
const trend20 = (closes[last] - closes[Math.max(0, last - 20)]) / closes[Math.max(0, last - 20)] * 100;
|
|
224
|
+
const trend60 = ma60[last] !== null ? (closes[last] - closes[Math.max(0, last - 60)]) / closes[Math.max(0, last - 60)] * 100 : 0;
|
|
225
|
+
if (trend20 > 5) score += 2; else if (trend20 < -5) score -= 2;
|
|
226
|
+
|
|
227
|
+
// === 优化: 趋势一致性 ===
|
|
228
|
+
const shortBull = trend5 > 1, midBull = trend20 > 2, longBull = trend60 > 5;
|
|
229
|
+
const shortBear = trend5 < -1, midBear = trend20 < -2, longBear = trend60 < -5;
|
|
230
|
+
if (shortBull && midBull && longBull) score += 3;
|
|
231
|
+
else if (shortBear && midBear && longBear) score -= 3;
|
|
232
|
+
else if ((shortBull && midBear) || (shortBear && midBull)) {
|
|
233
|
+
score = Math.round(score * 0.8);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// === 优化: ADX<20 震荡市惩罚 ===
|
|
237
|
+
if (adxVal < 15) score = Math.round(score * 0.4);
|
|
238
|
+
else if (adxVal < 20) score = Math.round(score * 0.6);
|
|
239
|
+
|
|
240
|
+
// === 优化: 量能确认 ===
|
|
241
|
+
if (score > 5 && vol5Expand) score = Math.round(score * 1.15);
|
|
242
|
+
else if (score > 5 && vol5Shrink) score = Math.round(score * 0.8);
|
|
243
|
+
|
|
244
|
+
return score;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ==================== 回测引擎 ====================
|
|
248
|
+
|
|
249
|
+
function backtest(klines, config = {}) {
|
|
250
|
+
const { holdDays = [3, 5, 10], startIdx = 60 } = config;
|
|
251
|
+
const n = klines.length;
|
|
252
|
+
|
|
253
|
+
const results = {
|
|
254
|
+
totalSignals: 0,
|
|
255
|
+
buySignals: 0,
|
|
256
|
+
sellSignals: 0,
|
|
257
|
+
neutralSignals: 0,
|
|
258
|
+
holdPeriods: {},
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
for (const days of holdDays) {
|
|
262
|
+
results.holdPeriods[days] = {
|
|
263
|
+
buyWins: 0, buyLosses: 0, buyTotalReturn: 0, buyReturns: [],
|
|
264
|
+
sellWins: 0, sellLosses: 0, sellTotalReturn: 0, sellReturns: [],
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const trades = [];
|
|
269
|
+
|
|
270
|
+
for (let i = startIdx; i < n; i++) {
|
|
271
|
+
const score = quickScore(klines, i);
|
|
272
|
+
results.totalSignals++;
|
|
273
|
+
|
|
274
|
+
let signalType = 'neutral';
|
|
275
|
+
if (score >= 5) { signalType = 'buy'; results.buySignals++; }
|
|
276
|
+
else if (score <= -5) { signalType = 'sell'; results.sellSignals++; }
|
|
277
|
+
else { results.neutralSignals++; continue; }
|
|
278
|
+
|
|
279
|
+
const entryPrice = klines[i].close;
|
|
280
|
+
|
|
281
|
+
for (const days of holdDays) {
|
|
282
|
+
const exitIdx = Math.min(i + days, n - 1);
|
|
283
|
+
if (exitIdx <= i) continue;
|
|
284
|
+
const exitPrice = klines[exitIdx].close;
|
|
285
|
+
const returnPct = +((exitPrice - entryPrice) / entryPrice * 100).toFixed(2);
|
|
286
|
+
|
|
287
|
+
const period = results.holdPeriods[days];
|
|
288
|
+
if (signalType === 'buy') {
|
|
289
|
+
period.buyTotalReturn += returnPct;
|
|
290
|
+
period.buyReturns.push(returnPct);
|
|
291
|
+
if (returnPct > 0) period.buyWins++; else period.buyLosses++;
|
|
292
|
+
} else {
|
|
293
|
+
// 卖出信号:做空收益 = -returnPct
|
|
294
|
+
const shortReturn = -returnPct;
|
|
295
|
+
period.sellTotalReturn += shortReturn;
|
|
296
|
+
period.sellReturns.push(shortReturn);
|
|
297
|
+
if (shortReturn > 0) period.sellWins++; else period.sellLosses++;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
trades.push({ date: klines[i].date, signal: signalType, score, price: entryPrice });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return { results, trades };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ==================== 输出报告 ====================
|
|
308
|
+
|
|
309
|
+
function formatBacktestReport(code, klines, btResult) {
|
|
310
|
+
const { results, trades } = btResult;
|
|
311
|
+
const lines = [];
|
|
312
|
+
|
|
313
|
+
lines.push('═'.repeat(60));
|
|
314
|
+
lines.push(` 回测报告: ${code}`);
|
|
315
|
+
lines.push(` 数据范围: ${klines[0].date} ~ ${klines[klines.length-1].date} (${klines.length}个交易日)`);
|
|
316
|
+
lines.push('═'.repeat(60));
|
|
317
|
+
|
|
318
|
+
lines.push('');
|
|
319
|
+
lines.push(`【信号统计】`);
|
|
320
|
+
lines.push(` 总分析天数: ${results.totalSignals}`);
|
|
321
|
+
lines.push(` 买入信号: ${results.buySignals}次 (${(results.buySignals/results.totalSignals*100).toFixed(1)}%)`);
|
|
322
|
+
lines.push(` 卖出信号: ${results.sellSignals}次 (${(results.sellSignals/results.totalSignals*100).toFixed(1)}%)`);
|
|
323
|
+
lines.push(` 观望信号: ${results.neutralSignals}次 (${(results.neutralSignals/results.totalSignals*100).toFixed(1)}%)`);
|
|
324
|
+
|
|
325
|
+
lines.push('');
|
|
326
|
+
lines.push('─'.repeat(60));
|
|
327
|
+
lines.push(`【买入信号验证】 (信号发出后持有N天的收益)`);
|
|
328
|
+
lines.push('');
|
|
329
|
+
|
|
330
|
+
for (const [days, period] of Object.entries(results.holdPeriods)) {
|
|
331
|
+
const totalBuy = period.buyWins + period.buyLosses;
|
|
332
|
+
if (totalBuy === 0) continue;
|
|
333
|
+
const winRate = (period.buyWins / totalBuy * 100).toFixed(1);
|
|
334
|
+
const avgReturn = (period.buyTotalReturn / totalBuy).toFixed(2);
|
|
335
|
+
const maxWin = period.buyReturns.length > 0 ? Math.max(...period.buyReturns).toFixed(2) : 0;
|
|
336
|
+
const maxLoss = period.buyReturns.length > 0 ? Math.min(...period.buyReturns).toFixed(2) : 0;
|
|
337
|
+
|
|
338
|
+
lines.push(` 持有${days}天: 胜率=${winRate}% | 平均收益=${avgReturn}% | 最大盈利=${maxWin}% | 最大亏损=${maxLoss}%`);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
lines.push('');
|
|
342
|
+
lines.push('─'.repeat(60));
|
|
343
|
+
lines.push(`【卖出信号验证】 (信号发出后若做空N天的收益)`);
|
|
344
|
+
lines.push('');
|
|
345
|
+
|
|
346
|
+
for (const [days, period] of Object.entries(results.holdPeriods)) {
|
|
347
|
+
const totalSell = period.sellWins + period.sellLosses;
|
|
348
|
+
if (totalSell === 0) { lines.push(` 持有${days}天: 无卖出信号`); continue; }
|
|
349
|
+
const winRate = (period.sellWins / totalSell * 100).toFixed(1);
|
|
350
|
+
const avgReturn = (period.sellTotalReturn / totalSell).toFixed(2);
|
|
351
|
+
const maxWin = period.sellReturns.length > 0 ? Math.max(...period.sellReturns).toFixed(2) : 0;
|
|
352
|
+
const maxLoss = period.sellReturns.length > 0 ? Math.min(...period.sellReturns).toFixed(2) : 0;
|
|
353
|
+
|
|
354
|
+
lines.push(` 持有${days}天: 胜率=${winRate}% | 平均收益=${avgReturn}% | 最大盈利=${maxWin}% | 最大亏损=${maxLoss}%`);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// 最近的信号
|
|
358
|
+
lines.push('');
|
|
359
|
+
lines.push('─'.repeat(60));
|
|
360
|
+
lines.push('【最近10次信号】');
|
|
361
|
+
const recentTrades = trades.slice(-10);
|
|
362
|
+
for (const t of recentTrades) {
|
|
363
|
+
const emoji = t.signal === 'buy' ? '▲买入' : '▼卖出';
|
|
364
|
+
lines.push(` ${t.date} ${emoji} 得分=${t.score} 价格=${t.price}`);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// 综合评价
|
|
368
|
+
lines.push('');
|
|
369
|
+
lines.push('═'.repeat(60));
|
|
370
|
+
const buy5 = results.holdPeriods[5];
|
|
371
|
+
const totalBuy5 = buy5.buyWins + buy5.buyLosses;
|
|
372
|
+
if (totalBuy5 > 0) {
|
|
373
|
+
const wr = (buy5.buyWins / totalBuy5 * 100).toFixed(0);
|
|
374
|
+
const ar = (buy5.buyTotalReturn / totalBuy5).toFixed(2);
|
|
375
|
+
let grade = '';
|
|
376
|
+
if (wr >= 65 && ar > 2) grade = '优秀 - 信号可靠性高';
|
|
377
|
+
else if (wr >= 55 && ar > 1) grade = '良好 - 有一定参考价值';
|
|
378
|
+
else if (wr >= 50) grade = '一般 - 需结合其他因素判断';
|
|
379
|
+
else grade = '较差 - 信号可靠性不足,需优化';
|
|
380
|
+
lines.push(` 综合评价(5日持有): 胜率${wr}% 均收益${ar}% → ${grade}`);
|
|
381
|
+
}
|
|
382
|
+
lines.push('═'.repeat(60));
|
|
383
|
+
|
|
384
|
+
return lines.join('\n');
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ==================== 主程序 ====================
|
|
388
|
+
|
|
389
|
+
function resolveCode(input) {
|
|
390
|
+
input = input.trim();
|
|
391
|
+
if (/^tw\d{4,6}$/i.test(input)) return input.toLowerCase();
|
|
392
|
+
if (/^\d{4}$/.test(input)) return 'tw' + input;
|
|
393
|
+
if (/^(sh|sz)\d{6}$/i.test(input)) return input.toLowerCase();
|
|
394
|
+
if (/^\d{6}$/.test(input)) return (input.startsWith('6') ? 'sh' : 'sz') + input;
|
|
395
|
+
return input;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const WATCH_LIST = ['sz002049', 'sh603893', 'sz300750', 'sh601138', 'sh600011', 'tw2330'];
|
|
399
|
+
|
|
400
|
+
async function main() {
|
|
401
|
+
const arg = process.argv[2] || 'all';
|
|
402
|
+
const codes = arg === 'all' ? WATCH_LIST : [resolveCode(arg)];
|
|
403
|
+
|
|
404
|
+
console.log('\n正在获取历史数据进行回测...\n');
|
|
405
|
+
|
|
406
|
+
for (const code of codes) {
|
|
407
|
+
try {
|
|
408
|
+
const klines = await fetchHistory(code, 500);
|
|
409
|
+
if (klines.length < 80) {
|
|
410
|
+
console.log(`${code} 数据不足(${klines.length}天),跳过\n`);
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const btResult = backtest(klines, { holdDays: [3, 5, 10], startIdx: 60 });
|
|
415
|
+
console.log(formatBacktestReport(code, klines, btResult));
|
|
416
|
+
console.log('');
|
|
417
|
+
} catch (e) {
|
|
418
|
+
console.log(`${code} 回测出错: ${e.message}\n`);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
main().catch(e => console.error('回测出错:', e.message));
|
package/bin/analyze.js
ADDED
package/bin/backtest.js
ADDED
package/bin/server.js
ADDED
package/dev.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 开发模式启动器 - 自动监听文件变化并重启服务
|
|
3
|
+
* 用法: node dev.js
|
|
4
|
+
*/
|
|
5
|
+
const { spawn } = require('child_process');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
const SERVER_FILE = path.join(__dirname, 'server.js');
|
|
10
|
+
const WATCH_FILES = [SERVER_FILE];
|
|
11
|
+
let child = null;
|
|
12
|
+
let restarting = false;
|
|
13
|
+
|
|
14
|
+
function start() {
|
|
15
|
+
console.log(`[dev] 启动 server.js ...`);
|
|
16
|
+
child = spawn('node', [SERVER_FILE], { stdio: 'inherit', cwd: __dirname });
|
|
17
|
+
child.on('exit', (code) => {
|
|
18
|
+
if (!restarting) {
|
|
19
|
+
console.log(`[dev] 进程退出 (code=${code})`);
|
|
20
|
+
process.exit(code);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function restart() {
|
|
26
|
+
if (restarting) return;
|
|
27
|
+
restarting = true;
|
|
28
|
+
console.log(`\n[dev] 检测到文件变化,重启中...`);
|
|
29
|
+
if (child) {
|
|
30
|
+
child.kill();
|
|
31
|
+
child.on('exit', () => {
|
|
32
|
+
restarting = false;
|
|
33
|
+
start();
|
|
34
|
+
});
|
|
35
|
+
} else {
|
|
36
|
+
restarting = false;
|
|
37
|
+
start();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 监听文件变化
|
|
42
|
+
let debounce = null;
|
|
43
|
+
for (const file of WATCH_FILES) {
|
|
44
|
+
fs.watch(file, () => {
|
|
45
|
+
if (debounce) clearTimeout(debounce);
|
|
46
|
+
debounce = setTimeout(restart, 500);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
start();
|