回测避坑笔记:复权口径怎么选,附一份可自测的 JS 实现

关键词:除权除息、复权因子、后复权、涨跌停、止损调整

先说结论:指标和信号用后复权价,成交、手数、涨跌停用不复权的真实价,止损线跨除权日按因子下调。 下面按“为什么 → 怎么算 → 代码 → 验证”的顺序展开。

为什么要关心这件事

除权除息让价格出现缺口,但持有人的总价值不变。不复权序列会把这个缺口当成下跌,均线策略就会误卖。

一组参考数据:我用单独写的研究脚本跑真实 A 股日线(2018–2025),除权当天收盘跌破 20 日均线 1684 次,其中 528 次在复权价下没有跌破,约 31%。这组数字来自单独研究脚本,工具包无法复现;本文代码只演示方法,不含数据。

三种口径速查

前复权:保持最新价格不变,往前调整历史。优点是和行情软件当前显示一致。缺点是每次发生新的分红送转,整段历史都会被重新计算,旧数据快照和新数据回测结果不一致,难以复现。

后复权:保持上市首日价格不变,往后累乘因子。历史数值不会被新事件改写,适合计算指标、信号和收益率。

不复权:真实成交价,除权日有缺口。成交价格、成交金额、手数、涨跌停都必须用它。

补充:等比复权下,前复权和后复权只差一个常数倍,均线交叉结论一致;选后复权主要是为了数据稳定、可复现。

怎么算:一个例子走完

示例(假设数字,非真实个股)。

前收盘 10.00 元,方案为 10 送 1 股、每 10 股派 1 元,即送转比例 0.1、每股红利 0.1 元。

第 1 步,除权参考价

参考价 = (前收盘 − 每股红利 + 配股价 × 配股比例) / (1 + 送转比例 + 配股比例)
      = (10 − 0.1 + 0) / (1 + 0.1 + 0) = 9.00

第 2 步,复权因子:10 / 9 ≈ 1.1111。

第 3 步,判断信号:除权日收盘 9.10,不复权 20 日均线约 9.60。不复权下 9.10 < 9.60,判为跌破;后复权下 9.10 × 1.1111 ≈ 10.11,高于均线,不是跌破。相对参考价,当天实际上涨约 1.1%。

第 4 步,涨跌停:以参考价 9.00 为基准。主板 10% → 9.90 / 8.10;创业板、科创板 20% → 10.80 / 7.20;主板 ST 5% → 9.45 / 8.55。北交所为 30%;新股上市初期另有规则。

第 5 步,止损调整:原止损 9.50,除权后调整为 9.50 / 1.1111 ≈ 8.55。

第 6 步,手数:10 万元、真实价 9.10,floor(100000 / 910) × 100 = 10900 股。

代码

零依赖,保存为 adj.js 后执行 node adj.js 即可运行文件末尾的自测。

// 除权参考价:(前收盘 - 每股现金红利 + 配股价×配股比例) / (1 + 送转比例 + 配股比例)
function exRefPrice(prevClose, ev) {
  const { cash = 0, bonus = 0, rightsRatio = 0, rightsPrice = 0 } = ev;
  return (prevClose - cash + rightsPrice * rightsRatio) / (1 + bonus + rightsRatio);
}

// 单次事件的复权因子 = 前收盘 / 除权参考价
function adjFactor(prevClose, ev) {
  return prevClose / exRefPrice(prevClose, ev);
}

// 后复权:以第一天为基准,因子逐次累乘,历史值不会因新分红而改变
// bars: [{date, open, close}](不复权);events: {date: {cash, bonus, rightsRatio, rightsPrice}}
function backwardAdjust(bars, events) {
  let cum = 1;
  return bars.map((b, i) => {
    const ev = events[b.date];
    let refPrev = i > 0 ? bars[i - 1].close : b.close;
    if (ev && i > 0) {
      cum *= adjFactor(bars[i - 1].close, ev);
      refPrev = exRefPrice(bars[i - 1].close, ev); // 除权日涨跌停以参考价为基准
    }
    return { ...b, factor: cum, adjClose: b.close * cum, limitBase: refPrev };
  });
}

function sma(arr, n, i) {
  if (i < n - 1) return NaN;
  let s = 0;
  for (let k = i - n + 1; k <= i; k++) s += arr[k];
  return s / n;
}

// 涨跌幅限制:主板 10%,创业板/科创板 20%,主板 ST 5%(北交所 30%)
function limitPct(board, isST) {
  if (board === 'gem' || board === 'star') return 0.2;
  if (board === 'bse') return 0.3;
  return isST ? 0.05 : 0.1;
}
const r2 = x => Math.round(x * 100) / 100;
function priceLimits(limitBase, board, isST) {
  const p = limitPct(board, isST);
  return { up: r2(limitBase * (1 + p)), down: r2(limitBase * (1 - p)) };
}

// 买入手数:A 股 100 股一手,按真实价算
function lotsFor(cash, rawPrice) {
  return Math.floor(cash / (rawPrice * 100)) * 100;
}

// 止损线跨除权日:按当次因子同步下调
function adjustStop(stop, factor) {
  return r2(stop / factor);
}

// 信号用复权价,成交用真实价
function run(bars, events, { board = 'main', isST = false, cash = 100000 } = {}) {
  const s = backwardAdjust(bars, events);
  const adj = s.map(x => x.adjClose);
  const log = [];
  for (let i = 0; i < s.length; i++) {
    const ma = sma(adj, 20, i);
    const lim = priceLimits(s[i].limitBase, board, isST);
    const atLimitDown = s[i].close <= lim.down; // 用真实价判断,跌停卖不出
    if (adj[i] < ma) log.push({ date: s[i].date, signal: 'sell', price: s[i].close, blocked: atLimitDown });
  }
  return { series: s, log, lots: lotsFor(cash, bars[bars.length - 1].close) };
}

module.exports = { exRefPrice, adjFactor, backwardAdjust, priceLimits, lotsFor, adjustStop, run };

// ---- 自测(示例,假设数字,非真实个股)----
if (require.main === module) {
  const assert = require('assert');
  const ev = { cash: 0.1, bonus: 0.1 }; // 10 送 1 股 + 每 10 股派 1 元
  const ref = exRefPrice(10, ev);
  const f = adjFactor(10, ev);
  console.log('除权参考价', ref.toFixed(2), '复权因子', f.toFixed(4));
  assert.strictEqual(r2(ref), 9.0);

  const bars = [];
  for (let i = 0; i < 20; i++) bars.push({ date: 'd' + i, open: 9.6, close: i === 19 ? 10.0 : 9.6 - 0.0 });
  bars.push({ date: 'ex', open: 9.05, close: 9.1 });
  const ma20raw = sma(bars.map(b => b.close), 20, 20);
  const res = run(bars, { ex: ev });
  const last = res.series[20];
  console.log('除权日 不复权收盘', last.close, '不复权MA20', ma20raw.toFixed(3), '→', last.close < ma20raw ? '跌破' : '未跌破');
  console.log('除权日 后复权收盘', last.adjClose.toFixed(2), '→ 信号数', res.log.length);
  assert.ok(last.close < ma20raw && res.log.length === 0);

  const lim = priceLimits(last.limitBase, 'main', false);
  console.log('除权日涨跌停(主板, 基准9.00)', lim, '创业板', priceLimits(9, 'gem'), '主板ST', priceLimits(9, 'main', true));
  assert.deepStrictEqual(lim, { up: 9.9, down: 8.1 });
  console.log('止损 9.50 跨除权日 →', adjustStop(9.5, f));
  assert.strictEqual(adjustStop(9.5, f), 8.55);
  console.log('10万元 按9.10 可买', lotsFor(100000, 9.1), '股');
  assert.strictEqual(lotsFor(100000, 9.1), 10900);
  console.log('全部断言通过');
}

函数说明

函数 作用 用哪种价格
exRefPrice / adjFactor 计算参考价与单次因子 真实前收盘
backwardAdjust 生成后复权序列、记录涨跌停基准 输出 adjClose 与 limitBase
priceLimits 按板块算涨跌停价 真实价 / 参考价
lotsFor 资金换算为 100 股整数倍 真实价
adjustStop 止损线跨除权日调整 真实价
run 信号用复权价,标记跌停无法卖出 两者分开

验证

自测数据:前 19 天收 9.60,第 20 天收 10.00,第 21 天除权,收 9.10。

除权参考价 9.00 复权因子 1.1111
除权日 不复权收盘 9.1 不复权MA20 9.595 → 跌破
除权日 后复权收盘 10.11 → 信号数 0
除权日涨跌停(主板, 基准9.00) { up: 9.9, down: 8.1 } 创业板 { up: 10.8, down: 7.2 } 主板ST { up: 9.45, down: 8.55 }
止损 9.50 跨除权日 → 8.55
10万元 按9.10 可买 10900 股
全部断言通过

输出与上面的手算一一对应,断言全部通过。

接入时的检查项

仅为方法演示,不荐股,不构成投资建议。

posted @ 2026-11-03 07:57  青灯夜001  阅读(2)  评论(0)    收藏  举报