Files
Chan/live/bitget_rest.py
T
jackandCursor 77056a8318 下单体去掉 tradeSide:账户是单向持仓,带它会被 40774 全拒
实盘首批 4 个信号全部下单失败,8 次尝试(4 信号 × 2 腿)都是
[40774] The order type for unilateral position must also be the unilateral
position type。投递链路其余部分完全正常:延后 0.0~0.2s、ssh 在线 255 分钟
零重连、去重 0、闸全过。纯粹是下单体语法。

官方文档:单向持仓下要忽略 tradeSide(side 取 buy/sell 即可),平仓用
side 取反 + reduceOnly=YES;而 reduceOnly 也只在单向模式下有效。双向持仓
才需要 tradeSide=open/close。我们发的是双向语法打到单向账户,是整体拒单
而不是部分降级。

三处下单体(entry_with_stop / tp_limit / close_market)都去掉 tradeSide。
reduceOnly 保留——它在单向模式下正是防止反手开出反向仓的那个参数。

另加 setup_position_mode():把模式钉成单向并读回核对,与既有的"每次启动
都设一遍逐仓和杠杆、不假设交易所侧状态"一致。调用点放在 reconcile 之后,
因为有持仓或挂单时交易所不允许切换。读回不是单向时推 Telegram 告警。

空跑不可能验到这个:dry=True 在发请求之前就返回假成功。这类"下单体字段
组合"的问题只有真单会暴露,和之前 oid 里 - 字符那条同一类。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 00:46:17 +08:00

246 lines
12 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 下单
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", "")
# 两个名字都收:另外两项是 BITGET_API_KEY / BITGET_API_SECRET
# 这一项却没有 API_,很容易顺手写成 BITGET_API_PASSPHRASE。写错的
# 后果是"密钥像是填了"但签名一直失败,排查起来很绕
self.passphrase = (passphrase
or os.environ.get("BITGET_PASSPHRASE", "")
or os.environ.get("BITGET_API_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 set_position_mode(self, mode: str = "one_way_mode") -> dict:
"""单向 / 双向持仓。**按 productType 生效,不是按 symbol。**
必须显式设,因为它决定下单体的语法,两者不匹配会被整体拒单:
单向:side=buy/sell**不带** tradeSide;平仓用 reduceOnly=YES
双向:side + tradeSide=open/closereduceOnly 在这个模式下无效
实盘上曾因为带着 tradeSide 打到单向账户,连续 8 次下单全被 40774 拒掉
(4 个信号 × 2 条腿),而链路其余部分完全正常。
我们永不同时持有两个方向,所以单向是对的模式;且 reduceOnly 只在单向
下可用,而出场腿依赖它防止反手开出反向仓。
交易所侧有持仓或挂单时切换会失败——所以调用点放在 reconcile 之后。
"""
return await self._req("POST", "/api/v2/mix/account/set-position-mode",
body={"productType": PRODUCT, "posMode": mode})
async def entry_with_stop(self, symbol: str, side: str, size: str,
stop_px: str, client_oid: str) -> dict:
"""市价入场,**同时**把止损挂到服务端。
`presetStopLossPrice` 触发后按市价执行,这正是成本模型要的(止损是
taker)。`clientOid` 给交易所级幂等——重发同一个 oid 会被拒,比本地
去重可靠,因为「已发出但没收到回复」这种情况本地判不了。
**不带 `tradeSide`**:账户是单向持仓,带了会被 40774 整体拒单。
"""
body = {"symbol": symbol, "productType": PRODUCT,
"marginMode": "isolated", "marginCoin": MARGIN_COIN,
"size": size, "side": side,
"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 会破坏预算。
单向持仓下平仓的写法是 `side` 取反 + `reduceOnly=YES`**不带**
`tradeSide`。`reduceOnly` 也正好只在单向模式下有效,它防止反手开出
一个反向仓。
"""
body = {"symbol": symbol, "productType": PRODUCT,
"marginMode": "isolated", "marginCoin": MARGIN_COIN,
"size": size, "side": side,
"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:
"""市价平(超时腿与对账用)。
同 tp_limit:单向持仓下是 `side` 取反 + `reduceOnly`,不带 `tradeSide`。
"""
side = "sell" if hold_side == "long" else "buy"
body = {"symbol": symbol, "productType": PRODUCT,
"marginMode": "isolated", "marginCoin": MARGIN_COIN,
"size": size, "side": side,
"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)