CCXT替换为Hyperliquid官方SDK存在的BUG
Hyperliquid SDK K 线请求严重 BUG — 第五轮因果链分析
分析日期: 2026-02-15
涉及文件:hyperliquid_candles.py,kline_data_filler.py,kline_data_filler_lazy.py
背景: 从 CCXT 迁移到 Hyperliquid 官方 SDK 后,K 线数据拉取层存在 3 个严重 BUG
概览
CCXT → SDK 迁移引入了 hyperliquid_candles.py 作为统一封装层,通过 fetch_candles() / fetch_candles_with_retry() / fetch_candles_range_with_retry() 三级函数向上提供 K 线数据。代码审查发现 3 个 BUG,其中 BUG #1 是致命的——在数据自愈、实时分析、回测三条路径上均会触发,导致 数据静默丢失。
BUG #1(致命 🔴🔴):symbol_to_coin 与 SDK candles_snapshot 双重映射 — 现货代币 KeyError
因果链
| 阶段 | 详情 |
|---|---|
| 输入 | 任何调用者请求 K 线数据,传入 symbol='PURR/USDC:USDC'(或任何 Hyperliquid 现货代币) |
| 状态变化 | symbol_to_coin('PURR/USDC:USDC') → 'PURR'(剥离 / 后的部分) |
| 调用路径 | fetch_candles(info, 'PURR/USDC:USDC', ...) → coin='PURR' → info.candles_snapshot('PURR', ...) |
| 出错点 | SDK info.py:501: req = {"coin": self.name_to_coin[name], ...} — SDK 内部再做一次 name_to_coin 查表 |
| 根因 | symbol_to_coin() 把交易对剥离为裸币名 'PURR',但 SDK 的 name_to_coin 中现货代币的 key 是 'PURR/USDC'(base/quote 格式),不包含裸币名 'PURR'。导致 KeyError: 'PURR' |
具体推演
# ① hyperliquid_candles.py:50-58 — 符号转换
def symbol_to_coin(symbol: str) -> str:
parts = symbol.split('/')
if len(parts) == 2:
return parts[0] # 'PURR/USDC:USDC' → split('/') → ['PURR', 'USDC:USDC']
return symbol # →→→ 返回 'PURR'
# ② hyperliquid_candles.py:105,113 — 调用 SDK
coin = symbol_to_coin(symbol) # coin = 'PURR'
raw = info.candles_snapshot(coin, interval, start_time_ms, end_time_ms)
# ↓ 等价于
# info.candles_snapshot('PURR', '4h', ..., ...)
# ③ SDK info.py:473,501 — SDK 内部实现
def candles_snapshot(self, name, interval, startTime, endTime):
req = {"coin": self.name_to_coin[name], ...}
# ↑
# self.name_to_coin 的 key 列表:
# 永续: 'BTC', 'ETH', 'HYPE', ... (asset_info["name"])
# 现货: 'PURR/USDC', 'JEFF/USDC', ... (spot_info["name"] 或 base/quote 格式)
#
# ❌ 'PURR' 不在 name_to_coin 中 → KeyError!
SDK name_to_coin 的构建逻辑(info.py:38-76)
# SDK 初始化时构建 name_to_coin:
self.name_to_coin = {}
# 1. 现货代币
for spot_info in spot_meta["universe"]:
self.name_to_coin[spot_info["name"]] = spot_info["name"]
# spot_info["name"] 格式: "@1", "PURR/USDC" 等(不是裸币名)
name = f'{base_info["name"]}/{quote_info["name"]}' # e.g. "PURR/USDC"
if name not in self.name_to_coin:
self.name_to_coin[name] = spot_info["name"]
# 2. 永续代币
for asset_info in meta["universe"]:
self.name_to_coin[asset_info["name"]] = asset_info["name"]
# asset_info["name"] 格式: "BTC", "ETH", "HYPE" 等(裸币名)
为什么"看起来在工作"
- BTC、ETH 等永续代币:
symbol_to_coin('BTC/USDC:USDC')='BTC',SDKname_to_coin['BTC']='BTC'→ ✅ 恰好能工作(因为永续代币的 key 就是裸币名) - HYPE:同时存在于永续和现货 →
name_to_coin['HYPE']='HYPE'→ ✅ 能工作 - PURR:如果 PURR 也存在于永续市场,则
name_to_coin['PURR']存在 → ✅ 能工作;但如果 PURR 是纯现货代币(或未来有任何纯现货代币),则 → ❌ KeyError
[!CAUTION]
关键问题:代码设计上就是错的。symbol_to_coin()剥离出的裸币名'PURR'被传入 SDK,SDK 内部再次查表name_to_coin[name]— 这是一次冗余的双重映射。对永续代币恰好工作只是巧合(name_to_coin['BTC']='BTC',查表等于没查)。正确做法是:要么跳过 SDK 的内部查表(直接构造 API 请求),要么传入 SDK 期望的 name 格式。
回测脚本中的同一 BUG
# backtest_purr_hype_zscore_4h_hyperliquid.py:68,94
coin = symbol_to_coin(symbol) # 'PURR/USDC' → 'PURR'
raw = info.candles_snapshot(coin, interval, current_since, end_batch)
# ↑ 同样传入裸币名,触发同样的 KeyError 风险
后果链
1. 数据自愈路径:
repair_executor.repair()
→ _fill_kline_gaps() → kline_data_filler.fill_missing_data_precise()
→ _fetch_ohlcv_with_retry() → fetch_candles_with_retry()
→ fetch_candles(info, 'PURR/USDC:USDC', ...)
→ info.candles_snapshot('PURR', ...) → KeyError
→ 异常被 hyperliquid_candles.py:114-118 捕获 → re-raise
→ 被 kline_data_filler.py:242 捕获 → return 0
→ K 线补充失败 → zscore 计算失败 → 自愈失败
2. 实时分析路径(KlineDataFillerLazy):
同上路径,影响实时数据补充
3. 回测路径:
backtest_purr_hype_zscore_4h_hyperliquid.py
→ info.candles_snapshot('PURR', ...) → KeyError
→ 回测无法获取 PURR 数据
修复方案
# 方案 A(推荐):不做 symbol_to_coin 转换,直接传原始 symbol 给 SDK
# SDK 的 candles_snapshot 会自己做 name_to_coin 映射
# 但需要确认传入格式与 SDK name_to_coin 的 key 匹配
# 方案 B:绕过 SDK 的 name_to_coin,直接构造 API 请求
def fetch_candles(info, symbol, interval, start_time_ms, end_time_ms):
coin = symbol_to_coin(symbol) # 'PURR/USDC:USDC' → 'PURR'
# 直接调用 info.post,跳过 name_to_coin 查表
req = {"coin": coin, "interval": interval,
"startTime": start_time_ms, "endTime": end_time_ms}
raw = info.post("/info", {"type": "candleSnapshot", "req": req})
...
# 方案 C:修改 symbol_to_coin,对现货代币返回 SDK 期望的格式
def symbol_to_coin(symbol: str) -> str:
"""
交易对转 SDK name:
- 永续: 'BTC/USDC:USDC' → 'BTC' (name_to_coin key)
- 现货: 'PURR/USDC:USDC' → 'PURR/USDC' (name_to_coin key)
"""
# 去掉合约后缀 ':USDC'
base_symbol = symbol.split(':')[0] if ':' in symbol else symbol
# 如果是永续格式(只有 base),返回裸币名
# 如果是现货格式(有 /),保留 base/quote
return base_symbol # 'PURR/USDC' 或 'BTC/USDC'
# 但这需要 SDK name_to_coin 中有 'BTC/USDC' 的 key...
[!IMPORTANT]
推荐方案 B:直接构造 API 请求,绕过 SDK 的name_to_coin查表。这是最稳定的方案——因为 Hyperliquid API 接受裸币名(perp)和@index(spot)作为 coin 参数,而name_to_coin的映射逻辑在 SDK 版本更新时可能变化。
BUG #2(严重 🔴):fetch_candles() 全局互斥锁持有整个 API 调用 — 性能瓶颈 + 死锁风险
因果链
| 阶段 | 详情 |
|---|---|
| 输入 | 多个线程并发请求 K 线数据(如数据自愈 + 实时分析 + 新币种监控) |
| 状态变化 | 第一个线程进入 with _candles_lock: 并发起 HTTP 请求 |
| 调用路径 | fetch_candles() → with _candles_lock: → info.candles_snapshot() → HTTP POST(耗时 200ms~30s) |
| 出错点 | hyperliquid_candles.py:107-124: 全局锁 _candles_lock 在 HTTP 请求期间不释放 |
| 根因 | 限流逻辑(检查间隔 + sleep)和 API 调用被放在同一个 lock 范围内,导致本应只保护几毫秒的限流检查变成了保护整个网络请求 |
具体推演
# hyperliquid_candles.py:107-124
_candles_lock = threading.Lock() # 全局唯一
def fetch_candles(info, symbol, interval, start_time_ms, end_time_ms):
coin = symbol_to_coin(symbol)
min_interval = KLINE_FILLER_API_INTERVAL # 2.5 秒
with _candles_lock: # ⬅ 获取全局锁
now = time.monotonic()
elapsed = now - _last_candles_request_time
if elapsed < min_interval:
time.sleep(min_interval - elapsed) # ⬅ sleep 期间持有锁!
# ⬅ HTTP 请求期间持有锁! (200ms ~ 30000ms)
raw = info.candles_snapshot(coin, interval, start_time_ms, end_time_ms)
globals()["_last_candles_request_time"] = time.monotonic()
# ⬅ 锁直到这里才释放
影响分析
时间线(3 个线程并发请求 K 线):
Thread-1: [━━━ sleep 2.5s ━━━][━━━ HTTP 2s ━━━]
锁释放 ↓
Thread-2: [━━ sleep 2.5s ━━][━━ HTTP 2s ━━]
锁释放 ↓
Thread-3: [━━ sleep ━━][━━ HTTP ━━]
总耗时: 3 × (2.5 + 2) = 13.5 秒
理想耗时 (只锁限流检查): 2.5 + 2 + 2.5 + 2 + 2.5 + 2 = 13.5 秒(此场景相同)
但在网络超时场景下:
Thread-1: [━━━ sleep 2.5s ━━━][━━━━━━━ HTTP TIMEOUT 30s ━━━━━━━━]
Thread-2: [等待 30+ 秒...]
Thread-3: [等待 30+ 秒...]
→ 一个请求超时导致所有线程阻塞 30+ 秒
→ 如果自愈系统需要修复 130 个 K 线时间点,每次超时都会级联阻塞
后果
- 正常情况:性能影响不大(限流间隔本身就串行化了请求)
- 网络抖动/超时:一个请求超时 → 所有并发线程阻塞等待 → 自愈超时失败
- 死锁风险:如果
info.candles_snapshot()内部再触发某个依赖_candles_lock的逻辑 → 死锁
修复方案
def fetch_candles(info, symbol, interval, start_time_ms, end_time_ms):
coin = symbol_to_coin(symbol)
min_interval = KLINE_FILLER_API_INTERVAL
# ✅ 只在限流检查时持有锁
with _candles_lock:
now = time.monotonic()
elapsed = now - _last_candles_request_time
if elapsed < min_interval:
time.sleep(min_interval - elapsed)
globals()["_last_candles_request_time"] = time.monotonic()
# ✅ HTTP 请求在锁外执行
try:
raw = info.candles_snapshot(coin, interval, start_time_ms, end_time_ms)
except KeyError as e:
logger.warning(f"交易所 meta 中无此 coin | symbol={symbol} coin={coin} | {e}")
raise
if not raw:
return []
return [_candle_to_row(c) for c in raw]
[!WARNING]
将 HTTP 请求移到锁外后,限流精度会降低(两个线程可能在极短间隔内各自发请求)。这是可以接受的——Hyperliquid 的 429 限流是服务端控制的,客户端限流只是辅助手段,不需要绝对精确。
BUG #3(严重 🟠):fetch_candles_range_with_retry 分页逻辑 — 数据重叠 + 潜在无限循环
因果链
| 阶段 | 详情 |
|---|---|
| 输入 | kline_data_filler.fill_missing_data() 调用 fetch_candles_range_with_retry(since_ms, until_ms) |
| 状态变化 | 分页循环拉取 K 线 |
| 调用路径 | fetch_candles_range_with_retry() → 循环调用 fetch_candles() |
| 出错点 | hyperliquid_candles.py:195: current_since = rows[-1][0] + 1(last timestamp + 1ms) |
| 根因 | Hyperliquid API 的 [startTime, endTime] 是双闭区间,但 +1ms 不保证跳过当前 K 线——下一页的 startTime 可能仍然命中上一页最后一根 K 线 |
具体推演
# hyperliquid_candles.py:188-199
while current_since < until_ms:
end_ms = min(current_since + api_limit * interval_ms, until_ms)
rows = fetch_candles(info, symbol, interval, current_since, end_ms)
if not rows:
break # 场景 A: API 返回空 → 跳出
all_rows.extend(rows)
current_since = rows[-1][0] + 1 # ⬅ 关键行:last_timestamp + 1ms
场景分析:
假设 4h K 线,interval_ms = 14400000ms
API 返回的 K 线时间戳是 K 线开盘时间(对齐到整小时)
第 1 页: startTime=T, endTime=T+14400000*1500
→ 返回 K 线: [T, T+14400000, T+28800000, ...]
→ rows[-1][0] = T+14400000*(N-1)
→ current_since = T+14400000*(N-1) + 1
第 2 页: startTime = T+14400000*(N-1) + 1
→ 这个 startTime 不是一个有效的 K 线开盘时间(+1ms 偏移)
→ API 行为取决于实现:
a) 返回下一根 K 线 T+14400000*N → ✅ 正确(API 取 >= startTime 的最近 K 线)
b) 返回同一根 K 线 T+14400000*(N-1) → ❌ 数据重叠
c) 不返回任何数据 → ❌ 数据丢失
[!WARNING]
根据 Hyperliquid API 文档,startTime和endTime是毫秒级 Unix 时间戳,API 返回时间戳>= startTime && <= endTime的所有 K 线。由于 K 线时间戳是对齐到周期边界的(如 4h K 线对齐到 0:00, 4:00, 8:00...),+1ms应该能避免重叠。但这依赖于 API 实现细节——如果 API 将 K 线时间戳存为秒级而非毫秒级,+1ms可能无效。
分支 BUG(更严重):API 返回部分空数据时的无限循环
while current_since < until_ms:
end_ms = min(current_since + api_limit * interval_ms, until_ms)
rows = fetch_candles(info, symbol, interval, current_since, end_ms)
if not rows:
break # ← 如果 API 返回空列表,直接退出
all_rows.extend(rows)
current_since = rows[-1][0] + 1
问题:如果 API 在某个时间范围内返回 K 线,但返回的最后一条 K 线的时间戳没有推进(例如 API 返回了相同的数据),则 current_since 不变 → 无限循环。
虽然这在正常情况下不太可能发生,但如果 API 行为异常(返回的 K 线时间戳不在 [startTime, endTime] 范围内),就会触发。
修复方案
while current_since < until_ms:
end_ms = min(current_since + api_limit * interval_ms, until_ms)
rows = fetch_candles(info, symbol, interval, current_since, end_ms)
if not rows:
break
all_rows.extend(rows)
new_since = rows[-1][0] + interval_ms # ✅ 跳过整个 interval,而非 +1ms
if new_since <= current_since:
logger.warning(f"分页未推进: current={current_since}, new={new_since}")
break # ✅ 防止无限循环
current_since = new_since
BUG 联合效应
graph TD
A["symbol = 'PURR/USDC:USDC'<br>调用 K 线补充"] --> B["symbol_to_coin()"]
B --> C["coin = 'PURR'"]
C --> D["fetch_candles(info, coin, ...)"]
D --> E["with _candles_lock:"]
E --> F["info.candles_snapshot('PURR', ...)"]
F --> G{"SDK name_to_coin<br>有 'PURR'?"}
G -->|"永续市场存在 PURR"| H["✅ 恰好能工作"]
G -->|"纯现货代币"| I["❌ BUG #1<br>KeyError: 'PURR'"]
H --> J["HTTP 请求 200ms~30s"]
J --> K{"网络正常?"}
K -->|"超时"| L["❌ BUG #2<br>全局锁阻塞所有线程"]
K -->|"正常"| M["K 线数据返回"]
M --> N["分页拉取<br>fetch_candles_range_with_retry"]
N --> O{"current_since 推进?"}
O -->|"API 异常"| P["❌ BUG #3<br>无限循环/数据重叠"]
O -->|"正常"| Q["✅ 数据完整"]
I --> R["K 线补充失败<br>数据自愈失败"]
L --> S["自愈超时<br>healing_timeout=300s"]
P --> T["CPU 100% 或<br>数据静默重复"]
style I fill:#ff4444,color:#fff
style L fill:#ff6b6b,color:#fff
style P fill:#ffa07a,color:#fff
优先级排序
| 优先级 | BUG | 严重性 | 触发条件 | 影响 | 修复复杂度 |
|---|---|---|---|---|---|
| P0 | BUG #1: symbol_to_coin + SDK 双重映射 |
致命 🔴🔴 | 纯现货代币 K 线请求 | 所有 K 线拉取失败 | 中(需理解 SDK 映射逻辑) |
| P1 | BUG #2: 全局锁持有 HTTP 请求 | 严重 🔴 | 网络超时/高并发 | 所有线程阻塞 | 低(缩小锁范围) |
| P2 | BUG #3: 分页 +1ms + 无推进检查 |
严重 🟠 | API 行为异常 | 无限循环/数据重复 | 低(改为 +interval_ms + 推进检查) |
与前四轮 BUG 的关系
| 轮次 | 发现 | 状态 |
|---|---|---|
| 第一轮 (BUG1.md) | _find_kline_gaps 概念混淆 |
✅ 已修复 |
| 第二轮 (BUG2.md) | 间隔硬编码 / 无新鲜度检查 / 时间窗口不匹配 | ✅ 已修复 |
| 第三轮 (BUG3.md) | SQL参数化 / 变量冲突 / 窗口边界 / 测试不兼容 | ✅ 已修复 |
| 第四轮 (BUG4.md) | timeline 无限制 / zscore_4h 无条件 / cointegration 硬编码 / 拼写错误 | ❌ 待修复 |
| 第五轮 (本轮) | SDK 双重映射 / 全局锁阻塞 / 分页边界 | ❌ 待修复 |
[!NOTE]
第五轮 BUG 属于基础设施层(K 线数据拉取),位于第一~四轮 BUG 所在的业务逻辑层(数据自愈/分析)之下。如果 BUG #1 触发,第一~四轮的所有修复都无法正常工作——因为数据根本拉不到。