WebSocket 独立频道迁移设计文档
WebSocket 独立频道迁移设计文档
版本: v1.0
日期: 2026-03-09
适用项目: Trading-in-websocket(全币种协整配对量化交易系统)
目录
1. 背景与目标
1.1 现状(迁移前)
系统使用 webData2 聚合频道接收交易 WebSocket 数据。该频道将 clearinghouseState、spotState、openOrders 等多类数据打包推送,存在以下问题:
| 问题 | 描述 |
|---|---|
| 冗余数据 | webData2 推送了大量不需要的字段(如 openOrders),浪费带宽和解析开销 |
| 统一账户受限 | 统一账户(Portfolio Margin)的余额只能通过 REST API 轮询获取,无法利用 WS 实时推送 |
| HIP-3 无法 per-DEX 订阅 | webData2 不支持 dex 参数,无法订阅 HIP-3 DEX 的独立持仓推送 |
| 关注点耦合 | 持仓、余额、订单状态混在同一频道,解析逻辑复杂 |
1.2 目标
将 webData2 聚合频道完全替换为独立频道(clearinghouseState、spotState),实现:
| 维度 | 目标 |
|---|---|
| 关注点分离 | 每个频道只推送一类数据,解析逻辑清晰 |
| 统一账户 WS 缓存 | spotState 独立频道使统一账户余额也能 WS 缓存优先、HTTP 降级 |
| HIP-3 per-DEX 订阅 | clearinghouseState 支持 dex 参数,实现逐 DEX 实时持仓推送 |
| 零冗余 | 只订阅需要的频道,去除 openOrders 等无用数据 |
2. 技术调研
2.1 Hyperliquid WS 频道体系
Hyperliquid 提供两类用户数据频道:
聚合频道(Legacy):
webData2 → 打包推送 clearinghouseState + spotState + openOrders + ...
独立频道(Recommended):
clearinghouseState → 持仓 + 保证金 + withdrawable
spotState → Spot 层余额(统一账户核心)
orderUpdates → 订单状态变更
userFills → 成交记录
2.2 独立频道特性
| 特性 | webData2 | 独立频道 |
|---|---|---|
| 数据范围 | 全量打包 | 按类型拆分 |
| HIP-3 per-DEX | 不支持 | clearinghouseState 支持 dex 参数 |
| 统一账户 spotState | 包含但与 perp 混在一起 | 独立频道,结构清晰 |
| 推送频率 | ~4.6-4.8s | ~4.6-4.8s(实测一致) |
| 数据嵌套 | 直接是 state 数据 | 外层多一层包装(需 unwrap) |
2.3 主网验证结果(2026-03-09)
通过 src/scripts/verify_independent_ws_channels.py 同时订阅两种频道,实测:
- clearinghouseState:独立频道与
webData2数据完全一致(键、值均匹配) - spotState:独立频道包含零余额代币(
webData2会过滤掉),属于无害差异 - 推送频率:独立频道与
webData2推送比 = 1.00,完全同步 - HIP-3 per-DEX:
clearinghouseState带dex参数正常推送
结论:独立频道可以安全替代 webData2。
3. 架构对比
3.1 迁移前(webData2 架构)
交易 WS ──→ webData2 ──→ _publish_user_events()
├─ clearinghouseState → PositionUpdatedEvent + BalanceChangedEvent
└─ (spotState 数据被忽略)
统一账户余额 ──→ 定时 HTTP 轮询(无 WS 缓存)
HIP-3 持仓 ──→ 定时 HTTP 轮询(无 WS per-DEX 缓存)
3.2 迁移后(独立频道架构)
交易 WS ──→ clearinghouseState ──→ _publish_clearinghouse_events()
│ ├─ PositionUpdatedEvent(dex="")
│ └─ BalanceChangedEvent
│
├─ clearinghouseState(dex=xyz) ──→ _publish_clearinghouse_events()
│ └─ PositionUpdatedEvent(dex="xyz")
│
├─ spotState ──→ _publish_spot_state_event()
│ └─ SpotStateUpdatedEvent
│
├─ orderUpdates ──→ _publish_order_status_events()
│ └─ OrderStatusEvent
│
└─ userFills ──→ _publish_fill_events()
└─ OrderFilledEvent
Executor 订阅:
PositionUpdatedEvent → 按 dex 字段分流(主 perp / HIP-3 per-DEX 缓存)
BalanceChangedEvent → 更新余额缓存(仅主 perp)
SpotStateUpdatedEvent → 更新 Spot 余额缓存(统一账户核心)
WebSocketReconnectedEvent(source="trading") → 清空全部缓存 + 补查订单
4. 迁移方案设计
4.1 迁移策略
采用 一步替换 策略(非渐进式),原因:
- 独立频道已通过主网实测验证,数据一致性有保障
webData2和独立频道不应同时订阅(避免重复事件)- 改动范围可控(7 个文件),风险可接受
4.2 实施步骤
Step 1: 主网验证(已完成)
└─ verify_independent_ws_channels.py 对比 webData2 vs 独立频道
Step 2: 核心替换(已完成)
├─ 新增 SpotStateUpdatedEvent 事件类
├─ WS 消息路由替换(enhanced_ws_manager.py)
├─ 订阅列表重构(realtime_kline_service_base.py)
└─ 缓存体系重构(executor.py)
Step 3: 辅助更新(已完成)
├─ 事件导出(events/__init__.py)
└─ 配置注释(config.py)
5. 文件改动清单
| 文件 | 改动类型 | 说明 |
|---|---|---|
src/events/trading_events.py |
新增+修改 | 新增 SpotStateUpdatedEvent 事件类;PositionUpdatedEvent 新增 dex 字段 |
src/events/__init__.py |
修改 | 导出 SpotStateUpdatedEvent;更新事件流文档 |
src/utils/websocket/enhanced_ws_manager.py |
修改 | 替换 webData2 处理为 clearinghouseState/spotState 独立频道处理 |
src/services/realtime_kline_service_base.py |
修改 | 订阅列表从 webData2 改为独立频道 + HIP-3 per-DEX |
src/trading/executor.py |
修改 | 缓存体系重构:per-DEX 缓存、spotState 缓存、统一账户 WS 优先 |
src/config.py |
修改 | 注释更新 |
src/scripts/verify_independent_ws_channels.py |
新增 | 主网验证脚本(对比两种频道数据一致性) |
6. 核心实现细节
6.1 事件层:新增 SpotStateUpdatedEvent
@dataclass(kw_only=True)
class SpotStateUpdatedEvent(Event):
"""Spot 层余额更新事件(统一账户核心数据源)"""
balances: List[Dict] = field(default_factory=list)
def __post_init__(self):
super().__post_init__()
self.priority = EventPriority.HIGH
6.2 事件层:PositionUpdatedEvent 新增 dex 字段
class PositionUpdatedEvent(Event):
positions: List[Dict] = field(default_factory=list)
account_value: float = 0.0
margin_summary: Dict = field(default_factory=dict)
dex: str = "" # 空字符串=主 perp,非空=HIP-3 DEX 名称
6.3 WS 消息路由(enhanced_ws_manager.py)
替换前(单个 webData2 处理函数):
elif channel == "webData2":
self._publish_user_events(data)
替换后(两个独立频道处理函数):
elif channel == "clearinghouseState":
self._publish_clearinghouse_events(data)
elif channel == "spotState":
self._publish_spot_state_event(data)
6.4 独立频道数据嵌套处理
独立频道数据比 webData2 多一层包装,需要 unwrap:
# clearinghouseState 独立频道推送格式:
# {"dex": "", "user": "0x...", "clearinghouseState": {实际数据}}
state = data.get("clearinghouseState", data)
dex = data.get("dex", "")
# spotState 独立频道推送格式:
# {"user": "0x...", "spotState": {"balances": [...]}}
inner = data.get("spotState", {})
balances = inner.get("balances", [])
6.5 订阅列表重构(realtime_kline_service_base.py)
def _build_trading_subscriptions(self) -> List[Dict]:
subs = [
{"type": "orderUpdates", "user": user_address},
{"type": "userFills", "user": user_address},
{"type": "clearinghouseState", "user": user_address}, # 主 perp
{"type": "spotState", "user": user_address}, # Spot 层
]
# HIP-3 per-DEX 订阅
for dex in hip3_dexes:
subs.append({"type": "clearinghouseState", "user": user_address, "dex": dex})
return subs
6.6 Executor 事件处理分流
def _on_position_updated(self, event: PositionUpdatedEvent):
with self._cache_lock:
if not event.dex:
# 主 perp → 更新主缓存
self._cached_positions = event.positions
self._cached_account_value = event.account_value
self._position_cache_ts = event.timestamp.timestamp()
else:
# HIP-3 → 更新 per-DEX 缓存
self._hip3_dex_cache[event.dex] = {
"account_value": event.account_value,
"positions": event.positions,
"ts": event.timestamp.timestamp(),
}
7. 数据结构变化
7.1 Executor 缓存字段
移除:
| 字段 | 原用途 |
|---|---|
_cached_hip3_account_value: float |
HIP-3 总账户价值(HTTP 轮询) |
_hip3_account_value_ts: float |
HIP-3 缓存时间戳 |
新增:
| 字段 | 用途 |
|---|---|
_cached_spot_balances: list[dict] |
spotState WS 推送的 Spot 余额列表 |
_spot_cache_ts: float |
Spot 缓存时间戳 |
_hip3_dex_cache: dict[str, dict] |
per-DEX WS 缓存 {dex: {"account_value", "positions", "ts"}} |
7.2 事件新增
| 事件 | 来源频道 | 用途 |
|---|---|---|
SpotStateUpdatedEvent |
spotState 独立频道 |
Spot 层余额更新,统一账户核心 |
7.3 事件修改
| 事件 | 变更 | 说明 |
|---|---|---|
PositionUpdatedEvent |
新增 dex: str = "" 字段 |
区分主 perp 和 HIP-3 DEX 持仓 |
8. 缓存策略
8.1 统一账户余额查询(get_account_value / get_available_balance)
迁移前: HTTP 轮询 → 解析 spotState → 返回
迁移后: spotState WS 缓存 → (TTL 有效 && WS 在线) → 返回
→ (TTL 过期 || WS 断连) → HTTP 降级
8.2 普通账户余额/持仓查询
迁移前: webData2 WS 缓存 → (TTL 有效 && WS 在线) → 返回
迁移后: clearinghouseState WS 缓存 → (TTL 有效 && WS 在线) → 返回
(逻辑不变,数据源从 webData2 切换到独立频道)
8.3 HIP-3 持仓/价值查询
迁移前: HTTP 轮询(每次 get_account_value 调用时查所有 DEX)
迁移后: per-DEX WS 缓存 → (所有 DEX 缓存有效 && WS 在线) → 返回
→ (任一 DEX 过期 || WS 断连) → HTTP 逐 DEX 降级
8.4 缓存失效场景
| 场景 | 行为 |
|---|---|
| 下单/平仓成功 | _invalidate_position_cache() 清零 position_cache_ts、spot_cache_ts、hip3_dex_cache |
| 交易 WS 重连 | _on_websocket_reconnected() 清空全部缓存字段 + 补查未完成订单 |
| 行情 WS 重连 | 忽略(行情重连不影响交易缓存) |
9. 验证方案与结果
9.1 验证脚本
src/scripts/verify_independent_ws_channels.py 同时订阅 webData2 + 独立频道,进行:
- 数据结构对比:键集合、值精度、嵌套层级
- 数值一致性对比:accountValue、withdrawable、持仓 szi/entryPx
- 推送频率对比:平均间隔、推送比
用法:
# 基础验证(60 秒)
python -m src.scripts.verify_independent_ws_channels --duration 60
# 包含 HIP-3 DEX 验证
python -m src.scripts.verify_independent_ws_channels --hip3-dex --duration 120
# 导出原始数据
python -m src.scripts.verify_independent_ws_channels --export-raw --duration 60
9.2 主网验证结果
clearinghouseState:
✅ 所有键完全一致
✅ 所有数值完全匹配
✅ 推送频率一致(ratio=1.00, avg≈4.7s)
spotState:
✅ 核心数据一致(balances 结构、coin/total/hold 值)
ℹ️ 独立频道包含零余额代币(webData2 过滤掉了),无害差异
ℹ️ 独立频道多出 isPortfolioMargin 和 tokenToAvailableAfterMaintenance 字段
✅ 推送频率一致
HIP-3 per-DEX clearinghouseState:
✅ 带 dex 参数的订阅正常推送
✅ 数据结构与主 perp 一致
9.3 编译验证
所有改动文件均通过 py_compile 验证:
python -m py_compile src/events/trading_events.py # OK
python -m py_compile src/events/__init__.py # OK
python -m py_compile src/utils/websocket/enhanced_ws_manager.py # OK
python -m py_compile src/services/realtime_kline_service_base.py # OK
python -m py_compile src/trading/executor.py # OK
10. 风险与注意事项
10.1 已知风险
| 风险 | 等级 | 缓解措施 |
|---|---|---|
| 独立频道 API 未来变更 | 低 | Hyperliquid 官方推荐使用独立频道,webData2 是 legacy |
| HIP-3 DEX 数量增长导致订阅数增加 | 低 | WS 1000 订阅上限远大于当前需求;仅订阅 cointegrated_pairs 中的 DEX |
| WS 断连期间数据丢失 | 中 | 已有断连检测 + HTTP 降级机制;重连后清空缓存 + 补查订单 |
10.2 注意事项
- 数据嵌套差异:独立频道数据多一层包装(
data.clearinghouseState/data.spotState),解析时需要 unwrap - spotState 零余额代币:独立频道会推送零余额代币,计算
sum(total)时不会影响结果(值为 0) - HIP-3 BalanceChangedEvent:仅主 perp 发布
BalanceChangedEvent,HIP-3 DEX 的withdrawable不直接使用(通过_hip3_dex_cache的account_value代替) - 验证脚本保留:
verify_independent_ws_channels.py保留在src/scripts/中,可用于未来回归验证
10.3 回滚方案
若需回滚到 webData2:
- 订阅列表恢复为
{"type": "webData2", "user": user_address} _cache_latest_data恢复webData2分支- 恢复
_publish_user_events()函数 - Executor 恢复旧缓存字段
由于改动集中在 7 个文件,git revert 即可完成回滚。