生产与研究分家:实盘执行器独立成 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
+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 " 无"