写量化策略时,我们习惯把注意力放在交易逻辑上:什么条件开仓,什么位置退出,怎样控制仓位。但策略真正运行起来,信号之前还有一层问题——程序看到的市场,究竟有多接近正在发生的市场?如果只做分钟级、小时级判断,这个问题有时不太显眼。可一旦同时观察多个交易所、多个品种,或者开始研究短暂的价差变化,行情获取方式就会直接影响策略结果。
一组价差刚刚扩大,轮询还没有走到它;两家交易所的报价都返回了,采样时间却错开了;下单之后,程序还在查询订单,另一条腿已经成交。这些事情未必出在信号公式上,却会改变公式最终能不能落到真实交易里。这也是这次发明者量化 WebSocket 接口全新升级值得展开聊的原因。
升级后的接口把行情订阅、事件消费、账户推送和异步交易组织到了一套较为统一的调用方式中。策略可以通过 ctx.subscribe() 持续接收市场变化,用 ctx.poll() 处理事件,再通过 ctx.submit() 在支持的账户上提交 WS 交易请求。它带来的变化,不只在于减少一次行情查询的等待。更值得关注的是,策略可以按照市场变化安排自己的工作:平时观察什么,出现机会时关注什么,持仓以后又把处理资源放在哪里。
市场的节奏,往往不是固定的
传统 REST 策略很好理解:程序主动询问,交易所返回结果。查一次报价,做一次判断,等待一段时间,再查下一次。这种方式清晰,也依然适合很多任务。市场信息、历史数据、账户核验,都可以通过 REST 明确地获取。但用固定频率观察持续变化的市场,需要在两个方向之间作取舍。查询慢一点,请求少一些,短暂变化可能从两次采样之间溜过去;查询快一点,又会增加请求量,占用限频和处理资源。品种一多,这个矛盾就更明显。
更麻烦的是,市场不会均匀地变化。有时几秒钟都没有值得处理的新信息,有时一瞬间却出现大量报价和成交。固定轮询只能按照预先设定的节奏工作,而 WS 订阅能够让程序随着实际到达的数据更新状态。对于波动突然放大的阶段,推送有助于减少轮询间隔带来的等待;对于同时观察多个市场的策略,持续维护各自的最新报价,也比逐个临时查询更容易组织。
不过,这里需要把“更及时”和“保证更快”分开。REST 可以复用连接、批量请求、并发执行;WS 也会受到交易所推送频率、网络路径和托管者负载影响。此次升级提供了更适合持续观察的接口,具体延迟改善多少,仍然要在目标环境中测量。策略更贴近市场的前提,是数据入口、决策方式和执行跟踪一起调整。 只把其中一个函数换掉,未必能得到相同效果。
这次升级,把哪些工作接到了一起
发明者过去已经能够通过 Dial() 连接原生 WebSocket。开发者可以直接处理交易所协议,灵活度很高,但不同交易所的订阅格式、字段、盘口更新和连接管理,也需要逐家适配。升级后的统一订阅接口,把这部分工作向平台连接器集中。策略层可以用相近的写法接收 ticker、最优买卖报价、深度、逐笔成交和 K 线;需要扩大观察范围时,还可以在支持的交易所订阅全市场 ticker,或者一次传入多个品种。对于策略作者,这意味着同样的精力可以更多放在“收到数据以后怎么判断”,而不是反复处理各家的消息格式。
例如,下面是一段可以在支持相应频道的币安永续配置下运行的只读示例。它订阅两个品种,持续维护各自的最新买一卖一,一分钟后退出,不进行交易。批量订阅返回的数组也带有 unsubscribe() 方法,可一次释放这组订阅。
javascript
function main() {
var subs = ctx.subscribe(exchange, {
channel: "bbo",
symbol: ["BTC_USDT.swap", "ETH_USDT.swap"],
mode: "latest"
});
var quotes = {}, started = Date.now(), lastLog = 0;
try {
while (Date.now() - started < 60000) {
var e = ctx.poll([], 200);
if (e && e.kind === "status" && e.prefillStatus === 2) {
Log("订阅失败", e.symbol, e.message);
} else if (e && e.kind === "ticker" &&
e.channel === "bbo" &&
e.bid > 0 && e.ask >= e.bid) {
quotes[e.symbol] = {
bid: e.bid, ask: e.ask, received: e.ts
};
}
if (Date.now() - lastLog >= 1000) {
Log("最新报价", JSON.stringify(quotes));
lastLog = Date.now();
}
}
} finally {
subs.unsubscribe();
}
}
这里的 poll([], 200) 是最多等待二百毫秒,有事件时会提前返回。它没有每二百毫秒重新请求一次行情。订阅登记后,首次 poll() 才会启动连接和订阅,所以拿到订阅对象,也不等于已经收到有效数据。还有一个迁移旧策略时必须讲清的地方:订阅 WS,不会把 GetTicker()、GetDepth() 等原有查询变成缓存读取。这些函数仍然走 REST,推送需要通过事件入口获取。真正使用新的数据链路,主循环也要相应调整。官方订阅说明
除了行情,升级后的接口还把账户状态接了进来。每个账户订阅一路 private,就可以按交易所实际支持情况接收订单、成交、持仓和余额。它覆盖整个账户,不需要为每个品种重复订阅。这使策略能够把市场变化与自己的交易状态放到同一个处理流程里:报价更新后重新估计机会,成交回报到达后更新交易记录,持仓变化后核对风险敞口。但统一接口并不意味着每家交易所都推送完全相同的内容。有的字段可能缺失,有些账户变化也未必实时推送。初始化和重连后,需要等待账户快照就绪;缺失信息和历史成交仍然需要查询补齐。
适配不同市场,先分清哪些数据值得保留
行情多了以后,很容易产生一个直觉:既然 WS 能持续推送,那就把能订的全部订上。实际跑过一段时间,往往会发现,数据越多并不一定越有帮助。真正的问题是,策略是否需要处理每一条数据,以及收到以后能不能及时处理完。
对于根据当前报价判断价差的策略,通常更关注最新的买一卖一。两秒前的报价排在队列里,逐条处理完才轮到现在,反而会让决策落后。mode: "latest" 允许旧行情被新行情覆盖,更适合这种用途。但对逐笔成交研究就不一样了。如果用短时间内的主动买卖成交计算订单流,中间成交被覆盖,统计含义也会改变。K 线同样需要区分初始化补入的历史数据、尚未结束的当前 K 线和真正收盘的 K 线。
因此,升级后的价值,也体现在可以按照用途组织数据,而不是所有频道都套用一种消费方式。下面是几个典型订阅入口。它们展示不同信息如何接入,同样需要在事件循环中分别处理,并在结束时释放订阅。
javascript
// 盘口:收到 depth 事件后,通过 e.book(n) 读取档位。
var depth = ctx.subscribe(exchange, {
channel: "depth", symbol: "BTC_USDT.swap", depth: 20
});
// 逐笔成交:研究成交方向、价格和数量。
var trades = ctx.subscribe(exchange, {
channel: "trade", symbol: "BTC_USDT.swap"
});
// K线周期单位为秒,收盘判断还需检查 closed、prefill。
var bars = ctx.subscribe(exchange, {
channel: "kline", symbol: "BTC_USDT.swap", interval: 60
});
// 整个账户的私有流,不传 symbol。
var account = ctx.subscribe(exchange, {channel: "private"});
盘口由托管者维护,策略不用自己针对每家交易所拼接增量深度。但 book() 读取的是当前维护的订单簿,不能把它理解为某条旧事件发生时的历史快照。同样,最新收到的数据也要有时间概念。事件中的 ts 是托管者收到数据的时间,当前时间减去它,可以观察内部处理和消费延迟,却不包含上游网络传输。跨所报价还要分别检查年龄和时间差,不能因为两边都通过 WS 进入程序,就默认它们来自同一时刻。事件字段说明
这两天做连接稳定性探索,感受比较深的一点也是如此:连接存在、收到过数据、数据仍然可用,是三个不同的状态。断网以后能恢复推送,当然很重要;但如果连接没有关闭、行情已经静默,策略仍然需要自己的保护。遇到目标报价过期,应停止依赖它开仓;恢复后,也要核对持仓和未决订单,而不是直接把上一轮计算接着跑下去。
在高波动和多品种场景下,还需要观察 ctx.connectors() 提供的丢弃、背压、缓冲容量和应答丢弃计数。每条行情都打印日志、重画面板,或者执行慢查询,都可能把事件循环拖慢。所以,更适应市场环境的运行方式,是根据当前任务分配订阅和计算资源。广泛观察时用较轻的数据,进入重点跟踪后增加必要信息,持仓时优先处理目标报价与账户状态。市场变忙,程序也应当知道哪些事情必须先做。
从看到变化,到确认完成交易
行情侧改成推送以后,如果下单仍然逐笔阻塞等待,随后再反复查询成交,整条路径还是会存在明显的等待环节。这次升级提供的 ctx.submit(),让支持 WS 交易的账户可以异步提交下单、撤单请求。发出请求后返回本地请求号,结果再通过 ctx.poll() 接收。
下面是一个 IOC 限价单的提交函数。价格、数量和最低下单要求,应当在调用前按目标市场规则检查完成;它展示的是交易入口,不包含完整订单管理。
javascript
function sendIOC(ex, symbol, side, price, amount, reduceOnly) {
var req = {
op: "order", symbol: symbol, side: side,
price: price, amount: amount, tif: "ioc"
};
if (reduceOnly) req.reduceOnly = true;
var localId = ctx.submit(ex, req);
if (localId instanceof Error) {
throw localId; // 请求没有发出,不会产生对应应答。
}
return localId;
}
// 主循环中按 localId 关联三个阶段:
// ack.reqId → 请求结果
// order.clientId → 订单状态、累计成交量、均价
// fill.clientId → 每笔真实成交及手续费信息
异步交易使程序可以在等待回报时继续处理行情,也方便尽快发出多条腿的请求。对于跨所交易,这比先等一条腿返回,再开始处理另一条腿更符合实际需要。但必须区分请求已发出、请求得到成功应答、订单实际成交。ack 成功不代表仓位已经建立,实际数量和价格还要看订单、成交及持仓核对。如果结果是 unknown,原请求可能已经被接受。正确做法是保留未决状态,按客户端订单号查询或等待私有流确认,不能直接重复下单。
ctx.submit() 本身只走 WS,不支持时返回错误,不会自动切到 REST。策略可以自行设计回退,但必须确认原请求没有发出,避免在结果不明时又发一笔。ctx.order()、ctx.cancel() 则属于 REST 交易路径,不能混为一谈。异步交易接口说明
批量提交也需要逐笔验收。不同交易所的批量方式和上限不同,批量请求可能部分成功;两家交易所之间,更不存在因为同时发出就保证同时成交的机制。WS 改善执行组织方式,单腿、部分成交和限频仍然需要策略自己管理。
一个实际应用
以跨所价差回归套利这个经典策略为例,传统写法需要分别调用多家交易所的行情接口,取得报价后再比较价差。品种一多,一轮查询就会拉长:第一家交易所的报价已经拿到了,第二家还在等返回,等到开始比较时,前面的价格可能又变了。下单以后,通常还要继续查询各自的订单,确认两边成交了多少、还有没有未完成的委托。
这里就可以用 WS 把这段过程组织起来。策略运行时,先为扫描品种订阅 BBO 行情,用 latest 模式维护各家交易所最新的买一卖一。报价到达后,通过 ctx.poll() 更新对应品种的状态,再检查当前是否具备交易条件。程序可以随着收到的行情作判断,减少逐个查询带来的等待,同时仍需检查两边报价的时效,避免把不同时间的价格直接拿来比较。
不过,观察范围也不必始终一样大。持仓以后,已经承担风险的两条腿更需要关注,策略便退订无关行情,只保留目标报价,退出后再恢复扫描。账户的 private 私有流一直保留,行情关注点变了,订单和持仓回报仍然继续接收。
等到需要交易时,支持 WS 下单的账户通过 ctx.submit() 尽快发出两条腿的请求,程序随后继续处理事件。新的报价可能先到,某条腿的成交也可能先到,都要更新到各自的记录里。ack 用来确认请求结果,订单和成交回报用于核对实际数量与价格,再结合持仓检查两边是否已经对上。等待订单结果的这段时间,行情也能继续进入同一个处理流程。
两条请求发出后,各自的成交结果仍要继续跟踪。结果不明时,保留未决状态,等待回报或查询确认。初始化、缺失数据和必要的账户核验仍由 REST 配合;是否回退下单,也要先分清原请求有没有发出。
沿着一次交易看下来,这几个接口的关系就清楚了:程序持续接收报价,根据变化安排工作,发单后继续跟踪,再把实际结果核对回来。接下来可以观察的,也就更具体了——报价在程序里等了多久,哪条腿先得到确认,等待期间市场又变了多少。这些记录,才是判断接口改造是否改善了实际运行的依据。
示范策略:跨所币股价差回归套利
- 1




