生产与研究分家:实盘执行器独立成 live/ 子树,加 AWS 部署

实盘要跑在 AWS(API key 绑了 IP 白名单),而信号在新加坡那台算。借这次
把生产从研究侧摘出来,四条具体代价里第一条已经咬过:

1. live_state.json 原先落在 research/out/,而那里 shadow_hb 会自动 rename
   归档、研究脚本会写、人也手工清过。那文件装的是 MAX_DAY_LOSS 累计与已
   处理信号键,被清掉不报错,只是两道闸静默失效。改到 LIVE_HOME。
2. 采集器十币清空 300~560ms 直接叠在信号到达执行器的延迟上。
3. 研究侧探针 OOM 过一次(14.9GB),当时若有仓位在场会连坐执行器。
4. 为读两个常量 import 研究侧 step43,把 numpy/pandas/pyarrow 拖进实盘
   进程。抽出 stdlib-only 的 live/exit_params.py,install.sh 加断言挡回归。

新增 live/ship_signals.py:AWS 侧 ssh tail 拉总线,每次重连从文件头重放
+ 按幂等键去重,断线期间的信号自愈;旧信号由 staleness 闸挡掉不补做。
带时钟倒流检测——两机时钟不同步会让那道闸静默放宽。

部署件:systemd 两单元(搬运挂了执行器仍管在场仓位的超时平仓)、
install.sh、dryrun.sh(验密钥/白名单/时钟/ssh/取整)、status.sh、README。

验证:live_exec 重构后端到端空跑,SOL 多头与 ADA 空头的止损/两级止盈/
数量取整逐项核对正确,isolated + post_only + reduceOnly 都在;搬运的去重、
重启不重复追加、脏数据跳过、断线重连重放均已测。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jack
2026-08-28 17:03:03 +08:00
co-authored by Cursor
parent af029bc26e
commit 335d891478
16 changed files with 884 additions and 15 deletions
+8
View File
@@ -49,3 +49,11 @@ research/out/run_meta_*.json
# Telegram 凭据。**不要提交** # Telegram 凭据。**不要提交**
research/live/deploy/tg.env research/live/deploy/tg.env
# 生产状态与信号总线。刻意放在仓库外(LIVE_HOME / BUS_DIR),这几条只防
# 有人把它们指回仓库里:里面是日亏损累计与已处理信号键,被 git clean
# 清掉等于两道闸静默失忆
/live/deploy/live.env
live_state.json
live_trades.jsonl
signals_live.jsonl
+150
View File
@@ -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 <repo> /tmp/chan && cd /tmp/chan
sudo ./live/deploy/install.sh
# 或让它自己克隆:sudo REPO=<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 <repo> && 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 <sha> && 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、
剩余半仓状态和已过根数,任一项猜错就跑成另一个收益结构,所以选择平掉。
+48
View File
@@ -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
+40
View File
@@ -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
+81
View File
@@ -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 "要 rootsudo $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"
+134
View File
@@ -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 "要 rootsudo $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"
+50
View File
@@ -0,0 +1,50 @@
# 生产配置。装到 /etc/chan-live/live.env,权限 640 root:chan。
# **不要提交填好的版本**——这里有交易权限的密钥。
#
# 改完要重启:sudo systemctl restart chan-live-exec
# ── Bitget 密钥 ───────────────────────────────────────────────────
# 权限只勾「只读」+「交易」,**不要勾提币**。
# IP 白名单填这台机的公网出口 IPcurl -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 USDT15 笔全亏 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
+78
View File
@@ -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 " 无"
+27
View File
@@ -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
@@ -13,7 +13,7 @@
## 出场结构为什么能拆成两个半仓 ## 出场结构为什么能拆成两个半仓
回测结构是 2 ATR 止损 / 3 ATR 减半 / 8 ATR 目标 / 48 根超时**剩余半仓的 回测结构是 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_stops` `ret` `(entry - low[j]) / a`从入场价算
`RUNNER_STOP == SL == 2.0`两半共用同一个不动的止损所以 `RUNNER_STOP == SL == 2.0`两半共用同一个不动的止损所以
@@ -41,7 +41,24 @@
亏损累积 策略真的不行但没人盯着 MAX_DAY_LOSS 亏损累积 策略真的不行但没人盯着 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 from __future__ import annotations
@@ -55,11 +72,16 @@ from decimal import Decimal
from pathlib import Path from pathlib import Path
HERE = Path(__file__).resolve() HERE = Path(__file__).resolve()
sys.path.insert(0, str(HERE.parents[1])) # 只插自己所在目录。**不要**把 research/ 加进来——见文件头第 4 条
sys.path.insert(0, str(HERE.parent)) sys.path.insert(0, str(HERE.parent))
import signal_bus # noqa: E402 import signal_bus # noqa: E402
from bitget_rest import Bitget # 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 = os.environ.get(
"SYMS", "BTC,ETH,SOL,BNB,XRP,DOGE,ADA,AVAX,LINK,LTC").split(",") "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")) STALE_S = float(os.environ.get("LIVE_STALE_S", "20"))
STATE = Path(os.environ.get("LIVE_STATE", "research/out/live_state.json")) STATE = Path(os.environ.get("LIVE_STATE", LIVE_HOME / "state" / "live_state.json"))
TRADES = Path(os.environ.get("LIVE_TRADES", "research/out/live_trades.jsonl")) 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: 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): if float(RUNNER_STOP) != float(SL):
raise SystemExit( raise SystemExit(
f"⛔ RUNNER_STOP({RUNNER_STOP}) != SL({SL}),两个半仓的分解不再\n" f"⛔ RUNNER_STOP({RUNNER_STOP}) != SL({SL}),两个半仓的分解不再\n"
+8
View File
@@ -0,0 +1,8 @@
# 生产执行器的全部依赖。**保持这个文件只有一行。**
#
# 每加一个包都要能回答"实盘进程崩在这个包里我怎么办"。numpy/pandas/pyarrow
# 曾经因为读两个常量被拖进来(见 live/exit_params.py 的说明),已经切掉。
#
# 版本下限的理由:3.9 起 aiohttp 才在 Python 3.12+ 上稳定编译;不锁上限是
# 因为这里只用 ClientSession.request 这一个最稳定的 API 面。
aiohttp>=3.9
+200
View File
@@ -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()
@@ -26,8 +26,11 @@ import os
import time import time
from pathlib import Path from pathlib import Path
# 默认不落在仓库里:git checkout/clean 会动仓库,而这里是跨进程(甚至跨机)
# 的交接点,被清掉就等于信号静默丢失。产信号的一侧和消费的一侧各自指到
# 自己的路径即可,同机时指到同一个文件
BUS = Path(os.environ.get( 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: def key_of(sym: str, kline_ts: int, direction: int) -> str:
+20 -5
View File
@@ -20,11 +20,14 @@ SHADOW_LEAN="${SHADOW_LEAN:-1}"
# 币池。默认三个流动性最好的做滑点测量;十币池是实际要交易的那批(TRX 剔除, # 币池。默认三个流动性最好的做滑点测量;十币池是实际要交易的那批(TRX 剔除,
# ATR 门控几乎全刷掉)。 # ATR 门控几乎全刷掉)。
# #
# 币数超过核数时排队会成为主项:所有币同一秒收盘2 核上十币实测清空要 # 币数超过核数时排队会成为主项:所有币同一秒收盘。此时**加 worker 没用**
# 约 640ms,最后一个币的信号落在 1376ms。此时**加 worker 没用**——CPU 密集 # ——CPU 密集的活,worker 超过核数不增吞吐,只会把等待从 queue_ms 挪到
# 的活,worker 超过核数不增吞吐,只会把等待从 queue_ms 挪到 inner_ms。 # inner_ms。增量路径(SHADOW_INCR=1,默认开)已把清空压到 300~560ms。
# 唯一出路是压单币耗时,走 init_stream/append_bar 增量路径(实测约 3.7x #
# 换算后十币 / 2 核清空约 265ms)。 # 再往下压的顺序见 HANDOFF §5.72(口径对齐后的实测):单币 inner 100ms ≈
# 信号链 30ms + 追加 2.81 根 37ms + 2 核争抢 33ms。争抢只有 1.49x,所以
# **加核收益有限**;最便宜的一刀是按币绑定 worker(现在 symbol 随机落
# worker,每份缓存都漏掉对方处理过的根,于是人人要追 2.81 根而非 1 根)。
SYMS="${SYMS:-BTC,ETH,SOL}" SYMS="${SYMS:-BTC,ETH,SOL}"
IMAGE="${SHADOW_IMAGE:-hummingbot/hummingbot:latest}" IMAGE="${SHADOW_IMAGE:-hummingbot/hummingbot:latest}"
HOURS="${HOURS:-168}" HOURS="${HOURS:-168}"
@@ -33,6 +36,10 @@ MAX_OFFSET_MS="${MAX_OFFSET_MS:-10}"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
OUT="$REPO_ROOT/research/out" 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; } die() { printf '\033[31m错误:%s\033[0m\n' "$*" >&2; exit 1; }
say() { printf '\n\033[1m==> %s\033[0m\n' "$*"; } 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" 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 "校验时钟同步" say "校验时钟同步"
offset_ms="" offset_ms=""
if command -v chronyc >/dev/null 2>&1; then 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_NOTIONAL="${TG_NOTIONAL:-500}" \
-e TG_LEVERAGE="${TG_LEVERAGE:-10}" \ -e TG_LEVERAGE="${TG_LEVERAGE:-10}" \
-e TG_STALE_S="${TG_STALE_S:-90}" \ -e TG_STALE_S="${TG_STALE_S:-90}" \
-e SIGNAL_BUS=/bus/signals_live.jsonl \
-v "$REPO_ROOT:/repo:ro" \ -v "$REPO_ROOT:/repo:ro" \
-v "$OUT:/out" \ -v "$OUT:/out" \
-v "$BUS_DIR:/bus" \
--entrypoint /opt/conda/envs/hummingbot/bin/python \ --entrypoint /opt/conda/envs/hummingbot/bin/python \
"$IMAGE" /repo/research/live/shadow_hb.py \ "$IMAGE" /repo/research/live/shadow_hb.py \
--hours "$HOURS" --workers "$WORKERS" --syms "$SYMS" >/dev/null --hours "$HOURS" --workers "$WORKERS" --syms "$SYMS" >/dev/null
+6 -2
View File
@@ -60,6 +60,7 @@ import json
import math import math
import os import os
import socket import socket
import sys
import time import time
from collections import deque from collections import deque
from concurrent.futures import ProcessPoolExecutor 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 from lib.shadow_budget import LAG_ALARM_MS, LAG_WINDOW, lag_healthy
import signal_bus # 总线模块住在生产子树 live/ 下。方向是刻意的:**生产不 import 研究侧**
import tg_notify # 研究侧反过来读生产持有的契约。见 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 要能合起来读,没有这一列就分不清哪行 # 站点标识。跨地对比时两台机器的 CSV 要能合起来读,没有这一列就分不清哪行
# 来自哪台。默认取主机名,部署脚本会显式传 SHADOW_SITE(如 sg-hetzner # 来自哪台。默认取主机名,部署脚本会显式传 SHADOW_SITE(如 sg-hetzner