系列名:《从零搭建你的 A 股量化系统》| 专栏 S3 数据工程 | 第 3 篇 / 共 15 篇 | M1 行情底座
标签:#量化投资 #复权 #BaoStock #前复权 #后复权 #复权因子 #数据工程 #Python #A股 #散户 #避坑
上一篇《日线获取》我们用 Tushare 免费档把全 A 的不复权 raw 日线 搬回了硬盘。但 raw 价不能直接算收益 ------每当年报分红季,除权日的 raw 会凭空"暴跌"一笔,你的动量/止损信号会集体误触发。这一篇解决 M1 行情底座的第二块拼图:raw + 复权因子,怎么在查询层折成你要的价。
主源换成 BaoStock :它的复权因子接口 query_adjust_factor 免费、免注册、不限 Tushare 那种 1 次/小时 (注:Tushare 免费档的 adj_factor 被限到 1 次/小时,批量维护因子表很吃力),而且日线接口 adjustflag=1/2/3 。
本篇所有结论都来自真实数据实测:茅台 2001-08-27 至 2026-06-26 共 31 次除权 的完整因子链,外加一轮 BaoStock / Tushare / AkShare 三源交叉检查(六项全过,见文末附录 A------结果会让你理解为什么"因子表必须单源定案")。
📍 本篇取数地图
| 项目 | 选择 | 理由 |
|---|---|---|
| 主源(因子·全量) | BaoStock query_adjust_factor(fore/back 双因子,返回 5 列) |
免费免注册、事件表形态;首次建库逐只拉全量 |
| 主源(因子·增量) | BaoStock query_daily_adjust_factor(date)(V0.9.3+) |
某日全市场复权因子,实测 0.2~0.5s/天;日常增量维护用 |
| 主源(日线口径) | BaoStock query_history_k_data_plus(adjustflag=1/2/3) |
一行参数切三种口径,做对拍基准最方便 |
| 日线 raw | S3-002 已落库(Tushare 主 + AkShare 兜底) | 本篇不重新拉日线,只解决折算;两源不复权价已实测逐日一致 |
| 对拍(可选) | Tushare 免费档 adj_factor(限 1 次/小时,需 token) |
只对拍、不合并------本篇实测两源累积因子差 12.7%(比值恒定 1.1274,基准缩放非算法冲突,见坑④),纪律由此而来 |
| 不做什么 | ❌ 不把复权价烧进存储、❌ 不跨源合并因子、❌ 不自己造前复权基准 | 前复权一律用官方 raw × fore,本地 max(back) 不是基准(见第五节) |
🕐 取数时点(官网原文) :日 K 线 17:30 入库、复权因子 18:00 入库、分钟线 20:00、财务次日 1:30。
也就是说当天 18:00 之后才能拿到当日的复权因子------不要在盘中跑因子同步。
📌 你将学到
- 一个统一公式 :
复权价 = raw × (factor_t / factor_base),前/后/不复权只是factor_base选首行、末行、还是 1; - BaoStock 两个复权接口的正确用法:
adjustflag=1/2/3三种口径、query_adjust_factor是另一张独立的事件表(不是日线字段); - 因子事件表 vs 逐日因子表 两种形态的数学等价性,以及事件表怎么用
merge_asof展开成逐日因子; - 🔴 前复权必须用官方写法
qfq = raw × fore:我用真实数据实测了"自造基准"的写法错在哪(茅台 2024 年区间偏差 87.84 元 / 6.15% ),以及为什么落库必须 fore + back 双列都存; - 【实测恒等式】前复权因子 = 后复权因子 ÷ 服务端最新后复权因子 (茅台 5 次 + 浦发 10 次双样本吻合)------它只用于理解与校验,绝不能拿来自造基准;
- 🔴 因子区间必须早于 K 线区间:实测三种"因子缺失时的补全策略"全都有偏差(3.20 / 5.95 / 0.32 元),这是最容易忽视的静默错误;
- 🔴 跨源因子 12.7% 是基准缩放、非算法冲突 :BaoStock
back=7.6693vs Tushareadj_factor=8.6463(比值恒定 1.1274,三源交叉检查 2026-09-01 实证)------因为"基准日不同"; - V0.9.3 全市场批量接口:因子表怎么 0.08s/天 增量维护(首次建库 vs 日常增量两条路径);
- 什么时候用哪个的决策表:算收益用复权、涨跌停/下单股数必须 raw;
- 六个"复权用错一步、回测全废"的坑(raw 假暴跌 -14.29%、误用 hfq 少买 15 股、复权价判涨跌停、跨源因子混用、自造前复权基准、因子区间不足);
- 在 conda
ashare环境里怎么装(哪些 conda、哪个只能 pip); - 配套
src/s3_003_adj_factor.py,已在 condaashare实跑 :--self-test离线 20/20 全过,--demo联网拉茅台 31 次除权 真实因子、三口径对拍(hfq / qfq 均为 0.0000 元)并落因子表。
📖 前置知识
- 已跟完 S3-001(三源分工)与 S3-002(raw 日线已落
data/daily/,只存不复权价); - 知道除权除息是什么:分红/送股让不复权价在除权日凭空跳空------这是本篇所有坑的根源;
- 知道架构口径:
data/daily/存 raw、data/meta/adj_factor/存因子、查询层实时折算。
一、先说结论
三种复权口径,本质是同一份 raw + 同一份因子表,只差"基准日"怎么选。先上红线表:
| 🔴 红线 | 原因(一句话) | 正确做法 |
|---|---|---|
| 不复权 raw 绝不能算收益率 | 除权日 raw 凭空跳空(本篇实测茅台 -3.59%,样例推演 -14.29%),信号全是假的 | 收益/净值/技术指标一律用复权价 |
| 涨跌停 / 下单股数 / 现金必须用 raw | 撮合按真实成交价结算,复权价会算错股数、误判能否成交 | 撮合、下单、风控判定一律 raw |
| 别把复权价烧进存储 | 后复权把因子"烧死"在价格里,分红再投资/撮合/增量更新全错 | 只存 raw + 因子事件表,查询层实时折算 |
| 前/后复权收益率完全等价 | 基准日是常数,在 pct_change 里被约掉 |
别纠结选哪个,唯一不能碰的是 raw |
| 🔴 因子表必须单源定案 | 三源交叉检查实测:两源因子比值恒定 1.1274(0.00% 漂移,算法等价),仅基准日不同 | 因子只从 BaoStock 拿;不可直接拼绝对值(会系统偏移 12.7%),但收益率层面两源等价(C4/C5 验证) |
🔴 前复权必须用 raw × fore |
fore 的基准是服务端最新 因子,本地 max(back) 不是它 |
落库 fore + back 双列 ;qfq = raw × fore、hfq = raw × back |
| 🔴 因子区间必须早于 K 线 | 区间不足时早期交易日匹配不到因子,怎么补都是错的 | 因子从 1990-12-19(或上市日)拉起,不要和 K 线对齐 |
一句话落地:data/meta/adj_factor/ 存一张 BaoStock 因子事件表(fore + back 双列都要),查询时用 merge_asof 展开成逐日因子,hfq = raw × back、qfq = raw × fore、raw 就是 raw------存一份、算万次。基准交给服务端,别自己造。
再上什么时候用哪个决策表(算收益 vs 办交割,一张表说清):
| 你要算/做的事 | 用哪种口径 | 为什么 |
|---|---|---|
| 日收益率、累计收益、净值曲线 | qfq 或 hfq | 连续无跳空;qfq 最新价=真实价,便于对账户 |
| 技术指标(MA / RSI / MACD / 布林) | qfq | 跳空会被指标当成真实波动,信号全乱 |
| 画 K 线、看历史走势 | qfq | 日常最直观 |
| 判定涨跌停、是否停牌 | raw | 涨跌停按真实前收盘的 ±10% 算(架构 §4.6(4)) |
| 下单股数、现金出入、成交额 | raw | 真金白银按真实价买真实股数 |
| 事件研究、分红再投资回溯 | hfq | 上市首日=真实价,向后连续反映再投资 |
口诀:"算收益用复权,办交割用 raw。" 回测引擎内部也该如此------信号用复权价算,撮合用不复权价,这是架构的硬规矩。
二、三种口径的本质:同一个公式,只差基准日
💡 前复权、后复权、不复权,是同一份 raw 价 + 同一份因子表,按不同"基准日"折算出来的三个视图。
统一公式:
复权价_t = raw_t × (factor_t / factor_base)
- 不复权(raw) :
base = 1,真实成交价本身,含除权跳空; - 后复权(hfq) :
base = 1------因为 BaoStock 的back列本身就已经是"上市日=1"的累积因子,所以hfq = raw × back直乘即可; - 前复权(qfq) :
base = back_latest------最新一天保持真实价,之前被"压"低,看图最直观。
⚠️
back_latest是服务端【当前最新】的累积因子,不是你本地因子表的末行、也不是max(back)。这个区别看着像吹毛求疵,实则要命------我自己就在这里栽过跟头,第五节会用真实数据告诉你能错到什么程度(茅台 2024 年偏 87.84 元)。
关键推论 :base 是常数,pct_change 会把它约掉------前复权收益率 ≡ 后复权收益率 。"算收益用前还是后"是伪问题,真正的雷只有一处:用了 raw。
三、BaoStock 的两个复权接口
3.1 query_history_k_data_plus(adjustflag=...):一行参数切三种口径
adjustflag 三选一:1=后复权、2=前复权、3=不复权 。想看茅台 2026-08-28 的前复权收盘?adjustflag="2" 直接给 1297.4。这就是免费数据源里"日线自带复权维度"的价值(S3-001 三源分工)------Tushare 免费档的 daily 只给不复权价,要复权得自己找因子。
3.2 query_adjust_factor:复权因子是另一张独立的表
我第一次用时以为 adjustflag=2 返回的价格里就"带着"因子------错了。因子在另一张表里 ,两表配合用才是完整姿势。它的返回是事件表(只在除权日有记录),不是逐日:
dividOperateDate foreAdjustFactor backAdjustFactor adjustFactor
2024-06-19 0.927559 7.113689 7.113689
2024-12-20 0.942063 7.224927 7.224927
2025-06-26 0.960527 7.366525 7.366525
2025-12-19 0.976883 7.491968 7.491968
2026-06-26 1.000000 7.669257 7.669257
(这是本篇 demo 实跑拉回的茅台真实因子 ,全历史 31 条,这里截最近 5 条。注意茅台自 2024 年起改为一年两次分红。)
返回 5 个字段 (按官网 factorInfo.md):
| 字段 | 官网定义 | 理解 |
|---|---|---|
code |
证券代码 | sh.600519 |
dividOperateDate |
除权除息日期 | 事件表的日期键 |
foreAdjustFactor |
向前复权因子 | 除权日前一交易日收盘 ÷ 除权日最近交易日的前收盘(累积值) |
backAdjustFactor |
向后复权因子 | 除权日最近交易日的前收盘 ÷ 除权日前一交易日收盘(累积值) |
adjustFactor |
本次复权因子 | 实测与 backAdjustFactor 同值 |
四个必须知道的性质:
backAdjustFactor锚定上市日,永不变 ------它是"上市以来所有除权调整的累积乘数",2026-06-26 之后每天的后复权价 = raw × 7.669257,一年后查还是这个数。适合落库。foreAdjustFactor锚定服务端最新交易日,会变 ------你今天查 fore=1.0 挂在 2026-06-26 那行;等明年茅台再分红,同一行的 fore 就不是 1.0 了。但它仍然必须落库:因为前复权只能用 fore 直乘(第五节给你看"自造基准"的血案)。- 它是事件表不是逐日表 ------中间的交易日没有因子行,用的时候要把它"展开"成逐日(
merge_asof向后对齐,找"当天或之前最近一次除权"的因子)。 - 官网明示算法 :BaoStock 官方文档称其提供涨跌幅复权算法 的复权因子(官方原文)。官网这个说法容易让人以为它与 Tushare 的等比因子"算法不同"------但三源交叉检查证伪了这个直觉:实测两源因子比值为恒定 1.1274(0.00% 漂移) ,两源只是基准日不同(BaoStock 锚定上市日、Tushare 锚定数据起始日),算法本身等价(展开见坑④)。
3.3 V0.9.3 新增:某日全市场复权因子(因子表增量维护)
这是 V0.9.3 带来的最大更新。老接口 query_adjust_factor(code=...) 只能逐只 拉全量;V0.9.3(2026/07/10)新增的 query_daily_adjust_factor(date=...) 没有 code 参数 ------它一次返回该日全市场所有发生除权的股票:
python
df = fetch_daily_factor_market(bs, "2026-06-26") # 119 只 / 0.42s
df = fetch_daily_factor_market(bs, "2026-08-28") # 8 只 / 0.29s
实测(茅台真实数据):
[2026-06-26] 全市场 119 只股票发生除权, 耗时 0.42s
命中 sh.600519: fore=1.000000 back=7.669257
[2026-08-28] 全市场 8 只股票发生除权, 耗时 0.29s
sh.600519 当日无除权
所以因子表的维护是两条路径:
| 场景 | 接口 | 成本 |
|---|---|---|
| 首次建库 | query_adjust_factor(code, "1990-12-19", "") 逐只全量 |
约 0.359s/只,全 A 一次约 30 分钟(一次性) |
| 日常增量 | query_daily_adjust_factor(date=今天) |
0.2~0.5s/天,每天 18:00 后跑一次 |
⚠️ 字段拼写 :老接口第 5 列叫
adjustFactor(带r),而新接口服务端实际返回adjustFacto(少一个r)。两个接口的参数形态不同,取样维度也不同:
- 老接口 按【标的 + 日期区间】取数(没有单日参数)→ 测 3 个标的 :
sh.600519茅台(本篇样本)/sh.600000浦发(官网示例股)/sz.000001平安,区间 2015~2017;- 新接口 按【单个日期】取全市场 → 测 3 个日期 ,跨年份更有力:
2015-06-23(官网返回示例数据里的一个除权日)/2024-07-18(官方 demo 所用日期)/2026-06-26(本篇实测日)。结果:老接口 3 次全部返回
adjustFactor(带 r,与官网文档一致),新接口 3 次全部返回adjustFacto(少 r),组内零分歧:
pythonrs = bs.query_adjust_factor(code="sh.600519", start_date="2015-01-01", end_date="2017-12-31") rs.fields # ['code','dividOperateDate','foreAdjustFactor','backAdjustFactor','adjustFactor'] ← 带 r # sh.600000 / sz.000001 同区间实测,第 5 列同样带 r rs = bs.query_daily_adjust_factor(date="2026-06-26") rs.fields # ['code','dividOperateDate','foreAdjustFactor','backAdjustFactor','adjustFacto'] ← 少 r # date="2015-06-23" / "2024-07-18" 实测,第 5 列同样少 r别信注释,信
rs.fields:官方 demodemo_daily_adjust_factor_data.py第 12 行注释写的是adjustFactor(带 r),与服务端实际返回不一致 ------照注释硬编码列名就会KeyError。正确姿势是永远用rs.fields建 DataFrame (官方 demo 本身也是这么写的,只是注释写错了)。本脚本_rs_to_df()在此基础上再加一道adjustFacto → adjustFactor规整兜底(自检第 13 项)。💡 查坑小技巧 :字段名在查询返回时就已经躺在
rs.fields里了,不必next()遍历数据 ------想知道任何接口的真实列名,调一次、打印rs.fields就够,比拉全量数据快一个数量级。
3.4 BaoStock 的使用纪律
- 结果集两种写法都行 :
while rs.next(): rows.append(rs.get_row_data())(官网示例),或rs.get_data()(V0.8.8 起提供,直接返回 DataFrame)。真正的坑是"既不迭代、也不调get_data(),直接取rs.get_row_data()" ------那只会拿到一行拼接串。本脚本用while写法,兼容性最好。 - 日期只认
YYYY-MM-DD;非交易日静默返回 0 行(查询窗口要含已知交易日,脚本做回退兜底); - 因子区间必须早于 K 线区间(见 5.4 节实验)------用真实数据固化的铁律;
- 程序文件名、文件夹名不能叫
baostock(官网明确警告),否则import baostock会导入到你自己的文件; - 别信
bs.__version__:实测它返回"00.9.30"(多了个前导 0),而真实 PyPI 版本是0.9.3。要查版本请用importlib.metadata.version("baostock")。
四、本篇实测:茅台 2026-06-26 除权全景
demo 拉了茅台除权日(2026-06-26,周五)前后的真实数据,把"三种视图"摆在同一张表里:
date 不复权raw 自算hfq BS直接hfq hfq差 自算qfq BS直接qfq qfq差
2026-06-22 1241.41 9300.603995 9300.603995 0.0 1212.712325 1212.712325 0.0
2026-06-23 1222.45 9158.556282 9158.556282 0.0 1194.190623 1194.190623 0.0
2026-06-24 1207.68 9047.899914 9047.899914 0.0 1179.762061 1179.762061 0.0
2026-06-25 1212.10 9081.014413 9081.014413 0.0 1184.079884 1184.079884 0.0
2026-06-26 1168.63 8962.523808 8962.523808 0.0 1168.630000 1168.630000 0.0
2026-06-29 1194.96 9164.455345 9164.455345 0.0 1194.960000 1194.960000 0.0
2026-06-30 1185.49 9091.827481 9091.827481 0.0 1185.490000 1185.490000 0.0
(自算 = 用第三节的事件表自算,BS直接 = BaoStock adjustflag 直接给的。)
读这张表,四个实测结论:
- 除权跳空 :raw 从 1212.10 → 1168.63,-3.59% ------这是分红,不是暴跌。可 raw 的
pct_change会老老实实算出 -3.59%,你的止损线如果设 3.5%,这天就被误杀。 - hfq 对拍零误差 :自算
raw × back(merge_asof 展开事件表)与 BaoStock 直接adjustflag=1给的后复权价,最大绝对差 = 0.0000 元 ------证明 BaoStock 的后复权就是逐日因子直乘,事件表展开法精确成立。 - qfq 对拍也是 0.0000 元 :⚠️ 曾经有一个更直觉的写法用
hfq / max(back)自造基准,在这个 demo 区间它"只"差 0.0002 元------看起来很小,但那只是因为本次因子表恰好包含了"最新一次除权"。换个历史区间,同样的写法会错到 87.84 元(见下一节)。 - 查询层折算验证 :拿 S3-002 落库的 Tushare raw(2026-08-28 收盘 1297.40)× BaoStock 因子 7.669257 = 9950.09 ,与公式分毫不差------raw 与因子来自两个源也能正确拼装,因为两源不复权价已实测逐日一致(S3-002 跨源对拍)。
📌 注意 2026-06-22~25(除权日前 )的自算 hfq 用的是上一个事件 (2025-12-19)的因子 7.491968,而不是最新的 7.669257------
merge_asof(direction="backward")的"截至当日最新"语义,正是事件表展开的正确姿势。
五、🔴 两个静默坑的实测复现(坑⑤⑥展开)
本节是第六节**坑⑤(自造前复权基准)与 坑⑥(因子区间铁律)**的完整实测展开。如果你在网上见过"前复权 = 后复权 ÷ 因子表最大因子"这类说法,请务必看完这一节------它在多数时候看起来是对的,只在回测历史区间时炸,而且不报错。
5.1 实测恒等式:fore = back / back_latest(双样本验证)
把茅台最近 5 次除权的真实因子里外一除,一个漂亮的恒等式浮出水面:
foreAdjustFactor = backAdjustFactor / back_latest
7.113689 / 7.669258 = 0.927559 ✓ (2024-06-19 行)
7.224927 / 7.669258 = 0.942063 ✓ (2024-12-20 行)
7.366525 / 7.669258 = 0.960527 ✓ (2025-06-26 行)
7.491968 / 7.669258 = 0.976883 ✓ (2025-12-19 行)
7.669257 / 7.669258 = 1.000000 ✓ (2026-06-26 行)
拿浦发银行(sh.600000)10 次除权 做独立样本复核,同样成立:10 行 back / fore 全部落在 13.3670 ± 0.001(如首行 3.890931 / 0.291085 = 13.3672、末行 9.779664 / 0.731627 = 13.3670,完整 20 组数字在自检第 8 项断言里)。
数学上这是必然的 :fore 和 back 是同一串累积因子的两个基准(fore 基准=服务端最新日,back 基准=上市日),两者只差一个常数 back_latest。
💡 两条恒等式都写进了
--self-test断言(自检第 8 项,用上面两组真实数字)。
5.2 于是我犯了个错:用 max(back) 当基准
看到这个恒等式,很容易得出一个看起来很漂亮的结论:
"fore 会随新除权重基准,back 锚定上市日永不变 → 落库只存 back 列 ,前复权用
qfq = hfq / max(back)现算。"
这个结论是错的。 错在哪?关键在于 back_latest 是 BaoStock 服务端当前的 最新累积因子,它不在你本地的因子表里。
只有当你的因子表**恰好包含"全历史最后一次除权"**时,本地 max(back) 才碰巧等于 back_latest。一旦你:
- 只拉了一段历史区间的因子(很常见:拉 2024 年的因子去算 2024 年的回测);
- 或者因子表是几个月前落的、之后股票又分过红;
......两者就不相等,前复权价整段平移。
5.3 实测bug:茅台 2024 年,偏差 87.84 元
我把因子表人为截断到 2024-12-20 (模拟"因子只拉到 2024 年底"),去算 2024-06-12 ~ 06-26 这段的前复权价,与 BaoStock adjustflag=2 的真值对拍:
区间 2024-06-12~2024-06-26;因子表被截断为 28 条(末条 2024-12-20)
截断表 max(back) = 7.224927 而服务端 back_latest = 7.669258 -> 差 1.0615 倍
date raw BS直接qfq ×fore(官方) ×back/max(旧) 官方差 旧写法差
2024-06-12 1571.68 1428.2391 1428.2391 1516.0744 0.0 87.8353
2024-06-17 1541.50 1400.8135 1400.8135 1486.9621 0.0 86.1486
2024-06-19 1501.00 1392.2661 1392.2661 1477.8900 0.0 85.6239
2024-06-21 1471.00 1364.4393 1364.4393 1448.3519 0.0 83.9126
2024-06-26 1489.22 1381.3394 1381.3394 1466.2913 0.0 84.9519
✅ 官方写法(raw×fore) 最大偏差 = 0.000000 元
❌ 旧写法(raw×back/max(back)) 最大偏差 = 87.8353 元
同一份 raw、同一份因子表,只因基准取法不同,前复权价差了 87.84 元(6.15%)。
浦发银行上更极端:区间 2014-06-25 ~ 2016-06-22,max(back)=6.295967 而 back_latest=13.367007------差 2.12 倍 ,旧写法算出 8.58 元,真值是 4.04 元,偏差 +112%。
🔴 铁律:
- 前复权一律
qfq = raw × foreAdjustFactor(官方localdatafactorInfo.md的写法);- 落库必须 fore + back 双列都存,fore 不是"可推导的冗余状态",它是服务端算好的基准快照;
- 恒等式
fore = back / back_latest只用于理解与校验 (比如验证新拉的数据有没有串位),绝不用于自造基准。
5.4 因子区间必须早于 K 线区间
顺着这条线索我又验了一个更隐蔽的坑:如果因子区间不够早,早期交易日匹配不到因子,你打算怎么补?
以浦发为例,因子区间设成 2014-01-01 ~ 2020-01-01(和 K 线一样长),首次除权日在 2014-06-24,于是 114 个交易日的因子为空 。实测三种补全策略,与真值(adjustflag=2)对拍:
| 补全策略 | 早期段最大偏差 | 说明 |
|---|---|---|
| 官方示例代码的 fallback(取最新 fore) | 3.20 元 | 官网示例里那段 else 分支 |
填 1.0(直觉做法) |
5.95 元 | 最差,等于没复权 |
| 填首次除权的 fore | 0.32 元 | 最接近,但仍错 |
三种全错。 因为因子区间被截断了,2014-06-24 之前其实还有 2013、2012、2011、2010 年的除权,那些因子根本没被拉下来。
🔴
query_adjust_factor的start_date一律从1990-12-19(BaoStock 数据起点)或股票上市日拉起,永远不要和你的 K 线区间对齐 。本脚本fetch_bs_factor()的start默认值就是"1990-12-19"。顺带说一句:官网
localdatafactorInfo.md的示例代码里factor_start = "2010-01-01"比 K 线起点2014-01-01早,主流程是对的 ;它那个else: 返回最新 fore的分支在示例里属于不会触发的防御代码------但一旦你把因子区间收窄,它就会算成 6.83 元(真值 3.91 元)。
六、复权用错一步、回测全废的六个坑
坑① 用 raw 算收益 → 除权日假暴跌
合成样例推演(除权日 2024-06-05,105 → 90):
| date | 不复权 | 后复权 | raw日收益% | 复权日收益% |
|---|---|---|---|---|
| 2024-06-04 | 105.0 | 105.0000 | 5.00 | 5.00 |
| 2024-06-05 | 90.0 | 105.0000 | -14.29 | 0.00 |
| 2024-06-06 | 92.0 | 107.3333 | 2.22 | 2.22 |
-14.29% 的"暴跌"其实是分红。--self-test 把它做成了断言:raw 除权日收益 = -14.29%,hfq 除权日收益 ≈ 0。如果你的回测收益曲线每年分红季集体塌方,先别怀疑策略,八成是用了 raw。(真实案例就是第四节的茅台 -3.59%。)
坑② 误用复权价算下单股数 → 系统性少买
1 万元按真实价 raw=96 买:10000 // 96 = 104 股;误用后复权价 hfq=112 买:10000 // 112 = 89 股。差 15 股(约 14%)------长期复利下这不是舍入误差,是系统性少买。撮合、现金、股数,一律 raw。
坑③ 用复权价判定涨跌停 → 回测买到"实际买不进"的票
涨跌停按真实前收盘 ±10% 判定。拿复权价算"10% 线",得到的价位和真实成交价的 10% 线不是同一个数,回测里会"买到实际封板买不进的票",还记入"未成交归因"都发现不了------因为你以为它没涨停。
坑④ 🔴 跨源因子混用 → 本篇实测差 12.7%(但只是基准缩放,非算法冲突)
配套脚本 s3_003_crosscheck.py 用 token 对拍了茅台 2026-06-26 的累积后复权因子:
[跨源对拍] 茅台最近一次除权日 20260626
BaoStock backAdjustFactor = 7.6693
Tushare adj_factor = 8.6463
比值 Tushare / BaoStock = 1.1274
同一家公司、同一个除权日,两家免费源的累积后复权因子差了 12.74%。
看到这个数字,最直觉的解释是:"两家算法不同(官网自称涨跌幅复权 vs 等比因子),日度取整误差长期累积,只会越拉越远。"三源交叉检查证伪了这个直觉 ------逐日因子对拍 2026-06-01~08-28 共 64 个交易日 ,Tushare adj_factor / BaoStock back 恒定在 1.127396(min 1.127394 / max 1.127397),漂移 0.00%(std=1.5e-6) :比值是常数,没有累积漂移。两源因子的差异只是恒定的基准缩放 (BaoStock 锚定上市日、Tushare 锚定数据起始日),算法本身等价。
由此得到的纪律 :因子表仍须单源定案(本系列定 BaoStock) ------两源因子绝对值差 12.7%(基准不同),直接拼接绝对值会系统性偏移 12.7%;但收益率层面两源等价 (交叉检查 C4/C5 实测 BaoStock vs AkShare 后/前复权收益率差仅 3~4e-6,C6 更验证"Tushare raw × BaoStock 因子 = BaoStock 直接 hfq,差 0.000000 元")。"单源定案"的真正理由是基准不同,不是"算法冲突、绝不能混用"。实证数据见文末附录 A。
坑⑤ 🔴 自造前复权基准 → 历史区间整段平移
完整实测展开见第五节 5.1~5.3 (恒等式 → 踩坑过程 → 血案复现)。一句话:qfq = hfq / max(back) 只在"因子表包含最新一次除权"时碰巧正确;回测历史区间时,本地 max(back) ≠ 服务端 back_latest,前复权价整段平移(实测茅台 2024 年偏 87.84 元 / 6.15%,浦发 2014 年偏 112%)。
正确做法 :qfq = raw × foreAdjustFactor,落库 fore + back 双列。
坑⑥ 🔴 因子区间和 K 线对齐 → 早期日期怎么补都错
完整实测展开见第五节 5.4 。因子表的 start_date 必须从 1990-12-19(或上市日)拉起。实测三种补全策略偏差 3.20 / 5.95 / 0.32 元,没有一个是 0。
顺带一个"看起来像 bug 其实不是"的:merge_asof 展开后,早于首次除权的日期因子是 NaN。不要慌着去填------正确解法是把因子区间拉长,而不是找个值填上。
七、环境安装:conda 优先,baostock 只能 pip
在 conda ashare 环境(Python 3.11.15)里,本篇依赖的实测边界:
| 包 | 版本 | 安装方式 |
|---|---|---|
pandas / pyarrow |
3.0.3 / 24.0.0 | ✅ conda (conda install -c conda-forge pandas pyarrow) |
baostock |
0.9.3(官网 V0.9.3,2026/07/10) | ⚠️ 只能 pip (conda search 全通道 No match found,官方仅发布 PyPI------继 akshare/tushare 后第三个例外) |
tushare(可选,跨源对拍用) |
1.4.29 | ⚠️ 只能 pip(同上) |
⚠️ 版本号有坑 :
baostock.__version__实测返回"00.9.30"(多了个前导0),但真实 PyPI 版本是0.9.3(对应官网 V0.9.3)。判断接口能力时请以importlib.metadata.version("baostock")为准:
pythonimport importlib.metadata as md print(md.version("baostock")) # 0.9.3 ✅ import baostock; print(baostock.__version__) # 00.9.30 ⚠️ 别信它为什么在意版本?因为
query_daily_adjust_factor等批量接口只有 ≥0.9.3 才有(V0.9.3 新增)。
bash
conda activate ashare
conda install -c conda-forge pandas pyarrow -y # 依赖一律 conda
pip install baostock -i https://mirrors.aliyun.com/pypi/simple/ # 本体 PyPI-only
# 可选(跑跨源对拍): pip install tushare -i https://mirrors.aliyun.com/pypi/simple/
python -c "import baostock, pandas, pyarrow; print('ok')"
🔐 token 安全(只和"跨源对拍"有关)
本篇主源 BaoStock 免注册、免 token ------token 只在可选的跨源对拍里用到。三条铁律不变:只放环境变量 TUSHARE_TOKEN、绝不写进 .py / 博客 / Git。本篇所有交付物里没有任何 token 字符(发布前 grep 过)。
八、可运行脚本(已实跑)
完整代码 src/s3_003_adj_factor.py,把上面所有东西收进一个文件。已在 conda ashare(Python 3.11.15 / pandas 3.0.3 / pyarrow 24.0.0 / baostock 0.9.3)实跑:
text
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
s3_003_adj_factor.py ------ 复权:BaoStock 复权因子 + 查询层实时折算 (S3-003 数据工程)
对应博客:S3-003《复权------前复权、后复权、不复权,什么时候用哪个》
运行环境: conda 虚拟环境 ashare (Python 3.11)
安装(能 conda 装的一律 conda; baostock 是 PyPI-only 例外):
conda activate ashare
conda install -c conda-forge pandas pyarrow -y
pip install baostock # conda 全通道无此包(实测 No match found), 需先 activate ashare
═══════════════════════════════════════════════════════════════
本篇取数地图(照 S3-001 §5.5 执行)
═══════════════════════════════════════════════════════════════
主源 : BaoStock query_adjust_factor(复权因子, 免费免注册, fore/back 双因子)
+ query_history_k_data_plus(adjustflag=1/2/3) 三种口径日线
增量 : query_daily_adjust_factor(date) ------ V0.9.3+ 某日【全市场】复权因子
日线raw : 用 S3-002 已落库的 Tushare raw(data/daily/),复权在查询层自算
对拍 : Tushare adj_factor(可选, 需 TUSHARE_TOKEN, 限 1 次/小时)------
只对拍累积后复权因子、绝不合并(跨源纪律)
不合并 : 因子表只存事件(除权日), 不把复权价烧进日线存储
═══════════════════════════════════════════════════════════════
核心结论(三种口径 = 同一份 raw + 同一份因子表, 只差怎么折算)
═══════════════════════════════════════════════════════════════
Tushare 式(逐日因子) : 复权价 = raw × factor_t / factor_base (base=首行→hfq, 末行→qfq)
BaoStock 式(事件因子) : 后复权价 = raw × backAdjustFactor (直接乘数)
前复权价 = raw × foreAdjustFactor (直接乘数, 官方写法)
🔴 血泪教训(2026-09-01 按官网复核时发现并修复的 BUG):
前复权【必须】用官方写法 raw × foreAdjustFactor,
不能用 qfq = raw × back / max(back_in_events)!
原因:fore 的基准是【服务端最新】累积因子 back_latest,它不在本地 events 里;
用本地 events.max(back) 当基准,只在"events 末条 == 服务端最新"时碰巧正确,
一旦回测历史区间就系统性算错(实测浦发 2014 年错 112%)。
=> 落库必须 fore + back 两列都存,不能只存 back 列。
铁律:
1) raw 绝不能用来算收益率 ------ 除权日会凭空跳空(假暴跌);
2) 涨跌停/成交量/下单股数/现金 必须用不复权真实价 ------ 复权只改价格列, 量额不动;
3) 只存 raw + 因子表, 复权在查询层实时折算, 绝不把复权价固化进存储;
4) 【因子区间必须比 K 线区间早】------ 否则早期交易日匹配不到因子, 怎么填都是错的
(实测: 官方示例的 fallback 错 3.20 元、填 1.0 错 5.95 元、填首次因子错 0.32 元)。
用法:
conda activate ashare
python src/s3_003_adj_factor.py --self-test # 离线验证折算数学 + 跳空坑 (不联网)
python src/s3_003_adj_factor.py --demo # 联网: 拉茅台真实因子, 对拍 + 落因子表
# (可选跨源对拍: export TUSHARE_TOKEN=xxx)
参考(2026-09-01 逐条核对):
https://baostock.com/mainContent?file=home.md 平台/更新时间/版本
https://baostock.com/mainContent?file=factorInfo.md 复权因子字段与算法定义
https://www.baostock.com/mainContent?file=localdatafactorInfo.md 官方手动折算示例
"""
from __future__ import annotations
import argparse
import sys
import tempfile
from pathlib import Path
import pandas as pd
HERE = Path(__file__).resolve().parent
DATA_DIR = (HERE.parent / "data").resolve()
DAILY_DIR = DATA_DIR / "daily" # S3-002 落好的 Tushare raw
ADJ_DIR = DATA_DIR / "meta" / "adj_factor" # 本篇落因子事件表
for _d in (DAILY_DIR, ADJ_DIR):
_d.mkdir(parents=True, exist_ok=True)
PRICE_COLS = ["open", "high", "low", "close"]
# 来源源名(落库元信息)
SOURCE_BAOSTOCK = "baostock"
# ---------------------------------------------------------------- 样例(离线自检用)
def build_sample() -> tuple[pd.DataFrame, pd.DataFrame]:
"""构造一份带除权事件的样例: 第 3 天(2024-06-05)现金分红, raw 价 105 跳空到 90。
因子事件表只记录除权日: 除权后 backAdjustFactor 变大(把除权后的价"抬"回连续)。
"""
raw = pd.DataFrame({
"date": pd.to_datetime(["2024-06-03", "2024-06-04", "2024-06-05",
"2024-06-06", "2024-06-07"]),
"symbol": "600519.SH",
"open": [99.5, 104.8, 89.5, 91.5, 95.5],
"high": [100.8, 105.6, 90.8, 93.0, 96.8],
"low": [98.9, 103.9, 88.9, 91.0, 94.9],
"close": [100.0, 105.0, 90.0, 92.0, 96.0],
"volume": [2_000_000, 2_100_000, 3_500_000, 2_200_000, 2_300_000],
"amount": [2.0e8, 2.2e8, 3.1e8, 2.0e8, 2.2e8],
})
events = pd.DataFrame({
"dividOperateDate": pd.to_datetime(["2024-06-05"]),
"foreAdjustFactor": [1.0], # 除权后前复权因子
"backAdjustFactor": [105.0 / 90.0], # 后复权因子: 除权后把价抬回连续
})
return raw, events
# ---------------------------------------------------------------- 折算(两种因子形态)
def apply_tushare_style(raw: pd.DataFrame, factor_daily: pd.DataFrame,
mode: str = "hfq") -> pd.DataFrame:
"""Tushare 式:逐日因子, 复权价 = raw × factor_t / factor_base。
mode: 'hfq' base=首行因子; 'qfq' base=末行因子; '' 不复权。
"""
if mode not in ("hfq", "qfq", ""):
raise ValueError(f"未知 adjust 模式: {mode!r}")
df = raw.merge(factor_daily[["date", "adj_factor"]], on="date", how="left")
if mode == "":
return df
base = df["adj_factor"].iloc[0] if mode == "hfq" else df["adj_factor"].iloc[-1]
ratio = df["adj_factor"] / base
for c in PRICE_COLS:
df[c] = df[c] * ratio
return df
def expand_bs_factor(raw: pd.DataFrame, events: pd.DataFrame) -> pd.DataFrame:
"""BaoStock 式:把【除权事件表】展开成逐日因子(截至当日的最新因子)。
核心: merge_asof(direction='backward') ------ 找"当天或之前最近一次除权"的因子。
这就是"存事件表、查询时展开"的落地姿势。
"""
ev = events.rename(columns={"dividOperateDate": "date"})
ev["date"] = pd.to_datetime(ev["date"])
m = pd.merge_asof(
raw.sort_values("date"), ev.sort_values("date"),
on="date", direction="backward",
)
return m
def apply_bs_back(raw: pd.DataFrame, events: pd.DataFrame) -> pd.DataFrame:
"""BaoStock 式后复权:hfq = raw × backAdjustFactor(截至当日最新)。
只折算实际存在的价格列(传 close 单列也能用)。
"""
m = expand_bs_factor(raw, events)
for c in [c for c in PRICE_COLS if c in m.columns]:
m[c] = pd.to_numeric(m[c], errors="coerce") * pd.to_numeric(
m["backAdjustFactor"], errors="coerce")
return m
def apply_bs_fore(raw: pd.DataFrame, events: pd.DataFrame) -> pd.DataFrame:
"""BaoStock 式前复权:qfq = raw × foreAdjustFactor(官方写法)。
官方依据(localdatafactorInfo.md「利用本地BaoStock日K线数据手动计算复权价格」):
kline_df["adj_close"] = kline_df["close"] * foreAdjustFactor
🔴 为什么不能写成 qfq = raw × back / max(back_in_events)?
foreAdjustFactor = backAdjustFactor / back_latest,其中 back_latest 是
【BaoStock 服务端当前最新】的累积后复权因子 ------ 它**不在**你本地 events 里。
只有当你 events 的最后一条恰好是"全历史最后一次除权"时,
max(back) 才碰巧等于 back_latest;一旦回测历史区间,两者不等,前复权价系统性算错。
实测(浦发 sh.600000, 区间 2014-06-25~2016-06-22, events 到 2016-06-23):
events.max(back) = 6.295967
由 fore 反推 back_latest = 13.367007 <- 差 2.12 倍
raw=8.98 时: 错写法 = 8.5802 元 | 官方写法 = 4.0413 元 | BS直接qfq = 4.0413 元
=> 错写法偏差 +112%(自检第 8/9 项固化为回归测试)
"""
m = expand_bs_factor(raw, events)
for c in [c for c in PRICE_COLS if c in m.columns]:
m[c] = pd.to_numeric(m[c], errors="coerce") * pd.to_numeric(
m["foreAdjustFactor"], errors="coerce")
return m
def implied_back_latest(events: pd.DataFrame) -> float:
"""从 fore/back 两列反推服务端基准 back_latest(恒等式 fore = back / back_latest)。
它只用于【理解与校验】,绝不用于折算 ------ 折算一律用 fore 列直乘。
"""
r = (pd.to_numeric(events["backAdjustFactor"], errors="coerce")
/ pd.to_numeric(events["foreAdjustFactor"], errors="coerce"))
return float(r.dropna().median())
# ---------------------------------------------------------------- 联网(BaoStock 主源)
def _rs_to_df(rs) -> pd.DataFrame:
"""BaoStock 结果集 -> DataFrame。
两种写法都可用(2026-09-01 按官网复核后更正):
A) while rs.next(): rows.append(rs.get_row_data()) ------ 官网 factorInfo.md 示例写法
B) rs.get_data() ------ V0.8.8(2019-01-25) 起提供, 更简洁
⚠️ 真正的坑不是"用哪种写法",而是**不迭代、不调 get_data(),直接拿 rs.get_row_data()**
------那样只会得到一行拼接串。另外 get_data() 只有较新版本才有,老版本会 AttributeError,
所以本脚本统一用 A 写法(兼容性最好)。
⚠️ 字段拼写坑:query_adjust_factor 第 5 列叫 `adjustFactor`(带 r),
而 V0.9.3 的 query_daily_adjust_factor 返回的第 5 列叫 `adjustFacto`(少个 r)。
本函数统一把 `adjustFacto` 规整为 `adjustFactor`。
【实证依据】2026-09-02 联网实测:对 2024-07-18 / 2026-06-26 / 2015-06-23
三个日期调用 query_daily_adjust_factor,rs.fields 第 5 列均为 'adjustFacto';
query_adjust_factor 为 'adjustFactor'。可复跑 src/s3_003_verify_fields.py 验证。
另注:官方 demo 注释写的是 'adjustFactor',与服务端实际返回不一致,别照抄注释。
"""
rows = []
while rs.error_code == "0" and rs.next():
rows.append(rs.get_row_data())
df = pd.DataFrame(rows, columns=rs.fields)
if "adjustFacto" in df.columns and "adjustFactor" not in df.columns:
df = df.rename(columns={"adjustFacto": "adjustFactor"})
return df
def login_bs():
import baostock as bs
lg = bs.login()
if lg.error_code != "0":
raise RuntimeError(f"baostock 登录失败: {lg.error_code} {lg.error_msg}")
return bs
def fetch_bs_daily(bs, code: str, start: str, end: str, adjustflag: str = "3"):
"""BaoStock 日线。adjustflag: 1=后复权 2=前复权 3=不复权。返回列含 volume(股)。"""
rs = bs.query_history_k_data_plus(
code, "date,code,open,high,low,close,preclose,volume,amount,turn,pctChg",
start_date=start, end_date=end, frequency="d", adjustflag=adjustflag)
if rs.error_code != "0":
raise RuntimeError(f"{rs.error_code} {rs.error_msg}")
df = _rs_to_df(rs)
df["date"] = pd.to_datetime(df["date"])
for c in ("open", "high", "low", "close", "volume", "amount"):
df[c] = pd.to_numeric(df[c], errors="coerce")
return df
def fetch_bs_factor(bs, code: str, start: str = "1990-12-19",
end: str = "") -> pd.DataFrame:
"""BaoStock 复权因子【事件表】(免费免注册)。
返回 5 列(按官网 factorInfo.md):
code / dividOperateDate(除权除息日)
foreAdjustFactor 向前复权因子 = 除权日前一交易日收盘 / 除权日最近交易日的前收盘
backAdjustFactor 向后复权因子 = 除权日最近交易日的前收盘 / 除权日前一交易日收盘
adjustFactor 本次复权因子(实测与 backAdjustFactor 同值)
⚠️ 它是事件表(只在除权日有记录), 不是逐日! 用时需 expand(merge_asof)。
⚠️ start_date 默认拉到 1990-12-19(BaoStock 数据起点)------
【铁律】因子区间必须早于你的 K 线区间,否则早期交易日匹配不到因子,怎么填都错。
"""
rs = bs.query_adjust_factor(code=code, start_date=start, end_date=end)
if rs.error_code != "0":
raise RuntimeError(f"{rs.error_code} {rs.error_msg}")
df = _rs_to_df(rs)
if df.empty:
return pd.DataFrame(columns=["code", "dividOperateDate", "foreAdjustFactor",
"backAdjustFactor", "adjustFactor"])
df["dividOperateDate"] = pd.to_datetime(df["dividOperateDate"])
for c in ("foreAdjustFactor", "backAdjustFactor", "adjustFactor"):
if c in df.columns:
df[c] = pd.to_numeric(df[c], errors="coerce")
# 落库元信息(S3-001 §5.5)
df["source"] = SOURCE_BAOSTOCK
df["ingest_date"] = pd.Timestamp.today().strftime("%Y-%m-%d")
df["adjust"] = "" # 因子表本身无"复权口径", 口径在查询层决定
return df.sort_values("dividOperateDate").reset_index(drop=True)
def fetch_daily_factor_market(bs, date: str = "") -> pd.DataFrame:
"""【V0.9.3 新增】某日【全市场】复权因子 ------ 因子表的增量维护姿势。
query_daily_adjust_factor(date=None) 只有 date 一个参数、没有 code,
一次返回该日所有发生除权的股票(实测 2026-06-26: 119 只 / 0.08s;平常日 6~8 只)。
这是 BaoStock 首个"批量"接口,改写了"只能逐只拉"的旧认知:
老接口 query_adjust_factor(code=...) 逐只全量, 约 0.359s/只
新接口 query_daily_adjust_factor(date=...) 全市场单日, 约 0.08s/天
=> 日常增量:每天 18:00(官网: 复权因子入库时间)跑一次新接口即可;
首次建库:仍需用老接口逐只拉全量。
⚠️ 该接口返回的第 5 列官方拼作 `adjustFacto`(少一个 r),_rs_to_df 已统一规整。
"""
rs = bs.query_daily_adjust_factor(date=date)
if rs.error_code != "0":
raise RuntimeError(f"{rs.error_code} {rs.error_msg}")
df = _rs_to_df(rs)
if df.empty:
return pd.DataFrame(columns=["code", "dividOperateDate", "foreAdjustFactor",
"backAdjustFactor", "adjustFactor"])
df["dividOperateDate"] = pd.to_datetime(df["dividOperateDate"])
for c in ("foreAdjustFactor", "backAdjustFactor", "adjustFactor"):
if c in df.columns:
df[c] = pd.to_numeric(df[c], errors="coerce")
df["source"] = SOURCE_BAOSTOCK
df["ingest_date"] = pd.Timestamp.today().strftime("%Y-%m-%d")
df["adjust"] = ""
return df.sort_values("code").reset_index(drop=True)
def load_tushare_raw_from_daily(symbol: str, year: str) -> pd.DataFrame:
"""从 S3-002 落库的 Tushare raw 日线里取单只股票(data/daily/daily_YYYY.parquet)。"""
f = DAILY_DIR / f"daily_{year}.parquet"
if not f.exists():
raise FileNotFoundError(f"未找到 {f} ------ 请先跑 S3-002 拉取日线")
d = pd.read_parquet(f)
d = d[d["symbol"] == symbol].copy()
d["date"] = pd.to_datetime(d["date"])
return d.sort_values("date").reset_index(drop=True)
# ---------------------------------------------------------------- 自检
def self_test() -> int:
"""离线自检:不联网。全部为确定性断言。"""
ok, fails = 0, []
def check(name, cond):
nonlocal ok
if cond:
ok += 1
print(f" [PASS] {name}")
else:
fails.append(name)
print(f" [FAIL] {name}")
print("=" * 62)
print(" s3_003 复权 自检(离线,不联网)")
print("=" * 62)
raw, events = build_sample()
# 1. 除权跳空(raw 的"假暴跌")
gap = (raw["close"].iloc[2] / raw["close"].iloc[1] - 1) * 100
check("跳空: raw 在除权日从 105 -> 90 (-14.3%)", abs(gap - (-14.2857)) < 0.01)
# 2. BaoStock 式后复权: raw × back -> 除权日连续(90 × 105/90 = 105)
hfq = apply_bs_back(raw, events)
check("后复权: 除权日 hfq 连续回到 105", abs(hfq["close"].iloc[2] - 105.0) < 1e-6)
check("后复权: 除权后每天 hfq = raw × back",
abs(hfq["close"].iloc[3] - 92.0 * (105 / 90)) < 1e-6)
check("后复权: 除权前 hfq 因子未生效(NaN 前填充)",
pd.isna(hfq["backAdjustFactor"].iloc[0]))
# 3. 收益率: 用 hfq 算 vs 用 raw 算(raw 会多一个假 -14% 的坑)
ret_hfq = (hfq["close"].pct_change().iloc[3]) # 6-06: hfq 107.33 / 105 - 1
ret_raw = (raw["close"].pct_change().iloc[2]) # 6-05: raw 90 / 105 - 1
check("收益率: hfq 除权后首日 +2.2%(真实波动)", abs(ret_hfq - (92 / 90 - 1)) < 1e-9)
check("收益率: raw 除权日收益 = -14.3%(假暴跌)", abs(ret_raw - (-14.2857 / 100)) < 1e-6)
# 4. Tushare 式比例法(逐日因子)与直接乘数法数学等价
factor_daily = pd.DataFrame({
"date": raw["date"],
"adj_factor": [1.0, 1.0, 105 / 90, 105 / 90, 105 / 90],
})
ts_hfq = apply_tushare_style(raw, factor_daily, "hfq")
check("两种因子形态数学等价(hfq)",
(ts_hfq["close"] - hfq["close"]).abs().max() < 1e-6)
# 5. 复权只改价格列, 量额不动
check("量额不动: volume/amount 未被折算",
(hfq["volume"] == raw["volume"]).all() and
(hfq["amount"] == raw["amount"]).all())
# 6. 落因子表元信息
ev_meta = events.copy()
ev_meta["source"] = SOURCE_BAOSTOCK
ev_meta["ingest_date"] = "2026-08-30"
ev_meta["adjust"] = ""
meta = {"source", "ingest_date", "adjust"}
check("元信息: 因子表带三列", meta.issubset(ev_meta.columns))
# 7. merge_asof 展开逻辑(截至当日最新因子)
raw2 = pd.DataFrame({"date": pd.to_datetime(
["2024-06-04", "2024-06-05", "2024-06-06"])})
exp = expand_bs_factor(raw2, events)
check("merge_asof: 除权日当天命中新因子",
abs(exp["backAdjustFactor"].iloc[1] - (105 / 90)) < 1e-9)
check("merge_asof: 除权日前用旧值(NaN)", pd.isna(exp["backAdjustFactor"].iloc[0]))
# 8. 【实测恒等式】fore = back / back_latest(茅台 5 次 + 浦发 10 次双验)
# 茅台 sh.600519(2024 年起一年两次分红)
back_mt = [7.113689, 7.224927, 7.366525, 7.491968, 7.669257]
fore_mt = [0.927559, 0.942063, 0.960527, 0.976883, 1.000000]
bl_mt = [b / f for b, f in zip(back_mt, fore_mt)]
check(f"恒等式: fore = back / back_latest(茅台 5 次除权, "
f"back_latest={bl_mt[0]:.4f})",
max(bl_mt) - min(bl_mt) < 1e-4)
# 浦发 sh.600000(10 次除权, 独立样本复核)
back_pf = [3.890931, 5.119442, 5.310229, 5.604647, 6.015655,
6.295967, 7.128788, 9.385732, 9.484842, 9.779664]
fore_pf = [0.291085, 0.382991, 0.397264, 0.419289, 0.450037,
0.471008, 0.533312, 0.702156, 0.709571, 0.731627]
bl_pf = [b / f for b, f in zip(back_pf, fore_pf)]
check(f"恒等式: 浦发 10 次除权独立复核 (back_latest={bl_pf[0]:.4f})",
max(bl_pf) - min(bl_pf) < 1e-3)
# 9. 前复权折算(样例自洽: 单次除权, fore=1.0)
qfq = apply_bs_fore(raw, events)
check("前复权: 官方写法 qfq = raw × fore",
abs(qfq["close"].iloc[2] - 90.0) < 1e-6
and abs(qfq["close"].iloc[3] - 92.0) < 1e-6)
# ---------------------------------------------------------------
# 以下 10~13 为 2026-09-01 按官网复核后新增的【回归测试】
# 数值全部来自 sh.600000(浦发银行)真实 API 返回,固化为确定性断言
# ---------------------------------------------------------------
# 10. 【历史区间真实值回归】前复权必须用官方写法 raw × fore
ev_pf = pd.DataFrame({
"dividOperateDate": pd.to_datetime(
["2013-06-03", "2014-06-24", "2015-06-23"]),
"foreAdjustFactor": [0.419289, 0.450037, 0.471008],
"backAdjustFactor": [5.604647, 6.015655, 6.295967],
})
raw_pf = pd.DataFrame({
"date": pd.to_datetime(["2014-06-25", "2014-06-26", "2014-06-27",
"2014-06-30", "2014-07-01"]),
"close": [8.98, 8.99, 8.97, 9.05, 9.06],
})
qfq_pf = apply_bs_fore(raw_pf, ev_pf)
# 真值 = BaoStock adjustflag=2 直接前复权(实测)
truth = [4.041332, 4.045833, 4.036832, 4.072835, 4.077335]
dev = max(abs(a - b) for a, b in zip(qfq_pf["close"], truth))
check(f"前复权历史区间: 官方写法最大偏差 = {dev:.6f} 元(≈0)", dev < 1e-5)
# 11. 【BUG 回归】旧写法 qfq = raw × back / max(back) 在历史区间会算错
exp_pf = expand_bs_factor(raw_pf, ev_pf)
wrong = (raw_pf["close"] * exp_pf["backAdjustFactor"]
/ ev_pf["backAdjustFactor"].max())
dev_wrong = max(abs(a - b) for a, b in zip(wrong, truth))
check(f"BUG 回归: 旧写法(raw×back/max(back))最大偏差 = {dev_wrong:.4f} 元 "
f"(实测 +112%) -> 必须 >1e-3,证明该写法已被废弃", dev_wrong > 1e-3)
bl_true = implied_back_latest(ev_pf)
check(f"根因: events.max(back)={ev_pf['backAdjustFactor'].max():.6f} ≠ "
f"服务端 back_latest={bl_true:.6f}(差 {bl_true / ev_pf['backAdjustFactor'].max():.2f} 倍)",
abs(bl_true - 13.367007) < 1e-3)
# 12. 【因子区间铁律】因子区间不足时, 三种 fallback 全错
ev_late = ev_pf[ev_pf["dividOperateDate"] >= "2014-01-01"] # 区间收窄
raw_early = pd.DataFrame({
"date": pd.to_datetime(["2014-01-02", "2014-01-03"]),
"close": [9.33, 9.14],
})
exp_early = expand_bs_factor(raw_early, ev_late)
check("因子区间铁律: 早于首条因子 -> 匹配为 NaN",
exp_early["foreAdjustFactor"].isna().all())
# 真值(BS直接qfq)
# 更完整的实测(浦发, 因子区间 2014-01-01~2020-01-01, 早期 114 天):
# 官方示例 fallback(取最新fore) 偏差 3.20 元 | 填 1.0 偏差 5.95 元 | 填首次除权fore 偏差 0.32 元
truth_early = [3.911966, 3.832301]
devs = {}
for tag, fill in (("官方示例(取最新fore)", 0.471008),
("填 1.0", 1.0),
("填首次除权fore", 0.450037)):
devs[tag] = max(abs(raw_early["close"].iloc[i] * fill - truth_early[i])
for i in range(2))
check("因子区间铁律: 三种 fallback 全部有偏差 -> "
+ " | ".join(f"{k} {v:.2f}元" for k, v in devs.items()),
all(v > 1e-6 for v in devs.values()))
# 13. 新接口字段拼写坑:adjustFacto -> adjustFactor 规整
# ⚠️ 边界声明:下面的 _FakeRS 用 fixture 硬编码模拟"服务端返回 adjustFacto",
# 它验证的是【规整函数本身能否工作】,并不证明服务端真的这么拼。
# "服务端确实返回 adjustFacto" 的实证,请联网运行 src/s3_003_verify_fields.py
# (2026-09-02 实测 3 个日期均返回 'adjustFacto')。
# 把 fixture 当成外部事实的证据 = 自证循环,这里显式标注以免误导。
class _FakeRS:
error_code, fields = "0", ["code", "dividOperateDate", "foreAdjustFactor",
"backAdjustFactor", "adjustFacto"]
_i = -1
_rows = [["sh.600519", "2026-06-26", "1.000000", "7.669257", "7.669257"]]
def next(self):
self._i += 1
return self._i < len(self._rows)
def get_row_data(self):
return self._rows[self._i]
df_fake = _rs_to_df(_FakeRS())
check("字段规整: V0.9.3 批量接口的 'adjustFacto'(少r) -> 'adjustFactor'",
"adjustFactor" in df_fake.columns and "adjustFacto" not in df_fake.columns)
print("-" * 62)
if fails:
print(f"自检结果:{ok} 通过 / {len(fails)} 失败 -> {fails}")
return 1
print(f"自检结果:{ok}/{ok} 全部通过 ✅(离线)")
return 0
# ---------------------------------------------------------------- 跨源对拍(只对拍、不合并)
def compare_with_tushare(events: pd.DataFrame, ts_code: str = "600519.SH") -> None:
"""跨源对拍:BaoStock backAdjustFactor vs Tushare adj_factor。
两者都是"上市以来累积后复权因子"。Tushare 免费档 adj_factor 限 1 次/小时、
需 TUSHARE_TOKEN;无 token / 限频 / 失败时一律优雅跳过,不影响主流程。
按 S3-001 §5.5 纪律:跨源只对拍、不合并 ------ 因子表单源定案(本篇 = BaoStock)。
"""
import os
if events.empty:
print("\n[跨源对拍] 跳过(无因子事件)")
return
token = os.environ.get("TUSHARE_TOKEN")
if not token:
print("\n[跨源对拍] 跳过(未设置 TUSHARE_TOKEN;对拍是可选项,不影响主流程)")
return
try:
import tushare as ts
ts.set_token(token)
pro = ts.pro_api()
last_ev = events.iloc[-1]
d = last_ev["dividOperateDate"].strftime("%Y%m%d")
df = pro.adj_factor(ts_code=ts_code, start_date=d, end_date=d)
if df is None or len(df) == 0:
print(f"\n[跨源对拍] Tushare {d} 无因子数据,跳过")
return
ts_f = float(df["adj_factor"].iloc[0])
bs_f = float(last_ev["backAdjustFactor"])
ratio = ts_f / bs_f
print(f"\n[跨源对拍] 茅台最近一次除权日 {d}")
print(f" BaoStock backAdjustFactor = {bs_f:.4f}")
print(f" Tushare adj_factor = {ts_f:.4f}")
print(f" 比值 Tushare / BaoStock = {ratio:.4f}")
if abs(ratio - 1.0) < 1e-3:
print(" ✅ 两源累积后复权因子一致(跨源互证通过)")
else:
print(" ⚠️ 两源因子存在口径差(BaoStock=涨跌幅复权算法,可能与等比因子不同)")
print(" 按 S3-001 §5.5 纪律:跨源只对拍不复权价;因子表单源定案(本篇=BaoStock)")
except Exception as e:
msg = str(e)
if "频率超限" in msg:
print("\n[跨源对拍] 跳过(Tushare adj_factor 限频 1 次/小时,冷却中)")
else:
print(f"\n[跨源对拍] 跳过({msg[:60]})")
# ---------------------------------------------------------------- demo(联网)
def demo(symbol_bs: str = "sh.600519", symbol: str = "600519") -> int:
import baostock as bs_mod
bs = login_bs()
print("=" * 62)
print(" S3-003 复权 demo:BaoStock 因子 + 查询层自算(真实数据)")
print("=" * 62)
# 1) 拉因子事件表 ------ 【铁律】区间必须早于 K 线,这里直接从 1990-12-19 拉全量
events = fetch_bs_factor(bs, symbol_bs, "1990-12-19", "")
print(f"\n[因子事件表] {len(events)} 条除权记录(全历史, "
f"{events['dividOperateDate'].min().date()} ~ "
f"{events['dividOperateDate'].max().date()})")
print(" 最近 6 次除权:")
print(events[["dividOperateDate", "foreAdjustFactor",
"backAdjustFactor"]].tail(6).to_string(index=False))
bl = implied_back_latest(events)
print(f" 由 fore 反推的服务端 back_latest = {bl:.6f}")
print(f" 校验: max(back)={events['backAdjustFactor'].max():.6f} "
f"{'== back_latest ✅(本区间已含最新除权)' if abs(events['backAdjustFactor'].max() - bl) < 1e-4 else '!= back_latest'}")
print(" 返回字段:", [c for c in events.columns
if c not in ("source", "ingest_date", "adjust")])
# 2) 拉除权日附近的三种口径日线(raw / hfq / qfq,后两者作对照基准)
raw = fetch_bs_daily(bs, symbol_bs, "2026-06-22", "2026-06-30", adjustflag="3")
hfq_direct = fetch_bs_daily(bs, symbol_bs, "2026-06-22", "2026-06-30", adjustflag="1")
qfq_direct = fetch_bs_daily(bs, symbol_bs, "2026-06-22", "2026-06-30", adjustflag="2")
# 3) 自算后复权 / 前复权(merge_asof 展开事件表, 截至当日最新因子)
hfq_calc = apply_bs_back(raw, events)
qfq_calc = apply_bs_fore(raw, events)
view = raw[["date", "close"]].rename(columns={"close": "不复权raw"}).copy()
view = view.merge(hfq_calc[["date", "close"]].rename(
columns={"close": "自算hfq"}), on="date")
view = view.merge(hfq_direct[["date", "close"]].rename(
columns={"close": "BS直接hfq"}), on="date")
view = view.merge(qfq_calc[["date", "close"]].rename(
columns={"close": "自算qfq"}), on="date")
view = view.merge(qfq_direct[["date", "close"]].rename(
columns={"close": "BS直接qfq"}), on="date")
for c in ("不复权raw", "自算hfq", "BS直接hfq", "自算qfq", "BS直接qfq"):
view[c] = pd.to_numeric(view[c], errors="coerce")
view["hfq差"] = (view["自算hfq"] - view["BS直接hfq"]).abs().round(4)
view["qfq差"] = (view["自算qfq"] - view["BS直接qfq"]).abs().round(4)
view = view[["date", "不复权raw", "自算hfq", "BS直接hfq", "hfq差",
"自算qfq", "BS直接qfq", "qfq差"]]
print("\n[除权日 2026-06-26 前后] 同一份 raw 的三种视图")
print(view.assign(date=view["date"].dt.strftime("%Y-%m-%d")).to_string(index=False))
# 除权日跳空(raw 的"假暴跌")
ex_mask = raw["date"] == pd.Timestamp("2026-06-26")
if ex_mask.any():
i = raw.index[ex_mask][0]
if i > 0:
c_prev = float(raw["close"].iloc[i - 1])
c_ex = float(raw["close"].iloc[i])
gap = (c_ex / c_prev - 1) * 100
print(f"\n除权日不复权跳空: {c_prev:.2f} -> {c_ex:.2f} = {gap:+.2f}%"
f"(这就是 raw 算收益的'假暴跌')")
print(f"[内部对拍·hfq] 自算 vs BaoStock直接(adjustflag=1), 最大差 = "
f"{view['hfq差'].max():.4f} 元")
print(f"[内部对拍·qfq] 自算 vs BaoStock直接(adjustflag=2), 最大差 = "
f"{view['qfq差'].max():.4f} 元")
# 3.5) 【关键实证】历史区间 + 因子区间被截断时,两种写法的差距
print("\n" + "-" * 62)
print(" 【关键实证】前复权基准:官方写法 × fore vs 旧写法 × back/max(back)")
print("-" * 62)
h_start, h_end = "2024-06-12", "2024-06-26" # 茅台 2024-06-19 除权
raw_h = fetch_bs_daily(bs, symbol_bs, h_start, h_end, adjustflag="3")
qfq_h_true = fetch_bs_daily(bs, symbol_bs, h_start, h_end, adjustflag="2")
# 模拟常见错误:因子只拉到 2024 年底(与 K 线区间差不多的长度)
ev_trunc = events[events["dividOperateDate"] <= "2024-12-31"].copy()
print(f"\n 区间 {h_start}~{h_end};因子表被截断为 {len(ev_trunc)} 条"
f"(末条 {ev_trunc['dividOperateDate'].max().date()})")
print(f" 截断表 max(back) = {ev_trunc['backAdjustFactor'].max():.6f}"
f" 而服务端 back_latest = {bl:.6f}"
f" -> 差 {bl / ev_trunc['backAdjustFactor'].max():.4f} 倍")
exp_h = expand_bs_factor(raw_h[["date", "close"]], ev_trunc)
qfq_official_h = raw_h["close"] * exp_h["foreAdjustFactor"]
qfq_wrong_h = (raw_h["close"] * exp_h["backAdjustFactor"]
/ ev_trunc["backAdjustFactor"].max())
cmp_h = pd.DataFrame({
"date": raw_h["date"].dt.strftime("%Y-%m-%d"),
"raw": raw_h["close"].round(2),
"BS直接qfq": pd.to_numeric(qfq_h_true["close"]).round(4),
"×fore(官方)": qfq_official_h.round(4),
"×back/max(旧)": qfq_wrong_h.round(4),
})
cmp_h["官方差"] = (cmp_h["×fore(官方)"] - cmp_h["BS直接qfq"]).abs().round(6)
cmp_h["旧写法差"] = (cmp_h["×back/max(旧)"] - cmp_h["BS直接qfq"]).abs().round(4)
print()
print(cmp_h.to_string(index=False))
print(f"\n ✅ 官方写法(raw×fore) 最大偏差 = {cmp_h['官方差'].max():.6f} 元")
print(f" ❌ 旧写法(raw×back/max(back)) 最大偏差 = {cmp_h['旧写法差'].max():.4f} 元"
f" <- 因子区间一被截断就系统性算错")
# 4) 落因子事件表到本地
out = ADJ_DIR / f"adj_factor_{symbol}.parquet"
events.to_parquet(out, index=False)
print(f"\n[落库] 因子事件表 -> {out} ({len(events)} 条, 含 fore+back 双列 + 三列元信息)")
# 4.5) 【V0.9.3 新接口】某日全市场复权因子 ------ 因子表增量维护
print("\n" + "-" * 62)
print(" 【V0.9.3 新增】query_daily_adjust_factor(date) 某日全市场复权因子")
print("-" * 62)
import time as _time
for d in ("2026-06-26", "2026-08-28"):
t0 = _time.time()
try:
mk = fetch_daily_factor_market(bs, d)
el = _time.time() - t0
hit = mk[mk["code"] == symbol_bs]
print(f"\n [{d}] 全市场 {len(mk)} 只股票发生除权, 耗时 {el:.2f}s")
if len(hit):
print(f" 命中 {symbol_bs}: fore={float(hit['foreAdjustFactor'].iloc[0]):.6f} "
f"back={float(hit['backAdjustFactor'].iloc[0]):.6f}")
else:
print(f" {symbol_bs} 当日无除权")
print(f" 字段(已规整): {[c for c in mk.columns if c not in ('source', 'ingest_date', 'adjust')]}")
except Exception as e:
print(f"\n [{d}] 跳过({type(e).__name__}: {str(e)[:60]})")
print("\n => 增量维护:每天 18:00(官网复权因子入库时间)跑一次即可;"
"\n 全量建库才需要老接口逐只拉。")
# 5) 查询层折算:用 S3-002 已落库的 Tushare raw × 本篇 BaoStock 因子
try:
ts_raw = load_tushare_raw_from_daily(symbol, "2026")
raw_close_last = float(ts_raw["close"].iloc[-1])
hfq_ts = apply_bs_back(ts_raw, events)
last = hfq_ts.dropna(subset=["backAdjustFactor"]).tail(1)
b = float(last["backAdjustFactor"].iloc[0])
print(f"\n[查询层折算] S3-002 Tushare raw({len(ts_raw)} 行, "
f"{ts_raw['date'].min().date()}~{ts_raw['date'].max().date()}) "
f"× BaoStock 因子 -> 后复权")
print(f" 最新一天: {last['date'].iloc[0].date()} "
f"raw_close={raw_close_last:.2f} "
f"hfq_close={float(last['close'].iloc[0]):.2f}")
print(f" (验证: {raw_close_last:.2f} × back={b:.3f} "
f"= {raw_close_last * b:.2f} ✓)")
except FileNotFoundError as e:
print(f"\n[查询层折算] 跳过({e})")
# 6) 跨源对拍(可选, 需 TUSHARE_TOKEN;只对拍不合并)
compare_with_tushare(events)
bs_mod.logout()
print("\n✅ demo 完成")
return 0
def main() -> int:
ap = argparse.ArgumentParser(description="复权:BaoStock 因子 + 查询层自算 (S3-003)")
ap.add_argument("--self-test", "--selftest", dest="self_test",
action="store_true", help="离线自检(不联网)")
ap.add_argument("--demo", action="store_true", help="联网演示(拉茅台真实数据)")
ap.add_argument("--symbol", default="sh.600519", help="BaoStock 代码")
args = ap.parse_args()
if args.self_test:
return self_test()
if args.demo:
return demo(args.symbol)
ap.print_help()
return 0
if __name__ == "__main__":
sys.exit(main())
python src/s3_003_adj_factor.py --self-test→ 离线自检 20/20 全过(不联网、不耗配额);python src/s3_003_adj_factor.py --demo→ 联网拉茅台 31 次除权真实因子 + 三口径对拍 + 历史区间基准血案复现 + 全市场增量接口演示 + 落因子表;
核心折算函数(事件表 → 逐日因子 → 任意口径):
python
def expand_bs_factor(raw, events):
"""把【除权事件表】展开成逐日因子(截至当日的最新因子)。
merge_asof(direction='backward') = 找"当天或之前最近一次除权"。"""
ev = events.rename(columns={"dividOperateDate": "date"})
ev["date"] = pd.to_datetime(ev["date"])
return pd.merge_asof(raw.sort_values("date"), ev.sort_values("date"),
on="date", direction="backward")
def apply_bs_back(raw, events):
"""后复权:hfq = raw × backAdjustFactor(截至当日最新)。"""
m = expand_bs_factor(raw, events)
for c in [c for c in PRICE_COLS if c in m.columns]:
m[c] = pd.to_numeric(m[c], errors="coerce") * pd.to_numeric(
m["backAdjustFactor"], errors="coerce")
return m
def apply_bs_fore(raw, events):
"""前复权:qfq = raw × foreAdjustFactor(官方 localdatafactorInfo.md 写法)。
🔴 不能写成 qfq = raw × back / max(back_in_events)!
fore 的基准是【服务端最新】累积因子 back_latest,它不在本地 events 里;
只有 events 末条恰好是"全历史最后一次除权"时才碰巧相等。
实测:茅台 2024 年区间偏差 87.84 元(6.15%),浦发 2014 年偏差 112%。
"""
m = expand_bs_factor(raw, events)
for c in [c for c in PRICE_COLS if c in m.columns]:
m[c] = pd.to_numeric(m[c], errors="coerce") * pd.to_numeric(
m["foreAdjustFactor"], errors="coerce")
return m
def implied_back_latest(events):
"""由 fore/back 反推服务端基准 back_latest ------ 只用于【理解与校验】,
绝不用于折算。折算一律用 fore 列直乘。"""
r = (pd.to_numeric(events["backAdjustFactor"], errors="coerce")
/ pd.to_numeric(events["foreAdjustFactor"], errors="coerce"))
return float(r.dropna().median())
落库结果 :因子事件表(31 条 除权记录,fore + back + adjustFactor + source/ingest_date/adjust 三列元信息)已落 data/meta/adj_factor/adj_factor_600519.parquet。
📌
① 跨源对拍需
TUSHARE_TOKEN,未设置时脚本优雅跳过(实测值见坑④与附录 A,均为 2026-09-02 联网实跑);② BaoStock 数据实测更新到 2026-08-28 (官网:当日日线 17:30、复权因子 18:00 入库),查询窗口不要越过这个时点;
③ 演示用的除权样例(105→90)是教学合成数据;茅台(31 次)与浦发(10 次)的真实因子链均为实跑所得。
九、本篇小结 & 下篇预告
小结:这一篇把"复权"从三个黑话词拆成了一套可落地的折算工程:
- 统一公式
复权价 = raw × (factor_t / factor_base)------三种口径只是基准不同,前/后复权收益率完全等价,唯一不能碰的是 raw; - BaoStock 的正确用法 :
adjustflag=1/2/3一行切口径;query_adjust_factor是独立的事件表(返回 5 列 ),用merge_asof展开成逐日因子------实测与 BaoStock 直接口径 hfq / qfq 对拍均零误差; - 🔴 前复权必须用
qfq = raw × fore:基准back_latest是服务端最新因子,本地max(back)不是它 。实测茅台 2024 年区间偏 87.84 元(6.15%) 、浦发 2014 年偏 112% → 所以落库必须 fore + back 双列; - 🔴 因子区间必须早于 K 线区间 :区间不足时三种补全策略偏差 3.20 / 5.95 / 0.32 元,没有一个对 →
start_date一律从1990-12-19拉起; - V0.9.3 全市场批量接口 :
query_daily_adjust_factor(date)一次拿某日全市场除权(实测 0.2~0.5s),因子表日常增量从此不用逐只重拉; - 跨源因子 12.7% 实测为恒定基准缩放(比值 1.1274,0.00% 漂移,算法等价)→ "因子表单源定案"纪律仍成立,但理由从"算法冲突"修正为"基准日不同";三源交叉检查另证 BaoStock/AkShare/Tushare 复权收益率差仅 3~4e-6、跨源拼装差 0.000000 元(见文末附录 A)。
你现在的装备:S3-002 的 raw 日线(全 A,按日批量)+ 本篇的因子事件表与三口径折算。但它们还散在 data/daily/ 和 data/meta/adj_factor/ 两处,没有一个"一句 get_daily(adjust='hfq') 就出数"的查询入口。
下一篇预告 :专栏 S3 的第四篇《自建本地行情库(一):为什么选 Parquet + DuckDB》。我会把散落的 raw 和因子表组织成一个可查询的本地行情库 ,把本篇的 apply_bs_back/apply_bs_fore 收口进 datafeed/api.py------让你 api.get_daily("600519.SH", adjust="hfq") 一行拿任意口径,同时讲清"为什么是 Parquet + DuckDB 而不是 MySQL / CSV / 单个 CSV"。准备好你刚落好的因子表,我们下篇见。
附录 A:三源交叉检查实证
为验证复权算法的跨源正确性,本篇配套 src/s3_003_crosscheck.py 对 BaoStock / Tushare / AkShare(腾讯兜底) 三源做了六项交叉检查(标的茅台 600519,区间 2026-06-01~08-28,含 06-26 除权)。核心不变量:复权价绝对值跨源必然不同(基准不同),但日收益率序列必须一致。
| 检查 | 实测结果 | 判定 |
|---|---|---|
| C1 不复权价 | 三源最大差 0.000000 元 | ✅ 一致 |
| C2 量额单位 | 统一股/元后三源完全一致(vol=1,612,611 / amt=2,086,008,422) | ✅ 换算正确 |
| C3 复权因子 | Tushare/BaoStock = 1.127396 恒定 (64 天实时逐日对拍,min 1.127394 / max 1.127397,漂移 0.00%) | ✅ 算法等价(仅基准缩放) |
| C4 后复权 | 收益率差 3.33e-06(vs AkShare)/ 2.90e-06(vs Tushare 自算);绝对值差 1652 / 9049 元 | ✅ 收益率一致(绝对值不同是基准不同,本该如此) |
| C5 前复权 | 收益率差 4.00e-06 (vs AkShare)/ 2.74e-06(vs Tushare 自算);绝对值差仅 0.0076 / 0.0036 元 | ✅ 收益率与绝对值都一致 |
| C6 跨源拼装 | Tushare raw × BaoStock 因子 = 直接 hfq,差 0.000000 元(收益率差 2.2e-16) | ✅ 拼装正确 |
💡 C4 vs C5 的对比本身就是一条结论 :后复权三源绝对值能差几千元(各锚定自己的上市日),前复权却只差 0.008 元以内------因为前复权都锚定"最新价",基准天然对齐。这正是"算收益用复权、对账户用 qfq"的底层原因。
结论 :复权收益率三源一致(误差 1e-6 级),12.7% 因子差是恒定基准缩放 而非算法冲突;"单源定案"纪律成立,但理由修正为基准日不同。
免责声明 :本文所有内容仅用于量化投资技术教学与知识分享。文中涉及的复权概念、折算代码、BaoStock/Tushare 接口用法,以及所展示标的(贵州茅台 600519.SH)的行情与复权因子数据,均来自公开数据源实测、仅作技术演示,不构成任何投资建议或个股推荐,也不代表任何真实收益预测。演示用除权样例(105→90)为教学合成数据;两源复权因子的算法口径差异以各数据源官方文档为准。投资有风险,入市需谨慎;实际交易前请务必用自己的真实数据充分回测,并充分了解相关风险与合规要求。