加跨地部署脚本与站点对比,数据全表加 site 列
目的是在第二台机器(AWS)上跑一套完全相同的采集,看不同地理位置的数据差异。 滑点里最大的一项是延迟漂移,而延迟含网络传输,所以机房选址是可优化参数。 数据侧两处必需改动: - 全部输出加 site 列(三张 CSV 加 gzip 里的盘口与成交流)。没有这一列,两台 机器的数据合起来就分不清来源。为免四处 writerow 漏加一处产生静默空值, 改在 _SiteWriter 里统一注入。 - start.sh 在时钟未同步或偏移超 10ms 时**拒绝启动**。所有延迟数字都是 「本地时钟 − 交易所 K 线收盘」,时钟偏 50ms 就全部同向偏 50ms,且不报错, 只会让跨地对比得出一个干净且完全错误的结论。 deploy/ 下四个文件:setup.sh(docker + chrony + 拉镜像)、start.sh(校验时钟、 写运行元数据、起容器)、status.sh(健康速查)、README。运行元数据记 git commit、 镜像摘要、时钟偏移——两地数据对不上时,这三项任一不同都足以解释差异。 compare_sites.py 做配对对比:只取各站都有的 K 线(不取交集可能在比不同时段, 而延迟对市场活跃度敏感),并报配对差的符号占比而非两个中位数相减。已用注入 120ms 的合成数据验证能精确还原。另有一条自检:同一固定延迟点上两站漂移应当 相同——漂移是市场性质,若也差很多则先查时钟与时段对齐。 status.sh 里按列名取字段下标而非写死数字:加 site 列时字段整体右移过一次, 写死 $8 会静默变成读 lag_signal_ms 而非 lag_data_ms。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
"""对比两个(或多个)采集站点的数据差异。
|
||||
|
||||
部署第二台机器的目的就是这个:延迟是「本地接收 − 交易所 K 线收盘」,直接
|
||||
取决于机器到交易所的网络距离,换个地理位置这个数会变。而滑点里最大的一项
|
||||
是延迟漂移,所以站点选址本身就是一个可优化的参数。
|
||||
|
||||
## 判读前必须先看的两件事
|
||||
|
||||
1. **时钟。** 两台机器的时钟偏移差多少,延迟对比就凭空差多少,且不报错。
|
||||
run_meta_*.json 里有各站启动时的 chrony 偏移,先确认都在 10ms 内。
|
||||
2. **同期。** 只比两站都有数据的那些 kline_ts。不取交集的话,比的可能是
|
||||
不同时段的市场状态,而延迟对市场活跃度是敏感的。
|
||||
|
||||
## 用法
|
||||
|
||||
把各站的 research/out/ 收到一处(文件名相同会覆盖,所以先按站点改名或
|
||||
分目录放),然后:
|
||||
|
||||
python research/live/compare_sites.py --glob 'collected/*/shadow_latency.csv'
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def load(patterns: list[str]) -> pd.DataFrame:
|
||||
paths: list[str] = []
|
||||
for p in patterns:
|
||||
paths.extend(sorted(glob.glob(p)))
|
||||
if not paths:
|
||||
raise SystemExit(f"没有匹配到文件:{patterns}")
|
||||
frames = []
|
||||
for p in paths:
|
||||
df = pd.read_csv(p)
|
||||
if "site" not in df.columns:
|
||||
raise SystemExit(
|
||||
f"{p} 没有 site 列。这是 2026-08-28 之前采的旧数据,"
|
||||
f"无法确定来源,不能用于跨地对比")
|
||||
df["_src"] = p
|
||||
frames.append(df)
|
||||
out = pd.concat(frames, ignore_index=True)
|
||||
print(f"读入 {len(paths)} 个文件、{len(out):,} 行、"
|
||||
f"站点 {sorted(out['site'].unique())}")
|
||||
return out
|
||||
|
||||
|
||||
def show_meta(out_dirs: list[Path]) -> None:
|
||||
print("\n########## 一、运行元数据 ##########")
|
||||
metas = []
|
||||
for d in out_dirs:
|
||||
metas.extend(sorted(d.glob("run_meta_*.json")))
|
||||
if not metas:
|
||||
print(" 没找到 run_meta_*.json。时钟偏移与代码版本无法核对——")
|
||||
print(" 两站数据若有差异,分不清是地理位置还是环境不同造成的")
|
||||
return
|
||||
rows = []
|
||||
for m in metas:
|
||||
try:
|
||||
rows.append(json.loads(m.read_text()))
|
||||
except Exception as e:
|
||||
print(f" {m.name} 读取失败:{e!r}")
|
||||
if not rows:
|
||||
return
|
||||
df = pd.DataFrame(rows)
|
||||
keep = [c for c in ("site", "clock_offset_ms", "git_commit", "git_dirty",
|
||||
"image_digest", "nproc", "mem_gb", "tz",
|
||||
"started_utc") if c in df.columns]
|
||||
print(df[keep].to_string(index=False))
|
||||
if "clock_offset_ms" in df and df["clock_offset_ms"].notna().any():
|
||||
o = df["clock_offset_ms"].astype(float)
|
||||
spread = float(o.max() - o.min())
|
||||
flag = "" if spread < 5 else " ⚠ 这个差会直接叠加到延迟对比上"
|
||||
print(f"\n 站点间时钟偏移极差 {spread:.3f}ms{flag}")
|
||||
if "git_commit" in df and df["git_commit"].nunique() > 1:
|
||||
print(" ⚠ 各站代码版本不同,差异可能来自代码而非地理位置")
|
||||
if "git_dirty" in df and df["git_dirty"].any():
|
||||
print(" ⚠ 有站点带未提交改动,无法复现")
|
||||
|
||||
|
||||
def compare_latency(df: pd.DataFrame, col: str = "lag_data_ms") -> None:
|
||||
"""延迟对比。只取各站都有的 kline_ts,避免比到不同时段。"""
|
||||
if col not in df.columns:
|
||||
print(f"\n没有 {col} 列")
|
||||
return
|
||||
sites = sorted(df["site"].unique())
|
||||
if len(sites) < 2:
|
||||
print(f"\n只有一个站点({sites[0]}),无从对比。"
|
||||
f"等第二台机器的数据到齐")
|
||||
return
|
||||
|
||||
print(f"\n########## 二、到达延迟({col}) ##########")
|
||||
print("\n 全量(各站各自的样本,时段可能不同)")
|
||||
for s in sites:
|
||||
x = df[df["site"] == s][col].dropna().astype(float)
|
||||
print(f" {s:<16} n={len(x):>6} 中位 {x.median():>7.0f}ms "
|
||||
f"P90 {np.percentile(x, 90):>7.0f}ms "
|
||||
f"P99 {np.percentile(x, 99):>7.0f}ms")
|
||||
|
||||
# 取交集:同一根 K 线在各站都有记录
|
||||
key = ["sym", "kline_ts"]
|
||||
piv = df.pivot_table(index=key, columns="site", values=col,
|
||||
aggfunc="first")
|
||||
both = piv.dropna()
|
||||
if both.empty:
|
||||
print("\n 各站没有共同的 K 线。可能是采集时段不重叠,")
|
||||
print(" 或 kline_ts 对不上(先查两站时区与时钟)")
|
||||
return
|
||||
print(f"\n 同根对比({len(both):,} 根 K 线,各站都有)")
|
||||
for s in sites:
|
||||
x = both[s].astype(float)
|
||||
print(f" {s:<16} 中位 {x.median():>7.0f}ms "
|
||||
f"P90 {np.percentile(x, 90):>7.0f}ms")
|
||||
base = sites[0]
|
||||
for s in sites[1:]:
|
||||
d = (both[s] - both[base]).astype(float)
|
||||
# 配对差的符号检验:同根配对消掉了市场状态,比两个中位数相减干净
|
||||
n_pos = int((d > 0).sum())
|
||||
print(f"\n {s} − {base}:中位差 {d.median():+.0f}ms "
|
||||
f"· 均值差 {d.mean():+.0f}ms")
|
||||
print(f" {s} 更慢的根占 {n_pos / len(d) * 100:.1f}%"
|
||||
f"(50% 表示无系统性差异)")
|
||||
for sym in sorted(both.index.get_level_values("sym").unique()):
|
||||
ds = d.xs(sym, level="sym")
|
||||
print(f" {sym:<5} 中位差 {ds.median():+7.0f}ms (n={len(ds)})")
|
||||
|
||||
|
||||
def compare_drift(patterns: list[str]) -> None:
|
||||
"""漂移对比。延迟差若能兑换成漂移差,才是钱上的差别。"""
|
||||
paths: list[str] = []
|
||||
for p in patterns:
|
||||
paths.extend(sorted(glob.glob(p)))
|
||||
if not paths:
|
||||
return
|
||||
frames = []
|
||||
for p in paths:
|
||||
d = pd.read_csv(p)
|
||||
if "site" in d.columns:
|
||||
frames.append(d)
|
||||
if not frames:
|
||||
return
|
||||
df = pd.concat(frames, ignore_index=True)
|
||||
if df["site"].nunique() < 2:
|
||||
return
|
||||
print("\n########## 三、延迟漂移(无条件,每根都记) ##########")
|
||||
for label in sorted(df["delay_label"].dropna().unique()):
|
||||
sub = df[df["delay_label"] == label]
|
||||
line = f" {label:>7}"
|
||||
for s in sorted(sub["site"].unique()):
|
||||
x = sub[sub["site"] == s]["drift_bp_long"].dropna().astype(float)
|
||||
if len(x):
|
||||
line += f" · {s} {x.abs().median():.3f}bp(n={len(x)})"
|
||||
print(line)
|
||||
print("\n 同一个固定延迟点上,两站的漂移应当几乎相同——漂移是市场性质,")
|
||||
print(" 与机器位置无关。若差异明显,先查时钟与采集时段是否对齐")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--glob", action="append", default=None,
|
||||
help="shadow_latency.csv 的路径模式,可给多次")
|
||||
ap.add_argument("--drift-glob", action="append", default=None)
|
||||
ap.add_argument("--meta-dir", action="append", default=None)
|
||||
a = ap.parse_args()
|
||||
|
||||
lat = a.glob or ["research/out/shadow_latency.csv",
|
||||
"collected/*/shadow_latency.csv"]
|
||||
drf = a.drift_glob or ["research/out/shadow_drift.csv",
|
||||
"collected/*/shadow_drift.csv"]
|
||||
metas = [Path(p) for p in (a.meta_dir or ["research/out", "collected"])]
|
||||
|
||||
show_meta([p for p in metas if p.is_dir()])
|
||||
df = load(lat)
|
||||
compare_latency(df)
|
||||
compare_drift(drf)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,97 @@
|
||||
# 影子采集器部署
|
||||
|
||||
在第二台机器上跑一套完全相同的采集,用来看不同地理位置的数据差异。
|
||||
|
||||
## 为什么要跨地采集
|
||||
|
||||
滑点里最大的一项是**延迟漂移**:从 K 线收盘到实际下单之间,价格已经走掉的
|
||||
那部分。而延迟 = 交易所出包 + 网络传输 + 本地处理。本机(新加坡)实测到达
|
||||
延迟中位 350~650ms,其中网络传输占多少、换个机房能压掉多少,只有实测。
|
||||
|
||||
延迟压下来直接等于滑点下降,所以机房选址是个可优化参数,不是固定成本。
|
||||
|
||||
## 三个必须一致,一个必须不同
|
||||
|
||||
必须一致,否则差异分不清是地理位置还是环境造成的:
|
||||
|
||||
- **代码版本**(`git_commit`)——同一个 commit
|
||||
- **镜像摘要**(`image_digest`)——同一个 hummingbot 镜像
|
||||
- **时钟**——两台都同步到 NTP,偏移都在 10ms 内
|
||||
|
||||
必须不同:
|
||||
|
||||
- **`SHADOW_SITE`**——写进每一行数据,是合并后区分来源的唯一依据
|
||||
|
||||
`start.sh` 会把这四项连同内核、核数、内存一起写进
|
||||
`research/out/run_meta_<site>.json`。两地数据对不上时先看这个文件。
|
||||
|
||||
## 时钟为什么是硬门槛
|
||||
|
||||
所有延迟数字都是「本地时钟 − 交易所 K 线收盘时间戳」。时钟偏 50ms,全部
|
||||
延迟就同向偏 50ms,而且**不会有任何报错**——只会让跨地对比得出一个干净、
|
||||
自信、且完全错误的结论。所以 `start.sh` 在时钟未同步或偏移超阈值时直接
|
||||
拒绝启动,而不是打个警告了事。
|
||||
|
||||
## 步骤
|
||||
|
||||
在新机器上:
|
||||
|
||||
```bash
|
||||
git clone ssh://jack@git.jackyu66.com:2222/jack/chan.git
|
||||
cd chan && git checkout chan
|
||||
|
||||
bash research/live/deploy/setup.sh # 装 docker + chrony,拉镜像
|
||||
SHADOW_SITE=aws-tokyo bash research/live/deploy/start.sh
|
||||
```
|
||||
|
||||
确认健康:
|
||||
|
||||
```bash
|
||||
bash research/live/deploy/status.sh
|
||||
```
|
||||
|
||||
启动日志里应当看到:
|
||||
|
||||
```
|
||||
[补丁] 覆盖生效:基类取首元素 … 本地取末元素 …
|
||||
成交流已挂 ['BTC', 'ETH', 'SOL']
|
||||
就绪 3.0s · 1m [2001, 2001, 2001] 根 · 5m [801, 801, 801] 根
|
||||
```
|
||||
|
||||
第一行尤其重要。上游 Bitget 连接器的换根解析有 bug(只取多根消息的首元素),
|
||||
补丁把它修掉拿回约 1.06 秒。补丁若失效是静默的——不崩不报错,只是延迟悄悄
|
||||
退回 1.4 秒,所以启动时做了断言。
|
||||
|
||||
## 对比
|
||||
|
||||
把两站的 `research/out/` 收到一处(同名文件会覆盖,所以分目录放):
|
||||
|
||||
```bash
|
||||
mkdir -p collected/sg collected/aws
|
||||
rsync -av sg-box:chan/research/out/ collected/sg/
|
||||
rsync -av aws-box:chan/research/out/ collected/aws/
|
||||
|
||||
python research/live/compare_sites.py \
|
||||
--glob 'collected/*/shadow_latency.csv' \
|
||||
--drift-glob 'collected/*/shadow_drift.csv' \
|
||||
--meta-dir collected/sg --meta-dir collected/aws
|
||||
```
|
||||
|
||||
对比脚本做两件事值得说明:
|
||||
|
||||
- **只取各站都有的 K 线**做配对比较。不取交集就可能在比不同时段,而延迟对
|
||||
市场活跃度敏感。
|
||||
- 报**配对差的符号占比**而不只是两个中位数相减。同根配对消掉了市场状态,
|
||||
「A 比 B 慢的根占多少」比「两个中位数差多少」更能说明有无系统性差异。
|
||||
|
||||
判读上有一条自检:**同一个固定延迟点上,两站的漂移应当几乎相同**——漂移是
|
||||
市场性质,与机器位置无关。若漂移也差很多,先怀疑时钟或时段没对齐,而不是
|
||||
急着下结论。
|
||||
|
||||
## 资源占用
|
||||
|
||||
本机实测:内存约 1.5GB(两个计算进程 + 盘口缓冲),CPU 单核不满。
|
||||
盘口与成交流落盘约 15MB/天(gzip)。一周 168 小时的量级在百 MB 内。
|
||||
|
||||
`--workers 2` 是因为信号计算走独立进程池、不能阻塞事件循环。核数少的机型
|
||||
可以给 1,但要看心跳里的 `compute_ms`:若接近 60 秒就会开始堆积。
|
||||
Executable
+87
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env bash
|
||||
# 影子采集器的机器初始化。幂等,可重复跑。
|
||||
#
|
||||
# 用途:在另一台机器(如 AWS)上部署一套完全相同的采集,用来看不同地理位置
|
||||
# 的数据采集有无差异。要比的主要是延迟——「本地接收 − K线收盘」这个量直接
|
||||
# 取决于机器到交易所的网络距离。
|
||||
#
|
||||
# 时钟同步是硬前置条件,不是可选项。所有延迟数字都是本地时钟减交易所时间戳,
|
||||
# 时钟偏 50ms 就等于所有延迟凭空多(或少)50ms,而且不会有任何报错。所以这里
|
||||
# 装并启用 NTP,start.sh 里还会再校验一次、不合格拒绝启动。
|
||||
#
|
||||
# bash research/live/deploy/setup.sh
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE="${SHADOW_IMAGE:-hummingbot/hummingbot:latest}"
|
||||
|
||||
say() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
|
||||
|
||||
say "系统信息"
|
||||
uname -a
|
||||
echo "内存:$(free -g | awk '/^Mem:/{print $2"GB 总 / "$7"GB 可用"}')"
|
||||
echo "CPU:$(nproc) 核"
|
||||
|
||||
say "安装 docker"
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
echo "已有 docker $(docker --version)"
|
||||
else
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq ca-certificates curl gnupg
|
||||
sudo install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
|
||||
| sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
sudo chmod a+r /etc/apt/keyrings/docker.gpg
|
||||
. /etc/os-release
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
|
||||
https://download.docker.com/linux/ubuntu ${VERSION_CODENAME} stable" \
|
||||
| sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq docker-ce docker-ce-cli containerd.io
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
# Amazon Linux 2023
|
||||
sudo dnf install -y -q docker
|
||||
sudo systemctl enable --now docker
|
||||
else
|
||||
echo "不认识的包管理器,请手动装 docker" >&2
|
||||
exit 1
|
||||
fi
|
||||
sudo usermod -aG docker "$USER" || true
|
||||
echo "已装 docker。若本次 shell 无权限,重新登录后再跑 start.sh"
|
||||
fi
|
||||
|
||||
say "启用时钟同步(延迟测量的前置条件)"
|
||||
if command -v chronyc >/dev/null 2>&1; then
|
||||
echo "已有 chrony"
|
||||
elif command -v apt-get >/dev/null 2>&1; then
|
||||
sudo apt-get install -y -qq chrony
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
sudo dnf install -y -q chrony
|
||||
fi
|
||||
sudo systemctl enable --now chrony 2>/dev/null \
|
||||
|| sudo systemctl enable --now chronyd 2>/dev/null || true
|
||||
sleep 3
|
||||
if command -v chronyc >/dev/null 2>&1; then
|
||||
chronyc tracking | grep -E 'Reference ID|System time|Last offset' || true
|
||||
else
|
||||
timedatectl 2>/dev/null | grep -i synchron || true
|
||||
fi
|
||||
|
||||
say "拉镜像 $IMAGE"
|
||||
docker pull "$IMAGE"
|
||||
docker image inspect "$IMAGE" --format '摘要 {{index .RepoDigests 0}}' 2>/dev/null || true
|
||||
|
||||
say "准备输出目录"
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
||||
mkdir -p "$REPO_ROOT/research/out"
|
||||
echo "$REPO_ROOT/research/out"
|
||||
|
||||
say "完成"
|
||||
cat <<'EOF'
|
||||
下一步:
|
||||
|
||||
SHADOW_SITE=aws-tokyo bash research/live/deploy/start.sh
|
||||
|
||||
SHADOW_SITE 必须显式给且两台机器不能相同——它会写进每一行数据,
|
||||
是之后区分数据来源的唯一依据。
|
||||
EOF
|
||||
Executable
+115
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env bash
|
||||
# 启动影子采集器。跑之前先 setup.sh。
|
||||
#
|
||||
# SHADOW_SITE=aws-tokyo bash research/live/deploy/start.sh
|
||||
# SHADOW_SITE=aws-tokyo HOURS=168 WORKERS=2 bash research/live/deploy/start.sh
|
||||
#
|
||||
# 为什么 SHADOW_SITE 必填:它写进每一行数据,是两台机器的数据合起来之后
|
||||
# 唯一的来源区分。缺了就只能靠文件路径猜,一合并就分不清了。
|
||||
#
|
||||
# 为什么时钟不同步就拒绝启动:所有延迟数字都是「本地时钟 − 交易所 K 线收盘
|
||||
# 时间戳」。时钟偏 50ms,全部延迟就凭空偏 50ms,而这**不会有任何报错**,
|
||||
# 只会让跨地对比得出一个完全错误的结论。这类静默错误必须在启动就挡掉。
|
||||
set -euo pipefail
|
||||
|
||||
NAME="${NAME:-shadow}"
|
||||
IMAGE="${SHADOW_IMAGE:-hummingbot/hummingbot:latest}"
|
||||
HOURS="${HOURS:-168}"
|
||||
WORKERS="${WORKERS:-2}"
|
||||
MAX_OFFSET_MS="${MAX_OFFSET_MS:-10}"
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
||||
OUT="$REPO_ROOT/research/out"
|
||||
|
||||
die() { printf '\033[31m错误:%s\033[0m\n' "$*" >&2; exit 1; }
|
||||
say() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
|
||||
|
||||
[[ -n "${SHADOW_SITE:-}" ]] || die "必须设 SHADOW_SITE,例如 SHADOW_SITE=aws-tokyo。
|
||||
它写进每一行数据,是跨地对比时区分来源的唯一依据。"
|
||||
|
||||
say "站点 $SHADOW_SITE"
|
||||
|
||||
say "校验时钟同步"
|
||||
offset_ms=""
|
||||
if command -v chronyc >/dev/null 2>&1; then
|
||||
if ! chronyc tracking >/dev/null 2>&1; then
|
||||
die "chrony 没在跑。先 sudo systemctl start chrony(或 chronyd)"
|
||||
fi
|
||||
# System time 那行形如 "0.000058703 seconds fast of NTP time"
|
||||
line="$(chronyc tracking | grep '^System time' || true)"
|
||||
secs="$(awk '{print $4}' <<<"$line")"
|
||||
offset_ms="$(awk -v s="$secs" 'BEGIN{printf "%.3f", s*1000}')"
|
||||
echo "$line"
|
||||
leap="$(chronyc tracking | awk -F': *' '/Leap status/{print $2}')"
|
||||
[[ "$leap" == "Normal" ]] || die "chrony leap status = $leap,尚未收敛。等几分钟再试"
|
||||
over="$(awk -v o="$offset_ms" -v m="$MAX_OFFSET_MS" 'BEGIN{print (o>m)?1:0}')"
|
||||
[[ "$over" == "0" ]] || die "时钟偏移 ${offset_ms}ms 超过阈值 ${MAX_OFFSET_MS}ms。
|
||||
所有延迟测量都会同向偏这么多且不报错,跨地对比会得出错误结论。
|
||||
先等 chrony 收敛,或调 MAX_OFFSET_MS(不建议)。"
|
||||
echo "偏移 ${offset_ms}ms,在 ${MAX_OFFSET_MS}ms 阈值内"
|
||||
elif command -v timedatectl >/dev/null 2>&1; then
|
||||
timedatectl | grep -qi 'synchronized: yes' \
|
||||
|| die "系统时钟未同步。装 chrony:见 setup.sh"
|
||||
echo "timedatectl 报已同步(无 chronyc,拿不到具体偏移)"
|
||||
else
|
||||
die "既无 chronyc 也无 timedatectl,无法确认时钟。装 chrony 后再启动"
|
||||
fi
|
||||
|
||||
say "检查镜像"
|
||||
docker image inspect "$IMAGE" >/dev/null 2>&1 || die "没有镜像 $IMAGE,先跑 setup.sh"
|
||||
digest="$(docker image inspect "$IMAGE" --format '{{if .RepoDigests}}{{index .RepoDigests 0}}{{end}}' 2>/dev/null || true)"
|
||||
|
||||
say "停掉旧容器"
|
||||
docker rm -f "$NAME" >/dev/null 2>&1 || true
|
||||
|
||||
mkdir -p "$OUT"
|
||||
|
||||
# 运行元数据。两地数据对不上时,先看这个文件——镜像摘要、代码版本、时钟偏移
|
||||
# 三者任一不同都足以解释差异,不必去猜。
|
||||
meta="$OUT/run_meta_${SHADOW_SITE}.json"
|
||||
cat >"$meta" <<EOF
|
||||
{
|
||||
"site": "$SHADOW_SITE",
|
||||
"hostname": "$(hostname)",
|
||||
"started_utc": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||
"tz": "$(date +%Z%z)",
|
||||
"clock_offset_ms": ${offset_ms:-null},
|
||||
"git_commit": "$(git -C "$REPO_ROOT" rev-parse --short HEAD 2>/dev/null || echo unknown)",
|
||||
"git_dirty": $(git -C "$REPO_ROOT" diff --quiet 2>/dev/null && echo false || echo true),
|
||||
"image": "$IMAGE",
|
||||
"image_digest": "${digest:-unknown}",
|
||||
"hours": $HOURS,
|
||||
"workers": $WORKERS,
|
||||
"kernel": "$(uname -r)",
|
||||
"nproc": $(nproc),
|
||||
"mem_gb": $(free -g | awk '/^Mem:/{print $2}')
|
||||
}
|
||||
EOF
|
||||
echo "元数据已写 $meta"
|
||||
|
||||
say "启动容器 $NAME"
|
||||
docker run -d --name "$NAME" -w /home/hummingbot \
|
||||
--restart unless-stopped \
|
||||
-e PYTHONPATH=/home/hummingbot:/repo/research:/repo/research/live:/repo \
|
||||
-e SHADOW_SITE="$SHADOW_SITE" \
|
||||
-v "$REPO_ROOT:/repo:ro" \
|
||||
-v "$OUT:/out" \
|
||||
--entrypoint /opt/conda/envs/hummingbot/bin/python \
|
||||
"$IMAGE" /repo/research/live/shadow_hb.py \
|
||||
--hours "$HOURS" --workers "$WORKERS" >/dev/null
|
||||
|
||||
echo "已启动。等启动自检(补丁断言 + 历史回填,约 60 秒)…"
|
||||
sleep 45
|
||||
docker logs "$NAME" 2>&1 | tail -12
|
||||
|
||||
cat <<EOF
|
||||
|
||||
看日志: docker logs -f $NAME
|
||||
看状态: bash research/live/deploy/status.sh
|
||||
停止: docker rm -f $NAME
|
||||
|
||||
启动日志里应能看到:
|
||||
[补丁] 覆盖生效 —— 换根解析补丁有效(缺了会静默慢 1.06 秒)
|
||||
成交流已挂 [...] —— maker 成交率要用
|
||||
就绪 … 根 —— 历史回填完成
|
||||
EOF
|
||||
Executable
+67
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
# 采集器健康速查。跨地部署时两台都跑一遍,对着看。
|
||||
set -uo pipefail
|
||||
|
||||
NAME="${NAME:-shadow}"
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
||||
OUT="$REPO_ROOT/research/out"
|
||||
|
||||
say() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
|
||||
|
||||
say "容器"
|
||||
docker ps -a --filter "name=^${NAME}$" \
|
||||
--format 'table {{.Names}}\t{{.Status}}\t{{.RunningFor}}' || true
|
||||
|
||||
say "时钟"
|
||||
if command -v chronyc >/dev/null 2>&1; then
|
||||
chronyc tracking | grep -E 'System time|Last offset|Leap status'
|
||||
fi
|
||||
|
||||
say "最近心跳"
|
||||
docker logs "$NAME" 2>&1 | grep '\[心跳\]' | tail -3 || echo "还没到第一次心跳(每 5 分钟一次)"
|
||||
|
||||
say "告警与异常"
|
||||
docker logs "$NAME" 2>&1 \
|
||||
| grep -E '⚠|错误|失效|损坏|停滞|Traceback|退化' | tail -10 \
|
||||
|| echo "无"
|
||||
|
||||
say "输出规模"
|
||||
for f in shadow_latency.csv shadow_drift.csv shadow_signals.csv; do
|
||||
p="$OUT/$f"
|
||||
if [[ -s "$p" ]]; then
|
||||
printf ' %-22s %8d 行\n' "$f" "$(( $(wc -l <"$p") - 1 ))"
|
||||
elif [[ -f "$p" ]]; then
|
||||
# 已建但表头还没冲刷:signals 只在有信号时才 flush,门控后每天仅 4~6 个
|
||||
printf ' %-22s %8s\n' "$f" "0(待首条)"
|
||||
else
|
||||
printf ' %-22s %8s\n' "$f" "无"
|
||||
fi
|
||||
done
|
||||
for f in shadow_books.jsonl.gz shadow_tape.jsonl.gz; do
|
||||
p="$OUT/$f"
|
||||
[[ -f "$p" ]] && printf ' %-22s %8s\n' "$f" "$(du -h "$p" | cut -f1)" \
|
||||
|| printf ' %-22s %8s\n' "$f" "无"
|
||||
done
|
||||
|
||||
say "各站点行数(确认 site 列生效)"
|
||||
p="$OUT/shadow_latency.csv"
|
||||
if [[ -f "$p" ]]; then
|
||||
awk -F, 'NR>1{c[$1]++} END{for(s in c) printf " %-16s %8d 行\n", s, c[s]}' "$p"
|
||||
else
|
||||
echo " 尚无数据"
|
||||
fi
|
||||
|
||||
say "到达延迟中位(本站,跨地对比的主指标)"
|
||||
# 按列名取下标,不写死数字:加了 site 列之后字段整体右移过一次,
|
||||
# 写死 $8 会静默变成读 lag_signal_ms
|
||||
if [[ -s "$p" ]]; then
|
||||
for sym in BTC ETH SOL; do
|
||||
med=$(awk -F, -v s="$sym" '
|
||||
NR==1 { for (i=1;i<=NF;i++) { if ($i=="sym") si=i; if ($i=="lag_data_ms") li=i } ; next }
|
||||
$si==s && $li!="" { print $li }' "$p" | sort -n | awk '
|
||||
{ v[NR]=$1 } END { if (NR) printf "%.0f %d", v[int((NR+1)/2)], NR }')
|
||||
[[ -n "$med" ]] && printf ' %-5s 中位 %6sms (n=%s)\n' "$sym" ${med} \
|
||||
|| printf ' %-5s 尚无数据\n' "$sym"
|
||||
done
|
||||
echo " 参考:本机(新加坡)补丁后实测 350~650ms,理论下限约 500ms"
|
||||
fi
|
||||
@@ -58,6 +58,8 @@ import csv
|
||||
import gzip
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
from collections import deque
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
@@ -69,6 +71,10 @@ import pandas as pd
|
||||
|
||||
from lib.shadow_budget import LAG_ALARM_MS, LAG_WINDOW, lag_healthy
|
||||
|
||||
# 站点标识。跨地对比时两台机器的 CSV 要能合起来读,没有这一列就分不清哪行
|
||||
# 来自哪台。默认取主机名,部署脚本会显式传 SHADOW_SITE(如 sg-hetzner)
|
||||
SITE = os.environ.get("SHADOW_SITE") or socket.gethostname()
|
||||
|
||||
SYMS = ("BTC", "ETH", "SOL")
|
||||
# 多存一根:deque 尾部是尚未收盘的当前根,剔除后正好剩 step39 定下的窗口
|
||||
LTF_BARS, HTF_BARS = 2001, 801 # 有效窗口 2000 / 800,命中率在此饱和
|
||||
@@ -108,12 +114,30 @@ def _writer(path: Path, cols: list[str]):
|
||||
flush=True)
|
||||
fresh = not path.exists() or path.stat().st_size == 0
|
||||
f = path.open("a", newline="")
|
||||
w = csv.DictWriter(f, fieldnames=cols)
|
||||
w = _SiteWriter(csv.DictWriter(f, fieldnames=cols))
|
||||
if fresh:
|
||||
w.writeheader()
|
||||
return f, w
|
||||
|
||||
|
||||
class _SiteWriter:
|
||||
"""DictWriter 的薄包装,自动补上 site 列。
|
||||
|
||||
逐个 writerow 手加 site 有四处,漏一处就是静默的空值,而跨地对比正是靠
|
||||
这一列区分数据来源。在这里注入,漏不掉。
|
||||
"""
|
||||
|
||||
def __init__(self, w: csv.DictWriter) -> None:
|
||||
self._w = w
|
||||
|
||||
def writeheader(self) -> None:
|
||||
self._w.writeheader()
|
||||
|
||||
def writerow(self, row: dict) -> None:
|
||||
row.setdefault("site", SITE)
|
||||
self._w.writerow(row)
|
||||
|
||||
|
||||
class BookLog:
|
||||
"""把完整盘口快照落成 gzip JSONL。
|
||||
|
||||
@@ -136,7 +160,7 @@ class BookLog:
|
||||
asks: np.ndarray) -> None:
|
||||
# 只留价与量两列,update_id 对离线分析没用。round 到 10 位避免
|
||||
# float repr 把文件撑大一倍
|
||||
rec = {"sym": sym, "kline_ts": kline_ts, "label": label,
|
||||
rec = {"site": SITE, "sym": sym, "kline_ts": kline_ts, "label": label,
|
||||
"delay_ms": delay_ms, "target": target, "book_ts": book_ts,
|
||||
"bids": [[round(float(p), 10), round(float(a), 10)]
|
||||
for p, a, *_ in bids],
|
||||
@@ -187,7 +211,7 @@ class TapeLog:
|
||||
d = self.acc.get(sym)
|
||||
if not d or (not d["b"] and not d["s"]):
|
||||
return
|
||||
rec = {"sym": sym, "bar_ts": bar_ts,
|
||||
rec = {"site": SITE, "sym": sym, "bar_ts": bar_ts,
|
||||
"buys": {f"{p:.10g}": round(v, 10)
|
||||
for p, v in sorted(d["b"].items())},
|
||||
"sells": {f"{p:.10g}": round(v, 10)
|
||||
@@ -291,7 +315,7 @@ class Shadow:
|
||||
d = out_dir()
|
||||
# 追加模式:长跑期间若重启,已收集的样本不该被清掉
|
||||
self.f_sig, self.w_sig = _writer(d / "shadow_signals.csv", [
|
||||
"sym", "kline_ts", "direction",
|
||||
"site", "sym", "kline_ts", "direction",
|
||||
"h1_agree", "ladder_ok", "gate_ok", "pass_all", "lag_ok",
|
||||
"atr_pct", "atr_bp",
|
||||
"t_close_ms", "t_data_ms", "t_signal_ms",
|
||||
@@ -301,12 +325,12 @@ class Shadow:
|
||||
"baseline_px", "mid", "best_px", "fill_px", "filled", "depth_ok",
|
||||
"slip_bp", "drift_bp", "spread_bp", "impact_bp"])
|
||||
self.f_lat, self.w_lat = _writer(d / "shadow_latency.csv", [
|
||||
"sym", "kline_ts", "t_close_ms", "t_data_ms", "t_signal_ms",
|
||||
"site", "sym", "kline_ts", "t_close_ms", "t_data_ms", "t_signal_ms",
|
||||
"lag_data_ms", "lag_signal_ms", "compute_ms", "n_bars", "n_hits",
|
||||
"n_pass", "atr_bp", "lag_med_ms", "lag_ok"])
|
||||
# 无条件漂移:每根都记,用来和信号根上的条件漂移对照
|
||||
self.f_drf, self.w_drf = _writer(d / "shadow_drift.csv", [
|
||||
"sym", "kline_ts", "delay_label", "delay_ms",
|
||||
"site", "sym", "kline_ts", "delay_label", "delay_ms",
|
||||
"book_ts", "book_lag_ms", "baseline_px", "mid", "drift_bp_long"])
|
||||
# 完整深度。挂在无条件漂移那条路径上,所以每根 K 线的四个固定延迟点
|
||||
# 都有一份,信号根上再补一份 actual 点
|
||||
|
||||
Reference in New Issue
Block a user