AllTick
-
港股 股票api市场数据金融科技开发

港股api接入实时行情时如何处理午间休市导致的数据空窗?

港股实时行情接入时,午间休市可能导致 Tick 暂停更新。本文介绍如何结合交易时段、WebSocket 心跳和行情数据状态判断正常休市与 API 异常,避免误触发重连,并确保下午恢复交易后行情正常更新。

AllTick阅读 5 分钟

开发港股行情监控系统时,午间休市是一个很容易被误判为 API 故障的场景。

例如,页面正在订阅腾讯控股(700.HK)的实时行情,上午 11:59 还能收到新的成交数据,12:00 之后价格突然停止更新。此时 WebSocket 连接没有断开,心跳也正常,但服务器不再推送新的 Tick。

如果系统只通过行情更新时间判断连接是否正常,就可能在午休期间不断触发重连。等到下午恢复交易,又可能因为重新订阅、数据缓存或前端状态处理不当,造成行情恢复延迟。

处理这类问题,不能只关注 API 有没有继续推送数据。开发者需要同时判断交易时段、WebSocket 连接状态和单只证券的数据更新情况。

港股12:00之后是否一定没有行情?

在编写港股实时行情程序之前,需要先弄清楚一个细节:并不是所有港股证券都适用完全相同的午间安排。

按照港交所证券市场交易规则,普通证券的持续交易时段为上午 09:30–12:00 和下午 13:00–16:00。也就是说,普通证券在 12:00–13:00 之间没有持续交易。

但交易所另有指定的 Extended Trading Securities,可以在 12:00–13:00 的延续早市继续交易。半日市也有不同安排,不能简单套用全日市的时间判断。

开发时可以先按照以下规则建立交易时段分类:

证券及交易日类型上午12:00–13:00下午
普通证券全日市09:30–12:00午间休市13:00–16:00
指定延续早市证券全日市09:30–12:00延续早市13:00–16:00
半日市09:30–12:00持续交易结束无午市

表格中的时间均为香港时间。半日市收市竞价时段等安排需要单独处理。

因此,假设系统同时监控多只港股,不能在每天 12:00 统一把全部证券标记为休市。应该先通过交易日历和证券适用的交易时段判断哪些标的进入午休,再决定是否需要对行情进行异常检测。

将市场状态和 WebSocket 状态分开判断

实际开发中,最容易出现的问题是把“没有收到 Tick”直接等同于“行情接口失效”。

这两种情况并不相同。

建议将行情系统拆成三个独立状态:市场交易状态、WebSocket 连接状态、证券行情更新状态。

检测情况具体表现合理处理方式
正常午间休市交易日历显示午休,心跳正常,没有新 Tick保留最后有效价格,不触发重连
交易时段内暂时没有成交连接正常,但某只证券没有新 Tick保留原始时间戳,检查成交活跃度
订阅失败收到订阅响应,但返回错误码检查证券代码、Token 和账户权限
连接异常WebSocket 断开或心跳长期无响应重新建立连接并恢复订阅

其中,心跳与行情推送需要特别区分。

心跳用于确认通信链路是否仍然正常,Tick 则代表实际的成交数据更新。即使连接状态正常,也不意味着每一只股票都会持续产生新的成交。

举个例子,上午最后一笔成交发生在 11:59:58,12:30 时页面仍然显示这笔价格是合理的。此时应该保留原始成交时间,并将状态显示为午间休市,而不是把价格时间更新成 12:30。

用香港时间和证券交易日历判断休市状态

为了避免将午间休市误判成连接异常,可以把交易时段判断放在行情服务端,作为 WebSocket 数据处理之前的一层状态管理。

下面的 JavaScript 示例用于识别普通证券和指定延续早市证券的持续交易状态:

text
function getHKMinutes(now = new Date()) {
  const parts = new Intl.DateTimeFormat("en-GB", {
    timeZone: "Asia/Hong_Kong",
    hour: "2-digit",
    minute: "2-digit",
    hourCycle: "h23"
  }).formatToParts(now);

  const hour = Number(
    parts.find(p => p.type === "hour").value
  );
  const minute = Number(
    parts.find(p => p.type === "minute").value
  );

  return hour * 60 + minute;
}

function getHKSession({
  now = new Date(),
  isTradingDay,
  isHalfDay = false,
  isExtendedTradingSecurity = false
}) {
  if (!isTradingDay) return "MARKET_CLOSED";

  const time = getHKMinutes(now);

  if (time >= 570 && time < 720) {
    return "MORNING_SESSION";
  }

  if (isHalfDay && time >= 720) {
    return "HALF_DAY_AFTER_CONTINUOUS_SESSION";
  }

  if (
    time >= 720 &&
    time < 780 &&
    isExtendedTradingSecurity
  ) {
    return "EXTENDED_MORNING_SESSION";
  }

  if (time >= 720 && time < 780) {
    return "LUNCH_BREAK";
  }

  if (time >= 780 && time < 960) {
    return "AFTERNOON_SESSION";
  }

  return "OUTSIDE_CONTINUOUS_SESSION";
}

这里有两个需要注意的地方。

第一,isTradingDay、isHalfDay 和 isExtendedTradingSecurity 应由交易日历及证券适用规则提供,不能仅通过星期一至星期五判断。港股公众假期、半日市以及特殊交易安排都会影响实际交易时间。

第二,这段代码判断的是持续交易时段,不包含开市前竞价和收市竞价。生产环境需要根据完整交易日历扩展相应状态,不能把它当作完整的港交所交易日历。

AllTick WebSocket在午休期间应该怎样处理?

对于需要持续更新行情的应用,WebSocket 比不断轮询 REST API 更适合接收逐笔成交数据。

以 AllTick 的港股接入为例,股票需要使用独立的股票 WebSocket 接入地址。以 700.HK 为例,最新成交订阅使用 22004,成功响应为 22005,后续成交数据通过 22998 推送。

连接建立后发送订阅消息:

text
{
  "cmd_id": 22004,
  "seq_id": 1,
  "trace": "hk-trade-sub",
  "data": {
    "symbol_list": [
      { "code": "700.HK" },
      { "code": "1288.HK" }
    ]
  }
}

午间休市期间,正常做法是维持 WebSocket 连接,而不是因为收不到新成交就主动关闭连接。

AllTick 的应用层心跳协议为 22000,服务器通过 22001 响应。官方接入说明要求每隔 10 秒发送一次心跳,超过 30 秒没有心跳请求时连接会被断开。

Node.js 可以使用 ws 库实现行情接收、心跳检测和断线恢复。

先安装依赖:

text
npm install ws

在服务端设置 ALLTICK_API_TOKEN 环境变量,然后使用下面的示例:

text
const WebSocket = require("ws");

const token = process.env.ALLTICK_API_TOKEN;

if (!token) {
  throw new Error("Missing AllTick API Token");
}

const symbols = ["700.HK", "1288.HK"];

let ws;
let heartbeatTimer;
let reconnectTimer;
let retryMs = 1000;
let sequence = 0;
let lastHeartbeatAck = 0;

const HEARTBEAT_INTERVAL_MS = 10000;

// 客户端自行设置的心跳响应监测阈值
const HEARTBEAT_ACK_TIMEOUT_MS = 30000;

function send(cmdId, data = {}) {
  if (!ws || ws.readyState !== WebSocket.OPEN) {
    return;
  }

  const seqId = ++sequence;

  ws.send(JSON.stringify({
    cmd_id: cmdId,
    seq_id: seqId,
    trace: `hk-${cmdId}-${seqId}`,
    data
  }));
}

function connect() {
  ws = new WebSocket(
    `wss://quote.alltick.co/quote-stock-b-ws-api?token=${encodeURIComponent(token)}`
  );

  ws.on("open", () => {
    console.log("WebSocket connected");

    retryMs = 1000;
    lastHeartbeatAck = Date.now();

    // 每次重新连接都发送完整订阅列表
    send(22004, {
      symbol_list: symbols.map(code => ({ code }))
    });

    clearInterval(heartbeatTimer);

    heartbeatTimer = setInterval(() => {
      if (
        Date.now() - lastHeartbeatAck >
        HEARTBEAT_ACK_TIMEOUT_MS
      ) {
        console.error("Heartbeat acknowledgement timeout");
        ws.terminate();
        return;
      }

      send(22000);
    }, HEARTBEAT_INTERVAL_MS);
  });

  ws.on("message", raw => {
    let message;

    try {
      message = JSON.parse(raw.toString());
    } catch (error) {
      console.error("Invalid server message:", error.message);
      return;
    }

    // 订阅结果
    if (message.cmd_id === 22005) {
      if (message.ret !== 200) {
        console.error(
          "Subscription failed:",
          message.ret,
          message.msg
        );
      } else {
        console.log("Subscription accepted");
      }
    }

    // 心跳响应
    else if (message.cmd_id === 22001) {
      lastHeartbeatAck = Date.now();
    }

    // 最新成交推送
    else if (message.cmd_id === 22998) {
      onTick(message.data);
    }
  });

  ws.on("close", () => {
    scheduleReconnect();
  });

  ws.on("error", error => {
    console.error("WebSocket error:", error.message);
    ws.terminate();
  });
}

function scheduleReconnect() {
  clearInterval(heartbeatTimer);

  if (reconnectTimer) return;

  const delay = retryMs + Math.floor(Math.random() * 500);

  reconnectTimer = setTimeout(() => {
    reconnectTimer = null;
    connect();
  }, delay);

  retryMs = Math.min(retryMs * 2, 30000);
}

function onTick(tick) {
  // 保留交易所事件时间,并单独记录本地接收时间
  console.log({
    ...tick,
    received_at: Date.now()
  });
}

connect();

这段示例包含了行情订阅、心跳请求、心跳响应处理、成交推送解析以及断线后的指数退避重连。示例中的 30 秒心跳响应超时是客户端自行选择的监控阈值,并非 AllTick 对响应延迟的 SLA 承诺。

实际部署时还需要做好两件事:

一是检查 22005 的返回结果。收到订阅失败响应时,应先排查证券代码、Token 和账户权限,而不是反复重连。

二是注意订阅列表的覆盖规则。AllTick 新的订阅请求会替换之前的订阅列表,因此重连后必须重新提交完整的 symbols,否则之前订阅的部分股票可能不再更新。

下午恢复交易后,怎样确认行情真的恢复?

到达 13:00 并不意味着所有股票都会立即产生新的成交数据。

系统应该根据交易日历将普通证券从 LUNCH_BREAK 切换到 AFTERNOON_SESSION,但行情是否更新,仍然需要看实际推送。

建议按照以下顺序处理:

  1. 检查 WebSocket 是否仍然连接,或断线后是否已经重新建立连接。
  2. 确认订阅响应 22005 返回成功。
  3. 收到 22998 后,更新对应证券的价格、成交时间和缓存。
  4. 如果心跳正常但暂时没有 Tick,检查该证券是否发生了新的成交,并通过 REST 行情查询进行交叉核对。

这里还有一个容易被忽略的细节:行情时间和系统接收时间不能混为一谈。

例如,下午 13:02 收到的 Tick,其成交时间可能是 13:01:58。系统应保留接口提供的原始 tick_time,另外记录本地收到消息的时间。两者之间的差值能够帮助开发者判断数据传输和处理过程是否存在延迟。

对于需要展示分钟 K 线的应用,也不要用 WebSocket 没有收到 Tick 作为依据,主动补造一笔成交。AllTick 的 WebSocket 不提供 K 线推送,K 线需要通过 HTTP REST 接口获取。如果自行利用 Tick 聚合分钟 K 线,则应该按照实际交易时段和成交数据进行处理。没有真实成交的时间段,不应伪装成发生了新的交易。

让港股行情系统正确处理午间休市

港股 API 接入中的午间数据空窗,本质上是交易日历管理和实时行情连接管理之间的协作问题。

交易日历决定当前证券是否处于交易时段,WebSocket 心跳确认通信状态,Tick 时间则反映实际成交更新。三者分开处理,才能避免把正常休市误判为 API 故障,也能在下午恢复交易后快速识别真正的数据异常。

AllTick 提供港股股票实时成交和盘口数据接口。开发者可以通过股票 WebSocket 接收最新 Tick,并结合 REST API 获取最新行情和历史 K 线。对于需要同时监控多只港股的行情服务,还可以将证券订阅列表、交易时段和行情缓存统一放在服务端管理。

正在开发港股实时行情监控系统,可以从 AllTick 官方产品入口 了解股票行情 API 的接入方式,并结合实际证券代码验证订阅权限、交易时段和数据更新流程。

立即开始接入市场数据

几秒内生成免费 API 密钥,通过一个端点连接所有市场。