From f11c6507b0c2bc0276385e3afff59dd4bd7f49a0 Mon Sep 17 00:00:00 2001 From: jack Date: Fri, 28 Aug 2026 16:07:28 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=9B=98=E6=89=A7=E8=A1=8C=E6=94=B9?= =?UTF-8?q?=E8=B5=B0=20Bitget=20v2=20REST=EF=BC=8C=E6=AD=A2=E6=8D=9F?= =?UTF-8?q?=E6=8C=82=E5=88=B0=E6=9C=8D=E5=8A=A1=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 丢掉 Hummingbot 的 PositionExecutor。它的连接器只暴露 LIMIT / LIMIT_MAKER / MARKET,没有触发单,于是 control_stop_loss() 只能本地盯价、触发时才发市价单 ——**进程一死仓位就是裸的**。而交易所本身支持 place-order 带 presetStopLossPrice,下单时就把止损挂到服务端。绕过连接器不是图省事,是为了 消掉一整类故障。顺带 TripleBarrierConfig 只有单级止盈,装不下两级,自己写更短。 ## 三条出场腿各自挂在哪 止损 交易所侧(presetStopLossPrice,随入场单一起到)→ 进程死了仍在 止盈 交易所侧(post_only reduce-only 限价) → 进程死了仍在 超时 本进程,48 分钟到点市价平 所以进程死掉只会让持仓超过 48 根,不会变成裸仓,退化是良性的。 ## 止盈不能用 presetStopSurplusPrice 它触发后按市价执行,而成本模型里止盈是 maker——那 60% 的出场不吃滑点、按 maker 费率计(LEG_IS_TAKER)。用 preset 会让这部分变成 taker,预算就不成立。 所以止盈单独挂 post_only + reduceOnly 限价单。止损反过来必须市价:stop-limit 在急跌里可能不成交,损失远大于省下的费。 ## clientOid 是交易所级幂等,但要小心两个坑 信号键形如 SOL:1787904388411:+1,`:` 和 `+` 未必被接受,带过去直接拒单——而 拒单发生在入场腿上,等于这笔信号静默漏掉。 清洗时不能简单把非字母数字换成下划线:那样 `+1` 和 `-1` 都变成 `_1`,同一根上 的多空信号得到相同 oid,第二笔被当重复拒掉。方向显式编码为 L/S。 用 clientOid 而非只靠本地去重,是因为「已发出但没收到回复」这种情况本地判不了, 重试就会开两次仓。 ## 空跑要走完 open_position 第一版在 on_signal 里 `if dry: return`,结果数量取整、价位对齐 tick、请求体 构造全都没被验到。现在空跑走完整条路径,不下真单由 Bitget(dry=True) 负责。 实测两笔:SOL 多头 entry 106.706 / ATR 11bp → 止损 106.471、止盈 107.058 与 107.645;BTC 空头 → 止损在上方 79747.5、止盈在下方。半仓精确一半(SOL 2.3+2.3、 BTC 0.0031+0.0031)。 Co-authored-by: Cursor --- research/live/bitget_rest.py | 193 +++++++++++++++++++ research/live/live_exec.py | 360 ++++++++++++++++++++--------------- 2 files changed, 402 insertions(+), 151 deletions(-) create mode 100644 research/live/bitget_rest.py diff --git a/research/live/bitget_rest.py b/research/live/bitget_rest.py new file mode 100644 index 0000000..80ee06f --- /dev/null +++ b/research/live/bitget_rest.py @@ -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) diff --git a/research/live/live_exec.py b/research/live/live_exec.py index 9698dfb..e1d5f4c 100644 --- a/research/live/live_exec.py +++ b/research/live/live_exec.py @@ -1,39 +1,45 @@ -"""自动化小额实盘执行器。读信号总线,用 Hummingbot 的 PositionExecutor 下单。 +"""自动化小额实盘执行器。读信号总线,直接调 Bitget v2 REST 下单。 -## 出场结构为什么能分解成两个半仓 +## 为什么不用 Hummingbot 的 PositionExecutor -回测的结构是:2 ATR 止损 / 3 ATR 减半 / 8 ATR 目标 / 48 根超时,且**剩余半仓 -的止损保持在入场价的 2 ATR、不移动**。这一条已在 `lib/exit_model.py:151` -核实——`runner_stops` 的 `ret` 是 `(entry - low[j]) / a`,从入场价算,且 -`RUNNER_STOP == SL == 2.0`。 +它的连接器只暴露 LIMIT / LIMIT_MAKER / MARKET,没有触发单,于是 +`control_stop_loss()` 只能在本地盯价、触发时才发市价单——**进程一死仓位就是 +裸的**。而交易所本身支持 `place-order` 带 `presetStopLossPrice`,下单时就把 +止损挂到服务端。绕过连接器不是图省事,是为了消掉一整类故障。 -Hummingbot 的 `TripleBarrierConfig` 只有单级止盈,装不下两级。但因为两个半仓 -共用同一个不动的止损,结构可以**精确分解**: +另外 `TripleBarrierConfig` 只有单级止盈,装不下 3 ATR 减半 + 8 ATR 目标; +自己写反而更短。 - 半仓 A 市价入场 · TP 3 ATR · SL 2 ATR · 48 分钟超时 - 半仓 B 市价入场 · TP 8 ATR · SL 2 ATR · 48 分钟超时 +## 出场结构为什么能拆成两个半仓 -止损先到则两半都在 -2 ATR 出场;3 ATR 先到则 A 出场、B 继续持有且止损仍在 -2 ATR。与回测逐情形一致。若哪天把 RUNNER_STOP 改成不等于 SL(比如移到成本), -这个分解就**不再成立**,必须改回单执行器加手工两级——`assert_decomposable()` -会在启动时挡住这种情况。 +回测结构是 2 ATR 止损 / 3 ATR 减半 / 8 ATR 目标 / 48 根超时,且**剩余半仓的 +止损保持在入场价的 2 ATR、不移动**。已核实 `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 -Bitget 连接器只支持 LIMIT / LIMIT_MAKER / MARKET,没有触发单。 -`PositionExecutor.control_stop_loss()` 是在本地控制循环里盯价、触发时才发市价 -单。所以**进程一死,仓位就是裸的**。10x 下强平需逆向 10%(约 100 个 ATR), -48 分钟内极不可能,单次代价仍封在保证金内;但这一类靠交易所侧的兜底止损才能 -真正消掉,见 `backstop.py`。 +止损先到则两半都在 -2 ATR 出场;3 ATR 先到则 A 出场、B 继续且止损仍在 2 ATR。 +与回测逐情形一致。若哪天把 RUNNER_STOP 改成不等于 SL(比如移到成本),这个 +分解就**不再成立**,`assert_decomposable()` 会在启动时挡住。 + +## 三条出场腿各自挂在哪 + + 止损 交易所侧(presetStopLossPrice,随入场单一起到)→ 进程死了仍在 + 止盈 交易所侧(post_only reduce-only 限价) → 进程死了仍在 + 超时 **本进程**,48 分钟到点市价平 + +所以进程死掉只会让持仓超过 48 根,不会变成裸仓——退化是良性的。 ## 硬约束才是这个文件的重点 -一笔止损只亏约 1 USDT,所以"亏损可控"对单笔是成立的。但有三类故障的代价 -**不随仓位缩小**,必须显式封住: +一笔止损只亏约 1 USDT,所以"亏损可控"对单笔成立。但三类故障的代价**不随仓位 +缩小**,必须显式封住: 失控下单 循环里的 bug 反复开仓,单笔小但笔数无界 → MAX_OPEN / MAX_DAY - 裸仓 进程在"已入场、止损未挂"之间死掉 → backstop + 重启对账 - 亏损累积 策略真的不行,但没人盯着 → MAX_DAY_LOSS + 亏损累积 策略真的不行,但没人盯着 → MAX_DAY_LOSS + 裸仓 进程在"已入场、止损未挂"之间死掉 → 服务端止损 + 重启对账 python research/live/live_exec.py --dry-run # 只打印不下单 """ @@ -53,7 +59,10 @@ sys.path.insert(0, str(HERE.parents[1])) sys.path.insert(0, str(HERE.parent)) import signal_bus # noqa: E402 +from bitget_rest import Bitget # noqa: E402 +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")) @@ -159,17 +168,30 @@ class Guard: self.save() -def legs(entry: float, atr_pct: float, direction: int) -> list[dict]: - """两个半仓的三重门参数。 +def legs() -> list[dict]: + """两个半仓的止盈位,用 ATR 倍数表达。 - 止盈/止损用**比例**表达(Hummingbot 的 TripleBarrierConfig 就是比例), - 所以直接用 ATR 的相对值,不必换成绝对价位。 + 止损两半相同(SL_ATR),所以不写在这里——它在 open_position 里算一次。 """ - a = atr_pct - return [ - {"tag": "scale", "tp": SCALE_ATR * a, "sl": SL_ATR * a}, - {"tag": "runner", "tp": RUNNER_ATR * a, "sl": SL_ATR * a}, - ] + 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: @@ -185,81 +207,82 @@ class Exec: self.dry = dry self.bus = bus self.guard = Guard() - self.conn = None - self.execs: dict = {} # key → [PositionExecutor, ...] + 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) - if self.dry: - print(" ⚠ 空跑模式:不连交易所、不下真单", flush=True) - return - key = os.environ.get("BITGET_API_KEY", "") - sec = os.environ.get("BITGET_API_SECRET", "") - pas = os.environ.get("BITGET_PASSPHRASE", "") - if not (key and sec and pas): + 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 验链路。") - from hummingbot.connector.derivative.bitget_perpetual.bitget_perpetual_derivative import ( # noqa: E501 - BitgetPerpetualDerivative, - ) - syms = os.environ.get( - "SYMS", "BTC,ETH,SOL,BNB,XRP,DOGE,ADA,AVAX,LINK,LTC").split(",") - self.conn = BitgetPerpetualDerivative( - bitget_perpetual_api_key=key, bitget_perpetual_secret_key=sec, - bitget_perpetual_passphrase=pas, - trading_pairs=[f"{s}-USDT" for s in syms], trading_required=True) - await self.conn.start_network() - for _ in range(60): - await asyncio.sleep(1) - if self.conn.ready: - break + "先跑 --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: """启动时把交易所的实际持仓对上。 - 进程崩溃重启后交易所可能还有仓位,而我们的 executor 全没了——那些仓位 - 没有任何止损在盯。**必须平掉而不是接管**:接管要重建入场价、ATR、剩余 - 半仓状态和已过的根数,任一项猜错都会让出场结构变成另一个东西,而平掉 - 的代价只是一笔小额亏损,且行为确定。 + 崩溃重启后交易所可能还有仓位。它们的服务端止损仍在(presetStopLossPrice + 挂在交易所侧,不随进程消失),但**超时腿丢了**,会一直持有到止损或止盈。 + + 选择平掉而非接管:接管要重建入场价、ATR、剩余半仓状态和已过根数,任一项 + 猜错就让出场结构变成另一个东西且不报错;平掉的代价只是一笔小额亏损, + 且行为确定。 """ try: - pos = list(self.conn.account_positions.values()) + pos = await self.api.positions() except Exception as e: print(f" ⚠ 对账读持仓失败 {type(e).__name__}: {e}", flush=True) return - pos = [p for p in pos if abs(float(p.amount)) > 0] if not pos: print(" 对账:交易所无持仓,干净启动", flush=True) return - print(f" ⚠ 对账:发现 {len(pos)} 个遗留持仓,全部市价平掉", flush=True) + print(f" ⚠ 对账:发现 {len(pos)} 个遗留持仓,撤挂单后市价平掉", + flush=True) for p in pos: - print(f" {p.trading_pair} {p.position_side} " - f"{p.amount} @ {p.entry_price}", flush=True) + sym, hs, sz = p["symbol"], p["holdSide"], p["total"] + print(f" {sym} {hs} {sz} @ {p.get('openPriceAvg')}", + flush=True) try: - await self.flatten(p) + 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 flatten(self, p) -> None: - from hummingbot.core.data_type.common import OrderType, PositionAction - amt = abs(Decimal(str(p.amount))) - is_long = float(p.amount) > 0 - fn = self.conn.sell if is_long else self.conn.buy - fn(trading_pair=p.trading_pair, amount=amt, - order_type=OrderType.MARKET, price=Decimal("NaN"), - position_action=PositionAction.CLOSE) - log_trade({"ev": "reconcile_flatten", "pair": p.trading_pair, - "amount": float(p.amount), "ts": int(time.time() * 1000)}) + f"需人工介入", flush=True) # ── 主循环 ──────────────────────────────────────────────────── async def poll(self) -> None: @@ -296,96 +319,131 @@ class Exec: self.guard.took(r["key"]) self.n_took += 1 - lg = legs(r["entry_px"], r["atr_pct"], r["direction"]) + 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}TP {x['tp'] * 1e4:6.1f}bp " - f"SL {x['sl'] * 1e4:6.1f}bp 超时 {MAXB}min", flush=True) + 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}) - if self.dry: - return + # 空跑也要走完 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: - from hummingbot.core.data_type.common import OrderType, TradeType - from hummingbot.strategy_v2.executors.position_executor.data_types import ( # noqa: E501 - PositionExecutorConfig, TripleBarrierConfig, - ) - from hummingbot.strategy_v2.executors.position_executor.position_executor import ( # noqa: E501 - PositionExecutor, - ) + """两笔「市价入场 + 服务端止损」,再各挂一个 maker 止盈。 - pair = f"{r['sym']}-USDT" - rule = self.conn.trading_rules.get(pair) - step = Decimal(str(rule.min_base_amount_increment)) if rule \ - else Decimal("0") - px = Decimal(str(r["entry_px"])) - # 取到步长的偶数倍,两个半仓才各是精确一半。不这么做 SOL 的半仓会是 - # 全仓的 43%(步长 0.1 币 ≈ 10.7 USDT),而回测假设 50/50 - if step > 0: - grid = step * 2 - n = max(Decimal("1"), (Decimal(str(NOTIONAL)) / px / grid) - .quantize(Decimal("1"))) - qty = n * grid - else: - qty = Decimal(str(NOTIONAL)) / px - half = qty / 2 - side = TradeType.BUY if r["direction"] > 0 else TradeType.SELL + 止损随入场单一起到交易所(presetStopLossPrice),所以不存在"已入场、 + 止损未挂"的裸仓窗口——那是本地盯价方案最危险的一段。 - made = [] + 止盈单独挂 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: - cfg = PositionExecutorConfig( - id=f"{r['key']}:{x['tag']}", controller_id="chanlun_1m", - timestamp=time.time(), trading_pair=pair, - connector_name="bitget_perpetual", side=side, - amount=half, leverage=LEVERAGE, - triple_barrier_config=TripleBarrierConfig( - stop_loss=Decimal(str(x["sl"])), - take_profit=Decimal(str(x["tp"])), - time_limit=MAXB * 60, - open_order_type=OrderType.MARKET, - # 止盈挂限价才是 maker,这是预算模型的一部分: - # 止盈那 60% 不吃滑点、按 maker 费率计 - take_profit_order_type=OrderType.LIMIT_MAKER, - stop_loss_order_type=OrderType.MARKET, - time_limit_order_type=OrderType.MARKET)) - ex = PositionExecutor(strategy=None, config=cfg) - ex.start() - made.append(ex) - self.execs[r["key"]] = made - print(f" 已起 {len(made)} 个 executor,各 {half} 币", flush=True) + 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: - """收掉已结束的 executor,把已实现盈亏计入当日上限。""" + """超时腿:48 分钟到点市价平。 + + 这是唯一必须靠本进程存活的出场腿。止损与止盈都在交易所侧,所以进程 + 死掉只会让持仓超过 48 根,不会变成裸仓——退化是良性的。 + """ while True: await asyncio.sleep(5) - for key, lst in list(self.execs.items()): - alive = [e for e in lst if not e.is_closed] - for e in lst: - if e.is_closed and getattr(e, "_counted", False) is False: - e._counted = True - pnl = float(getattr(e, "net_pnl_quote", 0.0) or 0.0) - self.guard.realized(pnl) - log_trade({"ev": "close", "key": key, - "id": e.config.id, - "close_type": str(getattr( - e, "close_type", "")), - "pnl_quote": pnl, - "pnl_day": self.guard.pnl_day}) - print(f" ◀ {e.config.id} 平仓 " - f"{getattr(e, 'close_type', '')} " - f"PnL {pnl:+.3f} · 当日 " - f"{self.guard.pnl_day:+.2f}", flush=True) - if alive: - self.execs[key] = alive - else: - self.execs.pop(key, None) + 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: