用 Node.js 写 A 股回测:5 个让收益虚高的 Bug 和修法(附代码)

业余自学量化一年多,从零用 Node.js 搭了一套 A 股回测和研究平台。下面是我写回测引擎时实际踩过的 5 个坑,每个都附最小可运行的代码。仅讨论工程实现,不荐股,不构成投资建议。

数据结构约定

// 一根日线
// { date: '2024-01-02', open, high, low, close, preClose, adjFactor, volume, paused }

1. 未来函数:用当天收盘价出信号,又按当天收盘价成交

错误写法:

for (let i = 20; i < bars.length; i++) {
  const ma = avg(bars.slice(i - 19, i + 1).map(b => b.close));
  if (bars[i].close > ma) buy(bars[i].close); // ❌ 收盘才知道信号,却按收盘价成交
}

修法:第 i 天收盘生成信号,第 i+1 天开盘成交。

let pending = null;
for (let i = 20; i < bars.length; i++) {
  if (pending) { execute(pending, bars[i].open, bars[i]); pending = null; } // ✅ 次日开盘成交
  const ma = avg(bars.slice(i - 19, i + 1).map(b => b.close));
  if (bars[i].close > ma) pending = { side: 'buy' };
}

自查技巧:把成交价从 close 换成次日 open 再跑一次,如果收益断崖式下降,原来的结果大概率依赖了做不到的成交。财报数据同理,要按公告日对齐,不能按报告期。

2. 幸存者偏差:股票池用的是「今天的」名单

// ❌ universe = 今天还在交易的所有股票
// ✅ 每个交易日按当天实际可交易的股票构建
function universeOn(date, allStocks) {
  return allStocks.filter(s =>
    s.listDate <= date && (!s.delistDate || s.delistDate > date));
}

数据源里必须包含退市股。指数成分也要用历史成分表,不能用最新名单。

3. 复权:指标和成交用错价格

  • 不复权:除权日出现假的「暴跌」,会误触止损。
  • 前复权:历史价格会随新的分红变化,今天跑和明年跑结果不一致。

我的做法是存原始价格加复权因子,按用途取价:

const adjClose = b => b.close * b.adjFactor; // 算指标、算收益率用(后复权)
const tradePrice = b => b.open;              // 下单、算手数用真实价格

4. A 股成本和交易规则

const COST = {
  commission: 0.00025, minCommission: 5, // 佣金约万2.5,单笔最低5元
  stampTax: 0.0005,                      // 卖出印花税 0.05%(2023-08 起)
  slippage: 0.001,                       // 滑点保守按单边 0.1%
};

function canTrade(bar, side) {
  if (bar.paused) return false;                      // 停牌
  const limitUp = +(bar.preClose * 1.1).toFixed(2);  // 主板 10%,创业板/科创板是 20%
  const limitDown = +(bar.preClose * 0.9).toFixed(2);
  if (side === 'buy' && bar.open >= limitUp) return false;    // 开盘涨停买不到
  if (side === 'sell' && bar.open <= limitDown) return false; // 开盘跌停卖不出
  return true;
}

function fee(amount, side) {
  const c = Math.max(amount * COST.commission, COST.minCommission);
  return c + (side === 'sell' ? amount * COST.stampTax : 0);
}

还有 T+1:持仓要记录买入日期,当天买入的仓位不能当天卖出。另外 A 股按 100 股一手下单,手数要向下取整。

粗算一下:一买一卖光固定成本加滑点就在 0.2% 以上,一年换手 50 次就是 10% 以上的拖累。高频的小策略往往就死在这里。

5. 过拟合:参数网格里挑最好的那一组

// 简易 walk-forward:用 3 年调参,往后测 1 年,再整体往前滚动
function walkForward(bars, params, trainYears = 3, testYears = 1) {
  const results = [];
  for (let start = 0; ; start += testYears * 244) {
    const train = bars.slice(start, start + trainYears * 244);
    const test = bars.slice(start + trainYears * 244, start + (trainYears + testYears) * 244);
    if (test.length < 244) break;
    const best = params.map(p => ({ p, r: backtest(train, p) }))
                       .sort((a, b) => b.r.sharpe - a.r.sharpe)[0].p;
    results.push(backtest(test, best)); // 只记样本外表现
  }
  return results;
}

另外要看参数附近是不是「平原」:均线取 20 很好、取 21 就崩,说明拿到的只是噪音。

小结

这 5 处全部修完,多数漂亮的回测曲线都会明显缩水,有的直接变负。这不是坏事,说明你离真实更近了。回测能帮你排除错误的想法,但证明不了未来一定赚钱。

以上代码均为学习研究示例,不构成任何投资建议。

posted @ 2026-10-31 14:25  青灯夜001  阅读(3)  评论(0)    收藏  举报