回测避坑笔记:复权口径怎么选,附一份可自测的 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 股
全部断言通过
输出与上面的手算一一对应,断言全部通过。
接入时的检查项
仅为方法演示,不荐股,不构成投资建议。

浙公网安备 33010602011771号