记录一次量化策略的"内部喊单"改造(v1.15)

事情的起因很朴素,就是亏钱

v1.14 这一版,被 4 个 bsc 上的土狗反复套了 43U,而这 43U 占了我 v1.13 整版亏损的 61%。换句话说,一版的亏损里,一大半都是被同一个坑慢慢啃出来的。

复盘的时候问题其实很直白:我的信号太慢了。等热榜(trenches)刷出来的时候,行情往往已经走了 1-2 个小时,这时候再进去,基本就是给先到的人接盘。

所以 v1.15 就换了个思路——与其等一个"事后聚合"的结果榜,不如直接去接平台精选的实时成交流。这一次改造用一句话概括,就是:**让信号从"看结果"变成"看动作"**。


什么叫"内部喊单"

说白了,就是平台上有一批被标记出来的"聪明钱"和"KOL",他们真实下单的成交记录会实时公开。我盯着的不是价格涨没涨,而是这些人正在买什么。

这次接了两个流:

  • GMGN 的 smartmoney:sol 上名单固定 15 个 maker,时延 5-15s
  • GMGN 的 kol:sol 上名单固定 6 个 maker,时延 7s

有意思的是,这两个流重合度是 0,也就是说是两批完全不同的钱包。更关键的是时效性——比热榜早 5-15 分钟。在土狗这种以分钟计的市场里,5-15 分钟就是天壤之别。

这里还顺手填了一个 v1.14 留下的坑。之前一直以为 GMGN 的 trenches 端点是"用户列表",探针打下去才发现它命名是错的,实际返回的是成交流,跟用户列表一点关系都没有。名字和内容对不上,这种坑不实测是真的发现不了。

数据源是一个一个试出来的

接一个新平台,最怕的就是文档和现实不一致。所以这次没有信任何文档,而是开探针硬试:列了 25 个候选端点,一个一个打过去,最后只有 2 个是有数据的,其余 23 个全是 404。

说句实在话,这种"广撒网"的笨办法,反而是最靠谱的。文档会过期,接口会改名,只有亲手打出来的响应不会骗人。

combo 评分 + 冷静期

信号接进来了,怎么用才是重点。这次加了两道机制:

一是 combo 评分。 单个 maker 买入不算数,要5 分钟内有 ≥3 个 maker 买同一个 token 才给加分。加分的权重 SMART_MONEY_BONUS 定在 1.5,跟 SIGNAL_FLOOR 持平,但故意弱于热度的 HOT_MOMENTUM(2.0)。原因是聪明钱流的窗口只有 5 分钟,比热榜的 24 小时短得多,权重给太猛会把原有的阈值冲掉。5 分钟这个窗口,也刚好跟信号 5-10 分钟的半衰期对得上。

二是同 maker 冷静期。 MAKER_COOLDOWN_MINUTES = 30,同一个 maker 30 分钟内买过 2 次以上的币,直接拒掉。这个参数的由来非常具体——v1.14 就是栽在一个 bsc 地址上,14 小时里被它套了 22 笔,亏了 19U,反复被同一个 maker 牵着走。冷静期跟原有的 COOLDOWN_MINUTES 是两个独立维度:一个查"我自己刚平过",一个查"喊单的 maker 刚买过",互补不冲突。

上线

这次动了 5 处:

  1. gmgn_client.py 加了 scan_smart_money_flow / scan_kol_flow / get_user_info
  2. db.py 加了 sim_smart_money_signals 表(16 列,UNIQUE (chain, tx_hash, maker, side))和 5 个 CRUD
  3. data_sync.py 接了 sync_smart_money_flow
  4. pages/9_sim_trading.py 加了 combo 评分、加分、_risk_check_buy
  5. 加了 scripts/cron_write_tuning_v15_smart_money.py

部署结果是 tuning_log id=42、sim_strategy_versions v1.15 is_active=1,跑起来决策频率 11.5/min,健康检查全绿。上线 1 小时,sim_smart_money_signals 收了 293 笔(55.6% 是买)、78 个 maker、121 个 token——数据量够撑起统计了。

另外配了一个每 4 小时的巡检 cron,报了 5 类情况:sim_loop 死了、卖出少于 5 笔、胜率低于 30%、加分触发不到 5%、冷静期一次没触发。任何一条不对,就说明策略开始跑偏了。

踩的两个坑,都挺要命

第一个是 mysql.connector 的 cursor 陷阱(CRITICAL)。 如果我连着写两次 cur = db.get_connection().cursor(),中间没有 fetchall 也没 close,第一个 cursor 会被 GC 回收,第二个 cursor 内部的 weakref 就指向了一个已经没了的对象,抛 Cursor is not connected。最坑的是——这个异常会被裸 except 静默吞掉,导致我新加的检查永远不会触发。修法是单 cursor + try/finally,并且 except 里绝不允许裸 pass,必须 print 留痕。教训是:新加的检查一定要 mock 数据验证它真的会被触发,不能信"代码看着对"。

第二个是 utf8mb4 的索引长度(CRITICAL)。 tx_hash 一开始开了 VARCHAR(200),在 utf8mb4 下就是 800 字节,超过了 767 的索引上限,init_db 直接报 1709。改成 VARCHAR(100) 就好了。规矩记下:utf8mb4 的表 + UNIQUE 索引,单个字段默认按 ≤191 字符来设计,留点 buffer。

这两个坑有个共同点——都不会在写代码的时候报错,只在跑起来之后悄悄失效。这也是为什么我现在越来越不信"看起来没问题"这件事。

我的期望,和我的止损线

接下来 24 小时,我给自己定了三条期望:

  • 内部喊单加分触发的 BUY 占比 ≥ 30%
  • 加分之后卖出的胜率 ≥ 45%
  • maker 30 分钟冷静期触发 SKIP ≥ 10%
  • 累计 pnl 相对 v1.14 同期改善 +3~8U

对应地也准备好了"止损线":如果 24 小时卖出不到 8 笔、或者胜率低于 30%,就把加分项砍回 v1.14;如果加分后的胜率比整体还低 10 个百分点,说明 maker 信号对涨幅根本没有指示性,这套逻辑就该丢;如果还在反复套 top5,那就是 30 分钟的冷静期不够严,得再加。

最后

做量化这几年最大的体会就是:策略的进步,往往不是靠更聪明的模型,而是靠把信号往前挪一点,再往前挪一点。

从"看结果"到"看动作",这次挪的其实就是这几分钟。至于值不值,24 小时后数据会说话。成不成另说,至少这一版,我知道自己为什么会亏了。