research: 影子交易器落在 Hummingbot 上,并修掉 Bitget 连接器的换根延迟

1m 腿的滑点余量只有几个 bp,所以要测的必须是生产路径的滑点——换个运行时
测出来的数就不作数。框架因此从「滑点已知后再定」提前到测量阶段就定为
Hummingbot(Spot/Perp 连接器均 v2.0,Bitget 是 Foundation Partner)。

新增 research/live/。前置测量:

- bench_compute.py 本机算力,1m 单币 0.318s、三币串行 1.38s
- venue_parity.py Binance 与 Bitget 同根信号重合仅 14.6~42.6%
- signal_sensitivity.py 0.25bp 扰动就换掉一半信号
- aggregate_robustness.py 但总体期望不降——脆的是信号身份,不是 alpha
- bitget_baseline.py 因此改用 Bitget 原生基线定预算:余量 BTC -0.13bp、
  ETH +4.02bp、SOL +2.92bp。BTC 本就为负,只作延迟测量的参照物

运行时选型:

- parity_env.py 容器与本机信号逐一相同(下标、中枢数、checksum 全等),
  容器内 0.26s/币反而更快。故 chanlun 直接挂载进容器,不必另起信号服务。
  装进现有 .venv 那条路走不通:Hummingbot 要 numba>=0.61.2 与
  aiohttp<3.14,与本机 Python 3.14 冲突
- latency_ccxt.py / latency_hummingbot.py / latency_compare.py 初测显示
  Hummingbot 比 ccxt.pro 慢约 1030ms,90 根逐根配对里 80~97% 更慢
- probe_ws_action.py 否掉「丢弃 snapshot」的猜测:换根首条就是 update
- probe_hb_vs_raw.py 与 latency_attribute.py 四路归因——容器网络 2~18ms、
  Hummingbot 处理 -10~-30ms,1350~1480ms 全落在解析方式上
- probe_ws_payload.py 定位根因:Bitget 换根会推一条带两根的消息
  [上一根, 新一根],而上游取 data["data"][0] 拿到的是上一根,新一根要等
  下一条单元素消息

修复:

- patched_candles.py 处理消息里的全部元素。不能简单改成 [-1]——那样上一根
  的收盘价会永远停在换根前约 1 秒的那次推送上,而信号对 0.25bp 都敏感
- verify_patch.py 60 根配对验证:拿回 1060~1090ms,与原始 WS 只差 5~14ms
  已贴理论下限,19 根已收盘 K 线 OHLCV 逐根未变。折算 ETH 省 0.54bp、
  SOL 省 0.42bp。此 bug 值得向上游反馈

影子交易器:

- shadow_hb.py 不下单,读连接器真实盘口按仓位吃单深度算成交价,与次根开盘价
  (回测 entry_delay=1 的口径)相减,分解成延迟漂移、盘口价差、深度冲击。
  盘口 10Hz 滚动缓冲 30 秒,把延迟变成自变量:每个信号记 0.5/1/2/5s 与实际
  算完时刻各一个滑点值,本机算得慢也不影响能读出的曲线
- shadow_signal.py 信号计算隔离到子进程。0.26s 是纯 CPU 且 chanlun 受 GIL
  限制,放进 asyncio 循环会把行情处理一起卡住
- shadow_report.py 首日延迟门槛与滑点曲线报表

不用 paper trade 测滑点:它的成交由 Hummingbot 自己的撮合模型模拟,
测出来是模型行为而非市场行为。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ubuntu
2026-08-27 23:51:57 +08:00
co-authored by Cursor
parent 66061f79a1
commit 7e339d2a54
44 changed files with 11946 additions and 0 deletions
+191
View File
@@ -0,0 +1,191 @@
"""环境等价性验证:同一份切片,在 .venv 与 Hummingbot 容器里必须算出同一组信号。
为什么要单独验这件事:容器里是 Python 3.13.14 + pandas 3.0.5 + numpy 2.4.6
本机 .venv 是 Python 3.14.4 + pandas 3.0.5 + numpy 2.5.2。pandas 同版本,
numpy 差一个小版本。信号已经被证明对 0.25bp 的数据扰动极度敏感(扰动会换掉
一半信号),所以浮点或 groupby 顺序上的任何细微差异都可能改变信号集合——
必须实测,不能推断。
用 --dump 先从 .venv 导出切片成 CSV,两个环境再读同一个 CSV,
这样数据来源差异为零,比出来的就是纯计算差异。
.venv/bin/python research/live/parity_env.py --dump # 导出切片
.venv/bin/python research/live/parity_env.py --run # 本机计算
docker run ... /app/parity_env.py --run --tag container # 容器计算
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
import warnings
from pathlib import Path
import numpy as np
import pandas as pd
warnings.filterwarnings("ignore")
for v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS"):
os.environ.setdefault(v, "1")
HERE = Path(__file__).resolve().parent
RESEARCH = HERE.parent
sys.path.insert(0, str(RESEARCH))
sys.path.insert(0, str(RESEARCH.parent))
LTF, HTF = "1m", "5m"
WINDOW = 2000 # step39 定下的窗口:命中率在此饱和
HTF_WINDOW = 800
SYMS = ("BTC", "ETH", "SOL")
def slice_dir() -> Path:
"""容器里挂在 /out,本机是 research/out。"""
p = Path("/out")
return p if p.is_dir() else RESEARCH / "out"
def dump() -> None:
"""从 Bitget 缓存导出末尾切片,供两个环境共用。
用 Bitget 而非 Binance 的数据:目标场地就是 Bitget,且这批缓存正是
bitget_baseline.py 算余量时用的同一份,口径可直接对上。
"""
d = slice_dir() / "parity_slices"
d.mkdir(parents=True, exist_ok=True)
cache = HERE / "cache"
for s in SYMS:
for tf, n in ((LTF, WINDOW), (HTF, HTF_WINDOW)):
src = cache / f"bitget_{s}_{tf}_30d.feather"
if not src.exists():
src = cache / f"bitget_{s}_{tf}_210d.feather"
if not src.exists():
print(f" {s} {tf}: 无缓存 {src.name},跳过")
continue
df = pd.read_feather(src)
if df is None or df.empty:
print(f" {s} {tf}: 缓存为空,跳过")
continue
out = df.tail(n).reset_index(drop=True)
f = d / f"{s}_{tf}.csv"
# 用 float 全精度写出,避免导出环节就引入舍入差异
out.to_csv(f, index=False, float_format="%.10f")
print(f" {s} {tf}: {len(out)} 根 -> {f.name}")
print(f"\n切片目录 {d}")
def signals(df_ltf: pd.DataFrame, df_htf: pd.DataFrame) -> dict:
"""复用 step39 的时点重建口径,返回信号下标与耗时。"""
from chanlun import TF_DF
from lib.fast_bsp3 import find_fast_bsp3
from lib.nested_level import build_htf_zones
t0 = time.perf_counter()
chan = TF_DF(df_ltf, 1, LTF)
cdf = chan.dataframe
zones = build_htf_zones(cdf, LTF, chan=chan)
raw: list[int] = []
if not zones.empty:
sig = find_fast_bsp3(cdf, zones.reset_index(drop=True))
if sig is not None and not sig.empty:
col = "idx" if "idx" in sig.columns else sig.columns[0]
raw = sorted(int(x) for x in sig[col].to_numpy())
dt = time.perf_counter() - t0
# 中枢边界是信号定义的核心中间量,一并指纹化:
# 若信号相同但中枢不同,说明差异只是被过滤掉了,仍是隐患
zsig = None
if not zones.empty:
num = zones.select_dtypes(include=[np.number])
zsig = float(np.nansum(num.to_numpy(dtype=float)))
return {"n_bars": int(len(df_ltf)), "n_signals": len(raw),
"signal_idx": raw, "zone_checksum": zsig,
"n_zones": int(len(zones)), "compute_s": round(dt, 4)}
def run(tag: str) -> None:
d = slice_dir() / "parity_slices"
res = {"tag": tag,
"python": sys.version.split()[0],
"pandas": pd.__version__,
"numpy": np.__version__}
per = {}
for s in SYMS:
f_ltf, f_htf = d / f"{s}_{LTF}.csv", d / f"{s}_{HTF}.csv"
if not f_ltf.exists():
print(f" {s}: 缺切片 {f_ltf}")
continue
df_ltf = pd.read_csv(f_ltf)
df_htf = pd.read_csv(f_htf) if f_htf.exists() else pd.DataFrame()
# date 存的是时间戳字符串,chanlun 的 kline builder 要真 datetime
# timestamp 是毫秒整数,保持数值不动
for df in (df_ltf, df_htf):
if not df.empty and "date" in df.columns:
df["date"] = pd.to_datetime(df["date"], utc=True)
r = signals(df_ltf, df_htf)
per[s] = r
print(f" {s}: {r['n_signals']} 信号 · {r['n_zones']} 中枢 · "
f"{r['compute_s']}s · zone_checksum={r['zone_checksum']}")
res["per_symbol"] = per
out = slice_dir() / f"parity_env_{tag}.json"
out.write_text(json.dumps(res, indent=2, ensure_ascii=False))
print(f"\n[{tag}] python {res['python']} pandas {res['pandas']} "
f"numpy {res['numpy']}")
print(f"产物写入 {out}")
def compare(a: str, b: str) -> None:
d = slice_dir()
ra = json.loads((d / f"parity_env_{a}.json").read_text())
rb = json.loads((d / f"parity_env_{b}.json").read_text())
print(f"{a}: python {ra['python']} pandas {ra['pandas']} numpy {ra['numpy']}")
print(f"{b}: python {rb['python']} pandas {rb['pandas']} numpy {rb['numpy']}")
print("\n########## 信号集合是否逐一相同 ##########")
ok = True
for s in SYMS:
pa, pb = ra["per_symbol"].get(s), rb["per_symbol"].get(s)
if not pa or not pb:
print(f" {s}: 缺结果,跳过")
continue
same_sig = pa["signal_idx"] == pb["signal_idx"]
same_zone = pa["n_zones"] == pb["n_zones"]
# checksum 是浮点求和,允许相对 1e-9 的差;超出即为真实分歧
za, zb = pa["zone_checksum"], pb["zone_checksum"]
same_cs = (za is None and zb is None) or (
za is not None and zb is not None
and abs(za - zb) <= 1e-9 * max(1.0, abs(za)))
ok = ok and same_sig and same_zone and same_cs
print(f" {s}: 信号 {'一致' if same_sig else '不一致'}"
f"{pa['n_signals']} vs {pb['n_signals']})· "
f"中枢 {'一致' if same_zone else '不一致'}"
f"{pa['n_zones']} vs {pb['n_zones']})· "
f"checksum {'一致' if same_cs else '不一致'}")
if not same_sig:
sa, sb = set(pa["signal_idx"]), set(pb["signal_idx"])
print(f"{a} 有: {sorted(sa - sb)[:10]}")
print(f"{b} 有: {sorted(sb - sa)[:10]}")
print(f" 耗时 {pa['compute_s']}s vs {pb['compute_s']}s")
print(f"\n结论:{'两环境等价,可直接在容器内算信号' if ok else '存在分歧,需把信号计算固定在单一环境'}")
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--dump", action="store_true")
ap.add_argument("--run", action="store_true")
ap.add_argument("--tag", default="venv")
ap.add_argument("--compare", nargs=2, metavar=("A", "B"))
args = ap.parse_args()
if args.dump:
dump()
if args.run:
run(args.tag)
if args.compare:
compare(*args.compare)
if __name__ == "__main__":
main()