写交易机器人这件事,我一开始也是抱着“接个接口、下个单,能有多难”的心态进去的,结果第一版跑通模拟盘用了两个晚上,真正敢把资金放进去跑却拖了将近一个月。中间卡住的地方大多不是策略本身,而是签名对不上、时间戳偏移、限频被惩罚、WebSocket 断了不重连、订单状态和本地记录对不上这一类“工程问题”。这篇就把我从接口接入一路推到生产级部署的全过程摊开讲,包括架构怎么拆、参数怎么算、坑在哪里、上线前的自检怎么做。内容偏实战,适合已经会写 Python、想把自己的交易机器人从“能跑”推进到“敢跑”的开发者,也适合做量化系统但没接触过数字资产交易接口的同学对照参考。文中代码都是可运行的骨架,参数和阈值需要按自己的账户规模调整,不要直接照搬金额。
1. 先想清楚架构,再动手写第一行代码
1.1 从“能下单”到“敢放钱”之间隔着什么
很多人写交易机器人的路径是这样的:找一个开源的单文件脚本,把 API Key 填进去,改一下买卖逻辑,跑起来看到成交记录,觉得成了。然后把它挂到一台服务器上,第二天早上起来发现仓位是空的,或者持仓翻了好几倍,日志里只有一行看不懂的报错。这个落差不是策略问题,是工程问题。
“能下单”只需要三个东西:地址、密钥、一个 POST 请求。“敢放钱”需要的东西就多了:网络断了要能自己恢复,接口返回超时要能判断订单到底成没成,本地记录和平台实际持仓必须能对上,程序崩了重启之后不能重复下单,行情剧烈波动时不能因为一个异常就把整个进程带崩。这些都不是策略逻辑,但每一条出问题都会直接变成真金白银的损失。
所以我给自己定的第一条原则是:策略代码和交易执行代码必须分离。策略只负责输出“我想在什么价位、什么方向、多大仓位”的意图,执行层负责把这个意图变成合法的订单、发出去、跟踪状态、处理异常。这样做的直接好处是,策略可以随便改、随便回测,执行层的稳定性不会被牵连;反过来,执行层做加固的时候,也不需要重新验证策略逻辑。
实际拆下来是三层:接入层(HTTP 与 WebSocket 的封装、签名、限频、重试)、执行层(订单生命周期管理、状态机、幂等、对账)、策略层(信号生成、仓位计算)。三层之间用明确的数据结构通信,不用全局变量互相污染。这个拆分听起来是老生常谈,但在交易系统里,它的价值会在第一次线上事故时体现得淋漓尽致。
1.2 三层拆分与两条独立链路
把系统按数据流切开,会得到两条链路:行情链路和交易链路。行情链路是公有的,走 WebSocket 订阅盘口和成交,数据量大、频率高、允许丢一点;交易链路是私有的,走鉴权后的 REST 和私有 WebSocket,数据量小但每一条都不能错。
这两条链路的可靠性要求完全不同,所以我在实现上也不一样。行情链路允许“断线重连后重新订阅、丢掉中间几百毫秒的数据”,因为它下一秒就会推新的过来;交易链路则必须“每条消息都有唯一 ID、能去重、能补拉”,因为一条成交回报丢了,本地的持仓就会永远偏一点。
一个常见的错误是把两条链路写在一个巨大的while True循环里,行情一卡,下单也卡;下单重试阻塞三秒,行情回调全积压。我后来的做法是:行情用独立的任务消费,交易指令进队列由专门的执行协程串行处理。串行这一点很重要——同一个交易标的的订单操作必须串行,否则并发下单很容易撞上仓位校验和可用余额的双重扣减问题。
1.3 技术选型里几个真正影响成败的决定
语言我选 Python + asyncio。理由很实际:接口封装的生态成熟,写起来快,改起来也快;asyncio 天生适合“大量等待 IO”的场景,行情推送和订单轮询都是 IO 密集。缺点也明显,GIL 决定了它不适合做重计算,如果有复杂的指标计算,我会把计算部分拆出去用别的语言写,或者用 numpy 向量化绕开。
HTTP 客户端用aiohttp,不用requests。requests是同步的,在异步框架里会阻塞事件循环,一个慢请求能把整个机器人冻住几十秒。这个坑我踩过,表现是行情突然不更新了,查了半天以为是平台断线,其实是自己阻塞了。
WebSocket 用websockets或者aiohttp自带的客户端都行,关键是自己要包一层心跳 + 断线重连 + 重订阅的逻辑。别指望库自带的自动重连,它不会帮你重新订阅频道,也不会帮你处理重连期间错过的私有消息。
存储用 SQLite 起步,够用而且省事。订单、成交、持仓快照各一张表,加上唯一索引防止重复写入。等数据量大了再换 PostgreSQL,接口层不用改。别一上来就上消息队列和分布式存储,单机 SQLite 能扛住绝大多数个人规模机器人的写入量。
最后是配置管理。API Key、密钥、passphrase 这类东西绝对不能出现在代码里,也不能提交到任何仓库。我用环境变量注入,容器里用 secret 挂载。这一条不是洁癖,是底线,后面讲密钥权限的时候还会提到。
2. 接口接入:签名、限频、连接这三道关
2.1 签名到底在签什么
签名是接入阶段最容易卡住的地方,也是最容易出安全问题的地方。原理其实不复杂:客户端把“时间戳 + 请求方法 + 请求路径 + 请求体”这四段拼成一个字符串,用密钥做 HMAC-SHA256,再 Base64 编码,放在请求头里发出去。服务端用同样的方式算一遍,两边一致就说明请求没被篡改、也没过期。
import base64 import hashlib import hmac import time def build_signature(secret: str, method: str, path: str, body: str = "") -> tuple[str, str]: ts = time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime()) + f".{int(time.time() * 1000) % 1000:03d}Z" message = f"{ts}{method.upper()}{path}{body}" digest = hmac.new(secret.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).digest() return ts, base64.b64encode(digest).decode()看起来简单,但坑全在细节里。第一,时间戳格式要严格对齐文档,差一个字符都会失败,而且报错信息通常只说“签名无效”,不会告诉你是哪一段错了。第二,请求体必须是实际发出的那个字符串,不是字典。如果你先序列化签名,再换个方式序列化发送,两边空格或字段顺序不一样,签名必然对不上。我的做法是先json.dumps(payload, separators=(",", ":"))得到字符串,签名用这个字符串,发送也用这个字符串,绝不二次序列化。
第三,GET 请求的查询参数要按实际拼进 path。很多人签名时用/api/v5/account/balance,发送时带上了?ccy=BTC,两边不一致,直接失败。正确做法是把查询串也拼进 path 再签名。
注意:签名失败时不要盲目重试。有些平台对连续失败的请求会临时限制,重试越多锁得越久。正确做法是记日志、打印实际签名的字符串(打码后),本地用同样的密钥复算一遍比对。
2.2 时间戳与时钟漂移
签名里带时间戳,服务端会校验它和服务器时间的偏差,通常允许几十秒的窗口。这个设计是为了防重放攻击,但对部署环境提出了要求:服务器的时钟必须准。
我遇到过一次特别隐蔽的问题:测试机上一切正常,部署到云主机后所有请求全挂。查了半天代码,最后发现那台机器没开时间同步,系统时间比标准时间慢了两分多钟,所有签名都被判定为过期。解决办法是装时间同步服务,并且把同步频率调高一点,同时在启动时做一次时间校准检查,偏差超过阈值就直接报警退出,不要带着错误的时间戳去撞接口。
代码里我会加一个启动自检:
async def check_clock_drift(session, base_url: str, threshold_s: int = 5) -> float: local_before = time.time() async with session.get(f"{base_url}/api/v5/public/time") as resp: data = await resp.json() local_after = time.time() server_ts = int(data["data"][0]["ts"]) / 1000 local_mid = (local_before + local_after) / 2 drift = local_mid - server_ts if abs(drift) > threshold_s: raise RuntimeError(f"时钟偏差过大: {drift:.2f}s") return drift这段代码的价值在于把“上线后莫名全挂”变成一个启动时就能看见的明确报错。生产环境里,凡是能提前发现的问题,都不应该留到运行中才发现。
2.3 限频:从被动挨打到主动排队
每个接口都有频率限制,有的是按秒算,有的是按两秒窗口算,有的是按接口分组共享配额。新手最常见的做法是“发出去,报错了就 sleep 一秒重试”,这个做法在低频场景能用,一上量就废——因为你不知道配额还剩多少,重试本身还在消耗配额,最后会陷入“越限越重试、越重试越限”的死循环。
正确的做法是本地令牌桶 + 全局配额视图。给每个接口分组维护一个令牌桶,发请求前先取令牌,取不到就排队等待,而不是硬发。这样请求永远不会撞上限频,代价只是延迟略微增加。
import asyncio import time class TokenBucket: def __init__(self, rate_per_sec: float, capacity: int): self.rate = rate_per_sec self.capacity = capacity self.tokens = float(capacity) self.updated = time.monotonic() self._lock = asyncio.Lock() async def acquire(self, cost: float = 1.0) -> None: async with self._lock: while True: now = time.monotonic() self.tokens = min(self.capacity, self.tokens + (now - self.updated) * self.rate) self.updated = now if self.tokens >= cost: self.tokens -= cost return await asyncio.sleep((cost - self.tokens) / self.rate)桶的容量不要设成满额,留 20% 的余量给突发请求和重试。另外要留意限频是按账号维度还是按 IP 维度,有些平台两者都算,如果你的爬虫和交易程序共用一个出口,很容易互相拖累。我的建议是把行情类和交易类的请求在出口上做区分,或者至少把它们的令牌桶分开算。
还有一点:下单、撤单这类写接口的配额通常比查询接口紧得多。如果你有定时轮询订单状态的逻辑,尽量用 WebSocket 的私有推送来替代轮询,能省下大量配额。
2.4 WebSocket 的公有与私有频道
公有频道推行情,不需要鉴权,订阅之后就能收;私有频道推账户和订单,需要先发登录消息,用签名验证身份,登录成功后才能订阅。这两类频道在实现上的差异很大。
公有频道的重连策略可以激进一点:断开后立刻重连,重新订阅,接受中间丢掉的数据。私有频道不行,断开期间可能正好有成交回报,重连之后必须做一次全量对账,把本地的订单和持仓和服务端对齐,不能只靠推送。
心跳也要自己发。很多平台要求客户端定期发 ping,超时会主动断开。我在实现里用一个独立协程定时发心跳,同时记录“最后一次收到任何消息的时间”,超过阈值就主动断开重连,而不是等着对方断。这个细节很重要:有些网络故障是“连接还在但数据不来”,被动等待会让机器人静默地卡死。
async def keepalive(ws, interval: int = 20, timeout: int = 60): last_recv = time.monotonic() while True: await asyncio.sleep(interval) try: await ws.send("ping") except Exception: return if time.monotonic() - last_recv > timeout: await ws.close() return私有频道的消息一定要做去重。重连之后平台可能会补发,同一条成交回报收两次,如果你的逻辑是“收到就加仓”,持仓就会翻倍。去重的依据是消息里的唯一 ID,本地维护一个最近 N 条的 ID 集合,收到重复的直接丢弃。
3. 核心链路落地:行情、下单、对账
3.1 行情接入与本地盘口维护
盘口数据是策略的输入,但推送过来的是一条条的增量消息,你必须自己在本地拼出一个完整的订单簿。这个过程叫维护本地盘口,是所有做市和高频策略的基础。
基本流程是:先通过 REST 拉一份快照,拿到当前的买单和卖单列表以及一个序号;然后订阅增量推送,每条推送带一个递增的序号;收到增量后,如果序号连续就更新对应价位,如果序号断了就丢弃本地盘口重新拉快照。这个“断号重拉”的机制必须做,否则盘口会慢慢变歪,最后算出来的价格全是错的。
class OrderBook: def __init__(self): self.bids: dict[float, float] = {} self.asks: dict[float, float] = {} self.last_seq: int | None = None def apply_snapshot(self, bids, asks, seq: int): self.bids = {float(p): float(s) for p, s, *_ in bids} self.asks = {float(p): float(s) for p, s, *_ in asks} self.last_seq = seq def apply_delta(self, bids, asks, seq: int) -> bool: if self.last_seq is None or seq != self.last_seq + 1: return False for p, s, *_ in bids: price, size = float(p), float(s) if size == 0: self.bids.pop(price, None) else: self.bids[price] = size for p, s, *_ in asks: price, size = float(p), float(s) if size == 0: self.asks.pop(price, None) else: self.asks[price] = size self.last_seq = seq return True实际用的时候,我还加了一层“时间戳过滤”:只接受比本地快照更新的增量,避免乱序消息把盘口搞乱。另外盘口深度不要全存,只保留前 N 档就够了,内存和计算都省。
心得:不要用浮点数当字典键然后又用浮点数去查。虽然 Python 的 float 哈希在同样的计算结果下是稳定的,但一旦中间经过一次加减乘除,就可能出现
0.1 + 0.2 != 0.3的经典问题。价格我统一用字符串或者整数(最小价格单位的倍数)来存,只在计算时转成 Decimal。
3.2 下单参数的计算过程
下单看着简单,其实参数计算是最容易出错的地方,因为每个平台对“数量”和“价格”的定义都不一样。有的按币的数量下单,有的按合约张数下单,张数还要乘以合约面值才是真实的币量;价格有最小变动单位,数量有最小变动单位,还有最小下单量和最大下单量。
我一般会在启动时把所有交易对的精度参数拉下来缓存,然后写一个统一的规整函数:
from decimal import Decimal, ROUND_DOWN def normalize_order(symbol_info: dict, price: Decimal, size: Decimal): tick = Decimal(symbol_info["tickSz"]) lot = Decimal(symbol_info["lotSz"]) min_sz = Decimal(symbol_info["minSz"]) price = (price / tick).to_integral_value(rounding=ROUND_DOWN) * tick size = (size / lot).to_integral_value(rounding=ROUND_DOWN) * lot if size < min_sz: raise ValueError(f"数量 {size} 小于最小下单量 {min_sz}") return price, size这里有几个必须想清楚的点。第一,方向决定取整方式。买入时价格向下取整是保守的(不容易成交),卖出时价格向下取整反而是激进的(更容易成交)。如果你的策略对成交概率敏感,取整方向要按方向分开处理,不能一律ROUND_DOWN。数量则一律向下取整,宁可少买一点,也不能因为超量被拒单。
第二,预留手续费和滑点。按比例算仓位的时候,如果直接用可用余额除以价格,下单往往会因为手续费不够被拒。我的做法是留 0.5% 到 1% 的余量,具体比例看平台的费率和你的成交频率。频繁交易的账户,手续费累积起来不是小数目,仓位计算里必须体现。
第三,名义价值的检查。有的平台要求订单的“价格 × 数量”达到一个最小值,这个和最小下单量是两个不同的约束,都要校验。
举个具体的例子:某合约的面值是 0.01 币,最小下单量是 1 张,价格最小变动是 0.1。你想买价值 100 币的仓位,当前价格 25000,那么理论上需要 100 / 25000 / 0.01 = 0.4 张,向下取整到 0 张,直接不满足最小下单量。这时候要么放大仓位,要么放弃这一笔。很多新手在这里困惑“为什么我的订单一直不成交”,其实是数量被规整成了 0。
3.3 订单状态机与幂等
订单从发出去到终结,会经过一系列状态:已提交、挂单中、部分成交、完全成交、已撤单、失败。这些状态在本地必须有一份完整的映射,而且要能处理乱序到达的情况——比如“成交回报”比“下单成功回报”先到。
我给每个订单维护一个状态机,只允许合法的状态迁移:
| 当前状态 | 允许迁移到 | 触发来源 |
|---|---|---|
| 待提交 | 提交中、失败 | 本地 |
| 提交中 | 挂单中、部分成交、完全成交、失败 | 下单响应 |
| 挂单中 | 部分成交、完全成交、已撤单 | 推送/轮询 |
| 部分成交 | 部分成交、完全成交、已撤单 | 推送/轮询 |
| 完全成交 | 终态 | 推送/轮询 |
| 已撤单 | 终态 | 推送/轮询 |
非法迁移一律记警告日志并丢弃。这个机制能挡住绝大多数的重复累加问题。
幂等的关键在客户端订单 ID。每个订单在提交前生成一个唯一 ID 传过去,本地用这个 ID 做主键。网络超时的时候,你不知道订单到底成没成,这时不要重新下一单,而是用原来的客户端订单 ID 去查一次状态。查到就更新,查不到再决定是否重发。这个流程能避免“超时重试导致双倍仓位”这个经典事故。
async def place_order_idempotent(executor, req, client_id: str): order = await executor.find_by_client_id(client_id) if order: return order try: resp = await executor.place(req, client_id=client_id) return resp except TimeoutError: for _ in range(3): await asyncio.sleep(1) order = await executor.find_by_client_id(client_id) if order: return order raise这里有个细节:查询本身也可能超时,所以要有重试上限和兜底策略。兜底策略我选的是“记录到待确认队列,交给对账流程处理”,而不是直接抛异常终止。机器人最怕的就是一个未捕获的异常把主循环干掉。
3.4 对账与持仓校准
对账是整个系统里最不起眼但最救命的部分。它的作用是:定期拿本地的订单和持仓,跟服务端的真实状态比对,发现差异就修正本地记录并告警。
比对的内容有三块:未结订单(本地有但服务端没有,或者反过来)、持仓数量(本地算的和实际的对不上)、余额(可用和冻结的分布)。频率我一般设成每 30 秒一次轻量对账,每 10 分钟一次全量对账。轻量只查未结订单,全量连持仓和余额一起查。
发现差异时的处理原则是:以服务端为准。本地记录永远是可重建的缓存,不是权威数据。持仓差异超过阈值(比如 1% 或者一个最小下单单位)时,除了修正,还要触发告警并且暂停新开仓,让人来看一眼。这个“暂停”很重要,因为持仓对不上通常意味着有消息丢了或者有订单状态没处理,继续交易只会把偏差放大。
对账还有一个容易被忽略的作用:发现幽灵订单。有时候程序崩在“发出请求”和“收到响应”之间,服务端其实已经挂单了,本地却没有记录。这种订单如果不处理,会一直挂在那里占用保证金。对账时把服务端所有未结订单拉下来,和本地做差集,多出来的就是幽灵订单,要么撤掉,要么补录进本地。
4. 生产级部署:无人值守下怎么活下来
4.1 进程模型与容器化
单机部署我推荐单进程 asyncio + 多任务,而不是多进程。理由是多进程之间的状态同步成本很高,订单状态、持仓、盘口这些东西如果分散在几个进程里,光是一个“谁说了算”的问题就够头疼。asyncio 单进程里所有任务共享内存,状态一致性天然有保证,只要保证 CPU 密集的操作不阻塞事件循环就行。
进程内部的任务划分大致是:一个行情任务(订阅、维护盘口)、一个交易执行任务(消费订单队列、串行下单)、一个私有推送任务(接收订单和账户回报)、一个对账任务(定时轮询)、一个指标与健康检查任务。每个任务都包一层异常捕获,出异常记日志、上报、然后重启这个任务,不能让异常冒到顶层把进程带走。
容器化用 Docker,镜像尽量小,基础镜像选 slim 版本,依赖用 lock 文件固定版本。启动命令里加上-u让 Python 不缓冲输出,否则日志在容器里会延迟很久才出现,排查问题时很误导人。
FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . ENV PYTHONUNBUFFERED=1 CMD ["python", "-u", "main.py"]编排上,简单的单机用docker compose就够了,配置一个restart: unless-stopped,进程挂了自动拉起。但要注意,自动重启不等于自动恢复。重启后必须走一遍启动自检:拉持仓、拉未结订单、对账、检查时钟、检查密钥权限,全部通过才允许开始交易。这个流程我写成一个bootstrap()函数,任何一步失败就直接退出,让编排层重启,而不是带着错误状态硬跑。
4.2 日志、指标与告警
日志用结构化格式,一行一个 JSON,字段包括时间、级别、模块、订单 ID、事件类型、关键参数。这样后面用工具检索的时候可以直接按字段过滤,不用写正则去抠字符串。
import json, logging, sys class JsonFormatter(logging.Formatter): def format(self, record): payload = { "ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S"), "level": record.levelname, "module": record.name, "msg": record.getMessage(), } if hasattr(record, "extra_fields"): payload.update(record.extra_fields) return json.dumps(payload, ensure_ascii=False)日志要分级,但不要什么都打 INFO。行情推送这种高频内容打 DEBUG,正常订单流程打 INFO,异常和重试打 WARNING,资金相关和状态不一致打 ERROR。日志量大了以后,磁盘和检索都会成问题,按天轮转、保留 14 天基本够用。
指标方面,至少要有这几类:请求成功率和延迟、限频桶的剩余量、WebSocket 重连次数、订单提交到成交的耗时、当前持仓和浮动盈亏、对账差异次数、任务心跳时间。这些指标用 Prometheus 的文本格式暴露一个/metrics端点,搭配轻量的采集和面板就够了,不需要上很重的方案。
告警要克制,不然会被淹没。我实际设的告警只有这几条:进程心跳超过 90 秒没更新、对账差异连续两次超过阈值、单日亏损超过设定上限、连续下单失败超过 5 次、密钥权限异常。每一条对应一个明确的处置动作,而不是“发个通知让人看看”。
注意:告警通道要有冗余,但不要依赖单一通道。我吃过一次亏,告警发到一个不常用的群里,出问题时没人看见,第二天才发现仓位已经平了。现在至少配两个渠道,并且关键告警会重复提醒直到有人确认。
4.3 灰度上线与回滚
从模拟盘切到实盘,中间必须有一个灰度阶段。我的做法是三段式:
第一段是只读模式。接真实接口,拉真实行情,算真实信号,但下单请求只写日志不发送。跑三天,比对“如果发了会怎样”和实际行情的偏差,验证信号和参数计算没问题。
第二段是最小资金实盘。用最小的下单量跑,主要验证的是执行链路:签名稳不稳、限频策略对不对、订单状态机有没有漏状态、对账能不能发现问题。这个阶段亏的钱就当是测试成本,别指望赚钱。
第三段才是逐步放量。按 10%、30%、60% 的比例把资金分几批放进去,每一批至少观察一天,确认没有异常再放下一批。
回滚方案要提前准备好,不能等出事再想。最简单的回滚是一键平仓 + 停止进程:写一个独立的脚本,能读取当前持仓,全部用市价单平掉,然后停掉交易进程。这个脚本要单独测试过,确保在交易进程挂掉的情况下也能独立运行。我每个月会演练一次,确保它还能用。
配置文件也要版本化,每次改动记录改了什么、为什么改。出问题时能快速对比“上次能跑的时候是什么配置”。这个习惯看着琐碎,实际能省下大量排查时间。
4.4 风控硬约束
策略层的风控是“应该怎么做”,执行层的风控是“绝对不允许怎么做”。后者必须硬编码在代码里,不依赖策略传入的参数,也不能被配置轻易关掉。
我设的硬约束有这么几条:单笔订单名义价值上限,防止参数算错导致一笔巨额订单;单账户总持仓上限,防止信号异常时不断加仓;单日最大亏损,触发后强制停止开新仓,只允许平仓;连续失败熔断,连续 N 次下单失败就暂停,等人工介入;价格偏离保护,下单价格偏离最新成交价超过 X% 直接拒单,防止盘口异常时被打穿。
密钥权限是另一层风控。API Key 只开交易权限,绝对不开提币权限,并且绑定 IP 白名单。这样即使密钥泄露,损失也被限制在交易范围内,不会被直接转走资产。这条不是技术问题,是意识问题,但每年都有人栽在这上面。
还有一条容易被忽略:频率熔断。如果一分钟内下单次数超过阈值,不管什么原因,先停下来。这能挡住策略逻辑死循环导致的刷单,也能挡住因为状态错误导致的反复开平仓。
5. 常见问题与排查实录
5.1 错误分类速查表
排查的第一步是把错误分类,而不是看到报错就改代码。我把遇到过的错误归成几类,每类有固定的排查路径:
| 错误类型 | 典型表现 | 排查顺序 |
|---|---|---|
| 签名类 | 401/签名无效,所有请求全挂 | 时间戳格式 → 时钟偏差 → 请求体序列化 → path 是否含查询串 |
| 参数类 | 400/参数错误,只有特定订单失败 | 精度规整 → 最小下单量 → 名义价值 → 字段类型 |
| 权限类 | 403/无权限 | Key 权限范围 → IP 白名单 → 是否误用了模拟盘 Key |
| 限频类 | 429/请求过于频繁 | 本地令牌桶配置 → 是否有轮询可换成推送 → 分组配额是否共享 |
| 余额类 | 可用余额不足 | 手续费预留 → 冻结资金 → 是否有未结订单占款 |
| 状态类 | 订单不存在/已终结 | 订单 ID 是否正确 → 是否已撤单 → 是否跨了结算周期 |
排查状态类问题有个技巧:先看服务端的订单历史,再看本地记录。本地记录是缓存,服务端才是真相。两边对不上时,以服务端为准,然后回头查为什么本地没跟上。
5.2 踩过的几个坑
第一个坑是JSON 序列化的空格。签名用的字符串里带了空格,发送出去的时候被客户端重新序列化去掉了空格,两边对不上。这个问题debug了很久,因为报错只说签名无效。后来我改成签名和发送用同一个字符串变量,问题消失。
第二个坑是WebSocket 重连后没有重新订阅私有频道。公有频道重连后行情恢复了,看起来一切正常,但订单回报全丢了,本地持仓一直不更新,直到对账才发现。修复方式是在重连回调里显式重放所有订阅,并且重连后强制做一次全量对账。
第三个坑是浮点数导致的精度漂移。盘口价格用 float 存,累加几次之后某个价位变成了25000.000000001,下单时被判定为不在价格精度内,直接拒单。改成 Decimal 之后彻底解决。
第四个坑是日志打太满导致磁盘写满。行情推送每秒几百条,每条都打 INFO,两天就把磁盘写满了,进程因为无法写日志卡住。后来把高频日志降到 DEBUG,并且加了日志轮转和磁盘水位检查。
第五个坑是重启后重复下单。进程崩溃时有一笔订单已经发出但没记录,重启后策略又算了一次信号,下了第二单。修复是启动时先做全量对账,把服务端未结订单补录进本地,再允许策略运行。
5.3 上线前自检清单
这份清单我每次部署新版本都会过一遍,看起来啰嗦,但每一条都对应过一次真实问题:
- 密钥从环境变量读取,代码和镜像里搜不到明文
- 密钥权限只开交易,未开提币,IP 白名单已配置
- 启动自检包含时钟校准、持仓拉取、未结订单对账
- 所有下单路径都走客户端订单 ID,具备幂等性
- 令牌桶容量留有余量,写接口和读接口分开计
- WebSocket 有心跳、有超时检测、有重连后重订阅
- 私有消息有去重,重连后有全量对账
- 订单状态机拒绝非法迁移,异常迁移有告警
- 对账任务定时运行,差异超阈值暂停开仓
- 风控硬约束全部生效,且无法通过配置关闭
- 一键平仓脚本独立可用,且经过演练
- 监控指标有采集,关键告警有至少两个通道
- 日志有轮转,磁盘水位有检查
- 配置文件有版本记录,回滚路径明确
最后分享一个我自己的习惯:每次改动上线前,先在模拟环境跑满 24 小时,观察对账差异次数、重连次数、限频触发次数这三个指标。这三个数如果都是零或者接近零,实盘翻车的概率会低很多。交易机器人这件事,稳定性带来的收益往往比策略本身的 alpha 更实在,因为一次事故就能把几个月的利润抹掉。