实盘执行改走 Bitget v2 REST,止损挂到服务端

丢掉 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 <cursoragent@cursor.com>
This commit is contained in:
jack
2026-08-28 16:07:28 +08:00
co-authored by Cursor
parent 360e4e1c41
commit f11c6507b0
2 changed files with 402 additions and 151 deletions
+193
View File
@@ -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)