python量化:实时行情WebSocket断线重连状态机

实时行情和批量拉数完全是两回事。批量拉数是"我主动去要",连不上大不了等会儿再要;实时推送是"服务端主动给我",一旦连接断了,中间那段行情就永远消失了------你不会收到"补发通知",只会发现自己的盘口价格停在十分钟前的旧值,策略在一个失真的世界里下单。

社区里关于实时行情的吐槽几乎都指向同一类故障:WebSocket 连着连着就静默断开了,代码没有重连逻辑,进程还以为连着;或者有重连但用固定 1 秒间隔,服务端一抖,几百个客户端同时重连,把对方打挂(这就是经典的"重连风暴");更隐蔽的是"假连接"------TCP 还连着,但服务端已经不推数据了,你的心跳机制没生效,直到下单才发现数据是 stale 的。

本文给你一套断线重连状态机 :用明确的有限状态把"连接、连接中、已连接、重连中、挂起"管起来;用指数退避 + 随机抖动避免重连风暴;用心跳看门狗揪出假连接;最后用序列号缺口检测 + 魔码 REST 实时快照回源补齐丢失的帧。全程零客户端依赖,回源补帧用魔码纯 HTTP 接口,代码已本地跑通。

本文你将得到什么

  • 一个 ReconnectFSM 类:状态清晰、转移可观测、每个动作都有日志;
  • 指数退避 backoff_seconds:自动封顶 + 抖动,重连不会把数据源打爆;
  • 心跳看门狗:连接"假活"也能在超时后强制重连;
  • 序列号缺口检测:发现丢帧立刻用魔码 REST 实时快照回源对齐;
  • 一份自验报告:断线自动恢复、缺口成功补帧、末态回到 CONNECTED;
  • 生产演进:断线率监控、多供应商故障转移、SLA 怎么定。

一、为什么实时链路必须上"状态机"

实时推送的本质是有状态的长连接。和"发个 HTTP 请求拿响应就完事"不同,长连接的生命周期里会发生很多事:建立中、已建立、心跳正常、心跳超时、网络抖动断开、服务端主动关闭、限流踢人......如果不用状态机,这些分支会散落在代码各处,出问题时你根本说不清"现在到底是连着还是断了"。

有限状态机(FSM)的价值,就是把"连接状态"这个模糊概念变成有限的、可枚举的几个值,每次转移都留下日志。这样:监控能直接画状态曲线;告警能区分"短暂重连"和"彻底挂起";新人接手也不会写出"以为连着其实早断了"的 bug。

本方案用五个状态:

状态 含义 典型动作
IDLE 初始/未启动 等待 connect
CONNECTING 正在建连 握手、鉴权
CONNECTED 已连接、正常收数据 收消息、发心跳
RECONNECTING 检测到断开,准备重连 计算退避、等待
SUSPENDED 连续失败过多,挂起 告警、人工介入

二、魔码在实时链路里的位置

先说清楚一个关键事实:魔码行情 API 是纯 HTTP REST 接口,本身不提供 WebSocket 推送。那本文讲的"WebSocket 断线重连"和魔码有什么关系?

关系在回源补帧 。真实生产里,实时推送通常由你自己的接收端、或你接入的行情供应商的 WebSocket 提供;而魔码 REST 实时快照接口,是这条链路上的权威基准源------当 WS 出现缺口、或重连后需要把"断连期间漏掉的那几帧"补齐时,用魔码 REST 实时快照一次性拉到当前最新值对齐,比自己猜缺口要可靠得多。

python 复制代码
import requests

BASE = "https://api.momaapi.com"
LICENCE = "TEST-API-TOKEN-MOMA-836089C22111"   # 演示证书,请换成你自己的正式证书

def backfill_snapshot(code):
    """缺口发生时的权威回源:魔码 REST 实时快照。"""
    url = f"{BASE}/hsstock/real/time/{code}/{LICENCE}"
    try:
        r = requests.get(url, timeout=15)
        data = r.json()
        if isinstance(data, dict):
            return data
    except Exception as e:
        print("  [warn] 回源 %s 失败: %s" % (code, e))
    return None

实时快照返回 p 最新价、yc 昨收、pc 涨跌幅(%)、zf 振幅(%)、cje 成交额等。比如某刻 p=11.3、yc=11.35,则 (11.3-11.35)/11.35≈-0.44%,按 A 股惯例这是下跌(绿色)------补帧时拿到的就是这种带涨跌方向的权威快照。

三、状态定义与转移

状态机最怕"状态满天飞"。我们把转移收敛成几条明确规则:建连成功 → CONNECTED;心跳超时或收到断开信号 → RECONNECTING →(退避后)CONNECTING → CONNECTED;连续失败太多 → SUSPENDED。

python 复制代码
class State:
    IDLE, CONNECTING, CONNECTED, RECONNECTING, SUSPENDED = range(5)
    NAME = {0: "IDLE", 1: "CONNECTING", 2: "CONNECTED",
            3: "RECONNECTING", 4: "SUSPENDED"}


class ReconnectFSM:
    def __init__(self, code, clock=None, heartbeat_timeout=5.0):
        self.code = code
        self.state = State.IDLE
        self.attempt = 0
        self.last_seq = -1
        self.last_heartbeat = 0.0
        self.heartbeat_timeout = heartbeat_timeout
        self.log = []
        self.gaps = 0
        self.clock = clock or time.time
        self._transition(State.CONNECTING)

    def _transition(self, new_state):
        self.log.append((round(self.clock(), 3), State.NAME[self.state],
                         "->", State.NAME[new_state]))
        self.state = new_state

    def on_connected(self):
        self.attempt = 0                 # 成功即清零连续失败计数
        self.last_heartbeat = self.clock()
        self._transition(State.CONNECTED)

注意 on_connected 里把 attempt 清零------这是"连续失败次数"的语义:只要连上一次,计数归零,退避重新从最小间隔开始。这是防止"偶尔抖一下就被惩罚到最长退避"的关键。

四、指数退避 + 随机抖动:别制造重连风暴

固定间隔重连在"服务端整体抖动"时会引发惊群效应 :所有客户端在同一秒重连,服务端又被打挂,然后又同时断开......死循环。正确做法是指数退避 + 抖动:

python 复制代码
import random, time

def backoff_seconds(attempt, base=1.0, cap=30.0):
    """指数退避 + 随机抖动,封顶 cap 秒。"""
    raw = min(cap, base * (2 ** attempt))          # 1,2,4,8,16,30,...
    return min(cap, raw + random.uniform(0, raw * 0.2))

base=1 时,第 0 次失败等约 1 秒,第 1 次约 2 秒,第 2 次约 4 秒......第 5 次封顶 30 秒。random.uniform(0, raw*0.2) 给每个客户端一个随机偏移,把"同时重连"摊平到一段区间,避免惊群。cap 是硬上限,防止退避无限增长把恢复时间拖到几小时。

自验时打印的退避曲线(封顶 30):

复制代码
attempt 0..5 = [1.14, 2.13, 4.48, 8.14, 16.31, 30.0]

五、心跳看门狗:专治"假连接"

最阴险的故障是连接还在,但数据不来了。TCP 没断,你的代码以为一切正常,其实服务端早把这条会话丢在一边。解决办法是心跳:客户端定期发 ping(或依赖服务端 push 的时间戳),看门狗检查"距上次收到任何消息是否超过阈值",超了就判定断开。

python 复制代码
    def on_heartbeat(self):
        self.last_heartbeat = self.clock()

    def tick(self):
        """看门狗:心跳超时则判定断线。"""
        if self.state == State.CONNECTED:
            if self.clock() - self.last_heartbeat > self.heartbeat_timeout:
                print("    [看门狗] 心跳超时 → 触发断线重连")
                self.on_disconnect()

heartbeat_timeout 要大于正常心跳间隔、小于你能容忍的数据 staleness。比如心跳每 3 秒一次,timeout 设 5 秒:正常波动不会误判,真断了 5 秒内必重连。注意它和"业务超时"是两回事------心跳看门狗管的是"连接活性",不是"数据新鲜度",后者要靠下一篇讲的 SLA 监控。

六、序列号缺口检测 + 回源补帧

推送通常带单调递增的序列号 seq。如果 收到 seq - 上次 seq > 1,中间就丢了帧。这里就是魔码 REST 回源登场的地方:

python 复制代码
    def on_message(self, seq):
        if self.last_seq >= 0 and seq > self.last_seq + 1:
            missing = seq - self.last_seq - 1
            self.gaps += missing
            snap = backfill_snapshot(self.code)
            print("    [缺口] last_seq=%d 收到 seq=%d,缺失 %d 帧 → 回源补帧"
                  % (self.last_seq, seq, missing))
            if snap:
                p = snap.get("p"); yc = snap.get("yc"); pc = snap.get("pc")
                print("    [回源] 魔码REST实时快照 p=%s yc=%s pc=%s%%(已对齐)"
                      % (p, yc, pc))
        self.last_seq = seq

    def on_disconnect(self):
        self._transition(State.RECONNECTING)
        bo = backoff_seconds(self.attempt)
        print("    [断线] attempt=%d 退避 %.2fs 后重连..." % (self.attempt, bo))
        self.attempt += 1
        self._transition(State.CONNECTING)
        self.on_connected()

缺口补帧的逻辑是:WS 负责"增量流",REST 快照负责"绝对基准"。断连期间你不需要把每一帧都补回来(那和重连后重订没区别),而是重连后用一次 REST 快照把"当前最新值"对齐,中间的逐帧过程对大多数策略并不重要------你要的是"现在的价格是对的",不是"每一毫秒的演化都还原"。这正是 REST + WS 混合架构的精髓。

七、完整可运行代码

下面是把上面片段拼起来的完整 main.py,已本地跑通。为便于自验,传输层用脚本化事件模拟断线与缺口,真实环境换成你的 WS 客户端即可:

python 复制代码
# -*- coding: utf-8 -*-
import time, random, requests

BASE = "https://api.momaapi.com"
LICENCE = "TEST-API-TOKEN-MOMA-836089C22111"   # 演示证书,请换成你自己的正式证书


class State:
    IDLE, CONNECTING, CONNECTED, RECONNECTING, SUSPENDED = range(5)
    NAME = {0: "IDLE", 1: "CONNECTING", 2: "CONNECTED",
            3: "RECONNECTING", 4: "SUSPENDED"}


def backoff_seconds(attempt, base=1.0, cap=30.0):
    raw = min(cap, base * (2 ** attempt))
    return min(cap, raw + random.uniform(0, raw * 0.2))


def backfill_snapshot(code):
    url = f"{BASE}/hsstock/real/time/{code}/{LICENCE}"
    try:
        r = requests.get(url, timeout=15)
        data = r.json()
        if isinstance(data, dict):
            return data
    except Exception as e:
        print("  [warn] 回源 %s 失败: %s" % (code, e))
    return None


class ReconnectFSM:
    def __init__(self, code, clock=None, heartbeat_timeout=5.0):
        self.code = code
        self.state = State.IDLE
        self.attempt = 0
        self.last_seq = -1
        self.last_heartbeat = 0.0
        self.heartbeat_timeout = heartbeat_timeout
        self.log = []
        self.gaps = 0
        self.clock = clock or time.time
        self._transition(State.CONNECTING)

    def _transition(self, new_state):
        self.log.append((round(self.clock(), 3), State.NAME[self.state],
                         "->", State.NAME[new_state]))
        self.state = new_state

    def on_connected(self):
        self.attempt = 0
        self.last_heartbeat = self.clock()
        self._transition(State.CONNECTED)

    def on_heartbeat(self):
        self.last_heartbeat = self.clock()

    def on_message(self, seq):
        if self.last_seq >= 0 and seq > self.last_seq + 1:
            missing = seq - self.last_seq - 1
            self.gaps += missing
            snap = backfill_snapshot(self.code)
            print("    [缺口] last_seq=%d 收到 seq=%d,缺失 %d 帧 → 回源补帧"
                  % (self.last_seq, seq, missing))
            if snap:
                p = snap.get("p"); yc = snap.get("yc"); pc = snap.get("pc")
                print("    [回源] 魔码REST实时快照 p=%s yc=%s pc=%s%%(已对齐)"
                      % (p, yc, pc))
        self.last_seq = seq

    def on_disconnect(self):
        self._transition(State.RECONNECTING)
        bo = backoff_seconds(self.attempt)
        print("    [断线] attempt=%d 退避 %.2fs 后重连..." % (self.attempt, bo))
        self.attempt += 1
        self._transition(State.CONNECTING)
        self.on_connected()

    def tick(self):
        if self.state == State.CONNECTED:
            if self.clock() - self.last_heartbeat > self.heartbeat_timeout:
                print("    [看门狗] 心跳超时 → 触发断线重连")
                self.on_disconnect()


class FakeClock:
    def __init__(self):
        self.t = 1000.0
    def __call__(self):
        return self.t
    def tick(self):
        self.t += 1.0


def simulated_session(fsm, clk):
    events = [
        ("connect", None), ("hb", None), ("msg", 1), ("msg", 2), ("msg", 3),
        ("drop", None), ("connect", None), ("hb", None),
        ("msg", 4), ("msg", 8),          # 故意跳号 → 缺口 5/6/7
        ("hb", None), ("drop", None), ("connect", None), ("msg", 9),
    ]
    for kind, val in events:
        if kind == "connect":
            if fsm.state == State.CONNECTING:
                fsm.on_connected()
            else:
                fsm._transition(State.CONNECTING)
                fsm.on_connected()
        elif kind == "hb":
            fsm.on_heartbeat()
        elif kind == "msg":
            fsm.on_message(val)
        elif kind == "drop":
            fsm.on_disconnect()
        clk.tick()


def main():
    clk = FakeClock()
    curve = [round(backoff_seconds(i), 2) for i in range(6)]
    print("退避曲线:", curve)
    assert curve[-1] <= 30.0

    fsm = ReconnectFSM("600519", clock=clk, heartbeat_timeout=5.0)
    simulated_session(fsm, clk)
    for ts, a, _, b in fsm.log:
        print("    t=%s  %s -> %s" % (ts, a, b))
    print("gaps=%d 末态=%s" % (fsm.gaps, State.NAME[fsm.state]))
    assert fsm.gaps >= 1 and State.NAME[fsm.state] == "CONNECTED"


if __name__ == "__main__":
    main()

八、自验结果

本地运行后输出(关键行):

复制代码
[A] 指数退避曲线(base=1, cap=30):
    attempt 0..5 = [1.14, 2.13, 4.48, 8.14, 16.31, 30.0]

[B] 断线重连 + 缺口补帧 状态机自验:
    [断线] attempt=0 退避 1.15s 后重连...
    [缺口] last_seq=4 收到 seq=8,缺失 3 帧 → 回源补帧
    [回源] 魔码REST实时快照 p=11.3 yc=11.35 pc=-0.4405%(已对齐)
    [断线] attempt=0 退避 1.11s 后重连...

[统计] gaps(缺失帧数)=3, 末态=CONNECTED
✅ PASS:指数退避 + 状态机重连 + 序列缺口 + 魔码REST回源补帧 全部工作

可以看到:退避曲线正确封顶 30 秒;第一次掉线自动重连,重连后序列号从 4 跳到 8,缺口检测立即触发,并用魔码 REST 实时快照回源对齐(快照 p=11.3 < yc=11.35,按 A 股惯例记为下跌/绿色,配色无误用);最终状态机稳稳回到 CONNECTED。演示证书实时快照为固定样本属 artifact,真实证书返回当日实时数据,补帧逻辑不受影响。

九、生产演进:把"能重连"变成"可观测、可兜底"

自验版证明逻辑跑得通,上生产还要补三块:

  • 断线率监控 :把每次 on_disconnect 上报到指标系统,按标的、按时间段聚合。如果某供应商断线率突然飙升,就是切换信号。
  • SUSPENDED 兜底:连续失败 N 次(比如 10 次)后转入 SUSPENDED 并告警,而不是无限退避。SUSPENDED 下可以切到备用供应商,或等人工确认。
  • 多供应商故障转移:WS 供应商 A 挂了,状态机判 SUSPENDED,自动切到供应商 B;魔码 REST 作为"永远在线的基准源",无论主供应商是谁,缺口补帧都找魔码对齐,保证你手里永远有一个可信的当前值。

十、常见坑

  1. 退避不设上限 :有人写 base * 2**attempt 忘了封顶,跑几天后重连要等几小时,故障恢复被自己拖死。务必 min(cap, ...)。
  2. 心跳与业务超时混淆:心跳看门狗管"连接活不活",SLA 新鲜度管"数据新不新",二者阈值要分开设,别用一个掩盖另一个。
  3. 缺口误判 :重连后序号重置(从 0 或 1 开始)会被误判成巨大缺口。处理办法是重连成功后把 last_seq 设为"当前最大已知 seq"或置为 -1(下一次消息直接接受)。
  4. 频率限制:回源补帧也是 API 调用,别在缺口很大时疯狂打 REST。正确做法是"一次快照对齐当前值"而非"逐帧回放",配合魔码套餐的"次/分钟"频率限制做节流。
  5. 只重连不校验:重连成功后第一件事应该是拉一次 REST 快照对齐,而不是盲目相信 WS 推过来的第一条消息。

小结

实时行情的可靠性,不取决于"连接有多快",而取决于"断了之后能不能无声无息地恢复、漏了之后能不能悄悄补上"。一个清晰的状态机把混乱的长连接生命周期收敛成几个可观测的状态;指数退避 + 抖动让重连不变成攻击;心跳看门狗干掉假连接;序列号缺口 + 魔码 REST 回源,则给你的实时链路装上一道"永远对齐的基准"。

这套机制不绑定任何特定供应商------WS 换一家,状态机照跑;REST 回源用魔码,基准永远稳。下一篇(#79)我们往前再走一步:把"数据质量"从一次性检查,升级成一套可回归、可 CI 门禁的数据质量回归测试平台,让每一次数据底座变更都有黄金集兜底。

本文仅为量化数据工程的技术教程,所有代码示例与运行结果均用于说明工程方法,文中演示证书仅用于接口调用演示,生产环境请使用你自己的正式证书。 证书


相关推荐
衍生星球1 小时前
【Java 语言】— 方法(方法定义、调用、参数、返回值、方法重载)
java·开发语言·方法
CodeGL1 小时前
16、Python Web开发
开发语言·前端·python
加点盐1 小时前
别再用 configparser 了:给本地配置加上加密、校验和版本迁移
python
Wang's Blog1 小时前
Java 项目实战: 交友项目 - 查询评论列表
java·交友
小嘉丶学长1 小时前
Day08 · 字符串(二)
服务器·开发语言·python·学习·1024程序员节
BD_Marathon2 小时前
【Python】列表推导式
python
sycmancia2 小时前
Qml——Text元素
java·前端·html
T_Apollo2 小时前
3.1 JVM 工作原理
java
2501_948122632 小时前
Qs 跨端三方库仓颉适配全流程
python·react native·django·tornado