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_preciselimitendTime 语义翻译错误 — 稀疏缺口数据静默截断

因果链

阶段 详情
输入 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: limitendTime 语义翻译错误 致命 🔴🔴 存在 ≥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 计算结果就是不可靠的。

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