从画图工具到 AI 蒸馏流水线:一个量化选股项目的目录治理与一次复权因子 bug 的复盘
摘要:想把一个量化选股策略的推荐结果喂给 AI 做蒸馏训练,第一步不是设计数据库,而是把 K 线画成图让 AI 看懂。围绕这个目标,演化出一套 F01-F05 目录治理、SQLite + JSON 双层台账设计,以及一次精彩的"qfq 断层"bug 全过程排查。
故事的起点
我想让一个 AI 看懂量化策略在某只股票「推荐日」前后长什么样,从而学习怎么判断「这个推荐该不该跟」。
数据有:策略输出的 day0 推荐日 + day0~day8 每天的 K 线。AI 不认识 K 线,得先画出来。
于是第一步不是建数据库,而是画图。
一、可视化优先的 AI 训练数据准备
很多 AI 训练项目第一步就扑在「怎么把数据 schema 化」上。我的经验是:先把数据画成图,让 AI 看懂,再谈抽象化。
理由很简单:K 线图是人类(含金融背景的 AI)最直觉的信息载体。一张包含 OHLC + 成交量 + 几个技术指标 + 推荐日标记的图,比 30 个 JSON 字段都管用。
所以我先做了一件事——把 4GB 的 Tushare 日线 CSV 流式读取,画成 9:16 竖屏 K 线图:
# 流式读 4GB CSV
for chunk in pd.read_csv("tushare_data.csv", chunksize=50_000):
df = pd.concat([df, chunk[chunk.ts_code == target]])
画 9:16 手机友好图
fig, axes = plt.subplots(5, 1, figsize=(7.79, 13.08),
gridspec_kw={"height_ratios": [6, 1, 1, 1, 1]})
主图 K线 + 副图 VOL/KDJ/MACD/DKDB 各 10%
这一步只用了 matplotlib(mplfinance 没有 DKDB 面板,副图配色也不灵活),价格统一用前复权 qfq。
画出来之后立刻发现一个现实问题:给 AI 看的数据,必须适配 AI 的注意力。所以接下来多轮调优:
- 颜色改成中国习惯(红涨空心框、青跌实心,黑底)
- KDJ 三色(K白 D黄 J紫)、MACD 三色(DIF白 DEA黄 MACD紫)
- 主图左上角方框:收盘价 + 涨跌幅 + 换手率 + 总值(板块字段预留空位)
- ±10% 价带(红虚线在上、青虚线在下)
- 双周对齐竖虚线,方便跨图对照
- 加一张砖型图副图(通达信原版公式)
这一轮调下来,图的「视觉信息密度」才到位。
二、F01-F05 目录治理
画图跑通之后,数据/代码/产物之间的关系开始复杂起来。我引入了一套分层目录:
F01_说明/ ── 人工导入说明 + AI 生成说明(两类分别归档)
F02_导入/ ── 原始数据:Tushare CSV + SAS 策略 CSV
F03_画图/ ── 画图代码 + 生成的 PNG(按 strategy/day0/ts_code/day 组织)
F04_懂子建库/ ── SQLite 库 + Python 包 + 决策 JSON
F05_懂子训练/ ── 数字人 skills + prompt(待开发)
这套约定的核心:
- K 线图唯一归属 F03,F04 只持结构化引用
kline_ref = {strategy, day0_date, ts_code, day}。图改了,引用会自动指向新路径 - 跨目录导入靠
sys.path.insert(0, F03_画图),零硬编码绝对路径。挪到任何机器都能跑 - 幂等性是底线:SAS 二次入库只跳过不覆盖,重复推荐日也不破坏数据
三、SQLite + JSON 双层台账设计
到了「把样本结构化入库」这一步,我选了一个不那么常见的方案:SQLite 瘦表 + JSON 决策明细双层。
为什么双层?
| 维度 | SQLite | JSON |
|---|---|---|
| 查询速度 | 快 | 慢 |
| 结构灵活 | 弱(schema 固定) | 强(嵌套) |
| 决策明细(嵌套数组) | 强行 schema 会冗长 | 天然合适 |
| 评分/字段迭代 | 痛苦(要 migration) | 自由 |
- SQLite
training_samples表只存瘦表:id, ts_code, trade_date, strategy, version, status, outcome, decisions_path - 决策明细(每天的 buy_record, review, lesson)放在
decisions/{code_dir}/{ts}_{date}_B1_V1.json - DB 行通过
decisions_path指向 JSON
这样既享受了 SQLite 的索引查询,又享受了 JSON 的灵活迭代——以后 AI 训练需要加新字段(比如「当时市场情绪」),改 JSON 即可,DB 不用动。
四、数据清洗:8 日过滤
入库前加了一道过滤:推荐日距离最新数据日期 < 8 个交易日的样本丢弃。
理由:day0~day8 的训练需要未来 8 天的真实数据,太新的样本还没走完,留下来就是给 AI 喂「未来未知」。
实际跑下来:
原始 329 条 → 过滤 8 个日期 12 条 → 入库 317 条
cutoff 自动 = 20260610
253 个不同 ts_code(其中 64 条是同股跨多个推荐日)
这一步是纯 SQL/规则逻辑,不需要 AI。
五、最精彩的一段:qfq 断层 bug 全过程复盘
做完上面四步之后,用户说:「你看一下 688301.SH,我看到的数据是不复权的 K 线,画出来会有断层,出现在 6 月 12 日。」
第一轮排查:检查代码有没有用错字段我做了全代码审计:
| 位置 | 字段情况 |
|---|---|
data_loader.py COLUMNS_KEEP |
全部 _qfq,零 _bfq |
plotter.py |
OHLC/MA/EMA/KDJ/MACD 全部 *_qfq |
dkdb.py |
price_col="close_qfq" |
| 缓存 schema | 22 列全 *_qfq |
发现根因:data_loader.py 里 _recompute_uniform_qfq 对 OHLC 和「线性 qfq 列」用了同一套 scale:
# 错的(OHLC 可以,但 MA/EMA 不行)
out[c] = tushare_ma_qfq * (adj_t / global_latest_adj)
但 MA/EMA/EXPMA/MACD 应该从(已校准的)close_qfq 自算,不是 rescale。数学证明:
错的:ma_qfq_f_t * (adj_t / global_latest_adj) ≠ true_ma_qfq
对的:true_ma_qfq_t = rolling_mean(close_qfq_t)
Tushare 的 ma_qfq_f_t 是用文件 f 的本地 latest_adj 算的,不是全局 latest_adj。简单 rescale 之后,每行的「过去 N 天」加权对不齐,就出现了视觉断层。
对 688301.SH 348 根 bar 跑 max abs diff:
| 指标 | max abs diff | 结论 |
|---|---|---|
ma_qfq_20 |
0.0 | ✅ |
expma_12_qfq |
0.0 | ✅ |
macd_dif_qfq |
0.0 | ✅ |
macd_dea_qfq |
0.0 | ✅ |
修完之后又发现 688301.SH 有 2 处 adj_factor 跳变(1.41×),但 raw 价在跳点几乎平(60.33→60.39;117.95→111.90 跌 5%)。按 qfq 公式算,这两个点仍然会有 ~30% 的视觉跳。
排查后确认:这是 tushare 数据本身的特性,不是 fix 引入的。tushare 对该股的 adj_factor 是内部回填,不是真的分红送股。
这个案例的教训:- 「看起来代码全对」≠「代码真的全对」。审计要追到「为什么这么算」的数学原理
- OHLC 用 scale rescale,MA/EMA/EXPMA/MACD 要从 close_qfq 自算——这是一个容易被忽视的细节
- fix 完之后还要判断哪些「跳变」是真 bug、哪些是数据本身的特性
写在最后
这个项目的核心洞察不是「怎么画 K 线」或「怎么设计数据库」,而是 「AI 训练数据的准备,可视化优先于抽象化」——AI 跟你一样,看图比看字段快。
而最有价值的一段经验,反而是那个 qfq 断层 bug:你以为代码全对,但数学不对。这件事提醒我:在量化领域,「自算」永远比「rescale」更可靠——尤其是涉及滚动均值、指数平滑这种「历史窗口」计算时。
下一篇文章打算写 F05 训练 pipeline:怎么让数字人跑 Day0→Day8 的决策流程,做首批训练评估。