从零搭建你的 A 股量化系统(二十九):复权——BaoStock 复权因子与查询层实时折算

系列名:《从零搭建你的 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.6693 vs Tushare adj_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已在 conda ashare 实跑--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 × forehfq = raw × back
🔴 因子区间必须早于 K 线 区间不足时早期交易日匹配不到因子,怎么补都是错的 因子从 1990-12-19(或上市日)拉起,不要和 K 线对齐

一句话落地:data/meta/adj_factor/ 存一张 BaoStock 因子事件表(fore + back 双列都要),查询时用 merge_asof 展开成逐日因子,hfq = raw × backqfq = raw × foreraw 就是 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 同值

四个必须知道的性质:

  1. backAdjustFactor 锚定上市日,永不变 ------它是"上市以来所有除权调整的累积乘数",2026-06-26 之后每天的后复权价 = raw × 7.669257,一年后查还是这个数。适合落库
  2. foreAdjustFactor 锚定服务端最新交易日,会变 ------你今天查 fore=1.0 挂在 2026-06-26 那行;等明年茅台再分红,同一行的 fore 就不是 1.0 了。但它仍然必须落库:因为前复权只能用 fore 直乘(第五节给你看"自造基准"的血案)。
  3. 它是事件表不是逐日表 ------中间的交易日没有因子行,用的时候要把它"展开"成逐日(merge_asof 向后对齐,找"当天或之前最近一次除权"的因子)。
  4. 官网明示算法 :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),组内零分歧

python 复制代码
rs = 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 :官方 demo demo_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 直接给的。)

读这张表,四个实测结论:

  1. 除权跳空 :raw 从 1212.10 → 1168.63,-3.59% ------这是分红,不是暴跌。可 raw 的 pct_change 会老老实实算出 -3.59%,你的止损线如果设 3.5%,这天就被误杀。
  2. hfq 对拍零误差 :自算 raw × back(merge_asof 展开事件表)与 BaoStock 直接 adjustflag=1 给的后复权价,最大绝对差 = 0.0000 元 ------证明 BaoStock 的后复权就是逐日因子直乘,事件表展开法精确成立
  3. qfq 对拍也是 0.0000 元 :⚠️ 曾经有一个更直觉的写法用 hfq / max(back) 自造基准,在这个 demo 区间它"只"差 0.0002 元------看起来很小,但那只是因为本次因子表恰好包含了"最新一次除权"。换个历史区间,同样的写法会错到 87.84 元(见下一节)。
  4. 查询层折算验证 :拿 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.60000010 次除权 做独立样本复核,同样成立: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_latestBaoStock 服务端当前的 最新累积因子,它不在你本地的因子表里

只有当你的因子表**恰好包含"全历史最后一次除权"**时,本地 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.295967back_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_factorstart_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 condaconda install -c conda-forge pandas pyarrow
baostock 0.9.3(官网 V0.9.3,2026/07/10) ⚠️ 只能 pipconda 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") 为准:

python 复制代码
import 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 次)的真实因子链均为实跑所得。


九、本篇小结 & 下篇预告

小结:这一篇把"复权"从三个黑话词拆成了一套可落地的折算工程:

  1. 统一公式 复权价 = raw × (factor_t / factor_base)------三种口径只是基准不同,前/后复权收益率完全等价,唯一不能碰的是 raw;
  2. BaoStock 的正确用法adjustflag=1/2/3 一行切口径;query_adjust_factor 是独立的事件表(返回 5 列 ),用 merge_asof 展开成逐日因子------实测与 BaoStock 直接口径 hfq / qfq 对拍均零误差
  3. 🔴 前复权必须用 qfq = raw × fore :基准 back_latest 是服务端最新因子,本地 max(back) 不是它 。实测茅台 2024 年区间偏 87.84 元(6.15%) 、浦发 2014 年偏 112% → 所以落库必须 fore + back 双列
  4. 🔴 因子区间必须早于 K 线区间 :区间不足时三种补全策略偏差 3.20 / 5.95 / 0.32 元,没有一个对start_date 一律从 1990-12-19 拉起;
  5. V0.9.3 全市场批量接口query_daily_adjust_factor(date) 一次拿某日全市场除权(实测 0.2~0.5s),因子表日常增量从此不用逐只重拉;
  6. 跨源因子 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.pyBaoStock / 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)为教学合成数据;两源复权因子的算法口径差异以各数据源官方文档为准。投资有风险,入市需谨慎;实际交易前请务必用自己的真实数据充分回测,并充分了解相关风险与合规要求。

相关推荐
quantdash_cc1 天前
批量请求和循环请求有什么区别?从 API 请求次数看量化数据获取的工程设计
开发语言·python·数据分析·pandas·量化交易·股票数据·quantdash
2601_966949652 天前
Python 量化开发为什么适合使用金融数据 SDK?从数据获取到策略研究的工程化实践
开发语言·python·数据分析·量化交易·股票数据·quantdash
海兰4 天前
【 Python 量化交易】开篇
开发语言·python·信息可视化·量化交易
李可以量化8 天前
Redis Client 从了解到精通(二)下:String 类型进阶操作全解
redis·git·python·量化交易·qmt
李可以量化9 天前
Redis Client 从入门到精通(一)下:redis-py 核心数据结构操作与连接管理
数据结构·redis·python·bootstrap·量化交易·qmt
yanxing52011 天前
从零搭建你的 A 股量化系统(二十四):配置管理与日志系统:出问题时能查得到
量化交易
李可以量化14 天前
Redis 从了解到精通(三)下:性能基准测试与量化场景性能避坑指南
redis·git·python·量化交易·qmt·ptrade