记录一次回测链路的抢修(2026-06-10)
记录一次回测链路的抢修(2026-06-10)
一个数字离谱到没人信的时候,问题多半不在那个数字上
新做的 12 号页跑完之后,v1.16.4 的 BUY 数量是 15254。而同一时间,11 号页里真实成交只有两百多笔。
差了将近两个数量级。这天做的事就是把这个数字修回来。
一张对不上的表:11 页 215,12 页 15254
起因很朴素:11 号页显示"买的币很少",12 号页却显示买了 15254 次。策略筛选出要买的币本来应该非常少,两个页面却给出了完全不同的结论。
这里先要分清两个页面在干什么。11 号页读的是 sim_trades,真实跑出来的成交;12 号页是反事实回测,用历史数据按每个版本的规则重放一遍。回测的意义在于"把历史数据和样本重新过一遍,快速筛出最近真正赚钱的策略,顺带看出优化方向"——它能跑出的笔数本来就该和实盘一个量级,绝不该多出几十倍。
15254 这个数字本身就在喊"我不对"。
根因一:combo 的 max() 被避雷策略钻了空子
第一层根因在 engine.py 的 score_combo()。
原来的写法是用 max() 取 4 个子策略里的最高分。4 个子策略是:score_follow_kol、score_follow_smart_money、score_hot_momentum、score_avoid_rug。
问题出在 score_avoid_rug(避雷策略):只要一个 token 不是 rug,它就能轻松拿到 5-7 分,而 combo 的门槛 combo_threshold=4。于是避雷分几乎必然压过其他所有策略,结果 86% 的 token 都通过了筛选。
"不是骗局"这件事被当成了"值得买",这是评分设计上的错位。避雷策略的职责应该是否决,不该去参与正向评分。
根因二:回测扫了整张表,实盘只扫热榜
修掉评分逻辑后,数字降到了几千,但还不够——用户一句话点破了:"不可能会有 6855 次 buy。"
第二层根因在 backtest_v12.py 的 _prefetch_all_data():它从 new_tokens 全表(180k 行)加载所有候选 token。而真实策略每一轮只扫描约 180 个出现在热榜信号里的 token。
也就是说,回测的候选范围和实盘完全不在一个量级。实盘每分钟面对的是热榜里那几十上百个新币,回测却把历史上出现过的 18 万个 token 全拿来评分——分母大了三个数量级,分子自然离谱。
两处修复
修复分两块,正好对上两个根因。
第一块,评分逻辑(engine.py):
score_combo()改为只参考跟单信号:score_follow_kol+score_follow_smart_moneyscore_hot_momentum降级为参考信息,不再参与正向评分score_avoid_rug只作否决(负分 block)want判定改为want = best >= combo_threshold and avoid_rug_score >= 0
一句话:跟单信号够强 + 无重大风险,才买。
第二块,候选范围(backtest_v12.py):
_prefetch_all_data()的 SQL 加上INNER JOIN sim_hot_token_signals,只取出现在热榜里的 token- 顺带把
version_risk_history.py的combo_threshold从 4 提高到 5,覆盖 v1.0 至 v1.16.4 全版本
修完之后的数字
v1.16.4:
1 | BUY 数量:15,254 → 802 |
v1.0:
1 | BUY 数量:234 (与 11 页 sim_trades 的 215 笔量级一致) |
候选池从 180k 缩到 5,461 个,筛选严格度提升 95-99%。页面刷新地址是 http://localhost:8501。
这里有个细节让我印象很深:v1.0 的 234 笔,跟真实成交的 215 笔几乎贴在一起了。修好之后,回测终于和实盘说同一种语言——这比某个具体数字更值得记。
一批数字的三方校对
同一天,审计任务里又做了一次更严格的校验,任务是"优化 v1.17 反事实回测 BUY 数逻辑"。
审计结论是:未发现 bug,BUY 逻辑通过。核心是计数守恒:
1 | n_skip + n_block + n_buy = 5515 (候选数) |
21 个历史版本三层校验全部通过。backtest_v12.py 的 BUY 决策逻辑(L499-527)分类清晰——score.want=false 计入 skipped,risk false 计入 blocked,否则计入 n_buy。
顺带查清了另一个困惑:v1.16.4 的 BUY 数从 802 变成 805。不是 bug,而是 sim_hot_token_signals 又新增了 70 个 hot token(5445 → 5515),其中 3 个被 v1.16.4 判为 BUY。同一批数据后来在旧库、新库、实时 dry-run 三方对齐,全部一致。
数据是活的,每次跑都会涨几个。 这句话后来被当成一条认知写进了笔记:固化成文档的那个 5444 候选 / 802 BUY,以后还会变,别再把某一次快照当成不变量。
审计里还留了一个 edge case:wash_trader 的 SELL 会触发 spam_makers(第 350 行没有 side 过滤),这是 v1.16.0 的一票否决设计意图,不修。
顺手交付的 11 号页
这天还交付了 11 号页——11_backtest.py(604 行,26KB),并在 app.py 加了 1 行注册到侧边栏。页面的结构是"全版本排名 + 单版本详情":
| 区块 | 内容 |
|---|---|
| 全版本排名总览 | 7 版本 × 10 列,v1.2 以 +82.18U 居首,自动高亮最优/最差 |
| 选版本看详情 | st.selectbox 下拉,默认 v1.2 |
| 历史持仓明细 | 37 笔 × 15 列 |
| 漏掉的好币(SKIP) | 50 笔 × 8 列,汇总 -2.58U(2/50 涨,占 4%) |
| BUY 决策 + 后续 PnL | 38 笔 × 13 列 |
开发过程踩了 3 个坑,都修了:db.list_decisions 默认 ORDER BY ts DESC LIMIT 500 会把老版本漏掉;pymysql 的 NOT IN %s 传 tuple 报 "Python type tuple cannot be converted",改成字面量;pandas styler.format 应用到"已下架"字符串会崩。
有一个问题没有当场修,而是明确标了 TODO:BUY 决策的"后续 PnL"是按 (chain, address) 累加所有 SELL 的,同 token 多次 BUY 会重复计算。所以页面上 v1.2 显示的 +4.42U/笔是虚高的,实际应是 +82 / 38 = +2.16U/笔。主功能可用,但这一项挂着。
顺手还理了一遍回测数据补采的方向:K 线 1d 的 res limit 是 500,对应 503 天历史;token_signal、smartmoney 都是实时流,需要自行采集。为此写了一份补采脚本 TEMPLATE_backfill_gmgn_data.py(3 阶段,dry-run 通过),配套 3 张新表的 DDL:sim_token_kline_archive、sim_creator_stats、sim_maker_stats。这一步把回测覆盖率从 60% 往 90% 以上的路径理清了。
还有一条运维约定被写死下来:12 号页的 backtest_runner.py 需要每 30 分钟跑一次。
关于为什么要有回测
这天真正的起点,其实是一个设想:"利用现有历史数据,回测这些版本的收益,快速筛出最近真正赚钱的策略。"
设想很快就撞到了旧账上。记得之前在摸排时发现的那笔 95U 异常单——把它从 v1.13 里剔掉之后,真实结果是 60 笔净亏 -56U。而那笔"利润"完全由一笔异常单撑起来。
按同一口径真正算下来,唯一的赢家是 v1.2:37 笔、+92U、5 笔超过 10U、平均 +2.49U、胜率 51.4%、回撤 1.8%。
而 v1.16.x 全系在样本到 N=20 之后排名非常接近:v1.16.1 / 1.16.2 / 1.16.3 三版胜率都在 37-40%、回撤都在 3% 左右,总 PnL 都在 -5 到 -25U 之间。加了隐藏信号,只是把波动从 v1.0 的 81% 回撤压到 3%,没有把胜率拉开。
最后
这一天的三层。
最表面的一层,是那个数字被修回来了:15254 → 802,v1.0 的 234 跟实盘 215 对上了。页面能看了。
中间一层,是两条设计规范被固化下来:
- 回测的候选 token 范围必须与实盘严格对齐——这是 15254 的直接教训,也是所有反事实回测最容易出错的地方。
- 避雷类策略只做否决,不参与正向评分;正向评分只用交易信号类策略——这是
max()被score_avoid_rug钻空子的教训。
最里面一层,还是那个老规矩。 2 分钟里,"15254 不对"和"6855 不对"都是先觉得离谱、再去查出来的。而且修完之后我没有停在"数字对了就没事了"——同一批数字做了三方校对,顺带把一条认知记下来:固化在文档里的不变量,会随着数据增长继续变。
一个交易系统最忌讳的,是拿一个自己想要的数字当结论。这天同样如此:先把难听的那部分算出来,再决定要不要高兴。