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>
192 lines
7.5 KiB
Python
192 lines
7.5 KiB
Python
"""环境等价性验证:同一份切片,在 .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()
|