@kamuira/stock-analyzer 1.0.5 → 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/README.md +172 -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 +4 -6
- package/scoring.js +541 -0
- package/server.js +69 -1532
- package/stock.js +0 -181
package/backtest.js
CHANGED
|
@@ -1,386 +1,309 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* 回测引擎 — P1 事件驱动版
|
|
3
|
+
*
|
|
4
|
+
* 关键变化(相比 P0):
|
|
5
|
+
* 1. 信号去重:已持仓时同向信号不重复入场
|
|
6
|
+
* 2. 止损止盈退出:用 scoring.js 提供的 stopLoss / takeProfit1,而不是固定持有 N 天
|
|
7
|
+
* 3. 反向信号退出:多头持仓时出现 score<=-5,立即平仓
|
|
8
|
+
* 4. 大盘环境过滤:每日重新计算上证趋势,与实盘一致
|
|
9
|
+
* 5. 完整交易记录:返回每笔"入场→退出"的完整流水
|
|
10
|
+
*
|
|
5
11
|
* 用法: node backtest.js [sz002049|sh601138|2330|all]
|
|
6
12
|
*/
|
|
7
13
|
|
|
8
|
-
const
|
|
9
|
-
const
|
|
14
|
+
const { fetchHistory, fetchTWHistory } = require('./analyze');
|
|
15
|
+
const { computeBacktestSnapshot } = require('./scoring');
|
|
16
|
+
const { SMA } = require('./indicators');
|
|
10
17
|
|
|
11
|
-
// ====================
|
|
18
|
+
// ==================== 大盘环境(任意历史时点) ====================
|
|
12
19
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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));
|
|
20
|
+
/**
|
|
21
|
+
* 给定上证指数 K 线和某个日期,返回那天的大盘环境
|
|
22
|
+
* 用于回测时让每一天都看到"当时"的大盘状态,与实盘 getMarketEnvironment 一致
|
|
23
|
+
*/
|
|
24
|
+
function getMarketEnvAtDate(indexKlines, date) {
|
|
25
|
+
// 找 date 当天或最近的前一交易日
|
|
26
|
+
let idx = -1;
|
|
27
|
+
for (let i = indexKlines.length - 1; i >= 0; i--) {
|
|
28
|
+
if (indexKlines[i].date <= date) { idx = i; break; }
|
|
102
29
|
}
|
|
103
|
-
return
|
|
30
|
+
if (idx < 20) return { trend: 'neutral', score: 0, signals: ['大盘数据不足'] };
|
|
31
|
+
const slice = indexKlines.slice(0, idx + 1);
|
|
32
|
+
const closes = slice.map(k => k.close);
|
|
33
|
+
const n = closes.length;
|
|
34
|
+
const ma5 = SMA(closes, 5);
|
|
35
|
+
const ma20 = SMA(closes, 20);
|
|
36
|
+
const trend20 = (closes[n - 1] - closes[n - 20]) / closes[n - 20] * 100;
|
|
37
|
+
let trend = 'neutral', score = 0;
|
|
38
|
+
const signals = [];
|
|
39
|
+
if (ma5[n - 1] > ma20[n - 1] && trend20 > 2) { trend = 'bull'; score = 1; signals.push(`上证偏强: 20日涨${trend20.toFixed(1)}%`); }
|
|
40
|
+
else if (ma5[n - 1] < ma20[n - 1] && trend20 < -2) { trend = 'bear'; score = -1; signals.push(`上证偏弱: 20日跌${trend20.toFixed(1)}%`); }
|
|
41
|
+
else { signals.push(`上证震荡: 20日${trend20.toFixed(1)}%`); }
|
|
42
|
+
return { trend, score, signals };
|
|
104
43
|
}
|
|
105
44
|
|
|
106
|
-
|
|
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
|
-
}
|
|
45
|
+
// ==================== 事件驱动回测引擎 ====================
|
|
115
46
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
47
|
+
/**
|
|
48
|
+
* @param {Array} klines 标的 K 线
|
|
49
|
+
* @param {Object} options
|
|
50
|
+
* - startIdx 从第几天开始(默认 60,保证指标有足够数据)
|
|
51
|
+
* - maxHoldDays 单笔最大持仓天数(默认 30)
|
|
52
|
+
* - indexKlines 上证指数 K 线(可选,用于大盘环境过滤)
|
|
53
|
+
* - scoreBuy 买入阈值(默认 5)
|
|
54
|
+
* - scoreSell 卖出阈值(默认 -5)
|
|
55
|
+
* - enableShort 是否回测做空(默认 false,只做多)
|
|
56
|
+
* - trailing 是否启用跟踪止损(默认 true)
|
|
57
|
+
* Chandelier Exit:stopLoss = max(原止损, 持仓最高价 - 2.5×ATR)
|
|
58
|
+
* - trailingATR 跟踪止损的 ATR 倍数(默认 2.5)
|
|
59
|
+
* - useTP1 是否在 TP1 止盈(默认 false,跟踪止损接管止盈)
|
|
60
|
+
* 关闭后,只靠跟踪止损 + 反向信号 + 超时退出 — 经典趋势跟随风格
|
|
61
|
+
*/
|
|
62
|
+
function backtest(klines, options = {}) {
|
|
63
|
+
const {
|
|
64
|
+
startIdx = 60,
|
|
65
|
+
maxHoldDays = 30,
|
|
66
|
+
indexKlines = null,
|
|
67
|
+
scoreBuy = 5,
|
|
68
|
+
scoreSell = -5,
|
|
69
|
+
enableShort = false,
|
|
70
|
+
trailing = false, // 默认关闭(高波动股开了反而拖累累计收益)
|
|
71
|
+
trailingATR = 2.5,
|
|
72
|
+
useTP1 = true,
|
|
73
|
+
} = options;
|
|
74
|
+
const n = klines.length;
|
|
75
|
+
const trades = [];
|
|
76
|
+
let position = null;
|
|
124
77
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
if (
|
|
78
|
+
for (let i = startIdx; i < n; i++) {
|
|
79
|
+
const today = klines[i];
|
|
80
|
+
const mEnv = indexKlines ? getMarketEnvAtDate(indexKlines, today.date) : null;
|
|
81
|
+
|
|
82
|
+
if (position === null) {
|
|
83
|
+
// ===== 空仓:扫描入场信号 =====
|
|
84
|
+
const snap = computeBacktestSnapshot(klines, i, { marketEnv: mEnv });
|
|
85
|
+
if (!snap) continue;
|
|
86
|
+
|
|
87
|
+
if (snap.score >= scoreBuy) {
|
|
88
|
+
position = {
|
|
89
|
+
type: 'long',
|
|
90
|
+
entryIdx: i, entryDate: today.date, entryPrice: today.close,
|
|
91
|
+
entryScore: snap.score,
|
|
92
|
+
entryATR: snap.atr || 0,
|
|
93
|
+
peakPrice: today.close, // 持仓期间最高价(跟踪止损用)
|
|
94
|
+
troughPrice: today.close, // 持仓期间最低价(做空跟踪用)
|
|
95
|
+
stopLoss: snap.stopLoss,
|
|
96
|
+
initialStopLoss: snap.stopLoss, // 记录初始止损,便于报告
|
|
97
|
+
takeProfit1: snap.takeProfit1,
|
|
98
|
+
takeProfit2: snap.takeProfit2,
|
|
99
|
+
};
|
|
100
|
+
} else if (enableShort && snap.score <= scoreSell) {
|
|
101
|
+
position = {
|
|
102
|
+
type: 'short',
|
|
103
|
+
entryIdx: i, entryDate: today.date, entryPrice: today.close,
|
|
104
|
+
entryScore: snap.score,
|
|
105
|
+
entryATR: snap.atr || 0,
|
|
106
|
+
peakPrice: today.close,
|
|
107
|
+
troughPrice: today.close,
|
|
108
|
+
stopLoss: snap.takeProfit1,
|
|
109
|
+
initialStopLoss: snap.takeProfit1,
|
|
110
|
+
takeProfit1: snap.stopLoss,
|
|
111
|
+
takeProfit2: null,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
continue;
|
|
133
115
|
}
|
|
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
116
|
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
}
|
|
117
|
+
// ===== 持仓中:检查退出条件 =====
|
|
118
|
+
let exitReason = null, exitPrice = null;
|
|
162
119
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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;
|
|
120
|
+
// 更新持仓期间的高低水位
|
|
121
|
+
if (today.high > position.peakPrice) position.peakPrice = today.high;
|
|
122
|
+
if (today.low < position.troughPrice) position.troughPrice = today.low;
|
|
173
123
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
}
|
|
124
|
+
// 跟踪止损:Chandelier Exit
|
|
125
|
+
// 做多:trailStop = peak - N×ATR,只上调不下调
|
|
126
|
+
// 做空:trailStop = trough + N×ATR,只下调不上调
|
|
127
|
+
if (trailing && position.entryATR > 0) {
|
|
128
|
+
if (position.type === 'long') {
|
|
129
|
+
const trail = position.peakPrice - trailingATR * position.entryATR;
|
|
130
|
+
if (trail > position.stopLoss) position.stopLoss = +trail.toFixed(2);
|
|
131
|
+
} else {
|
|
132
|
+
const trail = position.troughPrice + trailingATR * position.entryATR;
|
|
133
|
+
if (trail < position.stopLoss) position.stopLoss = +trail.toFixed(2);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
205
136
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
137
|
+
if (position.type === 'long') {
|
|
138
|
+
if (today.low <= position.stopLoss) {
|
|
139
|
+
exitReason = position.stopLoss > position.initialStopLoss ? 'trailingStop' : 'stopLoss';
|
|
140
|
+
exitPrice = today.open <= position.stopLoss ? today.open : position.stopLoss;
|
|
141
|
+
}
|
|
142
|
+
else if (useTP1 && today.high >= position.takeProfit1) {
|
|
143
|
+
exitReason = 'takeProfit';
|
|
144
|
+
exitPrice = today.open >= position.takeProfit1 ? today.open : position.takeProfit1;
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
const snap = computeBacktestSnapshot(klines, i, { marketEnv: mEnv });
|
|
148
|
+
if (snap && snap.score <= scoreSell) { exitReason = 'reverseSignal'; exitPrice = today.close; }
|
|
149
|
+
else if (i - position.entryIdx >= maxHoldDays) { exitReason = 'timeout'; exitPrice = today.close; }
|
|
150
|
+
}
|
|
151
|
+
} else {
|
|
152
|
+
if (today.high >= position.stopLoss) {
|
|
153
|
+
exitReason = position.stopLoss < position.initialStopLoss ? 'trailingStop' : 'stopLoss';
|
|
154
|
+
exitPrice = today.open >= position.stopLoss ? today.open : position.stopLoss;
|
|
155
|
+
} else if (useTP1 && today.low <= position.takeProfit1) {
|
|
156
|
+
exitReason = 'takeProfit';
|
|
157
|
+
exitPrice = today.open <= position.takeProfit1 ? today.open : position.takeProfit1;
|
|
158
|
+
} else {
|
|
159
|
+
const snap = computeBacktestSnapshot(klines, i, { marketEnv: mEnv });
|
|
160
|
+
if (snap && snap.score >= scoreBuy) { exitReason = 'reverseSignal'; exitPrice = today.close; }
|
|
161
|
+
else if (i - position.entryIdx >= maxHoldDays) { exitReason = 'timeout'; exitPrice = today.close; }
|
|
162
|
+
}
|
|
163
|
+
}
|
|
211
164
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
165
|
+
if (exitReason) {
|
|
166
|
+
const returnPct = position.type === 'long'
|
|
167
|
+
? (exitPrice - position.entryPrice) / position.entryPrice * 100
|
|
168
|
+
: (position.entryPrice - exitPrice) / position.entryPrice * 100;
|
|
169
|
+
trades.push({
|
|
170
|
+
type: position.type,
|
|
171
|
+
entryIdx: position.entryIdx, entryDate: position.entryDate, entryPrice: position.entryPrice,
|
|
172
|
+
entryScore: position.entryScore,
|
|
173
|
+
exitIdx: i, exitDate: today.date, exitPrice: +exitPrice.toFixed(2),
|
|
174
|
+
exitReason,
|
|
175
|
+
returnPct: +returnPct.toFixed(2),
|
|
176
|
+
holdDays: i - position.entryIdx,
|
|
177
|
+
stopLoss: position.stopLoss, takeProfit1: position.takeProfit1,
|
|
178
|
+
});
|
|
179
|
+
position = null;
|
|
180
|
+
}
|
|
219
181
|
}
|
|
220
182
|
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
183
|
+
// 数据末尾还有持仓:按最后收盘强制平仓
|
|
184
|
+
if (position) {
|
|
185
|
+
const last = n - 1;
|
|
186
|
+
const exitPrice = klines[last].close;
|
|
187
|
+
const returnPct = position.type === 'long'
|
|
188
|
+
? (exitPrice - position.entryPrice) / position.entryPrice * 100
|
|
189
|
+
: (position.entryPrice - exitPrice) / position.entryPrice * 100;
|
|
190
|
+
trades.push({
|
|
191
|
+
type: position.type,
|
|
192
|
+
entryIdx: position.entryIdx, entryDate: position.entryDate, entryPrice: position.entryPrice,
|
|
193
|
+
entryScore: position.entryScore,
|
|
194
|
+
exitIdx: last, exitDate: klines[last].date, exitPrice: +exitPrice.toFixed(2),
|
|
195
|
+
exitReason: 'endOfData',
|
|
196
|
+
returnPct: +returnPct.toFixed(2),
|
|
197
|
+
holdDays: last - position.entryIdx,
|
|
198
|
+
stopLoss: position.stopLoss, takeProfit1: position.takeProfit1,
|
|
199
|
+
});
|
|
234
200
|
}
|
|
235
201
|
|
|
236
|
-
|
|
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;
|
|
202
|
+
return summarize(trades, klines);
|
|
245
203
|
}
|
|
246
204
|
|
|
247
|
-
// ====================
|
|
248
|
-
|
|
249
|
-
function
|
|
250
|
-
const
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
205
|
+
// ==================== 汇总统计 ====================
|
|
206
|
+
|
|
207
|
+
function summarize(trades, klines) {
|
|
208
|
+
const longs = trades.filter(t => t.type === 'long');
|
|
209
|
+
const shorts = trades.filter(t => t.type === 'short');
|
|
210
|
+
|
|
211
|
+
function stats(group) {
|
|
212
|
+
if (group.length === 0) return null;
|
|
213
|
+
const wins = group.filter(t => t.returnPct > 0);
|
|
214
|
+
const losses = group.filter(t => t.returnPct <= 0);
|
|
215
|
+
const returns = group.map(t => t.returnPct);
|
|
216
|
+
const holdDaysArr = group.map(t => t.holdDays);
|
|
217
|
+
const sum = returns.reduce((a, b) => a + b, 0);
|
|
218
|
+
const reasonCount = {};
|
|
219
|
+
for (const t of group) reasonCount[t.exitReason] = (reasonCount[t.exitReason] || 0) + 1;
|
|
220
|
+
return {
|
|
221
|
+
total: group.length,
|
|
222
|
+
wins: wins.length,
|
|
223
|
+
losses: losses.length,
|
|
224
|
+
winRate: +(wins.length / group.length * 100).toFixed(1),
|
|
225
|
+
avgReturn: +(sum / group.length).toFixed(2),
|
|
226
|
+
totalReturn: +sum.toFixed(2),
|
|
227
|
+
maxWin: +Math.max(...returns).toFixed(2),
|
|
228
|
+
maxLoss: +Math.min(...returns).toFixed(2),
|
|
229
|
+
avgHoldDays: +(holdDaysArr.reduce((a, b) => a + b, 0) / group.length).toFixed(1),
|
|
230
|
+
exitReasons: reasonCount,
|
|
231
|
+
// 期望值 / 凯利公式所需的盈亏比
|
|
232
|
+
avgWin: wins.length > 0 ? +(wins.reduce((a, t) => a + t.returnPct, 0) / wins.length).toFixed(2) : 0,
|
|
233
|
+
avgLoss: losses.length > 0 ? +(losses.reduce((a, t) => a + t.returnPct, 0) / losses.length).toFixed(2) : 0,
|
|
265
234
|
};
|
|
266
235
|
}
|
|
267
236
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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 };
|
|
237
|
+
return {
|
|
238
|
+
trades,
|
|
239
|
+
long: stats(longs),
|
|
240
|
+
short: stats(shorts),
|
|
241
|
+
dataRange: klines.length > 0 ? `${klines[0].date} ~ ${klines[klines.length - 1].date}` : '',
|
|
242
|
+
totalDays: klines.length,
|
|
243
|
+
};
|
|
305
244
|
}
|
|
306
245
|
|
|
307
|
-
// ====================
|
|
246
|
+
// ==================== 报告输出 ====================
|
|
308
247
|
|
|
309
248
|
function formatBacktestReport(code, klines, btResult) {
|
|
310
|
-
const { results, trades } = btResult;
|
|
311
249
|
const lines = [];
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
lines.push(
|
|
315
|
-
lines.push(`
|
|
316
|
-
lines.push(
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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}%`);
|
|
250
|
+
const { long, short, trades, dataRange } = btResult;
|
|
251
|
+
|
|
252
|
+
lines.push('═'.repeat(64));
|
|
253
|
+
lines.push(` 回测报告: ${code} [事件驱动 / 真实策略 / 大盘过滤]`);
|
|
254
|
+
lines.push(` 数据范围: ${dataRange} (${klines.length}个交易日)`);
|
|
255
|
+
lines.push('═'.repeat(64));
|
|
256
|
+
|
|
257
|
+
if (!long && !short) {
|
|
258
|
+
lines.push('');
|
|
259
|
+
lines.push(' 整个回测周期内没有触发任何入场信号(score 始终在 [-5, 5] 区间)');
|
|
260
|
+
lines.push('═'.repeat(64));
|
|
261
|
+
return lines.join('\n');
|
|
339
262
|
}
|
|
340
263
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
const
|
|
351
|
-
const
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
lines.push(` 持有${days}天: 胜率=${winRate}% | 平均收益=${avgReturn}% | 最大盈利=${maxWin}% | 最大亏损=${maxLoss}%`);
|
|
264
|
+
function block(title, s) {
|
|
265
|
+
if (!s) return;
|
|
266
|
+
lines.push('');
|
|
267
|
+
lines.push('─'.repeat(64));
|
|
268
|
+
lines.push(`【${title}】`);
|
|
269
|
+
lines.push(` 交易笔数: ${s.total} 胜: ${s.wins} 负: ${s.losses} 胜率: ${s.winRate}%`);
|
|
270
|
+
lines.push(` 平均收益: ${s.avgReturn}% 累计收益: ${s.totalReturn}%`);
|
|
271
|
+
lines.push(` 平均盈利: ${s.avgWin}% 平均亏损: ${s.avgLoss}% 盈亏比: ${s.avgLoss < 0 ? Math.abs(s.avgWin / s.avgLoss).toFixed(2) : '∞'}`);
|
|
272
|
+
lines.push(` 最大盈利: ${s.maxWin}% 最大亏损: ${s.maxLoss}% 平均持有: ${s.avgHoldDays} 天`);
|
|
273
|
+
const reasonLabel = { stopLoss: '止损', trailingStop: '跟踪止损', takeProfit: '止盈', reverseSignal: '反向信号', timeout: '超时', endOfData: '数据末尾' };
|
|
274
|
+
const reasonStr = Object.entries(s.exitReasons)
|
|
275
|
+
.map(([k, v]) => `${reasonLabel[k] || k}:${v}`).join(' ');
|
|
276
|
+
lines.push(` 退出方式: ${reasonStr}`);
|
|
355
277
|
}
|
|
356
278
|
|
|
357
|
-
|
|
279
|
+
block('做多统计', long);
|
|
280
|
+
block('做空统计', short);
|
|
281
|
+
|
|
282
|
+
// 最近的交易
|
|
358
283
|
lines.push('');
|
|
359
|
-
lines.push('─'.repeat(
|
|
360
|
-
lines.push('【最近10
|
|
361
|
-
const
|
|
362
|
-
for (const t of
|
|
363
|
-
const
|
|
364
|
-
|
|
284
|
+
lines.push('─'.repeat(64));
|
|
285
|
+
lines.push('【最近 10 笔交易】');
|
|
286
|
+
const reasonLabel = { stopLoss: '止损', trailingStop: '跟踪止损', takeProfit: '止盈', reverseSignal: '反向', timeout: '超时', endOfData: '末尾' };
|
|
287
|
+
for (const t of trades.slice(-10)) {
|
|
288
|
+
const sign = t.type === 'long' ? '▲多' : '▼空';
|
|
289
|
+
const r = t.returnPct >= 0 ? `+${t.returnPct}%` : `${t.returnPct}%`;
|
|
290
|
+
lines.push(` ${t.entryDate} ${sign} @${t.entryPrice} → ${t.exitDate} @${t.exitPrice} | ${r} | ${t.holdDays}天 | ${reasonLabel[t.exitReason] || t.exitReason}`);
|
|
365
291
|
}
|
|
366
292
|
|
|
367
|
-
//
|
|
293
|
+
// 综合评级
|
|
368
294
|
lines.push('');
|
|
369
|
-
lines.push('═'.repeat(
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
else if (wr >= 55 && ar > 1) grade = '良好 - 有一定参考价值';
|
|
378
|
-
else if (wr >= 50) grade = '一般 - 需结合其他因素判断';
|
|
379
|
-
else grade = '较差 - 信号可靠性不足,需优化';
|
|
380
|
-
lines.push(` 综合评价(5日持有): 胜率${wr}% 均收益${ar}% → ${grade}`);
|
|
295
|
+
lines.push('═'.repeat(64));
|
|
296
|
+
if (long) {
|
|
297
|
+
let grade = '较差';
|
|
298
|
+
if (long.winRate >= 60 && long.avgReturn > 3) grade = '优秀 - 信号可靠';
|
|
299
|
+
else if (long.winRate >= 55 && long.avgReturn > 1.5) grade = '良好 - 有参考价值';
|
|
300
|
+
else if (long.winRate >= 50 || long.avgReturn > 0) grade = '一般 - 需结合其他因素';
|
|
301
|
+
else grade = '较差 - 信号不可靠';
|
|
302
|
+
lines.push(` 做多评级: 胜率 ${long.winRate}% / 均收益 ${long.avgReturn}% / ${long.total}笔 → ${grade}`);
|
|
381
303
|
}
|
|
382
|
-
lines.push(
|
|
383
|
-
|
|
304
|
+
lines.push(` 注: 用与实盘 analyze 一致的评分(scoring.js),并按 stopLoss/takeProfit 退出`);
|
|
305
|
+
lines.push(` 局限: 未计手续费和滑点(按用户要求)`);
|
|
306
|
+
lines.push('═'.repeat(64));
|
|
384
307
|
return lines.join('\n');
|
|
385
308
|
}
|
|
386
309
|
|
|
@@ -397,21 +320,51 @@ function resolveCode(input) {
|
|
|
397
320
|
|
|
398
321
|
const WATCH_LIST = ['sz002049', 'sh603893', 'sz300750', 'sh601138', 'sh600011', 'tw2330'];
|
|
399
322
|
|
|
400
|
-
async function
|
|
401
|
-
|
|
402
|
-
|
|
323
|
+
async function fetchAny(code, days) {
|
|
324
|
+
return code.startsWith('tw') ? fetchTWHistory(code, days) : fetchHistory(code, days);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function parseArgs(argv) {
|
|
328
|
+
const args = argv.slice(2);
|
|
329
|
+
const result = { code: 'all', trailing: false, trailingATR: 2.5, useTP1: true };
|
|
330
|
+
for (const a of args) {
|
|
331
|
+
if (a === '--trailing') result.trailing = true;
|
|
332
|
+
else if (a.startsWith('--trailing-atr=')) result.trailingATR = parseFloat(a.split('=')[1]);
|
|
333
|
+
else if (a === '--no-tp1') result.useTP1 = false;
|
|
334
|
+
else if (!a.startsWith('--')) result.code = a;
|
|
335
|
+
}
|
|
336
|
+
return result;
|
|
337
|
+
}
|
|
403
338
|
|
|
404
|
-
|
|
339
|
+
async function main() {
|
|
340
|
+
const args = parseArgs(process.argv);
|
|
341
|
+
const codes = args.code === 'all' ? WATCH_LIST : [resolveCode(args.code)];
|
|
342
|
+
|
|
343
|
+
const modeDesc = [];
|
|
344
|
+
if (args.trailing) modeDesc.push(`跟踪止损=${args.trailingATR}×ATR`);
|
|
345
|
+
if (!args.useTP1) modeDesc.push('无TP1');
|
|
346
|
+
if (modeDesc.length === 0) modeDesc.push('默认: TP1止盈,无跟踪');
|
|
347
|
+
console.log(`\n正在拉取数据并回测... [${modeDesc.join(', ')}]\n`);
|
|
348
|
+
|
|
349
|
+
let indexKlines = null;
|
|
350
|
+
try {
|
|
351
|
+
indexKlines = await fetchHistory('sh000001', 500);
|
|
352
|
+
if (indexKlines.length < 30) indexKlines = null;
|
|
353
|
+
} catch (e) {
|
|
354
|
+
console.log('大盘指数拉取失败,跳过大盘环境过滤');
|
|
355
|
+
}
|
|
405
356
|
|
|
406
357
|
for (const code of codes) {
|
|
407
358
|
try {
|
|
408
|
-
const klines = await
|
|
359
|
+
const klines = await fetchAny(code, 500);
|
|
409
360
|
if (klines.length < 80) {
|
|
410
|
-
console.log(`${code} 数据不足(${klines.length}天)
|
|
361
|
+
console.log(`${code} 数据不足(${klines.length}天),跳过\n`);
|
|
411
362
|
continue;
|
|
412
363
|
}
|
|
413
|
-
|
|
414
|
-
|
|
364
|
+
const btResult = backtest(klines, {
|
|
365
|
+
startIdx: 60, maxHoldDays: 30, indexKlines,
|
|
366
|
+
trailing: args.trailing, trailingATR: args.trailingATR, useTP1: args.useTP1,
|
|
367
|
+
});
|
|
415
368
|
console.log(formatBacktestReport(code, klines, btResult));
|
|
416
369
|
console.log('');
|
|
417
370
|
} catch (e) {
|
|
@@ -420,4 +373,8 @@ async function main() {
|
|
|
420
373
|
}
|
|
421
374
|
}
|
|
422
375
|
|
|
423
|
-
|
|
376
|
+
if (require.main === module) {
|
|
377
|
+
main().catch(e => console.error('回测出错:', e.message));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
module.exports = { backtest, formatBacktestReport, resolveCode, getMarketEnvAtDate };
|