生产与研究分家:实盘执行器独立成 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:
@@ -0,0 +1,193 @@
|
||||
"""Bitget v2 合约 REST 的最小客户端,只覆盖实盘执行要用的几个端点。
|
||||
|
||||
## 为什么不用 Hummingbot 下单
|
||||
|
||||
Hummingbot 的 Bitget 连接器只暴露 LIMIT / LIMIT_MAKER / MARKET,没有触发单。
|
||||
于是 `PositionExecutor` 的止损只能在本地控制循环里盯价、触发时才发市价单——
|
||||
**进程一死仓位就是裸的**。
|
||||
|
||||
而交易所本身完全支持:`place-order` 有 `presetStopLossPrice`,下单时就把止损
|
||||
挂到服务端。所以整个结构变成两个调用,止损从入场那一刻起就不依赖我们的进程
|
||||
存活。绕过连接器不是图省事,是为了消掉一整类故障。
|
||||
|
||||
## 止盈为什么不用 presetStopSurplusPrice
|
||||
|
||||
它触发后按**市价**执行。而成本模型里止盈是 maker——那 60% 的出场不吃滑点、
|
||||
按 maker 费率计(见 `lib/shadow_budget.LEG_IS_TAKER`)。用 preset 会让这部分
|
||||
变成 taker,预算模型就不成立了。所以止盈单独挂 `post_only` 的 reduce-only
|
||||
限价单。
|
||||
|
||||
止损反过来:必须是市价。stop-limit 在急跌里可能不成交,损失远大于省下的费。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
BASE = "https://api.bitget.com"
|
||||
PRODUCT = "usdt-futures"
|
||||
MARGIN_COIN = "USDT"
|
||||
|
||||
|
||||
class BitgetError(RuntimeError):
|
||||
def __init__(self, code: str, msg: str, path: str):
|
||||
super().__init__(f"{path} → [{code}] {msg}")
|
||||
self.code, self.msg = code, msg
|
||||
|
||||
|
||||
class Bitget:
|
||||
def __init__(self, key: str = "", secret: str = "", passphrase: str = "",
|
||||
dry: bool = False):
|
||||
self.key = key or os.environ.get("BITGET_API_KEY", "")
|
||||
self.secret = secret or os.environ.get("BITGET_API_SECRET", "")
|
||||
self.passphrase = passphrase or os.environ.get("BITGET_PASSPHRASE", "")
|
||||
self.dry = dry
|
||||
self._sess = None
|
||||
|
||||
def _sign(self, ts: str, method: str, path: str, body: str) -> str:
|
||||
msg = f"{ts}{method.upper()}{path}{body}"
|
||||
return base64.b64encode(hmac.new(
|
||||
self.secret.encode(), msg.encode(), hashlib.sha256).digest()
|
||||
).decode()
|
||||
|
||||
async def _req(self, method: str, path: str, params: dict | None = None,
|
||||
body: dict | None = None) -> dict:
|
||||
import aiohttp
|
||||
if self._sess is None:
|
||||
self._sess = aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=15))
|
||||
qs = ""
|
||||
if params:
|
||||
qs = "?" + "&".join(f"{k}={v}" for k, v in sorted(params.items()))
|
||||
payload = json.dumps(body) if body else ""
|
||||
ts = str(int(time.time() * 1000))
|
||||
headers = {
|
||||
"ACCESS-KEY": self.key,
|
||||
"ACCESS-SIGN": self._sign(ts, method, path + qs, payload),
|
||||
"ACCESS-PASSPHRASE": self.passphrase,
|
||||
"ACCESS-TIMESTAMP": ts,
|
||||
"Content-Type": "application/json",
|
||||
"locale": "en-US",
|
||||
}
|
||||
async with self._sess.request(method, BASE + path + qs,
|
||||
headers=headers,
|
||||
data=payload or None) as r:
|
||||
d = await r.json()
|
||||
if str(d.get("code")) != "00000":
|
||||
raise BitgetError(str(d.get("code")), str(d.get("msg")), path)
|
||||
return d.get("data")
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._sess is not None:
|
||||
await self._sess.close()
|
||||
self._sess = None
|
||||
|
||||
# ── 只读 ──────────────────────────────────────────────────────
|
||||
async def contracts(self) -> dict:
|
||||
"""合约规则。用于数量步长与价格 tick。"""
|
||||
d = await self._req("GET", "/api/v2/mix/market/contracts",
|
||||
{"productType": PRODUCT})
|
||||
return {c["symbol"]: c for c in d}
|
||||
|
||||
async def positions(self) -> list:
|
||||
d = await self._req("GET", "/api/v2/mix/position/all-position",
|
||||
{"productType": PRODUCT,
|
||||
"marginCoin": MARGIN_COIN})
|
||||
return [p for p in (d or []) if float(p.get("total") or 0) != 0]
|
||||
|
||||
async def fee_rate(self, symbol: str) -> dict:
|
||||
"""账户在该合约上的**实际**费率档。
|
||||
|
||||
这一项决定 ATR 门控阈值(约 5 + 1.1×taker_bp),进而决定可交易币池。
|
||||
接口的合约默认档是 VIP0,不是账户档,必须问这个端点。
|
||||
"""
|
||||
return await self._req("GET", "/api/v2/mix/market/query-position-lever",
|
||||
{"symbol": symbol, "productType": PRODUCT})
|
||||
|
||||
async def account(self) -> dict:
|
||||
return await self._req("GET", "/api/v2/mix/account/account",
|
||||
{"symbol": "BTCUSDT", "productType": PRODUCT,
|
||||
"marginCoin": MARGIN_COIN})
|
||||
|
||||
# ── 写 ────────────────────────────────────────────────────────
|
||||
async def set_leverage(self, symbol: str, lev: int,
|
||||
hold_side: str | None = None) -> dict:
|
||||
body = {"symbol": symbol, "productType": PRODUCT,
|
||||
"marginCoin": MARGIN_COIN, "leverage": str(lev)}
|
||||
if hold_side:
|
||||
body["holdSide"] = hold_side
|
||||
return await self._req("POST", "/api/v2/mix/account/set-leverage",
|
||||
body=body)
|
||||
|
||||
async def set_margin_mode(self, symbol: str,
|
||||
mode: str = "isolated") -> dict:
|
||||
return await self._req("POST", "/api/v2/mix/account/set-margin-mode",
|
||||
body={"symbol": symbol, "productType": PRODUCT,
|
||||
"marginCoin": MARGIN_COIN,
|
||||
"marginMode": mode})
|
||||
|
||||
async def entry_with_stop(self, symbol: str, side: str, size: str,
|
||||
stop_px: str, client_oid: str) -> dict:
|
||||
"""市价入场,**同时**把止损挂到服务端。
|
||||
|
||||
`presetStopLossPrice` 触发后按市价执行,这正是成本模型要的(止损是
|
||||
taker)。`clientOid` 给交易所级幂等——重发同一个 oid 会被拒,比本地
|
||||
去重可靠,因为「已发出但没收到回复」这种情况本地判不了。
|
||||
"""
|
||||
body = {"symbol": symbol, "productType": PRODUCT,
|
||||
"marginMode": "isolated", "marginCoin": MARGIN_COIN,
|
||||
"size": size, "side": side, "tradeSide": "open",
|
||||
"orderType": "market", "clientOid": client_oid,
|
||||
"presetStopLossPrice": stop_px}
|
||||
if self.dry:
|
||||
print(f" [dry] 入场+止损 {body}", flush=True)
|
||||
return {"orderId": "dry", "clientOid": client_oid}
|
||||
return await self._req("POST", "/api/v2/mix/order/place-order",
|
||||
body=body)
|
||||
|
||||
async def tp_limit(self, symbol: str, side: str, size: str, px: str,
|
||||
client_oid: str) -> dict:
|
||||
"""挂 maker 止盈。
|
||||
|
||||
`side` 传的是**平仓方向**(多头止盈是 sell)。`post_only` 保证是 maker:
|
||||
成本模型里止盈那 60% 按 maker 费率计且不吃滑点,用 taker 会破坏预算。
|
||||
`reduceOnly` 防止在单向模式下反手开出一个反向仓。
|
||||
"""
|
||||
body = {"symbol": symbol, "productType": PRODUCT,
|
||||
"marginMode": "isolated", "marginCoin": MARGIN_COIN,
|
||||
"size": size, "side": side, "tradeSide": "close",
|
||||
"orderType": "limit", "price": px, "force": "post_only",
|
||||
"reduceOnly": "YES", "clientOid": client_oid}
|
||||
if self.dry:
|
||||
print(f" [dry] 止盈限价 {body}", flush=True)
|
||||
return {"orderId": "dry", "clientOid": client_oid}
|
||||
return await self._req("POST", "/api/v2/mix/order/place-order",
|
||||
body=body)
|
||||
|
||||
async def close_market(self, symbol: str, hold_side: str,
|
||||
size: str, client_oid: str) -> dict:
|
||||
"""市价平(超时腿与对账用)。"""
|
||||
side = "sell" if hold_side == "long" else "buy"
|
||||
body = {"symbol": symbol, "productType": PRODUCT,
|
||||
"marginMode": "isolated", "marginCoin": MARGIN_COIN,
|
||||
"size": size, "side": side, "tradeSide": "close",
|
||||
"orderType": "market", "reduceOnly": "YES",
|
||||
"clientOid": client_oid}
|
||||
if self.dry:
|
||||
print(f" [dry] 市价平 {body}", flush=True)
|
||||
return {"orderId": "dry"}
|
||||
return await self._req("POST", "/api/v2/mix/order/place-order",
|
||||
body=body)
|
||||
|
||||
async def cancel_all(self, symbol: str) -> dict:
|
||||
body = {"symbol": symbol, "productType": PRODUCT,
|
||||
"marginCoin": MARGIN_COIN}
|
||||
if self.dry:
|
||||
print(f" [dry] 撤全部挂单 {symbol}", flush=True)
|
||||
return {}
|
||||
return await self._req("POST", "/api/v2/mix/order/cancel-all-orders",
|
||||
body=body)
|
||||
@@ -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、
|
||||
剩余半仓状态和已过根数,任一项猜错就跑成另一个收益结构,所以选择平掉。
|
||||
@@ -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
|
||||
@@ -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
|
||||
Executable
+81
@@ -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"
|
||||
Executable
+134
@@ -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"
|
||||
@@ -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
|
||||
Executable
+78
@@ -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 " 无"
|
||||
@@ -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
|
||||
@@ -0,0 +1,499 @@
|
||||
"""自动化小额实盘执行器。读信号总线,直接调 Bitget v2 REST 下单。
|
||||
|
||||
## 为什么不用 Hummingbot 的 PositionExecutor
|
||||
|
||||
它的连接器只暴露 LIMIT / LIMIT_MAKER / MARKET,没有触发单,于是
|
||||
`control_stop_loss()` 只能在本地盯价、触发时才发市价单——**进程一死仓位就是
|
||||
裸的**。而交易所本身支持 `place-order` 带 `presetStopLossPrice`,下单时就把
|
||||
止损挂到服务端。绕过连接器不是图省事,是为了消掉一整类故障。
|
||||
|
||||
另外 `TripleBarrierConfig` 只有单级止盈,装不下 3 ATR 减半 + 8 ATR 目标;
|
||||
自己写反而更短。
|
||||
|
||||
## 出场结构为什么能拆成两个半仓
|
||||
|
||||
回测结构是 2 ATR 止损 / 3 ATR 减半 / 8 ATR 目标 / 48 根超时,且**剩余半仓的
|
||||
止损保持在入场价的 2 ATR、不移动**。已核实 `research/lib/exit_model.py:151`——
|
||||
`runner_stops` 的 `ret` 是 `(entry - low[j]) / a`,从入场价算,且
|
||||
`RUNNER_STOP == SL == 2.0`。两半共用同一个不动的止损,所以:
|
||||
|
||||
半仓 A 市价入场 + 服务端止损 2 ATR · maker 止盈 3 ATR
|
||||
半仓 B 市价入场 + 服务端止损 2 ATR · maker 止盈 8 ATR
|
||||
|
||||
止损先到则两半都在 -2 ATR 出场;3 ATR 先到则 A 出场、B 继续且止损仍在 2 ATR。
|
||||
与回测逐情形一致。若哪天把 RUNNER_STOP 改成不等于 SL(比如移到成本),这个
|
||||
分解就**不再成立**,`assert_decomposable()` 会在启动时挡住。
|
||||
|
||||
## 三条出场腿各自挂在哪
|
||||
|
||||
止损 交易所侧(presetStopLossPrice,随入场单一起到)→ 进程死了仍在
|
||||
止盈 交易所侧(post_only reduce-only 限价) → 进程死了仍在
|
||||
超时 **本进程**,48 分钟到点市价平
|
||||
|
||||
所以进程死掉只会让持仓超过 48 根,不会变成裸仓——退化是良性的。
|
||||
|
||||
## 硬约束才是这个文件的重点
|
||||
|
||||
一笔止损只亏约 1 USDT,所以"亏损可控"对单笔成立。但三类故障的代价**不随仓位
|
||||
缩小**,必须显式封住:
|
||||
|
||||
失控下单 循环里的 bug 反复开仓,单笔小但笔数无界 → MAX_OPEN / MAX_DAY
|
||||
亏损累积 策略真的不行,但没人盯着 → MAX_DAY_LOSS
|
||||
裸仓 进程在"已入场、止损未挂"之间死掉 → 服务端止损 + 重启对账
|
||||
|
||||
## 为什么单独一个 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
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve()
|
||||
# 只插自己所在目录。**不要**把 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(",")
|
||||
NOTIONAL = float(os.environ.get("LIVE_NOTIONAL", "500"))
|
||||
LEVERAGE = int(os.environ.get("LIVE_LEVERAGE", "10"))
|
||||
|
||||
# ── 硬约束 ────────────────────────────────────────────────────────────
|
||||
# 并发仓位数。1 笔约占 50 USDT 保证金,3 笔 150 USDT。信号速率 5.3 笔/天、
|
||||
# 持仓 48 分钟,期望并发只有 0.18 笔,所以 3 已经很宽——超了说明有 bug
|
||||
MAX_OPEN = int(os.environ.get("LIVE_MAX_OPEN", "3"))
|
||||
# 日开仓上限。实测 5.3 笔/天,给 3 倍余量。这一条专门封"失控下单"
|
||||
MAX_DAY = int(os.environ.get("LIVE_MAX_DAY", "15"))
|
||||
# 日亏损上限(USDT)。一笔止损约 1 USDT,15 笔全亏 15 USDT
|
||||
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", LIVE_HOME / "state" / "live_state.json"))
|
||||
TRADES = Path(os.environ.get("LIVE_TRADES", LIVE_HOME / "state" / "live_trades.jsonl"))
|
||||
|
||||
# 出场结构从 exit_params 读,本文件不再抄一份字面量。抄一份的问题不是难看,
|
||||
# 是改了回测参数后这边不会跟上,而且不报错
|
||||
SL_ATR, SCALE_ATR, RUNNER_ATR = SL, SCALE_AT, RUNNER
|
||||
|
||||
|
||||
def assert_decomposable() -> None:
|
||||
"""两个半仓的分解依赖 RUNNER_STOP == SL,不成立就必须停机。
|
||||
|
||||
若有人把剩余半仓的止损改成移到成本(RUNNER_STOP=0)或任何 != SL 的值,
|
||||
这个分解就变成"两半共用同一止损"的错误近似,实盘跑的是另一个收益结构,
|
||||
而且不会报错。所以在启动时硬挡。
|
||||
"""
|
||||
if float(RUNNER_STOP) != float(SL):
|
||||
raise SystemExit(
|
||||
f"⛔ RUNNER_STOP({RUNNER_STOP}) != SL({SL}),两个半仓的分解不再\n"
|
||||
f" 成立。live_exec 的出场结构会与回测不一致且不报错。\n"
|
||||
f" 要改成单执行器 + 手工两级止盈,或把这两个值改回一致。")
|
||||
|
||||
|
||||
class Guard:
|
||||
"""硬约束与当日计数。状态落盘,重启后不清零。
|
||||
|
||||
不落盘的话,进程反复重启就等于反复重置日上限——"失控下单"这一类恰好常常
|
||||
伴随反复重启,那时上限必须还记得。
|
||||
"""
|
||||
|
||||
def __init__(self, path: Path = STATE):
|
||||
self.path = path
|
||||
self.day = time.strftime("%Y-%m-%d")
|
||||
self.n_day = 0
|
||||
self.pnl_day = 0.0
|
||||
self.done: set = set()
|
||||
self._load()
|
||||
|
||||
def _load(self) -> None:
|
||||
try:
|
||||
d = json.loads(self.path.read_text())
|
||||
except Exception:
|
||||
return
|
||||
# 跨日则计数归零,但已处理过的信号键要保留,否则会重开旧仓
|
||||
if d.get("day") == self.day:
|
||||
self.n_day = int(d.get("n_day", 0))
|
||||
self.pnl_day = float(d.get("pnl_day", 0.0))
|
||||
self.done = set(d.get("done", []))
|
||||
|
||||
def save(self) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self.path.with_suffix(".tmp")
|
||||
# 原子替换:直接覆写时若在写一半崩溃,状态文件会变成半个 JSON,
|
||||
# 重启后读不出来 → 日计数归零 → 上限失效
|
||||
tmp.write_text(json.dumps({
|
||||
"day": self.day, "n_day": self.n_day, "pnl_day": self.pnl_day,
|
||||
# 只留最近的,否则文件无界增长
|
||||
"done": sorted(self.done)[-5000:]}))
|
||||
tmp.replace(self.path)
|
||||
|
||||
def roll(self) -> None:
|
||||
today = time.strftime("%Y-%m-%d")
|
||||
if today != self.day:
|
||||
print(f" [guard] 跨日 {self.day} → {today},"
|
||||
f"当日 {self.n_day} 笔 / PnL {self.pnl_day:+.2f} USDT",
|
||||
flush=True)
|
||||
self.day, self.n_day, self.pnl_day = today, 0, 0.0
|
||||
self.save()
|
||||
|
||||
def blocks(self, key: str, n_open: int) -> str | None:
|
||||
"""返回拒绝原因,None 表示放行。"""
|
||||
self.roll()
|
||||
if key in self.done:
|
||||
return "已处理过(幂等)"
|
||||
if n_open >= MAX_OPEN:
|
||||
return f"并发仓位已达上限 {MAX_OPEN}"
|
||||
if self.n_day >= MAX_DAY:
|
||||
return f"当日开仓已达上限 {MAX_DAY}"
|
||||
if self.pnl_day <= -MAX_DAY_LOSS:
|
||||
return (f"当日亏损 {self.pnl_day:.2f} 已达上限 "
|
||||
f"-{MAX_DAY_LOSS},停止开新仓")
|
||||
return None
|
||||
|
||||
def took(self, key: str) -> None:
|
||||
self.done.add(key)
|
||||
self.n_day += 1
|
||||
self.save()
|
||||
|
||||
def realized(self, pnl: float) -> None:
|
||||
self.pnl_day += pnl
|
||||
self.save()
|
||||
|
||||
|
||||
def legs() -> list[dict]:
|
||||
"""两个半仓的止盈位,用 ATR 倍数表达。
|
||||
|
||||
止损两半相同(SL_ATR),所以不写在这里——它在 open_position 里算一次。
|
||||
"""
|
||||
return [{"tag": "scale", "atr": SCALE_ATR},
|
||||
{"tag": "runner", "atr": RUNNER_ATR}]
|
||||
|
||||
|
||||
def oid_of(key: str, tag: str) -> str:
|
||||
"""把信号键变成交易所能接受的 clientOid。
|
||||
|
||||
信号键形如 `SOL:1787904388411:+1`,里面的 `:` 和 `+` 未必被交易所接受,
|
||||
带过去会直接拒单——而拒单发生在入场腿上,等于这笔信号静默漏掉。只留
|
||||
字母数字和下划线。
|
||||
|
||||
clientOid 是**交易所级幂等**:重发同一个 oid 会被拒。这比本地去重可靠,
|
||||
因为「已发出但没收到回复」这种情况本地判不了,重试就会开两次仓。
|
||||
"""
|
||||
# 方向必须显式编码:直接把非字母数字换成下划线,会让 `+1` 和 `-1` 都变成
|
||||
# `_1`,同一根上的多空信号得到相同 oid,第二笔被交易所当重复拒掉
|
||||
k = key.replace(":+1", ":L").replace(":-1", ":S")
|
||||
safe = "".join(c if c.isalnum() else "_" for c in f"{k}_{tag}")
|
||||
return safe[:60]
|
||||
|
||||
|
||||
def log_trade(rec: dict, path: Path = TRADES) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("a") as f:
|
||||
f.write(json.dumps(rec) + "\n")
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
|
||||
|
||||
class Exec:
|
||||
def __init__(self, dry: bool, bus: Path):
|
||||
self.dry = dry
|
||||
self.bus = bus
|
||||
self.guard = Guard()
|
||||
self.api: Bitget | None = None
|
||||
self.rules: dict = {}
|
||||
self.execs: dict = {} # key → 该笔的腿与超时时刻
|
||||
self.offset = 0 # 已读到总线的哪一行
|
||||
self.n_seen = self.n_took = self.n_skip = 0
|
||||
|
||||
# ── 启动 ──────────────────────────────────────────────────────
|
||||
async def start(self) -> None:
|
||||
assert_decomposable()
|
||||
# 只从"现在"往后做。历史信号的参考成交价早已过期,补做等于随机入场
|
||||
self.offset = sum(1 for _ in signal_bus.read_all(self.bus))
|
||||
print(f" 总线已有 {self.offset} 条历史信号,全部跳过(参考价已过期)",
|
||||
flush=True)
|
||||
|
||||
self.api = Bitget(dry=self.dry)
|
||||
self.rules = await self.api.contracts()
|
||||
print(f" 合约规则 {len(self.rules)} 个", flush=True)
|
||||
if self.dry:
|
||||
print(" ⚠ 空跑模式:不下真单", flush=True)
|
||||
return
|
||||
if not (self.api.key and self.api.secret and self.api.passphrase):
|
||||
raise SystemExit("⛔ 缺 BITGET_API_KEY / SECRET / PASSPHRASE。"
|
||||
"先跑 --dry-run。")
|
||||
await self.setup_symbols()
|
||||
await self.reconcile()
|
||||
|
||||
async def setup_symbols(self) -> None:
|
||||
"""逐仓 + 杠杆。每次启动都设一遍,不假设交易所侧的状态。
|
||||
|
||||
杠杆若被人在 App 里改过,仓位大小就不是我们算的那个。设成幂等操作比
|
||||
读回来核对简单,且失败会直接暴露。
|
||||
"""
|
||||
for s in SYMS:
|
||||
sym = f"{s}USDT"
|
||||
for fn, arg in ((self.api.set_margin_mode, "isolated"),
|
||||
(self.api.set_leverage, LEVERAGE)):
|
||||
try:
|
||||
await fn(sym, arg)
|
||||
except Exception as e:
|
||||
print(f" ⚠ {sym} 设置失败 {type(e).__name__}: {e}",
|
||||
flush=True)
|
||||
print(f" 已设 {len(SYMS)} 个币为逐仓 {LEVERAGE}x", flush=True)
|
||||
|
||||
async def reconcile(self) -> None:
|
||||
"""启动时把交易所的实际持仓对上。
|
||||
|
||||
崩溃重启后交易所可能还有仓位。它们的服务端止损仍在(presetStopLossPrice
|
||||
挂在交易所侧,不随进程消失),但**超时腿丢了**,会一直持有到止损或止盈。
|
||||
|
||||
选择平掉而非接管:接管要重建入场价、ATR、剩余半仓状态和已过根数,任一项
|
||||
猜错就让出场结构变成另一个东西且不报错;平掉的代价只是一笔小额亏损,
|
||||
且行为确定。
|
||||
"""
|
||||
try:
|
||||
pos = await self.api.positions()
|
||||
except Exception as e:
|
||||
print(f" ⚠ 对账读持仓失败 {type(e).__name__}: {e}", flush=True)
|
||||
return
|
||||
if not pos:
|
||||
print(" 对账:交易所无持仓,干净启动", flush=True)
|
||||
return
|
||||
print(f" ⚠ 对账:发现 {len(pos)} 个遗留持仓,撤挂单后市价平掉",
|
||||
flush=True)
|
||||
for p in pos:
|
||||
sym, hs, sz = p["symbol"], p["holdSide"], p["total"]
|
||||
print(f" {sym} {hs} {sz} @ {p.get('openPriceAvg')}",
|
||||
flush=True)
|
||||
try:
|
||||
await self.api.cancel_all(sym)
|
||||
await self.api.close_market(
|
||||
sym, hs, sz, f"recon:{int(time.time() * 1000)}")
|
||||
log_trade({"ev": "reconcile_flatten", "symbol": sym,
|
||||
"hold_side": hs, "size": sz})
|
||||
except Exception as e:
|
||||
print(f" ⛔ 平仓失败 {type(e).__name__}: {e},"
|
||||
f"需人工介入", flush=True)
|
||||
|
||||
# ── 主循环 ────────────────────────────────────────────────────
|
||||
async def poll(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
await self.step()
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f" ⛔ 主循环异常 {type(e).__name__}: {e}", flush=True)
|
||||
traceback.print_exc()
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
async def step(self) -> None:
|
||||
recs = list(signal_bus.read_all(self.bus))
|
||||
if len(recs) <= self.offset:
|
||||
return
|
||||
new, self.offset = recs[self.offset:], len(recs)
|
||||
for r in new:
|
||||
self.n_seen += 1
|
||||
await self.on_signal(r)
|
||||
|
||||
async def on_signal(self, r: dict) -> None:
|
||||
age = time.time() - r["emit_ms"] / 1000.0
|
||||
n_open = sum(1 for v in self.execs.values() if v)
|
||||
why = self.guard.blocks(r["key"], n_open)
|
||||
if why is None and age > STALE_S:
|
||||
why = f"信号已过期 {age:.1f}s > {STALE_S:.0f}s"
|
||||
if why:
|
||||
self.n_skip += 1
|
||||
print(f" ⊘ {r['key']} 跳过:{why}", flush=True)
|
||||
log_trade({"ev": "skip", "key": r["key"], "why": why,
|
||||
"age_s": round(age, 2)})
|
||||
return
|
||||
|
||||
self.guard.took(r["key"])
|
||||
self.n_took += 1
|
||||
lg = legs()
|
||||
side = "LONG" if r["direction"] > 0 else "SHORT"
|
||||
print(f" ▶ {r['key']} {side} 名义 {NOTIONAL:.0f} {LEVERAGE}x "
|
||||
f"· 延后 {age:.1f}s · ATR {r['atr_pct'] * 1e4:.1f}bp",
|
||||
flush=True)
|
||||
for x in lg:
|
||||
print(f" {x['tag']:<7}止盈 {x['atr']:.0f} ATR = "
|
||||
f"{x['atr'] * r['atr_pct'] * 1e4:.1f}bp · 止损 "
|
||||
f"{SL_ATR * r['atr_pct'] * 1e4:.1f}bp · 超时 {MAXB}min",
|
||||
flush=True)
|
||||
log_trade({"ev": "entry", "key": r["key"], "side": side,
|
||||
"entry_px": r["entry_px"], "atr_pct": r["atr_pct"],
|
||||
"notional": NOTIONAL, "leverage": LEVERAGE,
|
||||
"age_s": round(age, 2), "legs": lg, "dry": self.dry})
|
||||
# 空跑也要走完 open_position:数量取整、价位对齐 tick、请求体构造都在
|
||||
# 那里,跳过等于什么都没验。不下真单由 Bitget(dry=True) 负责
|
||||
await self.open_position(r, lg)
|
||||
|
||||
def qty_of(self, sym: str, entry: float) -> tuple[str, str]:
|
||||
"""入场量与半仓量,都对齐步长。
|
||||
|
||||
入场量取到**步长的偶数倍**,半仓才是精确一半。不这么做 SOL 的半仓会是
|
||||
全仓的 43%(步长 0.1 币 ≈ 10.7 USDT),而回测假设 50/50。
|
||||
"""
|
||||
r = self.rules.get(f"{sym}USDT")
|
||||
if not r:
|
||||
raise RuntimeError(f"{sym} 没有合约规则")
|
||||
step = Decimal(str(r["sizeMultiplier"]))
|
||||
px = Decimal(str(entry))
|
||||
grid = step * 2
|
||||
n = max(Decimal("1"),
|
||||
(Decimal(str(NOTIONAL)) / px / grid).quantize(Decimal("1")))
|
||||
qty = n * grid
|
||||
return str(qty), str(qty / 2)
|
||||
|
||||
def snap(self, sym: str, px: float) -> str:
|
||||
r = self.rules[f"{sym}USDT"]
|
||||
tick = Decimal(str(r["priceEndStep"])) * (
|
||||
Decimal(10) ** -int(r["pricePlace"]))
|
||||
q = (Decimal(str(px)) / tick).quantize(Decimal("1")) * tick
|
||||
return str(q)
|
||||
|
||||
async def open_position(self, r: dict, lg: list[dict]) -> None:
|
||||
"""两笔「市价入场 + 服务端止损」,再各挂一个 maker 止盈。
|
||||
|
||||
止损随入场单一起到交易所(presetStopLossPrice),所以不存在"已入场、
|
||||
止损未挂"的裸仓窗口——那是本地盯价方案最危险的一段。
|
||||
|
||||
止盈单独挂 post_only 限价:成本模型里止盈按 maker 计且不吃滑点,用
|
||||
preset(触发后市价)会让这部分变成 taker,预算就不成立了。
|
||||
"""
|
||||
sym, d = r["sym"], r["direction"]
|
||||
pair = f"{sym}USDT"
|
||||
entry = r["entry_px"]
|
||||
a = entry * r["atr_pct"]
|
||||
_, half = self.qty_of(sym, entry)
|
||||
side = "buy" if d > 0 else "sell"
|
||||
close_side = "sell" if d > 0 else "buy"
|
||||
hold = "long" if d > 0 else "short"
|
||||
stop_px = self.snap(sym, entry - d * SL_ATR * a)
|
||||
|
||||
opened = []
|
||||
for x in lg:
|
||||
oid = oid_of(r["key"], x["tag"])
|
||||
try:
|
||||
await self.api.entry_with_stop(pair, side, half, stop_px, oid)
|
||||
except Exception as e:
|
||||
print(f" ⛔ {x['tag']} 入场失败 {e}", flush=True)
|
||||
log_trade({"ev": "entry_fail", "key": r["key"],
|
||||
"tag": x["tag"], "err": str(e)})
|
||||
continue
|
||||
tp_px = self.snap(sym, entry + d * x["atr"] * a)
|
||||
try:
|
||||
await self.api.tp_limit(pair, close_side, half, tp_px,
|
||||
oid + "-tp")
|
||||
except Exception as e:
|
||||
# 入场成了但止盈没挂上:仓位仍有服务端止损,不是裸仓。
|
||||
# 超时腿会兜住它,所以只告警不强平
|
||||
print(f" ⚠ {x['tag']} 止盈挂单失败 {e}"
|
||||
f"(仓位有服务端止损,超时腿会兜)", flush=True)
|
||||
opened.append({"tag": x["tag"], "oid": oid, "size": half,
|
||||
"tp_px": tp_px, "stop_px": stop_px})
|
||||
print(f" {x['tag']:<7}{half} 币 · 止损 {stop_px} · "
|
||||
f"止盈 {tp_px}", flush=True)
|
||||
|
||||
if opened:
|
||||
self.execs[r["key"]] = {
|
||||
"sym": sym, "pair": pair, "hold": hold,
|
||||
"deadline": time.time() + MAXB * 60, "legs": opened}
|
||||
log_trade({"ev": "opened", "key": r["key"], "legs": opened,
|
||||
"stop_px": stop_px})
|
||||
|
||||
async def sweep(self) -> None:
|
||||
"""超时腿:48 分钟到点市价平。
|
||||
|
||||
这是唯一必须靠本进程存活的出场腿。止损与止盈都在交易所侧,所以进程
|
||||
死掉只会让持仓超过 48 根,不会变成裸仓——退化是良性的。
|
||||
"""
|
||||
while True:
|
||||
await asyncio.sleep(5)
|
||||
now = time.time()
|
||||
for key, st in list(self.execs.items()):
|
||||
if now < st["deadline"]:
|
||||
continue
|
||||
try:
|
||||
pos = [p for p in await self.api.positions()
|
||||
if p["symbol"] == st["pair"]]
|
||||
if not pos:
|
||||
print(f" ◀ {key} 超时前已全部出场", flush=True)
|
||||
log_trade({"ev": "timeout_noop", "key": key})
|
||||
else:
|
||||
for p in pos:
|
||||
await self.api.cancel_all(st["pair"])
|
||||
await self.api.close_market(
|
||||
st["pair"], p["holdSide"], p["total"],
|
||||
oid_of(key, "timeout"))
|
||||
print(f" ◀ {key} 超时市价平 {pos[0]['total']} 币",
|
||||
flush=True)
|
||||
log_trade({"ev": "timeout_close", "key": key,
|
||||
"size": pos[0]["total"]})
|
||||
except Exception as e:
|
||||
print(f" ⛔ {key} 超时平仓失败 {type(e).__name__}: {e}",
|
||||
flush=True)
|
||||
continue
|
||||
self.execs.pop(key, None)
|
||||
|
||||
async def heartbeat(self) -> None:
|
||||
while True:
|
||||
await asyncio.sleep(300)
|
||||
n_open = sum(1 for v in self.execs.values() if v)
|
||||
print(f" [心跳] 见信号 {self.n_seen} · 已做 {self.n_took} · "
|
||||
f"跳过 {self.n_skip} · 在场 {n_open}/{MAX_OPEN} · "
|
||||
f"当日 {self.guard.n_day}/{MAX_DAY} 笔 · "
|
||||
f"当日 PnL {self.guard.pnl_day:+.2f}/-{MAX_DAY_LOSS}",
|
||||
flush=True)
|
||||
|
||||
async def run(self) -> None:
|
||||
await self.start()
|
||||
await asyncio.gather(self.poll(), self.sweep(), self.heartbeat())
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--dry-run", action="store_true",
|
||||
help="不连交易所、不下单,只验总线与约束逻辑")
|
||||
ap.add_argument("--bus", default=str(signal_bus.BUS))
|
||||
a = ap.parse_args()
|
||||
|
||||
print(f"实盘执行器 · 名义 {NOTIONAL:.0f} USDT · {LEVERAGE}x · "
|
||||
f"并发≤{MAX_OPEN} · 日开仓≤{MAX_DAY} · 日亏损≤{MAX_DAY_LOSS}")
|
||||
asyncio.run(Exec(a.dry_run, Path(a.bus)).run())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,8 @@
|
||||
# 生产执行器的全部依赖。**保持这个文件只有一行。**
|
||||
#
|
||||
# 每加一个包都要能回答"实盘进程崩在这个包里我怎么办"。numpy/pandas/pyarrow
|
||||
# 曾经因为读两个常量被拖进来(见 live/exit_params.py 的说明),已经切掉。
|
||||
#
|
||||
# 版本下限的理由:3.9 起 aiohttp 才在 Python 3.12+ 上稳定编译;不锁上限是
|
||||
# 因为这里只用 ClientSession.request 这一个最稳定的 API 面。
|
||||
aiohttp>=3.9
|
||||
@@ -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()
|
||||
@@ -0,0 +1,78 @@
|
||||
"""影子把过滤网的信号写到这里,实盘执行器读这里。
|
||||
|
||||
## 为什么不让实盘自己算信号
|
||||
|
||||
三个理由,第三个最要紧:
|
||||
|
||||
1. 2 核上再来一份十币计算,清空会从 247ms 推到 500ms+
|
||||
2. 实盘进程崩溃不该影响正在采的数据集
|
||||
3. **实盘交易的必须是影子测量的那一个信号。** 各算一份会让两边悄悄分叉,
|
||||
之后就没法把实盘的实际成交和影子测的滑点曲线对照——而那个对照是整件事
|
||||
的目的
|
||||
|
||||
## 为什么用 append-only 文件而不是队列
|
||||
|
||||
崩溃安全 + 留审计轨迹。实盘进程重启后能从文件里看到自己漏掉了哪些信号,
|
||||
而不是像内存队列那样直接消失。文件也让"影子在跑、实盘没在跑"这种状态成为
|
||||
可观测的(信号在攒着),而不是静默丢弃。
|
||||
|
||||
每行一个 JSON,字段见 `emit`。`key` 是幂等键,实盘按它去重——同一根被重复
|
||||
处理(补根、进程池重建后重放)不该开两次仓。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
# 默认不落在仓库里:git checkout/clean 会动仓库,而这里是跨进程(甚至跨机)
|
||||
# 的交接点,被清掉就等于信号静默丢失。产信号的一侧和消费的一侧各自指到
|
||||
# 自己的路径即可,同机时指到同一个文件
|
||||
BUS = Path(os.environ.get(
|
||||
"SIGNAL_BUS", Path.home() / "chan-live" / "state" / "signals_live.jsonl"))
|
||||
|
||||
|
||||
def key_of(sym: str, kline_ts: int, direction: int) -> str:
|
||||
return f"{sym}:{int(kline_ts)}:{int(direction):+d}"
|
||||
|
||||
|
||||
def emit(sym: str, kline_ts: int, direction: int, entry_px: float,
|
||||
atr_pct: float, lag_ms: float, path: Path | None = None) -> None:
|
||||
"""追写一条信号。任何失败只打日志——总线写不进去不能连坐采集。
|
||||
|
||||
`entry_px` 是次根开盘价,也就是回测口径的成交价。实盘据此算止损/止盈的
|
||||
绝对价位,**不要**用实盘自己看到的现价,否则价位会随执行延迟漂移,跑的
|
||||
就不是回测那个结构。
|
||||
"""
|
||||
p = path or BUS
|
||||
rec = {"key": key_of(sym, kline_ts, direction),
|
||||
"sym": sym, "kline_ts": int(kline_ts),
|
||||
"direction": int(direction),
|
||||
"entry_px": float(entry_px), "atr_pct": float(atr_pct),
|
||||
"lag_ms": float(lag_ms),
|
||||
"emit_ms": int(time.time() * 1000)}
|
||||
try:
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
with p.open("a") as f:
|
||||
f.write(json.dumps(rec) + "\n")
|
||||
f.flush()
|
||||
# 实盘要在毫秒级看到,且进程被 SIGKILL 时不能丢——这两点都要求
|
||||
# 落到磁盘,不能只停在 libc 缓冲里
|
||||
os.fsync(f.fileno())
|
||||
except Exception as e:
|
||||
print(f" [bus] 写信号失败 {type(e).__name__}: {e}", flush=True)
|
||||
|
||||
|
||||
def read_all(path: Path | None = None):
|
||||
"""读全部信号。坏行跳过——半行只可能出现在文件末尾的崩溃点。"""
|
||||
p = path or BUS
|
||||
if not p.exists():
|
||||
return
|
||||
for line in p.read_text().splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
yield json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
Reference in New Issue
Block a user