"""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 history_positions(self, start_ms: int | None = None, limit: int = 100) -> list: """已平仓位,用来取**已实现盈亏**。 为什么必须问交易所而不是自己算:止损与止盈都挂在交易所侧成交,本进程 看不到成交价;而且要算准还得含手续费与资金费。这个端点的 `netProfit` 已经是 `pnl + totalFunding + openFee + closeFee`,正是日亏损上限该用 的数。自己按标记价估会把费用漏掉,方向还总是偏乐观。 返回形状按文档是 `data.list`,但也见过直接给数组的写法,两种都收。 时间字段文档写 `ctime/utime`,官方 TS 类型写 `cTime/uTime`,同样都读。 """ p: dict = {"productType": PRODUCT, "limit": str(limit)} if start_ms: p["startTime"] = str(int(start_ms)) d = await self._req("GET", "/api/v2/mix/position/history-position", p) if isinstance(d, dict): return list(d.get("list") or []) return list(d or []) 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)