PMWallets
EN

WebSocket

建立低延迟连接,检测并补拉缺失成交。

WebSocket

GETwss://api.pmwallets.com/v1/wsAPI key

连接后会推送账户已订阅实体的成交,无需发送订阅消息。服务端忽略客户端发来的内容。 这是低延迟通道,跟单机器人通常使用此通道:一条长连接,不用每笔事件重做 DNS 和 TLS 握手, 也不要求你自己有公网 HTTPS 地址。本站的我的推送也使用此数据流。

node
import WebSocket from "ws";
import { randomBytes } from "node:crypto";

// One id per running client, the same on every reconnect: it is how the server tells
// "this client coming back after a network drop" from "a second client".
const clientId = randomBytes(16).toString("base64url");
const takeover = false;   // true = replace whichever API client holds this account's stream

function connect(first) {
  const ws = new WebSocket(`wss://api.pmwallets.com/v1/ws?clientId=${clientId}${first && takeover ? "&takeover=1" : ""}`, {
    headers: {
      "x-api-key": process.env.PMW_KEY,     // a handshake cannot set Authorization
    },
  });
  ws.on("close", (code) => {
    if (code === 4411) return stop("another API client holds this account's stream");   // refused: do not retry
    if (code === 1000) return stop("replaced by another client's takeover");            // do not fight back
    setTimeout(() => connect(false), code === 1012 ? 0 : backoff());   // 1012 = we are restarting: come right back
  });
  return ws;
}
const ws = connect(true);

socket 不重试。帧是尽力投递:交给打开的 socket 就算发出, 没有 socket 就丢弃,两者都不会重发。丢帧后可保存游标,用 GET /v1/account/fills 补拉缺失成交,详见下一节。 如需服务端主动推送重试,可同时接入 Webhook。两种方式都按 eventId 去重。

每个账户 1 条连接,API 客户端优先。用 API key 建立的连接会替换本站浏览器 标签页的连接(标签页以 4410 关闭);API 客户端在线时,浏览器标签页无法替换它, 我的推送会显示连接不可用,关闭码为 4409。

两条 API 连接之间,先连上的保留这条流。同一账户换一把 key 连接,或重复启动机器人时,第二条连接 会收到 {"type":"blocked","reason":"api_seat_taken"} 帧并以 4411 关闭, 正在推送的那条不受影响。如需替换现有连接,带上 ?takeover=1 连接:原来那条以 1000 关闭, 原客户端应停止重连,否则会重新占用连接。请带上 clientId(8–64 位 [A-Za-z0-9_-],每个运行中的客户端一个,每次重连都相同):客户端使用自己的 id 重连时会替换原连接,不需要 takeover,避免网络闪断后原连接仍占用 名额。服务端每 15 秒 ping 一次,无应答时断开连接。

我们重启推送服务时,打开的连接会以 1012(服务重启)关闭。接替进程正在接管,请立即重连,无需退避, 然后按下文补拉空档。官方 SDK 已处理这些步骤。

检测丢帧并补拉

每一帧都带 session 和 seq。同一个 session 内 序号是连续的,所以序号跳了就说明我们丢了帧。消费端处理不及时时,服务端会故意丢帧, 但保持连接,仅通过序号体现丢帧。session 变化说明发生过重连。这两种变化都可用于检测空档。

js
// Persist these across reconnects — comparing against a session you just overwrote
// can never detect anything.
let session = load("session"), seq = load("seq") ?? 0;
let lastBlock = load("block") ?? 0, lastLogIndex = load("logIndex") ?? 0;

// One message at a time: a replay is awaited, and a second frame arriving meanwhile
// would advance the cursor past the gap being repaired. Note the catch — chaining
// onto a rejected promise skips every later .then(), so a single failed replay would
// leave the socket open and silently processing nothing at all.
let queue = Promise.resolve();
ws.onmessage = (e) => {
  queue = queue
    .then(() => handleFrame(JSON.parse(e.data)))
    .catch((err) => { report(err); ws.close(); });   // reconnect and replay rather than go quiet
};

async function handleFrame(m) {
  if (m.type === "hello") {
    // A NEW session means frames were missed while the socket was down. Replay BEFORE
    // adopting it — overwriting session here is what silently swallows the outage.
    // a failing replay must not be swallowed: leave the cursor where it is and throw, so the
    // handler above closes the socket and the next connection tries again from the same place
    if (session !== null && m.session !== session) await replayFrom(lastBlock, lastLogIndex);
    session = m.session; seq = m.seq; save();
    return;
  }
  if (m.type !== "fill") return;

  if (m.session !== session || m.seq !== seq + 1) {   // dropped frame, or a session we never saw
    await replayFrom(lastBlock, lastLogIndex);
    session = m.session;
  }
  seq = m.seq;

  if (!seen.has(m.data.eventId)) { seen.add(m.data.eventId); handle(m.data); }
  lastBlock = m.data.block; lastLogIndex = m.data.logIndex;
  save();
}

检测到空档后补拉成交。GET /v1/account/fills 返回游标之后所有已订阅实体的 成交,按时间正序、keyset 分页。返回行与推送相同,使用相同的 eventId。按它去重即可安全处理重复拉取。只覆盖生效中的订阅, 且只返回各订阅 fromBlock 之后的成交:恢复订阅会把它移到链头,所以暂停期间的成交在这里拉不到。

js
// walk forward until the page is short; every row carries the same eventId as the stream
async function replayFrom(block, logIndex) {
  for (;;) {
    const r = await fetch(
      `https://api.pmwallets.com/v1/account/fills?sinceBlock=${block}&sinceLogIndex=${logIndex}&limit=500`,
      { headers: { "x-api-key": PMW_KEY } },
    );
    if (!r.ok) throw new Error(`replay failed: HTTP ${r.status}`);   // do NOT advance the cursor
    const page = await r.json();
    // fills are kept for 60 days: an older gap is gone, not empty
    if (page.truncated) console.warn(`fills before block ${page.retainedFrom} are no longer kept`);

    for (const row of page.rows) if (!seen.has(row.eventId)) { seen.add(row.eventId); handle(row); }
    if (!page.next) return;
    ({ sinceBlock: block, sinceLogIndex: logIndex } = page.next);
  }
}