记录一次模拟跟单系统的搭建(2026-06-05)

上午还在修一个报错,下午决定从零搭一整套系统

那天的开始很普通——修 page 8 的一个 format_func 报错。修完顺手看了一眼,页面上是 GMGN 的 trenches 新币数据,一行行刷出来。

然后就有了那个念头:数据都在这儿了,为什么不干脆拿它做一套模拟交易? 拿真金白银试错太贵,但拿一套虚拟账户去跑,成本是零——而且能跑出真实的数据。

于是就有了后面这一整天。


9 号页:4 套策略 + 一套硬风控

新建的页面是 pages/9_sim_trading.py,里面装了四套策略:

  • 跟 KOL
  • 跟聪明钱
  • 避雷
  • 综合

起始资金 1000U,纯虚拟。

策略可以有多个,但风控只有一套,而且是硬编码的:

规则 值
单笔金额 30U
同时持仓上限 5
止损 30%
止盈 100%
rug 比率 > 0.6 强平
sniper 数量 > 5 强平
最长持仓 72 小时
价格超时 24 小时未更新强平

把风控写成常量而不是配置,是刻意的选择。策略是要被反复比较的对象,风控不是。 如果风控也能随手改,那两版策略的收益差异就说不清是策略变好了还是风控放松了。

调优不是感觉,是 8 张表

这是那天最重要的决定:数据库从 4 张表扩到 8 张。

一开始想的很简单——账户、成交、持仓、净值曲线,4 张就够了。但真要做调优就会发现,4 张表只能告诉你"赚了还是亏了",**告诉不了你"为什么"**。

所以补上了另外 4 张:

表 存什么
sim_account 账户余额与已实现盈亏
sim_trades 成交明细,开仓和平仓各带一套信号列
sim_positions 持仓,含开仓信号、最高最低价、mark 次数
sim_snapshots 净值时间序列
sim_position_marks 持仓期间的价格序列,后台每 30 秒一次
sim_strategy_versions 策略版本快照,params_json 存风控参数
sim_market_snapshots 市场环境,含 regime 判定
sim_decisions BUY / SKIP / RISK_BLOCK / CLOSE 的全部决策日志

最后那张 sim_decisions 是关键。它记录的不是"做成了什么",而是"当时想做什么、为什么没做"。没有它,调优就只能看到结果,看不到被拒绝掉的那部分——而被拒绝掉的那部分,往往才是问题所在。

迁移写成了 idempotent 的,重复执行也不会出错。

7 类分析

光有数据还不够,得有人把它读出来。所以写了 analysis.py,出七类分析:

  1. 账户健康度
  2. 策略版本对比
  3. 按市场 regime 分组
  4. 开仓信号分桶
  5. 平仓预警
  6. 漏掉的好币
  7. 持仓 mark 序列

第 6 类"漏掉的好币"是我特意加的。因为策略的失误有两种:一种是买了不该买的,一种是没买该买的。前者亏钱看得见,后者不亏钱,但也在损失——而它只会在"漏掉的好币"里露头。

配套的还有一个 10 号页 pages/10_review.py,9 个分区,专门用来复盘和调参。

6 步工作流

调优这件事如果不定流程,很容易变成"凭感觉改参数"。那天把它固化成了 6 步:

  1. 先跑够 50 笔以上平仓,积累样本
  2. 看分桶,找出最赚的桶和最亏的桶
  3. 改 ACTIVE_VERSION(在 9 号页顶部)和策略评分
  4. 跑一轮,自动 upsert 出新的策略版本
  5. 用 2 号分析对比新旧版本
  6. 回到第 1 步

这 6 步里,"先跑够 50 笔"是最容易被跳过、也最不该跳过的一步。样本不够的时候,任何差异都是噪声——你会把运气当成策略。

第一轮数据:难看,但有用

v1.0 跑出来的结果是这样:

  • combo 策略**胜率 10%**(1 胜 9 负)
  • 累计 PnL −37.78U
  • cold 市场几乎没有 KOL 币(pct_with_kol=0,avg_renowned=0)
  • KOL=0 的那个桶,把 −37.78U 全亏了
  • 平均持仓 不到 6 分钟
  • **费用占比 99%**(累计 6.25U 手续费,实收 0U 利润)

单看第一条,这是套失败的策略。但把六条串起来看,它讲了一个完整的故事:

cold 市场里根本没有 KOL 参与,但策略照样开仓——因为"没有 KOL"这件事被当成了"中性",而不是"警告"。 于是所有单子都落在同一个桶里,一起亏。而"持仓不到 6 分钟"说明大部分单子一开仓就被止损或止盈扫掉了,这不是策略在交易,这是在阈值边缘来回摩擦——最直接的证据就是那 99% 的费用占比:钱没亏在判断上,亏在了周转上。

有了这个解释,调参方向就自己浮出来了:cold 时收紧 KOL 阈值或者干脆不交易、把 combo 阈值从 4 提到 5、把"持有人跌 10% 以上"和"rug 涨 0.3 以上"写进强制平仓。

这些结论不是猜的,是从 8 张表里读出来的。 这就是那天坚持把表扩到 8 张的回报。

三个坑

一是 PYTHONPATH 污染(这个最阴)。 环境里给 shell 设了 PYTHONPATH,指向一个 cp311 的 venv。但子进程用的是 conda 的 cp313 Python。结果就是 jsonschema 依赖的 rpds-py——那个 .pyd 是按 cp311 编的,跟 cp313 不兼容,直接报 No module named 'rpds.rpds'。

修法是启动子进程前 unset PYTHONPATH 和 unset PYTHONHOME。但更有价值的是那条诊断经验:conda 进程报"找不到某个子模块",八成是 PYTHONPATH 把别的 venv 插到了 sys.path 顶端——先 env | grep PYTHONPATH,别急着 pip install 重装。 重装是对的包,装在错的环境里,问题一点不会少。

二是 MySQL 没有 MEDIAN()。 想算中位数,得在 Python 客户端自己算。

三是 mysql.connector 返回的是 Decimal 和 None 的混合。 直接丢给 pandas 会出事,计算前必须 astype(float)。

另外还有一条工程上的小事:Streamlit 改了 app.py 在 Windows 上不会自动重启(mtime 偶尔不变),得先 taskkill /F 掉旧进程再启。

顺手接了两个源

那天还把两个数据源接进来了:

一是 GMGN 的 trenches(openapi.gmgn.ai 的 POST /v1/trenches,带 X-APIKEY 和 ts+uuid)。这里有个坑:gmgn.ai 主域和 defi.gmgn.ai 都被 Cloudflare 拦着,只有 openapi.gmgn.ai 这个子域能直连。而且没有走官方的 CLI,直接用 Python requests 打——因为 spawn 一个 CLI 会拖慢前端的自动刷新。

二是 gmgn.ai/ai 那个技能导航站,收录了 40 多个链上技能,列表在 skills_zh-CN.json 里。它的用法是"按需查找按需安装"——需要什么技能,先去检索,确认了再从 url 把 SKILL.md 拉下来装。不一次性全装,因为装了不用的技能,跟没装一样,只是占了位置。

顺带确认了代理配置:Clash 挂在 127.0.0.1:7890,curl -x http://127.0.0.1:7890 走代理。注意优先用 HTTPS——只支持 HTTP 的站走代理会被 Cloudflare 403。


最后

那天还有一条自我约束,我觉得比代码本身更值得记:

会话级的工作进度,不写进长期记忆。

todo 推进到哪了、这个 phase 完成了没、参数调到几了——这些是"当前状态",不是"长期事实"。把它们塞进记忆,7 天之后就是一堆垃圾,还得花时间清理。长期记忆只存环境、偏好、约定这类恒定的东西。

所以那天真正被记下来的,是"数据存在哪台机器""用什么命令启动""中位数要在客户端算"——而不是"今天改到了 v1.0"。

回到系统本身。它第一天跑出来是亏的,而且亏得挺难看。但那天结束时我一点都不沮丧,因为这 −37.78U 是第一次"可归因"的亏损:能说出钱亏在哪个市场状态、哪个信号桶、哪个环节。以前做策略,亏了只能归咎于"运气不好"。

这可能就是模拟盘真正的价值——它不是让你不亏钱,而是让你亏得明白。