Files
Chan/live/bitget_rest.py
T
jackandCursor 335d891478 生产与研究分家:实盘执行器独立成 live/ 子树,加 AWS 部署
实盘要跑在 AWS(API key 绑了 IP 白名单),而信号在新加坡那台算。借这次
把生产从研究侧摘出来,四条具体代价里第一条已经咬过:

1. live_state.json 原先落在 research/out/,而那里 shadow_hb 会自动 rename
   归档、研究脚本会写、人也手工清过。那文件装的是 MAX_DAY_LOSS 累计与已
   处理信号键,被清掉不报错,只是两道闸静默失效。改到 LIVE_HOME。
2. 采集器十币清空 300~560ms 直接叠在信号到达执行器的延迟上。
3. 研究侧探针 OOM 过一次(14.9GB),当时若有仓位在场会连坐执行器。
4. 为读两个常量 import 研究侧 step43,把 numpy/pandas/pyarrow 拖进实盘
   进程。抽出 stdlib-only 的 live/exit_params.py,install.sh 加断言挡回归。

新增 live/ship_signals.py:AWS 侧 ssh tail 拉总线,每次重连从文件头重放
+ 按幂等键去重,断线期间的信号自愈;旧信号由 staleness 闸挡掉不补做。
带时钟倒流检测——两机时钟不同步会让那道闸静默放宽。

部署件:systemd 两单元(搬运挂了执行器仍管在场仓位的超时平仓)、
install.sh、dryrun.sh(验密钥/白名单/时钟/ssh/取整)、status.sh、README。

验证:live_exec 重构后端到端空跑,SOL 多头与 ADA 空头的止损/两级止盈/
数量取整逐项核对正确,isolated + post_only + reduceOnly 都在;搬运的去重、
重启不重复追加、脏数据跳过、断线重连重放均已测。

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

194 lines
8.9 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 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)