实盘首批 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>
246 lines
12 KiB
Python
246 lines
12 KiB
Python
"""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/close;reduceOnly 在这个模式下无效
|
||
|
||
实盘上曾因为带着 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)
|