PURR 期现套利开仓门控 — 设计文档
PURR 期现套利开仓门控 — 设计文档
日期: 2026-03-25
状态: 待实现
1. 需求背景
当前 PURR|HYPE 配对交易的开仓决策完全依赖 Z-score 均值回归信号(Adaptive Bollinger 策略)。
本次新增一道期现价差门控:在入场信号触发后、风控审核前,检查 PURR 永续合约价格
与 PURR Spot 现货价格之间是否存在方向一致的套利空间。
- 存在套利空间 → 开仓放行
- 不存在套利空间 → 开仓拦截
2. 核心逻辑
2.1 价差计算
spread = (perp_mid - spot_mid) / spot_mid
perp_mid: PURR 永续合约 L2 订单簿中间价(已有 WS 缓存)spot_mid: PURR 现货 L2 订单簿中间价(新增 WS 订阅)
2.2 方向对齐规则
| 交易方向 | 要求的价差方向 | 经济含义 | 放行条件 |
|---|---|---|---|
| Long PURR | spread < 0(perp 折价) | perp 比 spot 便宜,预期向上收敛 | spread ≤ -min_spread |
| Short PURR | spread > 0(perp 溢价) | perp 比 spot 贵,预期向下收敛 | spread ≥ +min_spread |
min_spread默认 1%(0.01),通过环境变量配置。
2.3 拦截行为
不满足条件时:
- 记录 INFO 日志(含 perp 价、spot 价、spread、方向)
- 保存信号到 DB(action="rejected",reason="期现价差不足")
- 更新每日统计(entry_signals +1, trades_rejected +1)
- 发送拒绝告警
- 返回 False,不执行开仓
3. 作用范围
仅针对 PURR|HYPE 配对。判断依据:symbol_to_coin(symbol) == "PURR"。
其他配对不受此门控影响,直接跳过检查。
4. 价格源
4.1 Perp 中间价(已有)
已通过行情 WS 的 l2Book 频道订阅,缓存于 EnhancedWebSocketManager.latest_data["PURR:l2Book"]。
Executor 的 get_all_mids() → _get_all_mids_from_ws() 可直接读取。
4.2 Spot 中间价(新增)
Hyperliquid Spot 市场的 L2 订单簿使用 @{universe_index} 格式的 coin 标识符。
例如 PURR/USDC 在 spot universe 中的 index 为 N,则其 L2 Book 订阅 coin 为 @N。
解析流程(Executor 初始化时一次性完成):
spot_meta_and_asset_ctxs() API
↓
response[0]["universe"] → 找到 name 含 "PURR" 的条目 → 取其 index
↓
spot_coin_id = f"@{index}" # 例如 "@4"
WS 订阅(_build_market_subscriptions 追加):
{"type": "l2Book", "coin": "@4"}
数据缓存路径:
WS l2Book 推送 → EnhancedWebSocketManager._cache_latest_data()
→ latest_data["@4:l2Book"] = msg
读取路径(Executor 新增方法):
get_spot_mid_price("PURR")
→ 查 _spot_coin_ids["PURR"] = "@4"
→ 读 latest_data["@4:l2Book"]
→ _calculate_mid_from_l2_data(msg) → float
5. 文件变更清单
5.1 src/trading/config.py
TradingConfig 新增字段:
# 期现套利开仓门控
futures_spot_arb_enabled: bool = False
futures_spot_arb_min_spread_pct: float = 0.01 # 1%
load_trading_config() 新增环境变量读取:
futures_spot_arb_enabled=os.getenv(
"TRADING_FUTURES_SPOT_ARB_ENABLED", "false"
).lower() == "true",
futures_spot_arb_min_spread_pct=_env_float(
"TRADING_FUTURES_SPOT_ARB_MIN_SPREAD_PCT", "0.01"
),
环境变量:
| 变量名 | 默认值 | 说明 |
|---|---|---|
TRADING_FUTURES_SPOT_ARB_ENABLED |
false |
是否启用期现套利门控 |
TRADING_FUTURES_SPOT_ARB_MIN_SPREAD_PCT |
0.01 |
最小价差阈值(1%) |
5.2 src/trading/executor.py
__init__ 新增字段:
# Spot L2 coin ID 映射(coin → "@{index}",例如 {"PURR": "@4"})
self._spot_coin_ids: dict[str, str] = {}
initialize() 末尾追加: 当 futures_spot_arb_enabled=True 时调用 _resolve_spot_coin_ids()。
新增私有方法 _resolve_spot_coin_ids():
def _resolve_spot_coin_ids(self):
"""从 spot_meta_and_asset_ctxs 解析 spot coin 的 @index 标识符。
仅解析 PURR(当前唯一需要期现价差门控的币种)。
结果写入 self._spot_coin_ids,供 WS 订阅和价格读取使用。
"""
try:
data = retry_call(
lambda: self._info.spot_meta_and_asset_ctxs(),
description="Spot 元数据查询",
)
meta = data[0] if data else {}
for pair in meta.get("universe", []):
name = pair.get("name", "") # "PURR/USDC"
index = pair.get("index")
coin = name.split("/")[0] if "/" in name else name
if coin.upper() == "PURR" and index is not None:
self._spot_coin_ids["PURR"] = f"@{index}"
logger.info(
f"Spot coin ID 解析成功: PURR → @{index} (pair={name})"
)
return
logger.warning("Spot 元数据中未找到 PURR 配对")
except Exception as e:
logger.error(f"Spot coin ID 解析失败: {e}", exc_info=True)
新增公开方法 get_spot_coin_ids():
def get_spot_coin_ids(self) -> dict[str, str]:
"""返回 spot coin ID 映射,供 WS 订阅层使用。"""
return dict(self._spot_coin_ids)
新增公开方法 get_spot_mid_price(coin: str):
def get_spot_mid_price(self, coin: str) -> float:
"""从行情 WS 的 L2Book 缓存获取指定 coin 的 spot 中间价。
Args:
coin: 币种名(如 "PURR"),内部自动映射为 @index 格式
Returns:
spot 中间价,不可用时返回 0.0
"""
spot_id = self._spot_coin_ids.get(coin)
if not spot_id:
return 0.0
if not self._market_ws_manager:
return 0.0
cache_key = f"{spot_id}:l2Book"
try:
with self._market_ws_manager.latest_data_lock:
msg = self._market_ws_manager.latest_data.get(cache_key)
if msg is None:
return 0.0
return self._calculate_mid_from_l2_data(msg)
except Exception as e:
logger.debug(f"Spot L2 缓存读取失败 ({coin} → {spot_id}): {e}")
return 0.0
5.3 src/services/realtime_kline_service_base.py
_build_market_subscriptions() 末尾追加 Spot L2 订阅:
# Spot L2Book 订阅(期现套利门控)
if self._trading_orchestrator:
spot_coins = self._trading_orchestrator.get_spot_l2_coins()
for coin, spot_id in spot_coins.items():
subscriptions.append({"type": "l2Book", "coin": spot_id})
self.logger.info(
f"Spot L2Book 订阅已添加: {coin} → {spot_id}"
)
注意事项:
EnhancedWebSocketManager._cache_latest_data()已通用处理 l2Book 频道
(cache_key = f"{coin}:l2Book"),spot coin@N会被自动缓存为"@N:l2Book",无需改动。_get_all_mids_from_ws()中@N会出现在返回字典中,但不影响 perp 价格查询。
5.4 src/trading/orchestrator.py
新增方法 get_spot_l2_coins():
def get_spot_l2_coins(self) -> dict[str, str]:
"""返回需要订阅 Spot L2Book 的 coin ID 映射。
供 RealtimeKlineServiceBase._build_market_subscriptions() 调用,
在行情 WS 中追加 Spot L2Book 订阅。
"""
if self._executor is None:
return {}
return self._executor.get_spot_coin_ids()
on_entry_signal() 中追加门控(位于配对级频率限制之后、风控审查之前):
# 期现套利门控(仅 PURR|HYPE 配对)
if self._config.futures_spot_arb_enabled and symbol_to_coin(symbol) == "PURR":
perp_mids = self._executor.get_all_mids()
perp_price = perp_mids.get("PURR", 0.0)
spot_price = self._executor.get_spot_mid_price("PURR")
if perp_price > 0 and spot_price > 0:
spread = (perp_price - spot_price) / spot_price
min_spread = self._config.futures_spot_arb_min_spread_pct
pair_label = _pair_label((symbol, base_symbol or ""))
# Long → 要求 perp 折价(spread ≤ -min_spread)
# Short → 要求 perp 溢价(spread ≥ +min_spread)
arb_ok = (
(direction == "long" and spread <= -min_spread) or
(direction == "short" and spread >= min_spread)
)
if not arb_ok:
reason = (
f"期现价差不足 | {direction} | "
f"spread={spread:+.4%} (需{'≤' if direction == 'long' else '≥'}"
f"{-min_spread if direction == 'long' else min_spread:+.4%}) | "
f"perp=${perp_price:.6f} spot=${spot_price:.6f}"
)
logger.info(f"🚫 期现套利门控拦截 | {pair_label} | {reason}")
self._trade_repo.save_signal(
signal, "rejected", reason, self._config.network.value,
)
self._trade_repo.update_daily_stats(
network=self._config.network.value,
symbol=symbol,
base_symbol=base_symbol,
entry_signals=1,
trades_rejected=1,
)
self._send_rejected_alert(
signal, reason, adaptive_z,
self._executor.get_available_balance(),
)
return False
logger.info(
f"✅ 期现套利门控通过 | {pair_label} {direction} | "
f"spread={spread:+.4%} | perp=${perp_price:.6f} spot=${spot_price:.6f}"
)
else:
logger.warning(
f"⚠️ 期现套利门控跳过(价格不可用) | "
f"perp={'可用' if perp_price > 0 else '不可用'} "
f"spot={'可用' if spot_price > 0 else '不可用'}"
)
6. 数据流总览
初始化阶段
─────────
Executor.initialize()
│
├─ spot_meta_and_asset_ctxs() API
│ ↓
│ 解析 PURR spot universe index
│ ↓
│ _spot_coin_ids = {"PURR": "@4"}
│
▼
_build_market_subscriptions()
│
├─ 原有: {"type": "l2Book", "coin": "PURR"} ← perp L2
├─ 新增: {"type": "l2Book", "coin": "@4"} ← spot L2
│
▼
EnhancedWebSocketManager 连接主网 WS
运行阶段
─────────
WS 推送 l2Book (coin="PURR") → latest_data["PURR:l2Book"] ← perp
WS 推送 l2Book (coin="@4") → latest_data["@4:l2Book"] ← spot
信号触发
─────────
strategy._check_entry() → EntrySignal(direction="long", ...)
↓
orchestrator.on_entry_signal()
│
├─ 信号告警(不受门控影响)
├─ 全局频率限制
├─ 配对级频率限制
│
├─ 【新增】期现套利门控 ◄──────────────────────┐
│ │ │
│ ├─ get_all_mids()["PURR"] → perp_mid │
│ ├─ get_spot_mid_price("PURR") → spot_mid│
│ ├─ spread = (perp - spot) / spot │
│ │ │
│ ├─ Long + spread ≤ -1% → ✅ 放行 │
│ ├─ Short + spread ≥ +1% → ✅ 放行 │
│ └─ 否则 → 🚫 拦截 │
│ │
├─ 风控审查 │
└─ 执行开仓 │
7. 配置示例
# .env
TRADING_FUTURES_SPOT_ARB_ENABLED=true
TRADING_FUTURES_SPOT_ARB_MIN_SPREAD_PCT=0.01 # 1%
8. 风险与兜底
| 场景 | 处理 |
|---|---|
| Spot L2 缓存无数据(WS 断连/PURR spot 无流动性) | spot_price=0.0 → 跳过门控,输出 WARNING 日志,不阻塞开仓 |
| Perp L2 缓存无数据 | perp_price=0.0 → 跳过门控,同上 |
spot_meta_and_asset_ctxs 查不到 PURR |
初始化 WARNING → _spot_coin_ids 为空 → 运行时跳过门控 |
| 功能开关关闭 | futures_spot_arb_enabled=false → 完全跳过,零开销 |
| 非 PURR 配对 | symbol_to_coin(symbol) != "PURR" → 跳过 |
9. 日志示例
# 放行
✅ 期现套利门控通过 | PURR|HYPE long | spread=-1.52% | perp=$0.001230 spot=$0.001249
# 拦截
🚫 期现套利门控拦截 | PURR|HYPE | 期现价差不足 | long | spread=-0.32% (需≤-1.0000%) | perp=$0.001245 spot=$0.001249
# 价格不可用
⚠️ 期现套利门控跳过(价格不可用) | perp=可用 spot=不可用