Files
Chan/live/live_exec.py
T
jackandCursor 335d891478 生产与研究分家:实盘执行器独立成 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>
2026-08-28 17:03:03 +08:00

500 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""自动化小额实盘执行器。读信号总线,直接调 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 USDT15 笔全亏 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()