CCXT替换为Hyperliquid官方SDK存在的BUG2
Hyperliquid SDK K 线请求严重 BUG — 第五轮因果链分析
分析日期: 2026-02-15
涉及文件:hyperliquid_candles.py,kline_data_filler.py
背景: 从 CCXT 迁移到 Hyperliquid 官方 SDK 后,K 线数据拉取层存在 3 个 BUG
概览
CCXT → SDK 迁移引入了 hyperliquid_candles.py 作为统一封装层,将 CCXT 的 since + limit 语义翻译为 SDK 的 startTime + endTime 语义。但这个翻译层存在致命缺陷:limit(数量)被直接用于计算 endTime(时间范围),而两者语义完全不同。当缺失时间点分散时,覆盖范围严重不足,导致数据静默丢失。
BUG #1(致命 🔴🔴):fill_missing_data_precise 的 limit→endTime 语义翻译错误 — 稀疏缺口数据静默截断
因果链
| 阶段 | 详情 |
|---|---|
| 输入 | repair_executor._fill_kline_gaps(kline_gaps=[T49, T99], symbol, ...) — 两个相距很远的 K 线缺口 |
| 状态变化 | fill_missing_data_precise() 计算 limit = len([T49, T99]) + 2 = 4 |
| 调用路径 | _fetch_ohlcv_with_retry(since=T48, limit=4) → fetch_candles_with_retry(since_ms=T48, limit=4) |
| 出错点 | hyperliquid_candles.py:151: end_ms = since_ms + 4 * interval_ms = T52 |
| 根因 | API 只查 [T48, T52] 范围的数据,T99 完全不在查询范围内,K 线静默丢失 |
具体推演
# kline_data_filler.py:199-234 — fill_missing_data_precise
def fill_missing_data_precise(self, symbol, timeframe, missing_timestamps):
min_ts = min(missing_timestamps) # T49
max_ts = max(missing_timestamps) # T99
start_time = min_ts - interval # T48
end_time = max_ts + interval # T100 ← ✅ 正确的结束时间
limit = max(len(missing_timestamps) + 2, 10) # max(2+2, 10) = 10
# ↑ 只有缺口数量+2!
since = int(start_time.timestamp() * 1000) # T48 的毫秒时间戳
ohlcv = self._fetch_ohlcv_with_retry(
symbol, timeframe,
since=since, # ← 传了 since (T48)
limit=min(limit, 1500) # ← 传了 limit (10)
)
# end_time 被丢弃了!只用于日志!
# hyperliquid_candles.py:139-151 — fetch_candles_with_retry
def fetch_candles_with_retry(info, symbol, interval, since_ms, limit, ...):
interval_ms = _interval_ms(interval) # 4h = 14400000ms
end_ms = since_ms + min(limit, 1500) * interval_ms
# = T48 + 10 * 14400000
# = T58 ← ❌ 只到 T58,T99 根本覆盖不到!
return fetch_candles(info, symbol, interval, since_ms, end_ms)
# API 请求范围: [T48, T58] → 只返回这 10 个区间的 K 线
# T99 处的缺口 → 永远不会被修复!
CCXT vs SDK 语义差异 — 迁移的隐性陷阱
CCXT 语义:
exchange.fetch_ohlcv(symbol, '4h', since=T48, limit=10)
→ 含义: "从 T48 开始,给我 10 根 K 线"
→ 返回: 从 T48 起最近的 10 根存在的 K 线
→ 如果 T49~T58 都有数据,返回 [T48, T49, ..., T57]
→ ❌ 同样不覆盖 T99(CCXT 下就有此缺陷,但容易被掩盖)
Hyperliquid SDK 语义:
info.candles_snapshot(coin, '4h', startTime=T48, endTime=T58)
→ 含义: "给我 [T48, T58] 时间范围内的所有 K 线"
→ 返回: 该范围内所有 K 线(最多 5000 条)
→ ❌ 同样不覆盖 T99
关键区别:
CCXT: limit = 返回条数上限(交易所控制 endTime)
SDK: limit → endTime = since + limit * interval(客户端自己计算 endTime)
SDK 下,limit 被赋予了"时间范围"的含义,但 fill_missing_data_precise
用 len(missing) 来设置 limit — 这是"缺口数量"而非"时间跨度"!
触发场景
场景: 数据库有大量 K 线,但存在两个孤立缺口
K 线数据: [T0, T1, T2, ..., T48, ❌T49, T50, T51, ..., T98, ❌T99, T100, ...]
↑缺口1 ↑缺口2
_find_kline_gaps → kline_gaps = [T49, T99] (2 个缺口)
fill_missing_data_precise(missing_timestamps=[T49, T99])
计算:
min_ts = T49, max_ts = T99
start_time = T48
end_time = T100 ← ✅ 正确
limit = max(2+2, 10) = 10 ← ❌ 应该 = (T100-T48)/interval = 52
API 请求: [T48, T58] ← 只覆盖 T49 的缺口
结果: T49 被修复 ✅,T99 未被修复 ❌
第 2 轮自愈:
重新诊断 → 发现 T99 仍缺失 → 生成 kline_gaps = [T99]
fill_missing_data_precise(missing_timestamps=[T99])
limit = max(1+2, 10) = 10
since = T98, end_ms = T98 + 10 * interval = T108
→ ✅ 第 2 轮能覆盖 T99
→ 最终:BUG 不致命(多轮修复后可以修好),但造成修复效率低下、
自愈轮次浪费、可能超过 HEALING_MAX_ITERATIONS=3 的限制
[!CAUTION]
致命场景: 当缺口数量 > 2 且分布跨度 >limit=max(n+2,10)个 interval 时,每轮自愈只能覆盖前段缺口。
如果总缺口需要 >HEALING_MAX_ITERATIONS(3)轮才能全部修复 → 部分缺口永远无法修复 → 数据质量永久性降级。
修复方案
# fill_missing_data_precise 应该用 end_time 而非 limit 来驱动 API 请求
def fill_missing_data_precise(self, symbol, timeframe, missing_timestamps):
...
min_ts = min(missing_timestamps)
max_ts = max(missing_timestamps)
start_time = min_ts - interval_delta
end_time = max_ts + interval_delta
# ✅ 方案: 直接使用 start_time/end_time 调用 range 版本
since_ms = int(start_time.timestamp() * 1000)
until_ms = int(end_time.timestamp() * 1000)
ohlcv = fetch_candles_range_with_retry(
self._info, symbol, timeframe,
since_ms, until_ms,
api_limit=self.API_LIMIT,
)
...
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()
# ⬅ 锁直到这里才释放
影响分析
正常情况(API 响应 200ms):
Thread-1: [sleep 2.5s][HTTP 0.2s]
Thread-2: [sleep 2.5s][HTTP 0.2s]
→ 总耗时 ≈ 5.4s,影响不大
网络超时场景(API 响应 30s):
Thread-1: [sleep 2.5s][━━━━━ HTTP TIMEOUT 30s ━━━━━]
Thread-2: [等待锁 30+s...]
Thread-3: [等待锁 30+s...]
→ 一个超时导致所有线程阻塞 30+ 秒
→ 自愈系统超过 HEALING_TIMEOUT_SECONDS=300 → 修复失败
修复方案
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]
BUG #3(中 🟠):fetch_candles_range_with_retry 分页推进用 +1ms — 无限循环风险
因果链
| 阶段 | 详情 |
|---|---|
| 输入 | 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 |
| 根因 | +1ms 推进可能不足以跳过当前 K 线;且无推进检测保护 — 若 API 返回相同数据则无限循环 |
具体推演
# 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
all_rows.extend(rows)
current_since = rows[-1][0] + 1 # ⬅ +1ms 推进
问题 A(数据重叠):Hyperliquid API 返回 [startTime, endTime] 双闭区间。+1ms 在正常情况下能避免重叠(K 线时间戳对齐到秒/分钟边界),但依赖于 API 实现细节。
问题 B(无限循环):如果 API 异常返回的 K 线时间戳没有推进(rows[-1][0] == current_since - 1),则 current_since 不变,导致同一请求被无限重复。
修复方案
all_rows.extend(rows)
new_since = rows[-1][0] + interval_ms # ✅ 跳过整个 interval
if new_since <= current_since:
logger.warning(f"分页未推进: {current_since} → {new_since}")
break # ✅ 防止无限循环
current_since = new_since
BUG 联合效应
graph TD
A["数据自愈启动<br>发现 K 线缺口"] --> B["_find_kline_gaps()<br>gaps = [T49, T99]"]
B --> C["fill_missing_data_precise()"]
C --> D["limit = len(gaps)+2 = 4<br>end_time = T100 ✅"]
D --> E["fetch_candles_with_retry()<br>end_ms = T48 + 4*interval = T52"]
E --> F{"API 响应正常?"}
F -->|"正常"| G["返回 [T48,T52] 的 K 线<br>❌ BUG #1: T99 不在范围内"]
G --> H["T49 修复 ✅<br>T99 未修复 ❌"]
H --> I{"第 2 轮自愈<br>能覆盖 T99?"}
I -->|"MAX_ITERATIONS 未超"| J["✅ 多轮修复后才成功"]
I -->|"已达 3 轮上限"| K["❌ T99 永久丢失"]
F -->|"超时 30s"| L["❌ BUG #2: 全局锁阻塞<br>所有线程等待 30+s"]
L --> M["自愈超时失败"]
E --> N["分页拉取<br>fetch_candles_range_with_retry"]
N --> O{"current_since 推进?"}
O -->|"API 异常"| P["❌ BUG #3: 无限循环"]
style G fill:#ff4444,color:#fff
style K fill:#ff4444,color:#fff
style L fill:#ff6b6b,color:#fff
style P fill:#ffa07a,color:#fff
优先级排序
| 优先级 | BUG | 严重性 | 触发条件 | 影响 | 修复复杂度 |
|---|---|---|---|---|---|
| P0 | BUG #1: limit→endTime 语义翻译错误 |
致命 🔴🔴 | 存在 ≥2 个分散 K 线缺口 | K 线数据静默截断,后段缺口永不修复 | 中(改用 range 版本) |
| P1 | BUG #2: 全局锁持有 HTTP 请求 | 严重 🔴 | 网络超时/高并发 | 所有线程阻塞,自愈超时 | 低(缩小锁范围) |
| P2 | BUG #3: 分页 +1ms + 无推进检查 |
中 🟠 | API 行为异常 | 无限循环/数据重复 | 低(改为 +interval_ms + 推进检查) |
根因总结:CCXT→SDK 迁移的语义鸿沟
┌─────────────────────────────────────────────────────────────┐
│ CCXT 接口语义 │
│ fetch_ohlcv(symbol, timeframe, since, limit) │
│ → "从 since 开始,返回最多 limit 条 K 线" │
│ → endTime 由交易所服务端决定 │
│ → limit = 数据条数上限 │
└─────────────┬───────────────────────────────────────────────┘
│ 迁移
↓
┌─────────────────────────────────────────────────────────────┐
│ Hyperliquid SDK 接口语义 │
│ candles_snapshot(coin, interval, startTime, endTime) │
│ → "返回 [startTime, endTime] 范围内所有 K 线" │
│ → endTime 由调用者显式指定 │
│ → 无 limit 概念(最多 5000 条) │
└─────────────┬───────────────────────────────────────────────┘
│
↓
┌─────────────────────────────────────────────────────────────┐
│ 当前封装层(hyperliquid_candles.py) │
│ fetch_candles_with_retry(since_ms, limit) │
│ → end_ms = since_ms + limit * interval_ms ← ❌ 语义转换错 │
│ → 把"条数上限"当作"时间跨度" │
│ → fill_missing_data_precise 传入的 │
│ limit = len(missing) + 2(缺口数量)≠ 实际时间跨度 │
└─────────────────────────────────────────────────────────────┘
与前四轮 BUG 的关系
| 轮次 | 发现 | 层级 | 状态 |
|---|---|---|---|
| 第一轮 (BUG1.md) | _find_kline_gaps 概念混淆 |
业务层 | ✅ 已修复 |
| 第二轮 (BUG2.md) | 间隔硬编码 / 无新鲜度检查 | 业务层 | ✅ 已修复 |
| 第三轮 (BUG3.md) | SQL参数化 / 窗口边界 | 业务层 | ✅ 已修复 |
| 第四轮 (BUG4.md) | timeline 无限制 / cointegration 硬编码 | 业务层 | ❌ 待修复 |
| 第五轮 (本轮) | since+limit→startTime+endTime 语义错误 | 基础设施层 | ❌ 待修复 |
[!NOTE]
第五轮 BUG 位于基础设施层(K 线数据拉取),在前四轮 BUG 所在的业务逻辑层之下。即使第四轮的所有 BUG 都修复了,如果 K 线拉取层覆盖范围不足,上层拿到的 K 线数据就是不完整的,zscore 计算结果就是不可靠的。