2 Commits
Author SHA1 Message Date
jack be442783a1 Merge branch 'chan' of ssh://git.jackyu66.com:2222/jack/chan into chan 2026-08-28 16:07:28 +08:00
jackandCursor f11c6507b0 实盘执行改走 Bitget v2 REST,止损挂到服务端
丢掉 Hummingbot 的 PositionExecutor。它的连接器只暴露 LIMIT / LIMIT_MAKER /
MARKET,没有触发单,于是 control_stop_loss() 只能本地盯价、触发时才发市价单
——**进程一死仓位就是裸的**。而交易所本身支持 place-order 带
presetStopLossPrice,下单时就把止损挂到服务端。绕过连接器不是图省事,是为了
消掉一整类故障。顺带 TripleBarrierConfig 只有单级止盈,装不下两级,自己写更短。

## 三条出场腿各自挂在哪

    止损   交易所侧(presetStopLossPrice,随入场单一起到)→ 进程死了仍在
    止盈   交易所侧(post_only reduce-only 限价)        → 进程死了仍在
    超时   本进程,48 分钟到点市价平

所以进程死掉只会让持仓超过 48 根,不会变成裸仓,退化是良性的。

## 止盈不能用 presetStopSurplusPrice

它触发后按市价执行,而成本模型里止盈是 maker——那 60% 的出场不吃滑点、按
maker 费率计(LEG_IS_TAKER)。用 preset 会让这部分变成 taker,预算就不成立。
所以止盈单独挂 post_only + reduceOnly 限价单。止损反过来必须市价:stop-limit
在急跌里可能不成交,损失远大于省下的费。

## clientOid 是交易所级幂等,但要小心两个坑

信号键形如 SOL:1787904388411:+1,`:` 和 `+` 未必被接受,带过去直接拒单——而
拒单发生在入场腿上,等于这笔信号静默漏掉。

清洗时不能简单把非字母数字换成下划线:那样 `+1` 和 `-1` 都变成 `_1`,同一根上
的多空信号得到相同 oid,第二笔被当重复拒掉。方向显式编码为 L/S。

用 clientOid 而非只靠本地去重,是因为「已发出但没收到回复」这种情况本地判不了,
重试就会开两次仓。

## 空跑要走完 open_position

第一版在 on_signal 里 `if dry: return`,结果数量取整、价位对齐 tick、请求体
构造全都没被验到。现在空跑走完整条路径,不下真单由 Bitget(dry=True) 负责。

实测两笔:SOL 多头 entry 106.706 / ATR 11bp → 止损 106.471、止盈 107.058 与
107.645;BTC 空头 → 止损在上方 79747.5、止盈在下方。半仓精确一半(SOL 2.3+2.3、
BTC 0.0031+0.0031)。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 16:07:28 +08:00
2 changed files with 402 additions and 151 deletions
+193
View File
@@ -0,0 +1,193 @@
"""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)
+209 -151
View File
@@ -1,39 +1,45 @@
"""自动化小额实盘执行器。读信号总线,用 Hummingbot 的 PositionExecutor 下单。
"""自动化小额实盘执行器。读信号总线,直接调 Bitget v2 REST 下单。
## 出场结构为什么能分解成两个半仓
## 为什么不用 Hummingbot 的 PositionExecutor
回测的结构是:2 ATR 止损 / 3 ATR 减半 / 8 ATR 目标 / 48 根超时,且**剩余半仓
的止损保持在入场价的 2 ATR、不移动**。这一条已在 `lib/exit_model.py:151`
核实——`runner_stops` `ret` 是 `(entry - low[j]) / a`,从入场价算,且
`RUNNER_STOP == SL == 2.0`
它的连接器只暴露 LIMIT / LIMIT_MAKER / MARKET,没有触发单,于是
`control_stop_loss()` 只能在本地盯价、触发时才发市价单——**进程一死仓位就是
裸的**。而交易所本身支持 `place-order` `presetStopLossPrice`,下单时就把
止损挂到服务端。绕过连接器不是图省事,是为了消掉一整类故障
Hummingbot 的 `TripleBarrierConfig` 只有单级止盈,装不下两级。但因为两个半仓
共用同一个不动的止损,结构可以**精确分解**:
另外 `TripleBarrierConfig` 只有单级止盈,装不下 3 ATR 减半 + 8 ATR 目标;
自己写反而更短。
半仓 A 市价入场 · TP 3 ATR · SL 2 ATR · 48 分钟超时
半仓 B 市价入场 · TP 8 ATR · SL 2 ATR · 48 分钟超时
## 出场结构为什么能拆成两个半仓
止损先到则两半都在 -2 ATR 出场;3 ATR 先到则 A 出场、B 继续持有且止损仍在
2 ATR。与回测逐情形一致。若哪天把 RUNNER_STOP 改成不等于 SL(比如移到成本),
这个分解就**不再成立**,必须改回单执行器加手工两级——`assert_decomposable()`
会在启动时挡住这种情况。
回测结构是 2 ATR 止损 / 3 ATR 减半 / 8 ATR 目标 / 48 根超时,且**剩余半仓的
止损保持在入场价的 2 ATR、不移动**。已核实 `lib/exit_model.py:151`——
`runner_stops` 的 `ret` 是 `(entry - low[j]) / a`,从入场价算,且
`RUNNER_STOP == SL == 2.0`。两半共用同一个不动的止损,所以:
## 止损在机器人侧,不在交易所侧
半仓 A 市价入场 + 服务端止损 2 ATR · maker 止盈 3 ATR
半仓 B 市价入场 + 服务端止损 2 ATR · maker 止盈 8 ATR
Bitget 连接器只支持 LIMIT / LIMIT_MAKER / MARKET,没有触发单
`PositionExecutor.control_stop_loss()` 是在本地控制循环里盯价、触发时才发市价
单。所以**进程一死,仓位就是裸的**。10x 下强平需逆向 10%(约 100 个 ATR),
48 分钟内极不可能,单次代价仍封在保证金内;但这一类靠交易所侧的兜底止损才能
真正消掉,见 `backstop.py`。
止损先到则两半都在 -2 ATR 出场;3 ATR 先到则 A 出场、B 继续且止损仍在 2 ATR
与回测逐情形一致。若哪天把 RUNNER_STOP 改成不等于 SL(比如移到成本),这个
分解就**不再成立**`assert_decomposable()` 会在启动时挡住。
## 三条出场腿各自挂在哪
止损 交易所侧(presetStopLossPrice,随入场单一起到)→ 进程死了仍在
止盈 交易所侧(post_only reduce-only 限价) → 进程死了仍在
超时 **本进程**,48 分钟到点市价平
所以进程死掉只会让持仓超过 48 根,不会变成裸仓——退化是良性的。
## 硬约束才是这个文件的重点
一笔止损只亏约 1 USDT,所以"亏损可控"对单笔成立。但三类故障的代价
**不随仓位缩小**,必须显式封住:
一笔止损只亏约 1 USDT,所以"亏损可控"对单笔成立。但三类故障的代价**不随仓位
缩小**,必须显式封住:
失控下单 循环里的 bug 反复开仓,单笔小但笔数无界 → MAX_OPEN / MAX_DAY
裸仓 进程在"已入场、止损未挂"之间死掉 → backstop + 重启对账
亏损累积 策略真的不行,但没人盯着 → MAX_DAY_LOSS
亏损累积 策略真的不行,但没人盯着 → MAX_DAY_LOSS
裸仓 进程在"已入场、止损未挂"之间死掉 → 服务端止损 + 重启对账
python research/live/live_exec.py --dry-run # 只打印不下单
"""
@@ -53,7 +59,10 @@ sys.path.insert(0, str(HERE.parents[1]))
sys.path.insert(0, str(HERE.parent))
import signal_bus # noqa: E402
from bitget_rest import Bitget # noqa: E402
SYMS = os.environ.get(
"SYMS", "BTC,ETH,SOL,BNB,XRP,DOGE,ADA,AVAX,LINK,LTC").split(",")
NOTIONAL = float(os.environ.get("LIVE_NOTIONAL", "500"))
LEVERAGE = int(os.environ.get("LIVE_LEVERAGE", "10"))
@@ -159,17 +168,30 @@ class Guard:
self.save()
def legs(entry: float, atr_pct: float, direction: int) -> list[dict]:
"""两个半仓的三重门参数
def legs() -> list[dict]:
"""两个半仓的止盈位,用 ATR 倍数表达
盈/止损用**比例**表达(Hummingbot 的 TripleBarrierConfig 就是比例),
所以直接用 ATR 的相对值,不必换成绝对价位。
损两半相同(SL_ATR),所以不写在这里——它在 open_position 里算一次。
"""
a = atr_pct
return [
{"tag": "scale", "tp": SCALE_ATR * a, "sl": SL_ATR * a},
{"tag": "runner", "tp": RUNNER_ATR * a, "sl": SL_ATR * a},
]
return [{"tag": "scale", "atr": SCALE_ATR},
{"tag": "runner", "atr": RUNNER_ATR}]
def oid_of(key: str, tag: str) -> str:
"""把信号键变成交易所能接受的 clientOid。
信号键形如 `SOL:1787904388411:+1`,里面的 `:` 和 `+` 未必被交易所接受,
带过去会直接拒单——而拒单发生在入场腿上,等于这笔信号静默漏掉。只留
字母数字和下划线。
clientOid 是**交易所级幂等**:重发同一个 oid 会被拒。这比本地去重可靠,
因为「已发出但没收到回复」这种情况本地判不了,重试就会开两次仓。
"""
# 方向必须显式编码:直接把非字母数字换成下划线,会让 `+1` 和 `-1` 都变成
# `_1`,同一根上的多空信号得到相同 oid,第二笔被交易所当重复拒掉
k = key.replace(":+1", ":L").replace(":-1", ":S")
safe = "".join(c if c.isalnum() else "_" for c in f"{k}_{tag}")
return safe[:60]
def log_trade(rec: dict, path: Path = TRADES) -> None:
@@ -185,81 +207,82 @@ class Exec:
self.dry = dry
self.bus = bus
self.guard = Guard()
self.conn = None
self.execs: dict = {} # key → [PositionExecutor, ...]
self.api: Bitget | None = None
self.rules: dict = {}
self.execs: dict = {} # key → 该笔的腿与超时时刻
self.offset = 0 # 已读到总线的哪一行
self.n_seen = self.n_took = self.n_skip = 0
# ── 启动 ──────────────────────────────────────────────────────
async def start(self) -> None:
assert_decomposable()
# 只从"现在"往后做。历史信号的参考成交价早过期,补做等于随机入场
# 只从"现在"往后做。历史信号的参考成交价早过期,补做等于随机入场
self.offset = sum(1 for _ in signal_bus.read_all(self.bus))
print(f" 总线已有 {self.offset} 条历史信号,全部跳过(参考价已过期)",
flush=True)
if self.dry:
print(" ⚠ 空跑模式:不连交易所、不下真单", flush=True)
return
key = os.environ.get("BITGET_API_KEY", "")
sec = os.environ.get("BITGET_API_SECRET", "")
pas = os.environ.get("BITGET_PASSPHRASE", "")
if not (key and sec and pas):
self.api = Bitget(dry=self.dry)
self.rules = await self.api.contracts()
print(f" 合约规则 {len(self.rules)}", flush=True)
if self.dry:
print(" ⚠ 空跑模式:不下真单", flush=True)
return
if not (self.api.key and self.api.secret and self.api.passphrase):
raise SystemExit("⛔ 缺 BITGET_API_KEY / SECRET / PASSPHRASE。"
"先跑 --dry-run 验链路")
from hummingbot.connector.derivative.bitget_perpetual.bitget_perpetual_derivative import ( # noqa: E501
BitgetPerpetualDerivative,
)
syms = os.environ.get(
"SYMS", "BTC,ETH,SOL,BNB,XRP,DOGE,ADA,AVAX,LINK,LTC").split(",")
self.conn = BitgetPerpetualDerivative(
bitget_perpetual_api_key=key, bitget_perpetual_secret_key=sec,
bitget_perpetual_passphrase=pas,
trading_pairs=[f"{s}-USDT" for s in syms], trading_required=True)
await self.conn.start_network()
for _ in range(60):
await asyncio.sleep(1)
if self.conn.ready:
break
"先跑 --dry-run。")
await self.setup_symbols()
await self.reconcile()
async def setup_symbols(self) -> None:
"""逐仓 + 杠杆。每次启动都设一遍,不假设交易所侧的状态。
杠杆若被人在 App 里改过,仓位大小就不是我们算的那个。设成幂等操作比
读回来核对简单,且失败会直接暴露。
"""
for s in SYMS:
sym = f"{s}USDT"
for fn, arg in ((self.api.set_margin_mode, "isolated"),
(self.api.set_leverage, LEVERAGE)):
try:
await fn(sym, arg)
except Exception as e:
print(f"{sym} 设置失败 {type(e).__name__}: {e}",
flush=True)
print(f" 已设 {len(SYMS)} 个币为逐仓 {LEVERAGE}x", flush=True)
async def reconcile(self) -> None:
"""启动时把交易所的实际持仓对上。
进程崩溃重启后交易所可能还有仓位,而我们的 executor 全没了——那些仓位
没有任何止损在盯。**必须平掉而不是接管**:接管要重建入场价、ATR、剩余
半仓状态和已过的根数,任一项猜错都会让出场结构变成另一个东西,而平掉
的代价只是一笔小额亏损,且行为确定。
崩溃重启后交易所可能还有仓位。它们的服务端止损仍在(presetStopLossPrice
挂在交易所侧,不随进程消失),但**超时腿丢了**,会一直持有到止损或止盈。
选择平掉而非接管:接管要重建入场价、ATR、剩余半仓状态和已过根数,任一项
猜错就让出场结构变成另一个东西且不报错;平掉的代价只是一笔小额亏损,
且行为确定。
"""
try:
pos = list(self.conn.account_positions.values())
pos = await self.api.positions()
except Exception as e:
print(f" ⚠ 对账读持仓失败 {type(e).__name__}: {e}", flush=True)
return
pos = [p for p in pos if abs(float(p.amount)) > 0]
if not pos:
print(" 对账:交易所无持仓,干净启动", flush=True)
return
print(f" ⚠ 对账:发现 {len(pos)} 个遗留持仓,全部市价平掉", flush=True)
print(f" ⚠ 对账:发现 {len(pos)} 个遗留持仓,撤挂单后市价平掉",
flush=True)
for p in pos:
print(f" {p.trading_pair} {p.position_side} "
f"{p.amount} @ {p.entry_price}", flush=True)
sym, hs, sz = p["symbol"], p["holdSide"], p["total"]
print(f" {sym} {hs} {sz} @ {p.get('openPriceAvg')}",
flush=True)
try:
await self.flatten(p)
await self.api.cancel_all(sym)
await self.api.close_market(
sym, hs, sz, f"recon:{int(time.time() * 1000)}")
log_trade({"ev": "reconcile_flatten", "symbol": sym,
"hold_side": hs, "size": sz})
except Exception as e:
print(f" ⛔ 平仓失败 {type(e).__name__}: {e}"
f"人工介入", flush=True)
async def flatten(self, p) -> None:
from hummingbot.core.data_type.common import OrderType, PositionAction
amt = abs(Decimal(str(p.amount)))
is_long = float(p.amount) > 0
fn = self.conn.sell if is_long else self.conn.buy
fn(trading_pair=p.trading_pair, amount=amt,
order_type=OrderType.MARKET, price=Decimal("NaN"),
position_action=PositionAction.CLOSE)
log_trade({"ev": "reconcile_flatten", "pair": p.trading_pair,
"amount": float(p.amount), "ts": int(time.time() * 1000)})
f"需人工介入", flush=True)
# ── 主循环 ────────────────────────────────────────────────────
async def poll(self) -> None:
@@ -296,96 +319,131 @@ class Exec:
self.guard.took(r["key"])
self.n_took += 1
lg = legs(r["entry_px"], r["atr_pct"], r["direction"])
lg = legs()
side = "LONG" if r["direction"] > 0 else "SHORT"
print(f"{r['key']} {side} 名义 {NOTIONAL:.0f} {LEVERAGE}x "
f"· 延后 {age:.1f}s · ATR {r['atr_pct'] * 1e4:.1f}bp",
flush=True)
for x in lg:
print(f" {x['tag']:<7}TP {x['tp'] * 1e4:6.1f}bp "
f"SL {x['sl'] * 1e4:6.1f}bp 超时 {MAXB}min", flush=True)
print(f" {x['tag']:<7}止盈 {x['atr']:.0f} ATR = "
f"{x['atr'] * r['atr_pct'] * 1e4:.1f}bp · 止损 "
f"{SL_ATR * r['atr_pct'] * 1e4:.1f}bp · 超时 {MAXB}min",
flush=True)
log_trade({"ev": "entry", "key": r["key"], "side": side,
"entry_px": r["entry_px"], "atr_pct": r["atr_pct"],
"notional": NOTIONAL, "leverage": LEVERAGE,
"age_s": round(age, 2), "legs": lg, "dry": self.dry})
if self.dry:
return
# 空跑也要走完 open_position:数量取整、价位对齐 tick、请求体构造都在
# 那里,跳过等于什么都没验。不下真单由 Bitget(dry=True) 负责
await self.open_position(r, lg)
def qty_of(self, sym: str, entry: float) -> tuple[str, str]:
"""入场量与半仓量,都对齐步长。
入场量取到**步长的偶数倍**,半仓才是精确一半。不这么做 SOL 的半仓会是
全仓的 43%(步长 0.1 币 ≈ 10.7 USDT),而回测假设 50/50。
"""
r = self.rules.get(f"{sym}USDT")
if not r:
raise RuntimeError(f"{sym} 没有合约规则")
step = Decimal(str(r["sizeMultiplier"]))
px = Decimal(str(entry))
grid = step * 2
n = max(Decimal("1"),
(Decimal(str(NOTIONAL)) / px / grid).quantize(Decimal("1")))
qty = n * grid
return str(qty), str(qty / 2)
def snap(self, sym: str, px: float) -> str:
r = self.rules[f"{sym}USDT"]
tick = Decimal(str(r["priceEndStep"])) * (
Decimal(10) ** -int(r["pricePlace"]))
q = (Decimal(str(px)) / tick).quantize(Decimal("1")) * tick
return str(q)
async def open_position(self, r: dict, lg: list[dict]) -> None:
from hummingbot.core.data_type.common import OrderType, TradeType
from hummingbot.strategy_v2.executors.position_executor.data_types import ( # noqa: E501
PositionExecutorConfig, TripleBarrierConfig,
)
from hummingbot.strategy_v2.executors.position_executor.position_executor import ( # noqa: E501
PositionExecutor,
)
"""两笔「市价入场 + 服务端止损」,再各挂一个 maker 止盈。
pair = f"{r['sym']}-USDT"
rule = self.conn.trading_rules.get(pair)
step = Decimal(str(rule.min_base_amount_increment)) if rule \
else Decimal("0")
px = Decimal(str(r["entry_px"]))
# 取到步长的偶数倍,两个半仓才各是精确一半。不这么做 SOL 的半仓会是
# 全仓的 43%(步长 0.1 币 ≈ 10.7 USDT),而回测假设 50/50
if step > 0:
grid = step * 2
n = max(Decimal("1"), (Decimal(str(NOTIONAL)) / px / grid)
.quantize(Decimal("1")))
qty = n * grid
else:
qty = Decimal(str(NOTIONAL)) / px
half = qty / 2
side = TradeType.BUY if r["direction"] > 0 else TradeType.SELL
止损随入场单一起到交易所(presetStopLossPrice),所以不存在"已入场、
止损未挂"的裸仓窗口——那是本地盯价方案最危险的一段。
made = []
止盈单独挂 post_only 限价:成本模型里止盈按 maker 计且不吃滑点,用
preset(触发后市价)会让这部分变成 taker,预算就不成立了。
"""
sym, d = r["sym"], r["direction"]
pair = f"{sym}USDT"
entry = r["entry_px"]
a = entry * r["atr_pct"]
_, half = self.qty_of(sym, entry)
side = "buy" if d > 0 else "sell"
close_side = "sell" if d > 0 else "buy"
hold = "long" if d > 0 else "short"
stop_px = self.snap(sym, entry - d * SL_ATR * a)
opened = []
for x in lg:
cfg = PositionExecutorConfig(
id=f"{r['key']}:{x['tag']}", controller_id="chanlun_1m",
timestamp=time.time(), trading_pair=pair,
connector_name="bitget_perpetual", side=side,
amount=half, leverage=LEVERAGE,
triple_barrier_config=TripleBarrierConfig(
stop_loss=Decimal(str(x["sl"])),
take_profit=Decimal(str(x["tp"])),
time_limit=MAXB * 60,
open_order_type=OrderType.MARKET,
# 止盈挂限价才是 maker,这是预算模型的一部分:
# 止盈那 60% 不吃滑点、按 maker 费率计
take_profit_order_type=OrderType.LIMIT_MAKER,
stop_loss_order_type=OrderType.MARKET,
time_limit_order_type=OrderType.MARKET))
ex = PositionExecutor(strategy=None, config=cfg)
ex.start()
made.append(ex)
self.execs[r["key"]] = made
print(f" 已起 {len(made)} 个 executor,各 {half} ", flush=True)
oid = oid_of(r["key"], x["tag"])
try:
await self.api.entry_with_stop(pair, side, half, stop_px, oid)
except Exception as e:
print(f"{x['tag']} 入场失败 {e}", flush=True)
log_trade({"ev": "entry_fail", "key": r["key"],
"tag": x["tag"], "err": str(e)})
continue
tp_px = self.snap(sym, entry + d * x["atr"] * a)
try:
await self.api.tp_limit(pair, close_side, half, tp_px,
oid + "-tp")
except Exception as e:
# 入场成了但止盈没挂上:仓位仍有服务端止损,不是裸仓。
# 超时腿会兜住它,所以只告警不强平
print(f"{x['tag']} 止盈挂单失败 {e}"
f"(仓位有服务端止损,超时腿会兜)", flush=True)
opened.append({"tag": x["tag"], "oid": oid, "size": half,
"tp_px": tp_px, "stop_px": stop_px})
print(f" {x['tag']:<7}{half} 币 · 止损 {stop_px} · "
f"止盈 {tp_px}", flush=True)
if opened:
self.execs[r["key"]] = {
"sym": sym, "pair": pair, "hold": hold,
"deadline": time.time() + MAXB * 60, "legs": opened}
log_trade({"ev": "opened", "key": r["key"], "legs": opened,
"stop_px": stop_px})
async def sweep(self) -> None:
"""收掉已结束的 executor,把已实现盈亏计入当日上限。"""
"""超时腿:48 分钟到点市价平。
这是唯一必须靠本进程存活的出场腿。止损与止盈都在交易所侧,所以进程
死掉只会让持仓超过 48 根,不会变成裸仓——退化是良性的。
"""
while True:
await asyncio.sleep(5)
for key, lst in list(self.execs.items()):
alive = [e for e in lst if not e.is_closed]
for e in lst:
if e.is_closed and getattr(e, "_counted", False) is False:
e._counted = True
pnl = float(getattr(e, "net_pnl_quote", 0.0) or 0.0)
self.guard.realized(pnl)
log_trade({"ev": "close", "key": key,
"id": e.config.id,
"close_type": str(getattr(
e, "close_type", "")),
"pnl_quote": pnl,
"pnl_day": self.guard.pnl_day})
print(f"{e.config.id} 平仓 "
f"{getattr(e, 'close_type', '')} "
f"PnL {pnl:+.3f} · 当日 "
f"{self.guard.pnl_day:+.2f}", flush=True)
if alive:
self.execs[key] = alive
else:
self.execs.pop(key, None)
now = time.time()
for key, st in list(self.execs.items()):
if now < st["deadline"]:
continue
try:
pos = [p for p in await self.api.positions()
if p["symbol"] == st["pair"]]
if not pos:
print(f"{key} 超时前已全部出场", flush=True)
log_trade({"ev": "timeout_noop", "key": key})
else:
for p in pos:
await self.api.cancel_all(st["pair"])
await self.api.close_market(
st["pair"], p["holdSide"], p["total"],
oid_of(key, "timeout"))
print(f"{key} 超时市价平 {pos[0]['total']}",
flush=True)
log_trade({"ev": "timeout_close", "key": key,
"size": pos[0]["total"]})
except Exception as e:
print(f"{key} 超时平仓失败 {type(e).__name__}: {e}",
flush=True)
continue
self.execs.pop(key, None)
async def heartbeat(self) -> None:
while True: