记录一次「0 BUY」的归因(2026-06-06)

系统在跑,日志在滚,但一笔交易都没发生

这是做交易系统时最让人心慌的一种故障:**它不是崩了,它是"活着但不动"**。

v1.8 部署上去两个小时,0 BUY、0 SELL。进程在,决策在写,就是不下单。


四层诊断:把"不动"拆开

面对这种情况,最容易犯的错是直接去怀疑策略——是不是阈值太高了?是不是信号太差了?然后开始降阈值。

但那天没有先动手改参数,而是先做了一次分层排查。因为"0 BUY"这件事,可能在四个完全不同的地方断掉:

层 卡住的表现
1. 策略层 headless 跑出来的 want 比例过低
2. 风控层 策略想买,但被 _risk_check_buy 拒了
3. 仓位层 想买,有额度,但拿不到仓位
4. 持久化层 链路断了,数据根本没进来

排查结果:

第 1 层——健康。 headless 跑 sol + bsc + base 共 120 个 token,**111 个 want=True,通过率 92.5%**。策略评分没有任何问题。

第 2 层——有问题,但只说明一件事。 那 111 个想买的,全部被 _risk_check_buy 拒了。而拒绝原因只有一种:持仓数已达上限 5。

第 3 层——在这里找到根因。 5 个 OPEN 持仓,已经拿了 4 到 16 小时,mcap 从 −3% 到 +43%,全都还在持仓区间里,占满了 MAX_OPEN_POSITIONS=5 的全部额度。

第 4 层——无关。 bsc / base 链有 RST 失败,但 sol 链的数据是够的,不影响判断。

结论:死锁在第 3 层。

修法:只改一个数字

根因清楚了,修法就非常干净:

1
MAX_OPEN_POSITIONS: 5 → 7

给新策略留两个新仓位置。不动 combo 阈值(实战 80-95% 通过率已经够了),也不砍老持仓——老持仓里 id=236 的 mcap 还是 +17% 在涨,id=295 的 mcap 是 +43%,离 +50% 的止盈线只差 7%,让它跑完。

改完之后,30 秒内出了 65 个决策(2 BUY + 63 SKIP),新开的两个仓一个是 sol 的 mcap 20K,另一个从 2.4K 涨到了 4.5K。

前后对比很直观:

指标 v1.8(4 小时) v1.9(30 秒)
BUY 0 2
SKIP 2781 63
决策频率 11.6/min 130/min

这件事真正的价值

修一个数字只花了一分钟,但找出该改哪个数字,花了两小时。这两小时的产出不是那个"7",而是那条诊断路径:

一条链上"不动",先分层定位是哪一层不动,再动手。

如果那天直接去降 combo 阈值会怎样?表面上看也能"激活"系统——阈值一松,那些凑分的、本来不该买的 token 就会被买进来。这就是早期踩过的"无信号赌博单"陷阱:用降低标准制造出成交量,看起来像恢复了,其实是在把亏损提前。

后来这条被写成了永久方法论,每次遇到"不动"都按这个顺序走:

  1. 策略层:n_want / n_scanned >= 50% 算健康
  2. 风控层:按 reason 做 GROUP BY,如果卡住的只有一种原因,那基本就是它
  3. 仓位层:OPEN 全部在持仓区间 + 占满上限 = 仓位死锁的指纹
  4. 持久化层:链断、库写失败、ws 断开——这类调参解决不了,得修链路

另一半:v1.11 扩数据源

同一天还有一件方向性的事。

到 v1.10 为止,**前面十轮调优、九个版本,全都只在扫 new_creation**——也就是刚开盘、零成交的新币。

v1.11 第一次把视线移开。起因是发现 GMGN trenches 的 type=completed 这个类型,文档里没有,但返回的其实是全网热榜。

两个样本一对比,差距是量级的:

  • 30 个 sol 热榜样本,swaps_24h 中位数 248 笔
  • new_creation 的中位数是 24 笔

10 倍的交易热度差。 这意味着前十轮一直在最冷的那口井里找水。

把热榜接进来,一次性收齐了 111 个字段,包括 bot_degen_count、tg_call_count、twitter_is_tweet、x_user_follower、creator_created_count、is_wash_trading、is_honeypot 等等。

这里踩了两个很典型的坑:

第一个:is_honeypot 是字符串,不是布尔。 它返回的是 "no" / "yes"。如果直接 bool("no"),结果是 True——所有币都会被判定成蜜罐。正确写法是 str(x).lower() in ("yes", "true", "1")。这种坑的可怕之处在于它不报错,只是让逻辑整体反了过来。

第二个:completed 类型不要去看 progress 字段。 因为已经毕业到 DEX 了,progress 返回全是 0,拿它做判断没有意义。

探测手段也很土但很有效:写了个 probe_trenches_types.py,把 9 个 type 全试一遍。结论是只有 completed(三链)和 new_creation 真有数据,其余是空或者报错。

文档没写的东西,只能自己打出来。 跟上一篇里"25 个端点打一遍只有 2 个有数据"是同一个道理。

改动落在三处:gmgn_client.py 加了热榜扫描和 7 维评分;db.py 把新币字段从 26 扩到 41,新建 sim_hot_token_signals 表(32 列);9 号页里把 HOT_MOMENTUM_BONUS 定到 2.0,并加了 HOT_TAKE_PROFIT_PCT=0.20 让热榜币更快止盈。

部署后 1 分钟:1 BUY + 19 SKIP,三链各收了 60 行信号。那唯一一笔 BUY 是 bsc 上的一个币,renowned=5 + smart=3 + vol24h=112K + net_buy=+2071U——命中得非常整齐。

顺带也看到了 v1.10 的真实成绩:7 BUY / 2 SELL / PnL −32.62U,其中 4 笔是 0 信号的 OPEN,2 笔 SELL 全部是硬止损(一个持有人跌 92%,一个流动性跌 58%),一笔自然止盈都没有。这个形态就是"数据源太窄"的指纹——进来的东西质量不行,止损再多也救不回来。


顺手把部署流程工程化

那天还干了件"不解决业务问题、但从此一直受益"的事:把部署流程从脚本堆成了一层工具。

起因是个很阴的坑:用 wmic 拿 PID,老代码写的是 parts[1]。但 wmic /format:csv 的输出其实是三列——Node, CommandLine, ProcessId。所以 parts[1] 拿到的是整条命令行,不是 PID。

后果是什么?

taskkill //PID <一整条命令行> //F 会返回 ERROR: Invalid query——它不抛异常。脚本以为杀成功了,实际老进程还活着。结果新进程起来之后,两个 sim_loop 并行跑了十几秒,双写数据库,把调优数据污染了。

修法就一句话:**永远用 parts[-1]**。因为列数可能变,但 PID 永远是最后一列。

顺着这个坑,把部署固化成了 deploy_strategy.py,带 --dry-run,退出码做了明确语义:

退出码 含义
0 成功
1 杀 sim_loop 失败
2 RISK 里找不到 key
3 ACTIVE_VERSION 找不到
4 新进程 PID != 1
5 8501 挂了

再配一个 check_health.py,三项校验:Streamlit 8501 健康、sim_loop 进程数正好是 1、决策频率在 10-400/min 之间,并用 sim_strategy_versions.is_active=1 来判断当前版本(不用 tuning_log 的最后一行——因为"刚部署的"和"正在跑的"可能不同步)。

部署铁律也定死了五条:

  1. 杀进程 → 等 → 校验 PID == 0,不等于 0 就 abort,永远不启新的
  2. 启新 → 等 → 校验 PID == 1,不等于 1 就 abort
  3. 永远 parts[-1] 解析 wmic
  4. taskkill //F 至少等 3 秒,最多 retry 3 轮
  5. 不要用 "defi_dashboard" in line 过滤——cwd 不在命令行里,这个判断永远是 False

同一天还有个小坑:MySQL 5.6 从 latin1 转 utf8mb4,得先开 innodb_large_prefix + Barracuda,再把 VARCHAR(255) 的主键改成前缀索引 191,最后才 CONVERT。

而且长跑进程改了字符集之后,必须杀掉、清掉 __pycache__、再重启——否则老连接还在用 latin1,新写进去的数据继续变成 ????。那天真实发生过:sim_loop 的 PID 没杀干净,表已经改成 utf8mb4 了,它还在往里写乱码。

还有一条来自用户侧的明确要求:本机的 browser_* 工具启动 Chrome 失败,以后所有 UI 和截图验证一律走 minimax 的视觉能力。 这条被记下来当固定规矩。


最后

这天最值得回味的,是同一个现象("没动")在两个层面上的对称性。

在策略层,"0 BUY"的根因是仓位死锁——不是策略不想买,是没位置了。
在部署层,"杀进程失败"的根因是列解析错误——不是不想杀,是杀错了目标。

两个都是"流程看起来在走,但实际卡住了"。 而且它们都有同一个特征:不报错。仓位满了不报错,taskkill 失败也不报错。系统安安静静地偏离了预期,只有你主动去看数据、去看退出码,才会发现。

所以那天沉淀下来的东西,比任何一次调参都值钱:不要把"没报错"当成"正常运行"。 该有的探针(决策频率、PID 数量、健康检查)一个都不能省——它们不是锦上添花,它们是唯一能告诉你"系统已经停了但还在假装工作"的东西。