WebSocket 与 REST API 数据结构校验问题报告
WebSocket 与 REST API 数据结构校验问题报告
生成时间:2026-02-22
分析范围:测试网模拟交易环境下,对所有 WebSocket 频道和 REST API 返回的数据结构与代码实际解析逻辑进行全量比对
验证依据:scripts/out/fill_responses_20260222_124558.json(测试网实测数据)
目录
- 总体结论
- 数据结构对照验证表
- BUG-1(严重)userFills 频道数据解析完全失效
- BUG-2(重要)orderUpdates 未识别的拒绝状态类型
- BUG-3(重要)OrderFilledEvent.order_id 类型不匹配
- BUG-4(中等)BalanceChangedEvent.unrealized_pnl 语义错误
- BUG-5(中等)OrderFilledEvent 缺少 fill_id 元数据
- BUG-6(低)OrderBookUpdatedEvent 类型注解与实际数据格式不符
- INFO-1 WS 连接事件 source 固定为 websocket
- 优先修复建议
总体结论
| 级别 | 数量 | 说明 |
|---|---|---|
| 严重 (Critical) | 1 | 直接导致核心功能静默失效 |
| 重要 (High) | 2 | 影响正确性,有兜底但效果降级 |
| 中等 (Moderate) | 2 | 语义错误或设计不完整 |
| 低 (Low) | 1 | 类型注解问题,运行时不崩溃 |
| 信息 (Info) | 1 | 架构潜在隐患 |
最高优先级:BUG-1 必须立即修复。userFills 频道的所有成交价数据被完全丢弃,导致订单追踪系统无法获取真实成交均价,降级为依赖 orderUpdates 的限价兜底或 HTTP 补查,影响所有限价单的成交价精度。
数据结构对照验证表
| 频道 / 接口 | 字段路径 | 实际数据类型 | 代码解析方式 | 结论 |
|---|---|---|---|---|
userFills |
data |
dict {isSnapshot, user, fills:[]} |
raw if isinstance(raw, list) else [raw] |
❌ 解析错误 |
userFills |
data.fills[*] |
dict {coin, px, sz, oid, tid, ...} |
被外层 dict 包裹,oid 永远为 None |
❌ 数据丢失 |
orderUpdates |
data |
list [{order, status, statusTimestamp}] |
raw if isinstance(raw, list) else [raw] |
✅ 正确 |
orderUpdates |
data[*].status |
"minTradeNtlRejected" 等枚举 |
仅匹配 "rejected"/"filled"/"canceled" |
⚠️ 未知状态被忽略 |
orderUpdates |
data[*].order.limitPx |
str "956.3" |
_pos_float(order.get("limitPx")) |
✅ 正确 |
orderUpdates |
data[*].order.origSz |
str "0.001" |
_pos_float(order.get("origSz")) |
✅ 正确 |
user |
data.fills[*] |
dict {coin, px, sz, oid, ...} 直接列表 |
data.get("fills", []) 直接迭代 |
✅ 正确 |
user |
data.fills[*].oid |
int 48913699327 |
OrderFilledEvent(order_id=fill.get("oid","")) |
❌ 类型不匹配 |
user |
data.assetPositions[*] |
{position:{...}, type:"oneWayPosition"} |
p.get("position", p) 提取内层 |
✅ 正确 |
user |
data.marginSummary.totalNtlPos |
总持仓名义价值(非未实现盈亏) | 赋值给 unrealized_pnl 字段 |
❌ 语义错误 |
user |
data.withdrawable |
str 可用余额 |
float(data.get("withdrawable", 0)) |
✅ 正确 |
candle |
data.s |
str "BTC"(币种代码) |
data.get('s') → f"{coin}/USDC:USDC" |
✅ 正确 |
candle |
data.i |
str "5m"/"1h"/"4h" |
data.get('i') |
✅ 正确 |
candle |
data.t/o/h/l/c/v |
int/str 时间戳和价格 |
_safe_int / _safe_float 转换 |
✅ 正确 |
l2Book |
data.levels |
[[{px,sz,n},...],[{px,sz,n},...]] |
levels[0]、levels[1] 取 bids/asks |
✅ 正确(注解错误) |
l2Book |
data.coin |
str "HYPE" |
data.get("coin") |
✅ 正确 |
HTTP query_order_by_oid |
响应顶层 | {"status":"order","order":{<inner>}} |
解包 raw["order"] 返回内层 |
✅ 正确 |
HTTP query_order_by_oid |
<inner>.avgPx |
str 成交均价 |
_pos_float(resp.get("avgPx")) |
✅ 正确 |
HTTP query_order_by_oid |
<inner>.totalSz |
str 总成交量 |
_pos_float(resp.get("totalSz")) |
✅ 正确 |
HTTP open_orders |
响应 | list [{coin, oid, limitPx, sz, ...}] |
order.get("coin"), order.get("oid") |
✅ 正确 |
HTTP meta().universe |
assetInfo.marginMode |
str "strictIsolated" |
margin_mode == "strictIsolated" |
✅ 正确 |
BUG-1(严重)userFills 频道数据解析完全失效
文件:src/trading/websocket_order_manager.py,第 392–434 行
方法:_on_user_fill()
问题描述
userFills 频道推送的 WebSocket 消息中,真实成交记录嵌套在 data.fills 数组下,而非 data 本身。当前代码将整个 data dict 包裹成单元素列表后迭代,导致永远无法取到 oid 字段,所有成交记录被静默丢弃。
实测数据结构(测试网验证)
{
"channel": "userFills",
"data": {
"isSnapshot": true,
"user": "0x065a6505206d723440fc62a76cf58f5dda7c1132",
"fills": [
{
"coin": "VINE",
"px": "0.01804",
"sz": "14288.0",
"side": "A",
"time": 1771726639209,
"oid": 48913699327,
"tid": 227144388046239,
"fee": "0.038663",
"closedPnl": "0.0",
"feeToken": "USDC",
"twapId": null
}
]
}
}
当前代码(错误)
# src/trading/websocket_order_manager.py:398-411
def _on_user_fill(self, message: dict):
raw = message.get("data")
# raw = {"isSnapshot": True, "user": "0x...", "fills": [...]} ← dict,非 list
if not raw:
return
fills = raw if isinstance(raw, list) else [raw]
# fills = [{"isSnapshot": True, "user": "...", "fills": [...]}] ← 外层 dict 被包裹
for fill_data in fills:
if not isinstance(fill_data, dict):
continue
oid = _safe_int_oid(fill_data.get("oid"))
# fill_data.get("oid") = None ← 外层 dict 无 oid 字段,永远返回 None
if oid is None:
continue # ← 所有 fill 在此被丢弃
运行时验证
raw type: <class 'dict'>
fill_data.get("oid"): None
>>> BUG CONFIRMED: oid is None, fill is IGNORED
修复方案
# src/trading/websocket_order_manager.py:_on_user_fill()
def _on_user_fill(self, message: dict):
raw = message.get("data")
if not raw:
return
# 修复:处理嵌套结构 {"isSnapshot": ..., "fills": [...]}
if isinstance(raw, dict):
fills = raw.get("fills", [])
elif isinstance(raw, list):
fills = raw
else:
return
for fill_data in fills:
# ... 后续逻辑不变
影响范围
WebSocketOrderManager._on_user_fill()完全失效- 所有限价单的成交均价无法通过 WS 实时累积
- 系统降级为依赖
orderUpdates.order.limitPx兜底(限价而非实际成交价)或 HTTP 补查 - 加权均价计算逻辑(
_accumulate_fill)从未被触发 tracking.fill_count永远为 0,tracking.has_fill_price永远为False(直到 HTTP 兜底)
BUG-2(重要)orderUpdates 未识别的拒绝状态类型
文件:src/trading/websocket_order_manager.py,第 378–387 行
方法:_on_order_update()
问题描述
Hyperliquid 的 orderUpdates 推送包含多种语义为"拒绝"的 status 值(如 minTradeNtlRejected),但代码仅识别字面字符串 "rejected",其余拒绝类型均走 else: continue 分支被静默跳过,订单追踪无法及时解析。
实测数据(测试网验证)
{
"channel": "orderUpdates",
"data": [
{
"order": {
"coin": "ETH",
"side": "B",
"limitPx": "956.3",
"sz": "0.001",
"oid": 48920367120,
"origSz": "0.001"
},
"status": "minTradeNtlRejected",
"statusTimestamp": 1771739133080
}
]
}
状态映射分析
| 实际 status 值 | 代码处理结果 | 是否正确 |
|---|---|---|
"filled" |
FILLED → resolve | ✅ |
"canceled" |
CANCELED → resolve | ✅ |
"margincanceled" |
CANCELED → resolve | ✅ |
"rejected" |
REJECTED → resolve | ✅ |
"minTradeNtlRejected" |
else: continue → 被忽略 |
❌ |
"resting" |
else: continue → 正常跳过 |
✅(预期行为) |
"open" / "pending" |
else: continue → 正常跳过 |
✅(预期行为) |
影响范围
- 下单金额过小(低于交易所最低名义价值)时,订单追踪将等待全部超时周期(600 秒)才能通过 HTTP 兜底确认拒绝
- 延迟 10 分钟才能释放追踪资源
- 潜在存在的其他拒绝类型(如
"limitExceeded"等)同样不被处理
修复方案
# src/trading/websocket_order_manager.py:_on_order_update()
elif "rejected" in status_str or status_str == "rejected":
tracking._ws_status = OrderStatus.REJECTED
should_resolve = True
resolve_tracking = tracking
BUG-3(重要)OrderFilledEvent.order_id 类型不匹配
文件:src/utils/websocket/enhanced_ws_manager.py,第 1414 行
方法:_publish_user_events()
问题描述
OrderFilledEvent.order_id 字段类型注解为 str,但 user 频道推送的 fill 数据中 oid 字段为 int,代码未做类型转换,直接将 int 赋值给 str 字段。
代码对照
# src/events/trading_events.py:57
order_id: str = "" # 类型注解为 str
# src/utils/websocket/enhanced_ws_manager.py:1414
event = OrderFilledEvent(
...
order_id=fill.get("oid", ""), # oid 实际为 int: 48913699327
...
)
实测数据
{"oid": 48913699327, "tid": 227144388046239, ...}
oid 是 int,fill.get("oid", "") 返回 int,但 order_id 字段期望 str。Python 不在赋值时强制检查,但任何做字符串处理的下游消费者(如 str.startswith()、re.match()、字典键拼接等)都会在运行时抛 TypeError。
修复方案
order_id=str(fill.get("oid", "")),
BUG-4(中等)BalanceChangedEvent.unrealized_pnl 语义错误
文件:src/utils/websocket/enhanced_ws_manager.py,第 1439 行
方法:_publish_user_events()
问题描述
将 Hyperliquid marginSummary.totalNtlPos 字段赋值给 BalanceChangedEvent.unrealized_pnl,但两者语义不同:
| 字段 | 语义 |
|---|---|
totalNtlPos |
总持仓名义价值(Total Notional Position),即各仓位绝对值加总的敞口规模 |
unrealized_pnl(期望) |
未实现盈亏,即持仓浮动盈亏 |
当前代码
# src/utils/websocket/enhanced_ws_manager.py:1432-1441
event = BalanceChangedEvent(
timestamp=datetime.now(),
source="websocket",
available_balance=float(data.get("withdrawable", 0)),
total_balance=float(margin.get("accountValue", 0)),
margin_used=float(margin.get("totalMarginUsed", 0)),
unrealized_pnl=float(margin.get("totalNtlPos", 0)), # ← 实为总持仓规模,非盈亏
)
影响范围
当前 executor._on_balance_changed() 仅使用 event.available_balance 更新缓存,unrealized_pnl 字段未被直接消费,因此不影响现有交易逻辑。但如果未来风险监控或 PnL 报告逻辑依赖此字段,将得到错误数据。
Hyperliquid marginSummary 字段说明
{
"accountValue": "50000.25", // 账户总权益 = 保证金 + 未实现盈亏
"totalMarginUsed": "10000.0", // 已用保证金
"totalNtlPos": "40000.25", // 总持仓名义价值(敞口规模)
"totalRawUsd": "9800.0" // 原始 USD 保证金
}
未实现盈亏不在 marginSummary 中直接提供,可由 accountValue - totalRawUsd 近似计算,或从 assetPositions 各仓位汇总。
修复建议
# 方案1:使用语义更准确的字段名,或直接标注含义
unrealized_pnl=float(margin.get("totalNtlPos", 0)), # 注意:此为总持仓规模非盈亏
# 方案2:改用 BalanceChangedEvent 的字段存放正确含义
# 需同步修改事件定义,增加 total_notional_position 字段
BUG-5(中等)OrderFilledEvent 缺少 fill_id 元数据
文件:src/utils/websocket/enhanced_ws_manager.py,第 1411–1427 行
方法:_publish_user_events()
问题描述
设计文档(docs/OrderFilledEvent接入订单追踪设计.md)明确要求:
发布端和消费端必须调用同一函数
_make_fill_id(fill)生成 key,消费端强依赖metadata["fill_id"],不自行构造备用 key。
但实际发布代码未设置 metadata["fill_id"]:
# src/utils/websocket/enhanced_ws_manager.py:1411-1427
event = OrderFilledEvent(
timestamp=datetime.now(),
source="websocket",
order_id=fill.get("oid", ""),
client_order_id=fill.get("cloid"),
symbol=fill.get("coin", ""),
side=fill.get("side", ""),
filled_qty=float(fill.get("sz", 0)),
filled_price=float(fill.get("px", 0)),
fee=float(fill.get("fee", 0)),
closed_pnl=...,
# ← 缺少 metadata={"fill_id": _fill_key(fill)}
)
现状与风险
当前 WebSocketOrderManager 未订阅 OrderFilledEvent(通过 EventBus),而是直接通过 handle_message() 接收 userFills 原始消息,因此目前不会因缺少 fill_id 崩溃。
但设计文档的 Phase 2(接入 EventBus 驱动的消费者)若被实现,消费者访问 event.metadata["fill_id"] 将抛 KeyError。
修复方案
# 在 enhanced_ws_manager.py 顶部或工具模块中定义(与 websocket_order_manager._fill_key 统一)
def _make_fill_id(fill: dict) -> str:
tid = fill.get("tid")
if tid is not None:
return f"tid:{tid}"
raw = f"{fill.get('oid')}:{fill.get('px')}:{fill.get('sz')}:{fill.get('time')}"
return f"hash:{hashlib.md5(raw.encode()).hexdigest()}"
# 发布时附加
event = OrderFilledEvent(
...
metadata={"fill_id": _make_fill_id(fill)},
)
BUG-6(低)OrderBookUpdatedEvent 类型注解与实际数据格式不符
文件:src/events/trading_events.py,第 128–129 行
问题描述
事件字段注解为 List[List[float]],但实际 Hyperliquid L2 数据格式是 List[Dict]:
# src/events/trading_events.py:128-129
bids: List[List[float]] = field(default_factory=list) # 注解: [[price, size], ...]
asks: List[List[float]] = field(default_factory=list)
实际数据格式
{
"channel": "l2Book",
"data": {
"coin": "HYPE",
"levels": [
[{"px": "5.0", "sz": "100", "n": 1}, ...],
[{"px": "5.1", "sz": "50", "n": 1}, ...]
]
}
}
代码实际使用方式
executor.py 中使用 dict 访问方式,与实际格式一致:
best_bid = float(bids[0]["px"]) if bids else 0.0 # ← 正确:dict 方式
best_ask = float(asks[0]["px"]) if asks else 0.0
影响范围
Python 不在运行时检查类型注解,不会崩溃。但注解误导后续开发者,若新代码按 bids[0][0](列表索引)访问价格,将在运行时抛 TypeError。
修复建议
from typing import TypedDict
class L2Level(TypedDict):
px: str
sz: str
n: int
bids: List[L2Level] = field(default_factory=list)
asks: List[L2Level] = field(default_factory=list)
INFO-1 WS 连接事件 source 固定为 "websocket"
文件:src/utils/websocket/enhanced_ws_manager.py,第 636–648 行
现状
EnhancedWebSocketManager 发布的所有连接类事件(WebSocketConnectedEvent、WebSocketDisconnectedEvent、WebSocketReconnectedEvent)统一使用 source="websocket":
event = WebSocketReconnectedEvent(
timestamp=datetime.now(),
source="websocket", # 固定值,无法区分 market/trading WS
downtime_seconds=downtime,
)
双 WS 架构背景
设计文档(docs/双WebSocket架构设计.md)描述了 market WS(source="market")和 trading WS(source="trading")的分离架构,其中 Executor 应仅响应来自 trading WS 的重连事件触发补查。
当前实现状态
当前 realtime_kline_service_base.py 只初始化一个 ws_manager,所有订阅(K线 + L2 + 订单)共用同一 WebSocket 连接。双 WS 架构尚未在基类层面实现,因此 executor._on_websocket_reconnected() 中无 source 过滤逻辑也不会造成问题。
潜在风险
若未来真正实现双 WS 时,未能正确设置 source 参数,会导致:
- market WS 重连也触发
verify_pending_orders()(双轮 HTTP 补查),产生不必要的 API 调用 - 无法通过
event.source区分事件来源,事件过滤逻辑需要重构
优先修复建议
立即修复(影响当前生产功能)
| 编号 | 文件 | 行号 | 修复说明 |
|---|---|---|---|
| BUG-1 | src/trading/websocket_order_manager.py |
398–402 | 将 fills = raw if isinstance(raw, list) else [raw] 改为从 raw.get("fills", []) 提取 |
| BUG-2 | src/trading/websocket_order_manager.py |
378 | 将 elif status_str == "rejected": 改为 elif "rejected" in status_str: |
| BUG-3 | src/utils/websocket/enhanced_ws_manager.py |
1414 | 将 fill.get("oid", "") 改为 str(fill.get("oid", "")) |
建议修复(语义正确性)
| 编号 | 文件 | 行号 | 修复说明 |
|---|---|---|---|
| BUG-4 | src/utils/websocket/enhanced_ws_manager.py |
1439 | 确认 totalNtlPos 的语义,或使用正确的未实现盈亏字段 |
| BUG-5 | src/utils/websocket/enhanced_ws_manager.py |
1411–1427 | 发布 OrderFilledEvent 时附加 metadata={"fill_id": ...} |
低优先级(代码规范)
| 编号 | 文件 | 行号 | 修复说明 |
|---|---|---|---|
| BUG-6 | src/events/trading_events.py |
128–129 | 修正 bids/asks 类型注解为 List[L2Level] |
| INFO-1 | src/utils/websocket/enhanced_ws_manager.py |
636–648 | 为双 WS 架构实现时预留 source_name 参数 |