本文是我们程序化交易工程系列的第一篇。我们每天都在构建并测试交易所的系统,以及与之通信的客户端软件——这个系列分享的,正是这些一线工作沉淀下来的经验。无论你是在自动化一个简单策略,还是在负责一整套做市系统,都可以从专业团队长期依赖的这些习惯起步。
每一个严肃的交易业务,最终都会超出浏览器的能力范围。行情变化快过人手点击,报价需要昼夜不停地刷新,风控检查必须覆盖每一笔订单——而不是只覆盖你记得复核的那几笔。这正是 API 存在的意义。
但“会调用 API”和“能运营一条交易连接”之间隔着一道鸿沟。前者是一个下午的工作量,后者是一门纪律。这篇文章要讲的就是这门纪律:专业交易员和做市商究竟是怎样接入 Coincall 这样的交易所的,以及是哪些实践,把一套稳健的系统和一套总在最糟糕时刻掉链子的系统区分开来。
两条通道:REST 与 WebSocket
每条交易所连接都由两条通道组成,分清何时用哪一条,是第一个专业习惯。
REST 用于请求:你提出一个问题或下达一条指令,得到恰好一个回答——拉取合约、读取仓位、提交报价、撤销订单。REST 同步、明确、容易推理——但它是拉取式的。用 REST 轮询行情,既慢又浪费,而且迟早撞上限速。
WebSocket 用于订阅:你建立一条长连接,订阅各个频道——订单簿、标记价格、你自己的仓位与报价事件——更新会在发生的那一刻推送给你。行情数据和账户事件就该走这里。
专业模式很简单——持续更新的数据走 WebSocket,一次性的事务走 REST。用 REST 去发现有哪些合约存在;用 WebSocket 去了解它们正在发生什么。靠轮询 REST 拿价格的系统是在逆着架构做事;靠订阅获取数据的系统才是顺势而为。
限速也请从第一天起就认真对待。每一家成熟的平台都会限制单位时间内的请求数量——所有人(包括你)都依赖的撮合引擎,必须被保护起来,免受意外流量的冲击。留在限速之内,靠的主要是设计习惯,而不是限流代码:能订阅就不轮询;接口支持批量就用批量;很少变化的数据(例如合约列表)做缓存;当交易所要求你降速时(HTTP 429 或平台错误码),就真的降下来。各接口当前的限速值以 API 文档为准;任何会调用 REST 的循环,都应先查清限速,再确定执行频率。
认证:签什么,就发什么
Coincall 使用 HMAC-SHA256 签名来认证 API 请求。这个思路在专业交易平台中是通用的:你的 API Secret 永远不会通过网络传输。你用它给每个请求签名,交易所按同样的规则重新计算签名,以验证请求确实来自你。
签名覆盖一条由请求构造出的规范字符串:
GET/open/futures/position/get/v1?symbol=BTCUSD&uuid=<apiKey>&ts=<millis>&x-req-ts-diff=5000
GET/open/futures/position/get/v1?symbol=BTCUSD&uuid=<apiKey>&ts=<millis>&x-req-ts-diff=5000
这条字符串里有三个细节,是大多数集成出错的地方——每一个都对应一条通用的教训:
1. 参数顺序是固定的。交易所必须重建出与你所签内容完全一致的字符串,因此顺序不能有歧义。把你自己的查询参数按字母序排列,然后按固定顺序追加
uuid、ts、x-req-ts-diff——与上面那条字符串所示完全一致。规则是每次都用同样的顺序,而这条规范字符串就是参照。这一点是普适的:每个使用 HMAC 签名的交易所 API 都有自己的规范化规则,而“签名不匹配”错误几乎总是规范化的 bug。
2. 签名的字节和发送的字节必须完全一致。如果参与签名的是原始值,实际发送时却做了 URL 编码——或者反过来——交易所会算出不同的摘要,然后拒绝你。在我们自己的客户端里,我们甚至会在 URL 组装完成后重新读取一遍,只要它与签名内容有一个字节的差异就拒绝连接。字符串只构造一次:签什么,就发什么。永远不要让序列化库“好心地”重新格式化你已经签过的内容。
3. 时间戳限制重放窗口。每个签名请求都携带毫秒级时间戳(
ts)和一个允许的时间偏差窗口(x-req-ts-diff),后者既作为请求头发送,也参与签名字符串——当前默认值见文档。请求时间戳与服务器时间相差过大的请求会被拒绝。两个实际推论:用 NTP 管好你的时钟;重试请求时,用新的时间戳重新签名——重放旧签名既是安全隐患,也注定会被拒绝。签名相关的值通过请求头传输——API Key 放在
X-CC-APIKEY,大写十六进制摘要放在 sign,再加上 ts 和 x-req-ts-diff——同样的思路也延伸到 WebSocket:连接 URL 本身携带一段签名查询串,由一套独立的签名输入构成(确切格式见文档),认证在握手时即告完成。不存在需要另行处理的“登录”消息。最佳实践:用黄金测试向量(golden test vectors)钉死你的签名代码——一组已知输入,配上确切的预期待签字符串与摘要,一次生成,永不手改。生成时请使用测试用的 Key 和 Secret,绝不要用真实凭据:待签字符串里包含你的 API Key。签名代码是那种“看起来等价”的重构会悄悄破坏一切的地方,而黄金向量能把无声的破坏变成一个失败的测试。
连接未断,不等于连接可用
一条打开着的 WebSocket,不一定是一条工作着的 WebSocket。连接会悄然失效——NAT 超时、路由中断、代理卡死——而两端仍然相信套接字一切正常。因此,专业团队把存活当作需要测量的属性,而不是可以假设的前提。
两套机制协同工作:
- 你发出的心跳:Coincall 期望周期性的应用层心跳(
{"action":"heartbeat"})。发送节奏要稳稳落在平台的闲置阈值之内——当前超时值见文档;我们自己的系统每 20 秒或更快发送一次。卡着极限发,意味着一帧延迟就足以让你断线。 - 你监测的沉默:更微妙的另一半——记录你最后一次收到任何数据的时间。如果入站方向的沉默超过了你的阈值,发一个探测并开始计时;如果在响应超时内仍无任何回应,就判定连接已死,主动拆掉它。一条无法证明自己还活着的连接,就应当按死亡处理——等 TCP 自己发现问题,可能要花掉你耗不起的几分钟。
异步设计还有一个值得点名的陷阱:如果你的心跳只在事件循环轮询连接时才会发出,那么一个在慢操作上卡住的消费者——一次阻塞的数据库写入、一段昂贵的计算——就可能让心跳得不到调度,把自己活活断线。要么让读循环里的工作严格非阻塞,要么把存活检测与业务处理隔离开。
断线是常态:重连机制要做扎实
断线不是异常事件,而是日常,你的架构就该这样对待它。三个实践最重要:
指数退避,加上抖动:重连失败时,每次尝试都把延迟翻倍,直到一个上限(我们的上限以分钟计,不以秒计)。然后引入随机——在零和当前上限之间均匀随机地取延迟,而不是直接用上限值。为什么要抖动?因为交易所重启时,所有客户端会同时断开——没有抖动,所有客户端也会同时重连,形成一波波同步的请求洪峰,看起来像一场 DDoS,还会拖长故障时间。完全抖动(full jitter)正是用来打散这种惊群效应的。
在健康得到证明时重置退避,而不是在连接成功时:一个微妙但重要的细节——不要因为连接成功就清零退避计数。陷入麻烦的平台会照常接受你的连接,然后五秒后掐断——天真的清零会把你精心设计的退避变成连珠炮。只有当连接证明了自己健康——在一段有意义的窗口内持续收到数据(我们的标准是连续 60 秒有入站数据)——才重置。
彻底重建,不要带病运行:如果重连后某个订阅失败了——被平台拒绝,或者始终收不到确认——不要带着残缺的订阅继续跑。半死不活的会话比彻底死掉的更糟,因为它看起来健康,实际却在悄悄漏数据。拆掉它,在一条全新的连接上重建完整的订阅集。会话应当要么全有,要么全无。
信任行情流,校验每一帧
数据一旦流动起来,问题就变成:你怎么知道你眼中的市场仍然是对的?
在 Coincall 的期权与期货订单簿频道上,每一帧都携带频道深度内的完整订单簿——每次更新都替换你的本地状态,而不是在其上打补丁。这是一份对客户端友好的数据契约:没有可能被错误应用的增量,没有需要追补的序号缺口,漏一帧损失的只是时效,而不是正确性。(在确实按序号推送增量的平台上,缺口检测和快照恢复就成了你的任务——这也是要仔细阅读每个频道数据契约、而不是假设它们都一样的又一个理由。)
但友好不等于免检。每一帧都要在边界上校验完,才能触碰你的交易逻辑:
- 校验身份:这一帧属于你订阅的频道和合约(symbol)吗?根据平台在报文中提供的判别字段路由;永远不要靠猜。
- 校验数字:价格和数量应当解析为有限的正值。一个溜进定价计算的
NaN会无声传播,污染下游的一切。 - 校验不可能:买一价高于卖一价的盘口交叉,是行情在告诉你有什么出了问题——要么是数据源,要么是你的解码。拒绝它,别拿它交易。
- 校验时效,用对尺子:安静的订单簿不等于死掉的连接——一张深度虚值的期权完全可能几分钟纹丝不动。用心跳判断连接,用每个频道自身的节奏和时间戳判断数据。把两者混为一谈,结局要么是对着健康的行情恐慌性重连,要么是心安理得地信任一份冻结的数据。
解码要宽容而诚实:不认识的字段可以忽略(平台会加字段,你的集成不该因此坏掉),但你需要的字段一旦缺失或格式错误,就必须立即明确报错。“无法证实就报错,新增字段则兼容”,是我们构建解码时遵循的规则。
最难的问题:你不知道刚才发生了什么
这是把专业订单管理和其他一切区分开的场景。你提交了一笔报价(quote),请求超时了。交易所到底收到了没有?
你真的不知道。请求可能死在了去交易所的路上——也可能已被接受,只是响应丢了。这两种可能要求相反的动作,而无论朝哪边猜错都代价高昂:盲目重试,你可能背上重复的敞口;盲目放弃,你可能丢下一笔仍在生效、而你已不知道自己拥有的报价。
专业的答案分三部分:
1. 先给每个故障分类,再做反应。错误并非生而平等,你的重试逻辑必须懂得区分:
- 临时性:网络抖动、5xx、429、网关超时。对读请求和其他可安全重复的请求,带退避重试。一个改变状态的请求超时了,绝不只是“临时性”——它属于下面的“结果不确定”。
- 持久性:应用层拒绝——参数错误、权限、校验不通过。原样重试只会原样失败;这类问题需要人或需要改代码,连续出现足够多次,就该触发你的紧急停止开关。
- 结果不确定:一个可能到达也可能没到达交易所的状态变更请求。绝不盲目重试。
- 冲突:交易所说你想要的状态已经存在。往往根本不是故障:对账,然后继续。
一个推论:只自动重试那些可以安全重复的东西。读请求是幂等的——在限速额度内放心重试。创建报价不是——只试一次,然后去查明真相。
2. 遇到不确定,先查询,不要猜。当一个状态变更请求以不确定告终,先向交易所查询真实状态,再做任何别的事。自己保留一份准确的发送记录——合约、方向、数量、价格、时间——这样你才能在交易所的回答里认出你的订单;如果平台支持在下单时附加你自己指定的标识符,请务必使用——用你选的 ID 匹配,远比按属性匹配可靠,尤其是当多笔相似订单同时挂着的时候。还要清楚一次查询能证明什么、不能证明什么:在单独一次未成交订单快照里缺席,并不能证明你的创建失败了——订单可能只是还没变得可见。留出宽限期,再用一次定向查询确认,然后才下结论。
3. 持续对账。即使不确定性处理得再完善,你的本地记录和交易所的事实也会漂移——一条错过的 WebSocket 事件、一次竞态、另一个终端上的人工操作。运行一个周期性的对账循环(我们的每分钟跑一次):拉取交易所视角下你的挂单和报价,把此前未知的订单纳入本地,把不再需要的孤立订单撤掉。交易所的状态是事实;你的本地状态只是缓存。忘记这一点的系统,会在第一次事故里重新学会它。
安全护栏:假设你自己的代码就是威胁
做市商运行的自动化系统,每天会数千次地动用资金。专业团队的应对是分层的、故障时默认拒绝(fail-closed)的安全设计——假设 bug 一定会发生,并预先锁死它的损害边界:
- 默认 dry-run(只计算、不下单):新部署应当计算一切——价格、风控检查、拟发的报价——但一单不发,直到你明确拨动开关。任何交易系统的默认状态都应该是“不交易”。
- 一道故障时默认拒绝的交易前闸门:每笔出站订单都要过最后一道检查——数量上限、名义价值上限、价格合理性(有限、为正、高于下限)、行情时效。关键在于——如果闸门自己抛了异常,答案是拒绝,不是“放行”。让不安全的路径在结构上不可能:我们喜欢的一种模式,是把代码组织成没过闸门的订单根本无法被递交给下单函数。
- 一套真正管用的紧急停止开关(kill switch):跟踪连续的持久性故障;超过阈值后,停止发送新订单、撤掉现有挂单、并确认撤单真的生效了——撤单请求在平台不可达时同样会失败,所以要以合适的节奏持续重试并通知人工,而不是假设成功。剩余仓位如何处置,由人来决定、由人来盯着执行。恢复也要郑重:会自动复位的紧急停止开关只是个限流器。必须由人来重启。
- 启停时全部撤单:启动时,先撤掉一切再报价——你可能正在继承一个崩溃前任留下的订单。停机时,再撤一遍。也要对局限保持诚实:一个已经彻底崩溃的进程什么也撤不了,因为它已不在运行——这正是启动时全撤重要的原因,也是值得查一查文档中有哪些平台侧保护适用于你账户的原因。永远不要留下没人盯着的报价。
- 过期数据即拒绝:如果行情已经超过你的时效阈值——或者你根本无法判断它有多老——你手里的就不是价格,而是记忆。别拿记忆报价。
这些护栏没有一条是精巧的。它们的力量恰恰在于无条件。
密钥安全:像对待资金一样对待你的 API 密钥
你的 API Key 和 Secret 就是你的账户。以下规则没有商量余地:
- 密钥只进环境,不进代码:从环境变量或不入库的
.env文件加载;把.env写进.gitignore,只提交一份.env.example——里面有变量名,没有值。 - 机密要有专门的类型:用机密类型(
SecretStr、SecretString或你的语言里的等价物)封装密钥,这样一次手滑的调试打印显示的是[REDACTED],而不是你的 Secret。把敏感请求头标记为敏感,你的 HTTP 栈也就不会回显它们。 - 先脱敏,再写日志:WebSocket 的认证信息在 URL 里——这意味着一行随手写下的“connection failed to {url}”日志,会把你的签名和 API Key 一并泄露。用规则、而不是靠记性,从每条错误消息和每份日志里清洗敏感参数。
- 权限最小化,角色相互隔离:只申请每个系统所需的权限——一个行情读取器没有任何理由持有交易权限。不同角色用不同密钥,彼此绝无共用,这样一处的 bug 永远无法借用另一处的权限。
- 永远加密:生产流量只走
https/wss。唯一站得住脚的明文例外是本地测试的环回地址——即便这一条,也值得用代码强制,而不是靠约定。
离线测试,真实环境验证——并且分清两者
最后一个专业习惯,关乎证据。
常规测试永远不触碰交易所:你的默认测试套件应当完全离线运行——签名用黄金向量,解码器用录制的真实报文,本地 mock 服务器断言客户端发出的确切字节。这让测试快速、确定,并且每次改动都能安全地跑。关于 mock 有一条用代价换来的教训:手写的假交易所,和你的代码共享同一套假设——如果你误读了 API 文档,假交易所会体现同一个误读,而测试照样通过。录制的真实响应能证伪你的假设;编造的响应只会附和它们。只是别忘了在录制报文成为测试夹具之前先做脱敏:私有频道里携带着你的账户标识和仓位。
真实环境验证是郑重且独立的:当你确实需要对真实平台验证行为时,把它做成一次显式的、需要主动触发的运行——一个独立的可执行程序,绝不是常规测试命令能误触的东西——能只读就只读,任何会改变状态的操作都尽量远离生产环境(你的账户可用哪些测试设施,请查阅文档)。结果要诚实地记为三态,而不是两态:通过(证据支持结论)、失败(证据与结论矛盾)、无结论(这次运行根本没有建立起可供判断的条件——配置错误、权限问题、连接中断)。第三种状态,恰恰是新手最容易跳过的。一个没能运行的测试不是一个通过的测试,一份空结果什么也证明不了。
机制背后的心法
剥掉具体细节,这篇文章里的实践可以归结为几条原则:
- 签什么,就发什么。规范化决定成败。
- 存活要测量,不能假设。沉默本身就是一种故障。
- 退避要带抖动,健康得到证明再重置。别给故障中的平台雪上加霜。
- 在边界上校验。未经验证的数据不碰交易逻辑。
- 交易所是事实;遇到不确定,先查询。你的本地状态只是缓存。
- 处处故障时默认拒绝。面对不确定,默认答案是“不交易”。
- 密钥就是资金。按资金的标准来管理。
- 逻辑靠离线测试,线上事实靠显式验证。并且坦然承认“无结论”。
这里没有一样东西需要特殊的基础设施。它需要的,是在故障发生之前就认真对待故障——而这一点,比其他任何东西都更能把专业级的连接,和一个只在演示里跑通过的脚本区分开。
本系列下一篇,我们将深入 WebSocket 行情:规模化的订阅管理、数据时效检测,以及如何构建一套数百个策略都能信任的行情服务。
Coincall 提供期权与期货交易的 API 接入。完整 API 文档见 docs.coincall.com。本文中的技术细节——接口、请求头、签名格式与限速——仅作示例,以发布时为准;官方文档才是权威来源,参数可能变更。本文不构成任何投资建议。文中实践来自我们自身基础设施工作的经验;请结合你自己的风险承受能力取舍,并在实盘交易前完成充分测试。
評論
0 條評論
請登入寫評論。