@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/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 http = require('http');
9
- const https = require('https');
14
+ const { fetchHistory, fetchTWHistory } = require('./analyze');
15
+ const { computeBacktestSnapshot } = require('./scoring');
16
+ const { SMA } = require('./indicators');
10
17
 
11
- // ==================== 数据获取(复用) ====================
18
+ // ==================== 大盘环境(任意历史时点) ====================
12
19
 
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));
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 result;
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
- 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
- }
45
+ // ==================== 事件驱动回测引擎 ====================
115
46
 
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
- }
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
- 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;
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
- 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
- }
117
+ // ===== 持仓中:检查退出条件 =====
118
+ let exitReason = null, exitPrice = null;
162
119
 
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;
120
+ // 更新持仓期间的高低水位
121
+ if (today.high > position.peakPrice) position.peakPrice = today.high;
122
+ if (today.low < position.troughPrice) position.troughPrice = today.low;
173
123
 
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
- }
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
- // 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
- }
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
- 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;
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
- 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);
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
- // === 优化: 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;
202
+ return summarize(trades, klines);
245
203
  }
246
204
 
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: [],
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
- 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 };
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
- 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}%`);
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
- 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}%`);
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(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}`);
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(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}`);
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('═'.repeat(60));
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 main() {
401
- const arg = process.argv[2] || 'all';
402
- const codes = arg === 'all' ? WATCH_LIST : [resolveCode(arg)];
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
- console.log('\n正在获取历史数据进行回测...\n');
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 fetchHistory(code, 500);
359
+ const klines = await fetchAny(code, 500);
409
360
  if (klines.length < 80) {
410
- console.log(`${code} 数据不足(${klines.length}天),跳过\n`);
361
+ console.log(`${code} 数据不足(${klines.length}天),跳过\n`);
411
362
  continue;
412
363
  }
413
-
414
- const btResult = backtest(klines, { holdDays: [3, 5, 10], startIdx: 60 });
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
- main().catch(e => console.error('回测出错:', e.message));
376
+ if (require.main === module) {
377
+ main().catch(e => console.error('回测出错:', e.message));
378
+ }
379
+
380
+ module.exports = { backtest, formatBacktestReport, resolveCode, getMarketEnvAtDate };