diff --git a/.gitignore b/.gitignore index 01c06f4..2aebbcf 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,11 @@ research/out/run_meta_*.json # Telegram 凭据。**不要提交** research/live/deploy/tg.env + +# 生产状态与信号总线。刻意放在仓库外(LIVE_HOME / BUS_DIR),这几条只防 +# 有人把它们指回仓库里:里面是日亏损累计与已处理信号键,被 git clean +# 清掉等于两道闸静默失忆 +/live/deploy/live.env +live_state.json +live_trades.jsonl +signals_live.jsonl diff --git a/research/live/bitget_rest.py b/live/bitget_rest.py similarity index 100% rename from research/live/bitget_rest.py rename to live/bitget_rest.py diff --git a/live/deploy/README.md b/live/deploy/README.md new file mode 100644 index 0000000..f4c0f66 --- /dev/null +++ b/live/deploy/README.md @@ -0,0 +1,150 @@ +# 小额实盘执行器 · 部署 + +## 为什么是两台机 + +Bitget 的 API key 绑了 IP 白名单,只能从 AWS 那台发单;信号是新加坡那台采集器 +算出来的。于是分工固定成: + +``` +新加坡(采集/研究机) AWS(生产机) + shadow_hb.py 算信号 ship_signals.py 拉总线 + └→ ~/chan-live/state/ └→ /var/lib/chan-live/state/ + signals_live.jsonl ──ssh tail──→ signals_live.jsonl + live_exec.py 读总线 → Bitget REST +``` + +**生产机上不装采集侧的任何东西**(Docker / Hummingbot / pandas / chanlun)。 +理由不是洁癖,是四条具体代价: + +1. `live_state.json` 原先落在 `research/out/`,而那个目录 `shadow_hb.py` 会在 + CSV 表头变化时自动 rename 归档、研究脚本会写、人也会手工清数据。那个文件装 + 的是 `MAX_DAY_LOSS` 累计与已处理信号键,**被清掉不报错,只是两道闸静默 + 失效**。现在改到 `/var/lib/chan-live`。 +2. 采集器十币清空 300~560ms,直接叠在信号到达执行器的延迟上。 +3. 研究侧的探针 OOM 过一次(14.9 GB、负载 12)。当时若有仓位在场,执行器会被 + 一起杀掉,只剩交易所侧止损兜着。 +4. 依赖面:执行器只需标准库 + `aiohttp`。原先为读两个常量 import 研究侧的 + `step43`,把 numpy/pandas/pyarrow 全拖进实盘进程。 + +`install.sh` 里有一条断言会真的挡住第 4 条回归。 + +## 机器要求 + +CPU 无所谓(执行器几乎不算东西,信号 6.8 个/天)。要的是: + +- 出口 IP 固定,且已加进 Bitget 该 key 的白名单 +- `chrony` 能同步。**这一条是硬要求**:`LIVE_STALE_S` 那道闸靠两机时钟一致才 + 有意义,采集机时钟快 5 分钟就等于把闸放宽 5 分钟,一个早已失效的参考价会被 + 当成新鲜的照做 +- 能 ssh 到采集机(拉总线用) + +## 一、生产机(AWS) + +```bash +# 1. 取代码。只需要 live/ 这一个子树,但整仓克隆更省事 +git clone -b chan /tmp/chan && cd /tmp/chan +sudo ./live/deploy/install.sh +# 或让它自己克隆:sudo REPO= ./live/deploy/install.sh + +# 2. 填密钥与参数 +sudo vi /etc/chan-live/live.env +``` + +`live.env` 里必须改的四项:`BITGET_API_KEY` / `SECRET` / `PASSPHRASE` / +`SHIP_FROM`。密钥权限**只勾只读 + 交易,不要勾提币**。 + +```bash +# 3. 装拉总线用的 ssh key +sudo -u chan ssh-keygen -t ed25519 -N '' \ + -f /var/lib/chan-live/home/.ssh/id_ed25519 +sudo cat /var/lib/chan-live/home/.ssh/id_ed25519.pub +# 把这一行加到采集机的 ~/.ssh/authorized_keys + +# 4. 空跑验全链(不下真单) +sudo ./live/deploy/dryrun.sh +``` + +`dryrun.sh` 验的是那些"上线才暴露、且暴露方式是花钱"的环节:密钥能不能用、 +IP 白名单对不对、chrony 同步没有、ssh 通不通、对端总线有没有信号、数量与价位 +取整合不合交易所规则。**不要跳过。** + +```bash +# 5. 真跑 +sudo systemctl enable --now chan-live-ship chan-live-exec +./live/deploy/status.sh +``` + +## 二、采集机(新加坡) + +采集器要重启一次才会开始往总线写——`signal_bus.emit` 是后加的,跑着的进程没有 +加载。重启会丢已采的几分钟,数据本身不受影响(CSV 是追加的)。 + +```bash +cd && git pull +docker stop shadow && docker rm shadow +SHADOW_SITE=sg-tencent SYMS=BTC,ETH,SOL,BNB,XRP,DOGE,ADA,AVAX,LINK,LTC \ + bash research/live/deploy/start.sh +``` + +`start.sh` 会把 `$HOME/chan-live/state` 挂进容器成 `/bus`,总线落在 +`$HOME/chan-live/state/signals_live.jsonl`。**总线刻意放在仓库外**,因为它是 +交给另一台机的交接点,而仓库会被 git 动。 + +确认在写: + +```bash +ls -la ~/chan-live/state/ +# 等一个信号(6.8 个/天,可能要等几小时) +tail -f ~/chan-live/state/signals_live.jsonl +``` + +## 日常 + +```bash +./live/deploy/status.sh # 一屏体检 +journalctl -u chan-live-exec -f # 执行器日志 +journalctl -u chan-live-ship -f # 搬运日志 +``` + +**怎么判健康:** 信号 6.8 个/天,所以"很久没有新信号"是正常的,不能当健康 +指标。要看的是 `chan-live-ship` 的心跳(每 5 分钟一条),里面报 ssh 在线时长与 +重连次数。管道死了但进程还活着是这里最危险的状态——`ServerAliveInterval=15` +负责让它变成一次可见的断开。 + +## 停机与回滚 + +```bash +# 停新开仓,但保留在场仓位的管理(48 分钟超时平仓在执行器进程里) +sudo systemctl stop chan-live-ship + +# 全停。执行器收到 SIGINT 会先平掉在场仓位再退出,给了 90s +sudo systemctl stop chan-live-exec + +# 代码回滚 +cd /opt/chan && sudo git reset --hard && sudo systemctl restart chan-live-exec +``` + +⚠️ **状态目录 `/var/lib/chan-live` 不要跟着回滚。** 它存的是当日计数与已处理 +信号键;清掉等于日上限归零、且可能重开已经做过的仓。 + +## 故障处理 + +| 症状 | 大概率原因 | +|---|---| +| 启动即 `40018` / 签名错 | 出口 IP 不在白名单,或密钥抄错。`curl https://api.ipify.org` 对一下 | +| 搬运日志 `Permission denied (publickey)` | 第 3 步的 pubkey 没加到采集机 | +| 搬运在线但一直没信号 | 采集机没重启过(`signal_bus.emit` 没加载),或对端总线路径不对 | +| 日志 `⛔ 时间倒流 Xs` | 两机时钟不同步,**staleness 闸已不可信**。查两边 `chronyc tracking` | +| 信号收到但都被跳过 | `age > LIVE_STALE_S`。看是搬运慢还是时钟偏;也可能是重连重放的旧信号(这种跳过是对的) | +| `systemctl status` 显示 start-limit-hit | 5 分钟内重启 5 次,systemd 停手了。先看 journal 找真因,再 `systemctl reset-failed` | +| 执行器起不来,报缺 numpy/pandas | 有人给生产侧加了研究侧的 import。`install.sh` 的依赖断言就是挡这个 | + +## 已知的退化边界 + +- **进程死掉不会变成裸仓。** 止损与止盈都挂在交易所侧(`presetStopLossPrice` + 与 post-only reduce-only 限价单),只有 48 分钟超时平仓在本进程。所以进程死 + 掉的后果是持仓超过 48 根,不是失去保护。 +- **断线超过 20s 就等于漏掉那期间的信号。** 重连会把总线重放上来,但旧信号会被 + staleness 闸挡掉。这是对的——参考成交价是次根开盘价,过了就不是回测那个价。 +- **重启时会平掉交易所上已有的仓位**(`reconcile`)。接管要重建入场价、ATR、 + 剩余半仓状态和已过根数,任一项猜错就跑成另一个收益结构,所以选择平掉。 diff --git a/live/deploy/chan-live-exec.service b/live/deploy/chan-live-exec.service new file mode 100644 index 0000000..05ae66f --- /dev/null +++ b/live/deploy/chan-live-exec.service @@ -0,0 +1,48 @@ +[Unit] +Description=chan 小额实盘执行器(读信号总线,直接调 Bitget REST) +# 搬运挂了执行器仍要活着——它得继续管在场仓位的 48 分钟超时平仓。 +# 所以这里只写 Wants(弱依赖),不写 Requires +Wants=network-online.target chan-live-ship.service +After=network-online.target chan-live-ship.service +# 频繁重启说明有真问题,别让它无限打交易所。5 分钟内起 5 次就停下等人。 +# 注意这两项在 systemd 229+ 属于 [Unit],写在 [Service] 里会被忽略 +StartLimitIntervalSec=300 +StartLimitBurst=5 + +[Service] +Type=simple +User=chan +Group=chan +WorkingDirectory=/opt/chan +# 密钥与参数在这个文件里,权限必须 600。用 EnvironmentFile 而不是 +# Environment=,后者会出现在 `systemctl show` 的输出里 +EnvironmentFile=/etc/chan-live/live.env +ExecStart=/opt/chan/.venv/bin/python /opt/chan/live/live_exec.py +Restart=always +RestartSec=5 + +# 收到 stop 时给足时间:执行器要平掉在场仓位再退出 +KillSignal=SIGINT +TimeoutStopSec=90 + +StandardOutput=journal +StandardError=journal +SyslogIdentifier=chan-live-exec + +# ── 收紧权限 ────────────────────────────────────────────────────── +# 生产进程只需要读 /opt/chan 和读写状态目录,别的一概不给。这几条很廉价, +# 但真出了远程代码执行,爆炸半径小很多——而这个进程手里有交易权限的密钥 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/lib/chan-live +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictSUIDSGID=true +LockPersonality=true +MemoryMax=512M + +[Install] +WantedBy=multi-user.target diff --git a/live/deploy/chan-live-ship.service b/live/deploy/chan-live-ship.service new file mode 100644 index 0000000..37b8f44 --- /dev/null +++ b/live/deploy/chan-live-ship.service @@ -0,0 +1,40 @@ +[Unit] +Description=chan 信号搬运(把采集机的总线拉到本机) +After=network-online.target +Wants=network-online.target +StartLimitIntervalSec=300 +StartLimitBurst=10 + +[Service] +Type=simple +User=chan +Group=chan +WorkingDirectory=/opt/chan +EnvironmentFile=/etc/chan-live/live.env +# SHIP_FROM 在 live.env 里给,形如 sg-collector 或 user@1.2.3.4 +ExecStart=/opt/chan/.venv/bin/python /opt/chan/live/ship_signals.py \ + --from ${SHIP_FROM} --remote-bus ${SHIP_REMOTE_BUS} +Restart=always +RestartSec=5 + +StandardOutput=journal +StandardError=journal +SyslogIdentifier=chan-live-ship + +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +# chan 用户的家目录放在 /var/lib/chan-live/home,只装拉总线用的那一把 ssh +# key。这样 ProtectHome=true 挡住 /home 与 /root 的同时,ssh 仍能读到 +# ~/.ssh(它在 /var/lib 下,不受 ProtectHome 影响),也能写 known_hosts +ProtectHome=true +Environment=HOME=/var/lib/chan-live/home +ReadWritePaths=/var/lib/chan-live +ProtectKernelTunables=true +ProtectKernelModules=true +RestrictSUIDSGID=true +LockPersonality=true +MemoryMax=256M + +[Install] +WantedBy=multi-user.target diff --git a/live/deploy/dryrun.sh b/live/deploy/dryrun.sh new file mode 100755 index 0000000..f9f4b8e --- /dev/null +++ b/live/deploy/dryrun.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# 空跑验全链:真连交易所读规则/持仓,**不下任何真单**。 +# +# 上真单之前必须过这一步。它验的是那些"上线才会暴露、且暴露方式是花钱"的 +# 环节:密钥能不能用、IP 白名单对不对、时钟同不同步、搬运通不通、 +# 数量与价位取整合不合交易所规则。 +set -euo pipefail + +APP=/opt/chan +CONF=/etc/chan-live/live.env +STATE=/var/lib/chan-live +USER_NAME=chan +SECS="${SECS:-90}" + +die() { echo "⛔ $*" >&2; exit 1; } +ok() { echo " ✓ $*"; } + +[[ $EUID -eq 0 ]] || die "要 root:sudo $0" +[[ -f "$CONF" ]] || die "缺 $CONF,先跑 install.sh" + +set -a; . "$CONF"; set +a + +echo "── 1. 配置自检 ──" +for v in BITGET_API_KEY BITGET_API_SECRET BITGET_PASSPHRASE SHIP_FROM; do + [[ -n "${!v:-}" ]] || die "$CONF 里 $v 还是空的" +done +ok "密钥三项与 SHIP_FROM 都已填" +[[ "$LIVE_HOME" != /opt/chan* ]] || die "LIVE_HOME 不能指到仓库里(会被 git 清掉)" +ok "LIVE_HOME=$LIVE_HOME 在仓库外" + +echo "── 2. 时钟 ──" +# staleness 闸靠两机时钟一致才有意义。这里只能验本机;跨机偏差由 +# ship_signals 在收到信号时报「时间倒流」 +if command -v chronyc >/dev/null; then + chronyc tracking | grep -E "Reference ID|System time|Leap status" | sed 's/^/ /' + src="$(chronyc tracking | awk '/Reference ID/{print $NF}')" + [[ "$src" != "()" && -n "$src" ]] || die "chrony 还没同步上,等一会再跑" + ok "chrony 已同步" +else + die "没装 chrony。staleness 闸不可信,先 apt install chrony" +fi + +echo "── 3. 出口 IP 是否在白名单内 ──" +myip="$(curl -s --max-time 10 https://api.ipify.org || true)" +[[ -n "$myip" ]] && echo " 本机出口 IP:$myip" || echo " ⚠ 取不到出口 IP" +echo " 对照 Bitget 后台该 key 的 IP 白名单,不一致下面会报 40018 之类" + +echo "── 4. 能否 ssh 到采集机 ──" +if sudo -u "$USER_NAME" ssh -o BatchMode=yes -o ConnectTimeout=10 \ + "$SHIP_FROM" 'echo ok' >/dev/null 2>&1; then + ok "ssh $SHIP_FROM 通" + rb="${SHIP_REMOTE_BUS:-~/chan-live/state/signals_live.jsonl}" + n="$(sudo -u "$USER_NAME" ssh -o BatchMode=yes "$SHIP_FROM" \ + "wc -l < $rb 2>/dev/null || echo 0")" + echo " 对端总线现有 $n 条信号" + [[ "$n" -gt 0 ]] || echo " ⚠ 对端总线是空的。采集机接总线了吗?" +else + die "ssh $SHIP_FROM 不通。装 key: + sudo -u $USER_NAME ssh-keygen -t ed25519 -N '' -f $STATE/home/.ssh/id_ed25519 + 再把 $STATE/home/.ssh/id_ed25519.pub 加到采集机的 authorized_keys" +fi + +echo "── 5. 执行器空跑 ${SECS}s(真连交易所,不下单)──" +# --dry-run 下 Bitget 客户端只读不写:合约规则、持仓、费率照查, +# 下单一律只打印。所以这一步会真实验到密钥与白名单 +# 让 chan 自己 source 配置($CONF 是 640 root:chan,它读得到)。 +# 不用 `env "$(grep ...)"` 那种拼法:值里有空格就会被切开 +set +e +sudo -u "$USER_NAME" bash -c \ + "set -a; . '$CONF'; set +a; exec timeout $SECS \ + '$APP/.venv/bin/python' '$APP/live/live_exec.py' --dry-run" +rc=$? +set -e +# timeout 到点是 124,属于预期 +[[ $rc -eq 124 || $rc -eq 0 ]] || die "空跑退出码 $rc,看上面报错" +ok "空跑没有报错退出" + +echo +echo "空跑过了。真跑:" +echo " sudo systemctl enable --now chan-live-ship chan-live-exec" +echo " $APP/live/deploy/status.sh" diff --git a/live/deploy/install.sh b/live/deploy/install.sh new file mode 100755 index 0000000..d9a4bef --- /dev/null +++ b/live/deploy/install.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# 在生产机(有 Bitget API key 白名单的那台)上装实盘执行器。 +# +# 只装执行侧:标准库 + aiohttp。**不装** Docker / Hummingbot / pandas / +# chanlun 引擎——那些是采集与研究侧的依赖,理由见 live/live_exec.py 文件头。 +# +# sudo ./install.sh # 从当前 checkout 安装 +# sudo REPO=git@host:jack/chan.git ./install.sh # 或指定远程克隆 +# +# 幂等:重复跑只会更新代码与依赖,不动 /etc/chan-live/live.env 和状态目录。 +set -euo pipefail + +APP=/opt/chan +STATE=/var/lib/chan-live +CONF=/etc/chan-live +USER_NAME=chan +BRANCH="${BRANCH:-chan}" +REPO="${REPO:-}" + +die() { echo "⛔ $*" >&2; exit 1; } +say() { echo " $*"; } + +[[ $EUID -eq 0 ]] || die "要 root:sudo $0" + +# ── 1. 系统依赖 ──────────────────────────────────────────────────── +say "装系统包" +if command -v apt-get >/dev/null; then + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + # chrony 不是可选项:staleness 闸靠两机时钟一致才有意义, + # 采集机时钟快 5 分钟就等于把闸放宽 5 分钟(见 ship_signals.py) + apt-get install -y -qq python3-venv python3-pip git chrony openssh-client +elif command -v dnf >/dev/null; then + dnf install -y -q python3 python3-pip git chrony openssh-clients +else + die "只认 apt/dnf,其他发行版请手工装 python3-venv git chrony" +fi +systemctl enable --now chrony 2>/dev/null || systemctl enable --now chronyd + +# ── 2. 专用用户与目录 ────────────────────────────────────────────── +if ! id -u "$USER_NAME" >/dev/null 2>&1; then + say "建系统用户 $USER_NAME(无登录 shell)" + useradd --system --home-dir "$STATE/home" --create-home \ + --shell /usr/sbin/nologin "$USER_NAME" +fi +install -d -o "$USER_NAME" -g "$USER_NAME" -m 750 "$STATE" "$STATE/state" "$STATE/home" +install -d -o "$USER_NAME" -g "$USER_NAME" -m 700 "$STATE/home/.ssh" +install -d -o root -g "$USER_NAME" -m 750 "$CONF" + +# ── 3. 代码 ──────────────────────────────────────────────────────── +if [[ -n "$REPO" ]]; then + if [[ -d "$APP/.git" ]]; then + say "更新已有 checkout" + git -C "$APP" fetch --quiet origin "$BRANCH" + git -C "$APP" checkout --quiet "$BRANCH" + git -C "$APP" reset --hard --quiet "origin/$BRANCH" + else + say "克隆 $REPO" + rm -rf "$APP"; git clone --quiet --branch "$BRANCH" "$REPO" "$APP" + fi +else + SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + [[ -f "$SRC/live/live_exec.py" ]] || die "$SRC 不像仓库根(缺 live/live_exec.py)" + if [[ "$SRC" != "$APP" ]]; then + say "从 $SRC 同步代码到 $APP" + install -d "$APP" + # 只同步生产要的那一个子树。研究侧的 research/ 不上生产机: + # 它带 pandas/pyarrow/hummingbot,而且探针 OOM 过一次(14.9GB) + cp -a "$SRC/live" "$APP/" + fi +fi +chown -R root:root "$APP/live" +find "$APP/live" -type f -exec chmod 644 {} + ; chmod 755 "$APP/live" "$APP/live/deploy" +chmod 755 "$APP"/live/deploy/*.sh + +# ── 4. venv ─────────────────────────────────────────────────────── +say "建 venv 并装依赖(应当只有 aiohttp)" +[[ -d "$APP/.venv" ]] || python3 -m venv "$APP/.venv" +"$APP/.venv/bin/pip" install --quiet --upgrade pip +"$APP/.venv/bin/pip" install --quiet -r "$APP/live/requirements.txt" + +# 断言生产进程没被拖进重量级依赖。这条会真挡住——曾经为读两个常量 +# import 研究侧的 step43,把 numpy/pandas/pyarrow 全拉进实盘进程 +say "验依赖面" +"$APP/.venv/bin/python" - <<'PY' || die "生产进程拖进了重量级依赖,看上面输出" +import sys +sys.path.insert(0, "/opt/chan/live") +import live_exec +live_exec.assert_decomposable() +heavy = [m for m in ("numpy", "pandas", "pyarrow", "hummingbot", "scipy") + if m in sys.modules] +if heavy: + print(f" ⛔ 启动路径加载了 {heavy}") + raise SystemExit(1) +print(f" ✓ 只有标准库 + aiohttp · 出场结构 " + f"{live_exec.SL_ATR}/{live_exec.SCALE_ATR}/{live_exec.RUNNER_ATR} ATR " + f"/{live_exec.MAXB} 根") +PY + +# ── 5. 配置模板 ──────────────────────────────────────────────────── +if [[ ! -f "$CONF/live.env" ]]; then + say "写配置模板 $CONF/live.env(密钥要你手工填)" + install -o root -g "$USER_NAME" -m 640 \ + "$APP/live/deploy/live.env.example" "$CONF/live.env" + NEED_FILL=1 +else + say "$CONF/live.env 已存在,不动" +fi + +# ── 6. systemd ──────────────────────────────────────────────────── +say "装 systemd 单元" +install -m 644 "$APP"/live/deploy/chan-live-*.service /etc/systemd/system/ +systemctl daemon-reload + +echo +echo "装好了。接下来按顺序做(**不要**跳过空跑那步):" +echo +if [[ -n "${NEED_FILL:-}" ]]; then +echo " 1. 填密钥与参数:sudo vi $CONF/live.env" +echo " BITGET_API_KEY / SECRET / PASSPHRASE 用只读+交易权限," +echo " **不要开提币权限**。SHIP_FROM 填采集机的 ssh 目标。" +echo +fi +echo " 2. 装拉总线用的 ssh key,并确认能连上采集机:" +echo " sudo -u $USER_NAME ssh-keygen -t ed25519 -N '' -f $STATE/home/.ssh/id_ed25519" +echo " # 把 $STATE/home/.ssh/id_ed25519.pub 加到采集机的 authorized_keys" +echo " sudo -u $USER_NAME ssh -o BatchMode=yes <采集机> 'echo ok'" +echo +echo " 3. 空跑验全链(不下真单,跑够看到一次心跳再停):" +echo " sudo $APP/live/deploy/dryrun.sh" +echo +echo " 4. 真跑:" +echo " sudo systemctl enable --now chan-live-ship chan-live-exec" +echo " $APP/live/deploy/status.sh" diff --git a/live/deploy/live.env.example b/live/deploy/live.env.example new file mode 100644 index 0000000..800ea42 --- /dev/null +++ b/live/deploy/live.env.example @@ -0,0 +1,50 @@ +# 生产配置。装到 /etc/chan-live/live.env,权限 640 root:chan。 +# **不要提交填好的版本**——这里有交易权限的密钥。 +# +# 改完要重启:sudo systemctl restart chan-live-exec + +# ── Bitget 密钥 ─────────────────────────────────────────────────── +# 权限只勾「只读」+「交易」,**不要勾提币**。 +# IP 白名单填这台机的公网出口 IP(curl -s https://api.ipify.org 看)。 +# 这也是执行器必须跑在这台机上的唯一原因——密钥绑了这个 IP。 +BITGET_API_KEY= +BITGET_API_SECRET= +BITGET_PASSPHRASE= + +# ── 信号来源(采集机)───────────────────────────────────────────── +# ssh 目标。可以是 ~/.ssh/config 里的别名,或 user@ip +SHIP_FROM=sg-collector +# 采集机上总线文件的路径(在对端 shell 里展开,可用 ~) +SHIP_REMOTE_BUS=~/chan-live/state/signals_live.jsonl + +# ── 状态与总线 ──────────────────────────────────────────────────── +# 生产状态的根。**不要指到仓库里**:git checkout/clean 会动仓库,而这里存的 +# 是日亏损累计与在场仓位,被清掉等于 MAX_DAY_LOSS / MAX_OPEN 两道闸失忆。 +# systemd 单元里 ReadWritePaths 也是这个路径,改了要一起改 +LIVE_HOME=/var/lib/chan-live +SIGNAL_BUS=/var/lib/chan-live/state/signals_live.jsonl + +# ── 仓位 ───────────────────────────────────────────────────────── +# 每笔名义额(USDT)。杠杆**不改**手续费与滑点(都按名义额收),所以抬名义额 +# 有真实成本;抬它的唯一理由是压掉步长取整:实测最差币的偏差 +# 100U → 6.7%(SOL)、500U → 1.8%、1000U → 0.6% +LIVE_NOTIONAL=500 +# 杠杆只影响占用保证金,不影响名义敞口/手续费/滑点/盈亏绝对值。 +# 名义 500 在 10x 下占 50 USDT 保证金;止损在 2 ATR ≈ 0.2%,而 10x 强平约需 +# 逆向 10% = 100 个 ATR,差 50 倍。交易所侧记得设**逐仓** +LIVE_LEVERAGE=10 + +# ── 硬约束:封的是「代价不随仓位缩小」的那几类故障 ───────────────── +# 并发仓位数。信号 6.8 笔/天、持仓 48 分钟 → 期望并发 0.23 笔,3 已很宽。 +# 超了说明有 bug,不是行情好 +LIVE_MAX_OPEN=3 +# 日开仓上限。专门封「循环里的 bug 反复开仓」——单笔小但笔数无界 +LIVE_MAX_DAY=15 +# 日亏损上限(USDT)。一笔止损约 1 USDT,15 笔全亏 15 USDT +LIVE_MAX_DAY_LOSS=20 +# 信号超过这么久就不做。参考成交价是次根开盘价,过期后跑的不是回测那个价。 +# ⚠️ 这道闸依赖两机时钟一致,chrony 必须在跑(install.sh 会装) +LIVE_STALE_S=20 + +# 交易的币池。要与采集机一致,否则会收到不做的币的信号(会被忽略但徒增噪声) +SYMS=BTC,ETH,SOL,BNB,XRP,DOGE,ADA,AVAX,LINK,LTC diff --git a/live/deploy/status.sh b/live/deploy/status.sh new file mode 100755 index 0000000..597b9da --- /dev/null +++ b/live/deploy/status.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# 生产机一屏体检。不改任何状态,随时可跑。 +set -uo pipefail + +APP=/opt/chan +STATE=/var/lib/chan-live/state +CONF=/etc/chan-live/live.env + +hr() { printf '─── %s\n' "$1"; } + +hr "服务" +for u in chan-live-ship chan-live-exec; do + act="$(systemctl is-active "$u" 2>/dev/null)" + since="$(systemctl show -p ActiveEnterTimestamp --value "$u" 2>/dev/null)" + nrs="$(systemctl show -p NRestarts --value "$u" 2>/dev/null)" + printf ' %-16s %-8s 自 %s · 重启 %s 次\n' \ + "$u" "$act" "${since:-?}" "${nrs:-0}" +done + +hr "时钟(staleness 闸依赖它)" +if command -v chronyc >/dev/null; then + chronyc tracking 2>/dev/null | grep -E "Reference ID|System time" | sed 's/^/ /' +else + echo " ⚠ 没装 chrony" +fi + +hr "信号总线" +bus="${SIGNAL_BUS:-$STATE/signals_live.jsonl}" +[[ -f "$CONF" ]] && bus="$(grep -E '^SIGNAL_BUS=' "$CONF" | tail -1 | cut -d= -f2-)" +bus="${bus:-$STATE/signals_live.jsonl}" +if [[ -s "$bus" ]]; then + n="$(wc -l < "$bus")" + last_ts="$(tail -1 "$bus" | grep -o '"kline_ts":[0-9]*' | cut -d: -f2)" + if [[ -n "$last_ts" ]]; then + age=$(( $(date +%s) - last_ts / 1000 )) + printf ' %s 条 · 最近一条 %d 分钟前\n' "$n" "$((age / 60))" + else + echo " $n 条(最后一行没有 kline_ts)" + fi + # 信号 6.8 个/天,所以「几小时没有」是正常的。真要看的是搬运连着没有 + echo " 注:6.8 个/天,长时间没有新信号是正常的;要判健康看下面的搬运心跳" +else + echo " 空或不存在:$bus" +fi + +hr "闸的状态" +# 在场仓位**不落盘**:重启时由 reconcile 查交易所并平掉(见 live_exec.py +# 的 reconcile 注释)。所以这里只报当日计数,仓位要看交易所或下面的成交流 +if [[ -f "$STATE/live_state.json" ]]; then + python3 - "$STATE/live_state.json" <<'PY' +import json, sys, time +d = json.load(open(sys.argv[1])) +today = time.strftime("%Y-%m-%d") +day = d.get("day", "?") +stale = "" if day == today else f" ⚠ 是 {day} 的,跨日后首次开仓时才归零" +print(f" 当日 {day}{stale}") +print(f" 已开 {d.get('n_day', 0)} 笔 · 盈亏 {d.get('pnl_day', 0.0):+.2f} USDT") +print(f" 已处理信号键 {len(d.get('done', []))} 个(幂等去重用,留最近 5000)") +PY +else + echo " 还没有状态文件(没开过仓)" +fi + +hr "最近成交" +if [[ -s "$STATE/live_trades.jsonl" ]]; then + tail -5 "$STATE/live_trades.jsonl" | sed 's/^/ /' +else + echo " 还没有成交记录" +fi + +hr "搬运心跳(最近 3 条)" +journalctl -u chan-live-ship -n 200 --no-pager 2>/dev/null \ + | grep -F "[心跳]" | tail -3 | sed 's/^/ /' \ + || echo " 还没有心跳(每 5 分钟一条)" + +hr "最近报错" +journalctl -u chan-live-exec -u chan-live-ship -n 400 --no-pager -p warning 2>/dev/null \ + | tail -8 | sed 's/^/ /' || echo " 无" diff --git a/live/exit_params.py b/live/exit_params.py new file mode 100644 index 0000000..df44508 --- /dev/null +++ b/live/exit_params.py @@ -0,0 +1,27 @@ +"""出场结构的唯一来源。**只用标准库**,这是硬约束。 + +为什么单独一个模块、且不许引第三方库:执行器要跑在只装了 `aiohttp` 的生产机 +上。这几个数原先从 `research/step43_fill_aware_budget.py` 读,那个模块顶层 +`import pandas`,于是生产机为了两个 float 得装 pandas + pyarrow(实测 +`assert_decomposable()` 一调就把 numpy/pandas/pyarrow 全拖进来)。 + +方向也要注意:**生产拥有这个契约,研究侧反过来读它。** 反过来写成生产 import +研究侧,就等于把回测的依赖树绑到实盘进程上。 + +⚠️ 研究侧还散着 6 处同样的字面量(step44/47/48/49/52 与 exit_model 的默认 +参数),本次没有统一。改这里的值**不会**自动改到那些脚本,对表要手工。 +统一它们要重跑那批脚本确认结果不变,属于独立的一次改动。 +""" +from __future__ import annotations + +# 以 ATR 为单位的出场结构。来源:research/step43_fill_aware_budget.py 的 +# 参数扫描结论(1m 主线)。含义见 live_exec.py 顶部注释 +SL = 2.0 # 止损:入场价的 2 ATR +SCALE_AT = 3.0 # 减半点:3 ATR 处平掉一半 +RUNNER = 8.0 # 剩余半仓的目标:8 ATR +RUNNER_STOP = 2.0 # 剩余半仓的止损,**不移动**,仍在入场价的 2 ATR +MAXB = 48 # 超时:48 根(1m 上即 48 分钟) + +# 两个半仓共用同一个不动止损,这是「一次入场拆两腿」能等价于回测的前提。 +# RUNNER_STOP != SL 时该等价性失效,`live_exec.assert_decomposable()` 会硬挡 +DECOMPOSABLE = RUNNER_STOP == SL diff --git a/research/live/live_exec.py b/live/live_exec.py similarity index 90% rename from research/live/live_exec.py rename to live/live_exec.py index e1d5f4c..d0ff93c 100644 --- a/research/live/live_exec.py +++ b/live/live_exec.py @@ -13,7 +13,7 @@ ## 出场结构为什么能拆成两个半仓 回测结构是 2 ATR 止损 / 3 ATR 减半 / 8 ATR 目标 / 48 根超时,且**剩余半仓的 -止损保持在入场价的 2 ATR、不移动**。已核实 `lib/exit_model.py:151`—— +止损保持在入场价的 2 ATR、不移动**。已核实 `research/lib/exit_model.py:151`—— `runner_stops` 的 `ret` 是 `(entry - low[j]) / a`,从入场价算,且 `RUNNER_STOP == SL == 2.0`。两半共用同一个不动的止损,所以: @@ -41,7 +41,24 @@ 亏损累积 策略真的不行,但没人盯着 → MAX_DAY_LOSS 裸仓 进程在"已入场、止损未挂"之间死掉 → 服务端止损 + 重启对账 - python research/live/live_exec.py --dry-run # 只打印不下单 +## 为什么单独一个 live/ 子树、不放在 research/ 下 + +生产与研究共处一个目录/进程/机器有四条具体代价,其中第一条已经咬过一次: + + 1. `live_state.json` 原先落在 `research/out/`,而那里 `shadow_hb.py` 会在 + CSV 表头变化时自动 rename 归档、研究脚本会写、人也会手工清数据。那个文件 + 装的是 MAX_DAY_LOSS 累计与在场仓位,**闸的状态被清掉不报错,只是静默 + 失效**。所以生产状态改到独立目录(LIVE_HOME)。 + 2. 采集器十币清空 300~560ms,直接叠在信号到达执行器的延迟上。 + 3. 研究侧的探针 OOM 过一次(14.9GB、负载 12),当时若有仓位在场,执行器会 + 被一起杀掉,只剩交易所侧止损兜着。 + 4. 依赖面:本文件只需标准库 + aiohttp。原先为读两个常量 import 研究侧的 + step43,把 numpy/pandas/pyarrow 全拖进生产进程。 + +因此本目录**不 import research/ 下的任何东西**(`exit_params.py` 是生产自己 +持有的契约,研究侧反过来读它)。 + + python live/live_exec.py --dry-run # 只打印不下单 """ from __future__ import annotations @@ -55,11 +72,16 @@ from decimal import Decimal from pathlib import Path HERE = Path(__file__).resolve() -sys.path.insert(0, str(HERE.parents[1])) +# 只插自己所在目录。**不要**把 research/ 加进来——见文件头第 4 条 sys.path.insert(0, str(HERE.parent)) import signal_bus # noqa: E402 from bitget_rest import Bitget # noqa: E402 +from exit_params import MAXB, RUNNER, RUNNER_STOP, SCALE_AT, SL # noqa: E402 + +# 生产状态的根目录。默认放 ~/chan-live,**不落在仓库里**:仓库会被 git +# checkout/clean 动,而这里存的是日亏损累计与在场仓位,丢了等于闸失忆 +LIVE_HOME = Path(os.environ.get("LIVE_HOME", Path.home() / "chan-live")) SYMS = os.environ.get( "SYMS", "BTC,ETH,SOL,BNB,XRP,DOGE,ADA,AVAX,LINK,LTC").split(",") @@ -77,10 +99,12 @@ MAX_DAY_LOSS = float(os.environ.get("LIVE_MAX_DAY_LOSS", "20")) # 信号超过这么久就不做了。参考成交价是次根开盘价,过期后跑的不是回测那个价 STALE_S = float(os.environ.get("LIVE_STALE_S", "20")) -STATE = Path(os.environ.get("LIVE_STATE", "research/out/live_state.json")) -TRADES = Path(os.environ.get("LIVE_TRADES", "research/out/live_trades.jsonl")) +STATE = Path(os.environ.get("LIVE_STATE", LIVE_HOME / "state" / "live_state.json")) +TRADES = Path(os.environ.get("LIVE_TRADES", LIVE_HOME / "state" / "live_trades.jsonl")) -SL_ATR, SCALE_ATR, RUNNER_ATR, MAXB = 2.0, 3.0, 8.0, 48 +# 出场结构从 exit_params 读,本文件不再抄一份字面量。抄一份的问题不是难看, +# 是改了回测参数后这边不会跟上,而且不报错 +SL_ATR, SCALE_ATR, RUNNER_ATR = SL, SCALE_AT, RUNNER def assert_decomposable() -> None: @@ -90,7 +114,6 @@ def assert_decomposable() -> None: 这个分解就变成"两半共用同一止损"的错误近似,实盘跑的是另一个收益结构, 而且不会报错。所以在启动时硬挡。 """ - from step43_fill_aware_budget import RUNNER_STOP, SL if float(RUNNER_STOP) != float(SL): raise SystemExit( f"⛔ RUNNER_STOP({RUNNER_STOP}) != SL({SL}),两个半仓的分解不再\n" diff --git a/live/requirements.txt b/live/requirements.txt new file mode 100644 index 0000000..6772bdc --- /dev/null +++ b/live/requirements.txt @@ -0,0 +1,8 @@ +# 生产执行器的全部依赖。**保持这个文件只有一行。** +# +# 每加一个包都要能回答"实盘进程崩在这个包里我怎么办"。numpy/pandas/pyarrow +# 曾经因为读两个常量被拖进来(见 live/exit_params.py 的说明),已经切掉。 +# +# 版本下限的理由:3.9 起 aiohttp 才在 Python 3.12+ 上稳定编译;不锁上限是 +# 因为这里只用 ClientSession.request 这一个最稳定的 API 面。 +aiohttp>=3.9 diff --git a/live/ship_signals.py b/live/ship_signals.py new file mode 100644 index 0000000..00ecae0 --- /dev/null +++ b/live/ship_signals.py @@ -0,0 +1,200 @@ +"""把产信号那台机的总线搬到本机(生产机)。跑在**消费侧**,即 AWS 上。 + +## 为什么要搬 + +Bitget 的 API key 绑了 IP 白名单,只能从 AWS 那台发单;而信号是新加坡那台 +采集器算出来的。执行器不自己算信号的理由见 `signal_bus.py` 顶部——最要紧的 +是「实盘交易的必须是影子测量的那一个信号」,各算一份会悄悄分叉。 + +## 为什么是拉而不是推 + +拉的一侧是生产机,它对自己的输入负责。推的话,研究机上一个脚本挂了就会静默 +断供,而生产机看不出区别(信号本来就 6.8 个/天,长时间没有是正常的)。 + +## 断线怎么自愈 + +每次重连都 `tail -c +0`,即从文件头重放全部内容,本地按 `key` 去重后只追加 +新的。所以断线期间产生的信号会在重连时补齐,不需要记录偏移量。 + +⚠️ 补齐**不等于**补做:重放上来的旧信号会被 `live_exec` 的 `LIVE_STALE_S` +(默认 20s)挡掉。这是对的——参考成交价是次根开盘价,过了就不是回测那个价。 +所以断线超过 20s 就等于漏掉那些信号,这是可接受的退化,不是 bug。 + +## 为什么单独一个进程 + +搬运挂掉时,执行器要继续管在场仓位(48 分钟超时平仓在本进程里)。合成一个 +进程会让传输故障连坐到仓位管理。 + + python live/ship_signals.py --from sg-collector # 用 ~/.ssh/config 的别名 + python live/ship_signals.py --from user@1.2.3.4 --remote-bus /home/user/chan-live/state/signals_live.jsonl +""" +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import sys +import time +from pathlib import Path + +HERE = Path(__file__).resolve() +sys.path.insert(0, str(HERE.parent)) + +import signal_bus # noqa: E402 + +# ssh 参数的理由: +# BatchMode 不要交互提示密码,否则进程会挂在那里等输入 +# ServerAliveInterval/CountMax 45s 内探测不到就断开重连。没有这两条, +# NAT 静默丢弃连接后 tail 会永远挂着不返回,表现为 +# 「进程活着但再也收不到信号」——最难发现的那种故障 +# ExitOnForwardFailure/StrictHostKeyChecking 留默认,主机指纹要人工确认过 +SSH_OPTS = ["-T", "-o", "BatchMode=yes", + "-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=3", + "-o", "ConnectTimeout=10"] + +REMOTE_BUS = os.environ.get( + "SHIP_REMOTE_BUS", "~/chan-live/state/signals_live.jsonl") +BACKOFF_MAX = 60.0 +# 允许的负龄。1s 覆盖正常的 NTP 抖动与网络传输,超出就该当时钟问题查 +SKEW_TOL_S = 1.0 + + +class Shipper: + def __init__(self, host: str, remote_bus: str, local_bus: Path) -> None: + self.host = host + self.remote_bus = remote_bus + self.bus = local_bus + self.seen: set[str] = set() + self.n_new = 0 + self.n_dup = 0 + self.connected_at = 0.0 + self.last_signal_ts = 0.0 + self.n_reconnect = 0 + self.n_skew = 0 + + def load_seen(self) -> None: + """本地已有的键先读进来,避免重启后把整个文件再追加一遍。""" + self.bus.parent.mkdir(parents=True, exist_ok=True) + for rec in signal_bus.read_all(self.bus): + k = rec.get("key") + if k: + self.seen.add(k) + print(f" 本地已有 {len(self.seen)} 条信号,按 key 去重", flush=True) + + def absorb(self, line: str) -> None: + line = line.strip() + if not line: + return + try: + rec = json.loads(line) + except json.JSONDecodeError: + # 半行:tail 在写入中途读到。重连重放时会拿到完整的那一行 + print(f" ⚠ 跳过无法解析的一行({len(line)} 字节)", flush=True) + return + k = rec.get("key") + if not k: + print(f" ⚠ 跳过无 key 的记录:{line[:80]}", flush=True) + return + if k in self.seen: + self.n_dup += 1 + return + self.seen.add(k) + self.n_new += 1 + self.last_signal_ts = time.time() + # 原样追加,不重新序列化——保持与源文件逐字节一致,便于事后对账 + with self.bus.open("a", encoding="utf-8") as f: + f.write(line + "\n") + f.flush() + os.fsync(f.fileno()) + age = time.time() - rec.get("kline_ts", 0) / 1000.0 + if age < -SKEW_TOL_S: + # 负龄说明产信号那台机的时钟快于本机。这不是无害的:staleness 闸 + # 靠 age 判断,时钟快 5 分钟就等于把闸放宽 5 分钟,一个早已失效的 + # 参考价会被当成新鲜的照做。两台都必须挂 NTP(部署文档里是硬要求) + self.n_skew += 1 + print(f" ⛔ {k} 时间倒流 {-age:.1f}s —— 两机时钟不同步," + f"staleness 闸已不可信。查 chronyd/systemd-timesyncd", + flush=True) + mark = "" if age <= 20 else " ⚠ 已超 20s,执行器会挡掉" + print(f" ▶ 收到 {k} · 距参考价成立 {age:.1f}s{mark}", flush=True) + + async def pump(self) -> None: + """连一次,读到断为止。返回即表示需要重连。""" + cmd = ["ssh", *SSH_OPTS, self.host, + f"tail -c +0 -F {self.remote_bus}"] + proc = await asyncio.create_subprocess_exec( + *cmd, stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE) + self.connected_at = time.time() + print(f" ssh 已连上 {self.host}", flush=True) + assert proc.stdout is not None + try: + async for raw in proc.stdout: + self.absorb(raw.decode("utf-8", "replace")) + finally: + err = b"" + if proc.stderr is not None: + try: + err = await asyncio.wait_for(proc.stderr.read(), 2.0) + except asyncio.TimeoutError: + pass + if proc.returncode is None: + proc.kill() + await proc.wait() + up = time.time() - self.connected_at + msg = err.decode("utf-8", "replace").strip() + print(f" ssh 断开(在线 {up:.0f}s,退出码 {proc.returncode})" + f"{':' + msg if msg else ''}", flush=True) + + async def run(self) -> None: + self.load_seen() + asyncio.create_task(self.heartbeat()) + backoff = 1.0 + while True: + try: + await self.pump() + backoff = 1.0 # 正常断开:立刻重连 + except Exception as e: # noqa: BLE001 + print(f" ⚠ 搬运出错:{type(e).__name__}: {e}", flush=True) + self.n_reconnect += 1 + await asyncio.sleep(backoff) + backoff = min(backoff * 2, BACKOFF_MAX) + + async def heartbeat(self) -> None: + """信号 6.8 个/天,所以「很久没收到」是正常的,不能当健康指标。 + + 真正要报的是**连接**在不在:ssh 在线时长与重连次数。管道死了但进程 + 活着是这里最危险的状态,ServerAliveInterval 负责让它变成一次断开。 + """ + while True: + await asyncio.sleep(300) + up = time.time() - self.connected_at if self.connected_at else 0 + last = (f"{(time.time() - self.last_signal_ts) / 60:.0f} 分钟前" + if self.last_signal_ts else "本次启动后还没有") + skew = f" · ⛔ 时钟倒流 {self.n_skew} 次" if self.n_skew else "" + print(f" [心跳] ssh 在线 {up / 60:.0f} 分钟 · 重连 " + f"{self.n_reconnect} 次 · 新增 {self.n_new} 条" + f"(重放去重 {self.n_dup})· 最近一条 {last}{skew}", + flush=True) + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--from", dest="host", required=True, + help="产信号那台机的 ssh 目标,如 sg-collector 或 user@ip") + ap.add_argument("--remote-bus", default=REMOTE_BUS, + help="对端总线路径(对端 shell 展开,可用 ~)") + ap.add_argument("--bus", default=str(signal_bus.BUS), + help="本机总线路径,执行器读同一个") + a = ap.parse_args() + s = Shipper(a.host, a.remote_bus, Path(a.bus)) + print(f"信号搬运:{a.host}:{a.remote_bus} → {a.bus}", flush=True) + try: + asyncio.run(s.run()) + except KeyboardInterrupt: + print(" 停止", flush=True) + + +if __name__ == "__main__": + main() diff --git a/research/live/signal_bus.py b/live/signal_bus.py similarity index 89% rename from research/live/signal_bus.py rename to live/signal_bus.py index 1ec3150..39090c0 100644 --- a/research/live/signal_bus.py +++ b/live/signal_bus.py @@ -26,8 +26,11 @@ import os import time from pathlib import Path +# 默认不落在仓库里:git checkout/clean 会动仓库,而这里是跨进程(甚至跨机) +# 的交接点,被清掉就等于信号静默丢失。产信号的一侧和消费的一侧各自指到 +# 自己的路径即可,同机时指到同一个文件 BUS = Path(os.environ.get( - "SIGNAL_BUS", "research/out/signals_live.jsonl")) + "SIGNAL_BUS", Path.home() / "chan-live" / "state" / "signals_live.jsonl")) def key_of(sym: str, kline_ts: int, direction: int) -> str: diff --git a/research/live/deploy/start.sh b/research/live/deploy/start.sh index 8d90fca..ab37db9 100755 --- a/research/live/deploy/start.sh +++ b/research/live/deploy/start.sh @@ -20,11 +20,14 @@ SHADOW_LEAN="${SHADOW_LEAN:-1}" # 币池。默认三个流动性最好的做滑点测量;十币池是实际要交易的那批(TRX 剔除, # ATR 门控几乎全刷掉)。 # -# 币数超过核数时排队会成为主项:所有币同一秒收盘,2 核上十币实测清空要 -# 约 640ms,最后一个币的信号落在 1376ms。此时**加 worker 没用**——CPU 密集 -# 的活,worker 超过核数不增吞吐,只会把等待从 queue_ms 挪到 inner_ms。 -# 唯一出路是压单币耗时,走 init_stream/append_bar 增量路径(实测约 3.7x, -# 换算后十币 / 2 核清空约 265ms)。 +# 币数超过核数时排队会成为主项:所有币同一秒收盘。此时**加 worker 没用** +# ——CPU 密集的活,worker 超过核数不增吞吐,只会把等待从 queue_ms 挪到 +# inner_ms。增量路径(SHADOW_INCR=1,默认开)已把清空压到 300~560ms。 +# +# 再往下压的顺序见 HANDOFF §5.72(口径对齐后的实测):单币 inner 100ms ≈ +# 信号链 30ms + 追加 2.81 根 37ms + 2 核争抢 33ms。争抢只有 1.49x,所以 +# **加核收益有限**;最便宜的一刀是按币绑定 worker(现在 symbol 随机落 +# worker,每份缓存都漏掉对方处理过的根,于是人人要追 2.81 根而非 1 根)。 SYMS="${SYMS:-BTC,ETH,SOL}" IMAGE="${SHADOW_IMAGE:-hummingbot/hummingbot:latest}" HOURS="${HOURS:-168}" @@ -33,6 +36,10 @@ MAX_OFFSET_MS="${MAX_OFFSET_MS:-10}" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" OUT="$REPO_ROOT/research/out" +# 信号总线的宿主机目录。**刻意放在仓库外**:这是交给实盘执行器(另一台机)的 +# 交接点,而仓库会被 git checkout/clean 动。执行器那台用 ssh tail 拉这个文件, +# 所以它也不能在容器内部,必须挂出来 +BUS_DIR="${BUS_DIR:-$HOME/chan-live/state}" die() { printf '\033[31m错误:%s\033[0m\n' "$*" >&2; exit 1; } say() { printf '\n\033[1m==> %s\033[0m\n' "$*"; } @@ -42,6 +49,12 @@ say() { printf '\n\033[1m==> %s\033[0m\n' "$*"; } say "站点 $SHADOW_SITE" +mkdir -p "$BUS_DIR" +# 容器内是 root,写出来的总线文件宿主机上归 root。执行器那台用普通用户 +# ssh 过来 tail,所以目录要可进入、文件要可读 +chmod 755 "$BUS_DIR" 2>/dev/null || true +echo "信号总线:$BUS_DIR/signals_live.jsonl(容器内挂成 /bus)" + say "校验时钟同步" offset_ms="" if command -v chronyc >/dev/null 2>&1; then @@ -119,8 +132,10 @@ docker run -d --name "$NAME" -w /home/hummingbot \ -e TG_NOTIONAL="${TG_NOTIONAL:-500}" \ -e TG_LEVERAGE="${TG_LEVERAGE:-10}" \ -e TG_STALE_S="${TG_STALE_S:-90}" \ + -e SIGNAL_BUS=/bus/signals_live.jsonl \ -v "$REPO_ROOT:/repo:ro" \ -v "$OUT:/out" \ + -v "$BUS_DIR:/bus" \ --entrypoint /opt/conda/envs/hummingbot/bin/python \ "$IMAGE" /repo/research/live/shadow_hb.py \ --hours "$HOURS" --workers "$WORKERS" --syms "$SYMS" >/dev/null diff --git a/research/live/shadow_hb.py b/research/live/shadow_hb.py index f31030b..842c74d 100644 --- a/research/live/shadow_hb.py +++ b/research/live/shadow_hb.py @@ -60,6 +60,7 @@ import json import math import os import socket +import sys import time from collections import deque from concurrent.futures import ProcessPoolExecutor @@ -71,8 +72,11 @@ import pandas as pd from lib.shadow_budget import LAG_ALARM_MS, LAG_WINDOW, lag_healthy -import signal_bus -import tg_notify +# 总线模块住在生产子树 live/ 下。方向是刻意的:**生产不 import 研究侧**, +# 研究侧反过来读生产持有的契约。见 live/live_exec.py 文件头 +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "live")) +import signal_bus # noqa: E402 +import tg_notify # noqa: E402 # 站点标识。跨地对比时两台机器的 CSV 要能合起来读,没有这一列就分不清哪行 # 来自哪台。默认取主机名,部署脚本会显式传 SHADOW_SITE(如 sg-hetzner)