Files
Chan/live/bitget_rest.py
T
jackandCursor 15f4aa088e 加 Telegram 通知,并修好一道死掉的闸
接 Telegram 时查出 Guard.realized() 定义了但全仓库没有调用点——pnl_day
恒为 0,MAX_DAY_LOSS 完全不生效。三条硬约束里最重要的一条是死的。

根因是止损与止盈都挂在交易所侧成交,本进程收不到通知,而 sweep 只在 48
分钟到点才查持仓。连带第二个后果:execs 条目不清,MAX_OPEN 把已出场的
仓位继续算在场,新信号被白挡到截止时刻。

补 watch() 循环(10s):持仓消失即判出场,去 history-position 取
netProfit(= pnl + 资金费 + 开平手续费)记回闸并释放名额。盈亏取交易所的
数而不自己按标记价估——估会漏掉费用且方向总偏乐观。历史未落库时留到下轮,
不会漏记。字段名按文档与官方 TS 类型的差异同时兼容 ctime/cTime。

Telegram(live/tg.py,stdlib + aiohttp):推开仓、平仓带已实现盈亏、被硬
约束挡住、报错、对账平仓、跨日结算、启动与停机。不推信号过期跳过(常态,
搬运重连会重放旧信号)与心跳,否则真事会被淹掉。启动那条兼作通道自检。
研究侧 tg_notify.send 改为复用生产的传输层,方向与 signal_bus 一致。

status.sh 增加一条判读:开过仓但 pnl 仍为 0 就是 watch() 出了问题。

实测:8 类消息渲染、_match_hist 的过早/方向不符/币不符/驼峰字段/取最近
五种情形、100 USDT 下 SOL 与 ADA 的端到端空跑(两腿等量,50/50 精确)。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 17:30:11 +08:00

214 lines
10 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", "")
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)