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" 等(裸币名)

为什么"看起来在工作"

  1. BTC、ETH 等永续代币symbol_to_coin('BTC/USDC:USDC') = 'BTC',SDK name_to_coin['BTC'] = 'BTC' → ✅ 恰好能工作(因为永续代币的 key 就是裸币名)
  2. HYPE:同时存在于永续和现货 → name_to_coin['HYPE'] = 'HYPE' → ✅ 能工作
  3. 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 线时间点,每次超时都会级联阻塞

后果

  1. 正常情况:性能影响不大(限流间隔本身就串行化了请求)
  2. 网络抖动/超时:一个请求超时 → 所有并发线程阻塞等待 → 自愈超时失败
  3. 死锁风险:如果 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 文档,startTimeendTime毫秒级 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 触发,第一~四轮的所有修复都无法正常工作——因为数据根本拉不到。

Read more

跑步的技巧(滚动落地)

“滚动落地(rolling contact / rolling foot strike)”不是一种教条式的“脚法”,而是一种 让冲击沿着整只脚、整条后链逐级传递的落地机制。 它的核心不是“你先用哪儿着地”,而是: 你的脚落地之后,冲击是不是像轮子一样滚过去,而不是像锤子一样砸下去。 这就是滚动落地的本质。 一、什么叫“滚动落地”? 你可以把它理解成两种完全不同的落地方式: 1. 砸地(撞击式) 脚像锤子一样拍到地上: * 要么后跟先砸 * 要么前掌先戳 * 冲击集中在一个点 * 一个结构瞬间吃掉大部分载荷 结果就是: * 后跟砸 → 膝盖难受 * 前掌戳 → 前脚掌磨烂 * 都不是长跑友好模式 这叫 撞击式着地(impact strike)。 2. 滚地(滚动式) 脚像轮胎一样“滚”过地面: * 不是某一点硬砸 * 而是外侧中足先轻触 * 再向前滚到前掌 * 最后从大脚趾蹬离

By SHI XIAOLONG

AMI的优越性

世界模型(World Models)的具体例子 如下,我按类型分类,便于理解。每类都附带实际实现、演示效果和应用场景。 1. Yann LeCun / Meta 的 JEPA 系列(最直接对应“世界模型”概念) 这些是 LeCun 主张的非生成式抽象预测世界模型代表。 * I-JEPA(Image JEPA,2023) 输入一张图像,模型把不同区域(context 和 target)编码成抽象表示,然后预测 target 的表示(不在像素级别重建)。 例子:给定一张遮挡了部分物体的图片,模型能预测“被遮挡物体的大致位置和属性”,构建对物体持久性和空间关系的理解。 这是一个“原始世界模型”,能学习物理常识(如物体不会凭空消失)。 * V-JEPA / V-JEPA 2(Video JEPA,

By SHI XIAOLONG

什么是:“世界模型(World Models)”

世界模型(World Models) 是人工智能领域的一个核心概念,尤其在 Yann LeCun 等研究者推动的下一代 AI 架构中占据中心位置。它指的是 AI 系统在内部构建的对现实世界的抽象模拟或内部表示,让机器能够像人类或动物一样“理解”物理世界、预测未来、规划行动。 简单比喻 想象你闭上眼睛也能“看到”房间里的物体会如何移动、碰撞或掉落——这就是你大脑里的世界模型。AI 的世界模型就是类似的“数字孪生”(digital twin)或“内部模拟器”:它不是简单记住数据,而是学习世界的动态、因果关系和物理直觉(如重力、物体持久性、遮挡、因果等)。 为什么需要世界模型? 当前主流的大型语言模型(LLM) 擅长处理文本(统计模式预测),但存在根本局限: * 缺乏对物理世界的真正理解 → 容易“幻觉”、无法可靠规划。 * 样本效率低 → 人类/

By SHI XIAOLONG

K线周期可配置化设计方案

K线周期可配置化设计方案 1. 背景与目标 当前 Beta 套利策略的 K 线周期硬编码为 "1h",分散在多个文件中。需要: 1. 将 K 线周期从 1h 改为 2h 2. 提取为环境变量 BETA_ARB_KLINE_INTERVAL,使其可在 .env 中配置 2. 影响范围分析 2.1 需要修改的文件(共 6 个) 文件 硬编码位置 修改内容 src/trading/config.py BetaArbConfig dataclass 新增 kline_interval 字段,

By SHI XIAOLONG