推送按合约规则取整,减半腿精确一半
100 USDT 的仓位在 Bitget 上不是最小下单量的问题(minTradeUSDT=5、 minTradeNum 极小,10U 都能下),是**步长取整**的问题: SOL 步长 0.1 币 ≈ 10.7U → 50U 腿只能取 0.4 币 = 42.7U,偏 14.6% LINK 步长 1 币 ≈ 11.7U → 4 币 = 46.8U,偏 6.4% BTC 步长 0.0001 ≈ 8.0U → 0.0006 = 47.8U,偏 4.4% 减半腿变成全仓的 43% 而非 50%,剩下 57% 暴露在 8ATR 目标上,而回测的收益 结构假设 50/50。小额下这不影响机制验证,但会让 P&L 读不出回测那个结构。 解法是入场量取到**步长的偶数倍**,这样一半天然落在步长上。实测十个币减半腿 全部精确 50%,名义额落在 93.6~106.7 USDT——小额实盘无所谓。 同时把价位对齐到 tick(priceEndStep × 10^-pricePlace),否则限价单会被拒。 规则拉一次缓存;拉不到就退化为不取整并打日志,不阻断推送。 费率一事已核实无需改动:预算假设的挂牌 taker 0.040% / maker 0.016% 正是 Bitget VIP2 的官方档(返 50% 后 2.0 / 0.8bp)。合约接口返的 6bp/2bp 是 VIP0 基础档,不适用。8bp 门控阈值不变。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -42,6 +42,68 @@ ENABLED = bool(TOKEN and CHAT)
|
||||
SL_ATR, SCALE_ATR, RUNNER_ATR, MAXB = 2.0, 3.0, 8.0, 48
|
||||
|
||||
_sent: set = set()
|
||||
_rules: dict = {}
|
||||
|
||||
CONTRACTS = ("https://api.bitget.com/api/v2/mix/market/contracts"
|
||||
"?productType=usdt-futures")
|
||||
|
||||
|
||||
async def load_rules() -> dict:
|
||||
"""拉一次合约规则,缓存。拉不到就返回空——推送退化为不取整,不阻断。
|
||||
|
||||
要的是数量步长和价格 tick。缺了它们推出去的价位可能被交易所拒单
|
||||
(价格不在 tick 上),或者数量被取整到与计划差很多。
|
||||
"""
|
||||
if _rules:
|
||||
return _rules
|
||||
try:
|
||||
import aiohttp
|
||||
async with aiohttp.ClientSession() as s:
|
||||
async with s.get(CONTRACTS,
|
||||
timeout=aiohttp.ClientTimeout(total=15)) as r:
|
||||
d = await r.json()
|
||||
for c in d.get("data") or []:
|
||||
sym = c["symbol"]
|
||||
if not sym.endswith("USDT"):
|
||||
continue
|
||||
_rules[sym[:-4]] = {
|
||||
"step": float(c["sizeMultiplier"]),
|
||||
"min_qty": float(c["minTradeNum"]),
|
||||
"min_usdt": float(c["minTradeUSDT"]),
|
||||
# priceEndStep 是 tick 的整数倍数,pricePlace 是小数位
|
||||
"tick": float(c["priceEndStep"]) * 10 ** -int(c["pricePlace"]),
|
||||
}
|
||||
print(f" [TG] 已载入 {len(_rules)} 个合约的下单规则", flush=True)
|
||||
except Exception as e:
|
||||
print(f" [TG] 拉合约规则失败 {type(e).__name__}: {e},推送不做取整",
|
||||
flush=True)
|
||||
return _rules
|
||||
|
||||
|
||||
def quantize(notional: float, px: float, r: dict) -> tuple[float, float]:
|
||||
"""算入场数量与减半腿,返回 (入场量, 减半量)。
|
||||
|
||||
入场量取到**步长的偶数倍**,这样一半天然落在步长上。不这么做的话,
|
||||
SOL 步长 0.1 币 ≈ 10.7 USDT,100 USDT 的仓位一半是 0.45 币、不可表示,
|
||||
只能取 0.4——减半腿变成全仓的 43% 而不是 50%,而回测的收益结构假设
|
||||
50/50。名义额因此会在目标值上下浮动(SOL 约 85~107),小额实盘无所谓。
|
||||
"""
|
||||
step = r["step"]
|
||||
if step <= 0:
|
||||
return notional / px, notional / px / 2
|
||||
tgt = notional / px
|
||||
# 以 2×step 为格点取最近的一格,至少一格
|
||||
grid = step * 2
|
||||
n = max(1.0, round(tgt / grid))
|
||||
qty = n * grid
|
||||
return qty, qty / 2.0
|
||||
|
||||
|
||||
def snap_px(px: float, tick: float) -> float:
|
||||
"""把价位对齐到 tick,否则限价单会被拒。"""
|
||||
if tick <= 0:
|
||||
return px
|
||||
return round(px / tick) * tick
|
||||
|
||||
|
||||
def levels(entry: float, atr: float, direction: int) -> dict:
|
||||
@@ -68,25 +130,34 @@ def _fmt(px: float) -> str:
|
||||
|
||||
def build(sym: str, direction: int, entry: float, atr_pct: float,
|
||||
kline_ts: int, lag_ms: float, budget_bp: float,
|
||||
age_s: float) -> str:
|
||||
age_s: float, rule: dict | None = None) -> str:
|
||||
atr = entry * atr_pct
|
||||
lv = levels(entry, atr, direction)
|
||||
side = "做多 LONG" if direction > 0 else "做空 SHORT"
|
||||
qty = NOTIONAL / entry
|
||||
stale = age_s > STALE_S
|
||||
|
||||
if rule:
|
||||
qty, half = quantize(NOTIONAL, entry, rule)
|
||||
tick = rule["tick"]
|
||||
lv = {k: snap_px(v, tick) for k, v in lv.items()}
|
||||
qty_line = (f"入场 {qty:.6g} 币 ≈ {qty * entry:,.1f} USDT"
|
||||
f" · 减半腿 {half:.6g} 币(正好一半)")
|
||||
else:
|
||||
qty = NOTIONAL / entry
|
||||
qty_line = f"入场 {qty:.6g} 币 ≈ {NOTIONAL:,.0f} USDT(未取整)"
|
||||
|
||||
head = f"⛔ 已失效({age_s:.0f}s > {STALE_S:.0f}s)· 不要入场" if stale \
|
||||
else f"✅ {side} {sym}"
|
||||
lines = [
|
||||
head,
|
||||
"",
|
||||
f"参考成交价 {_fmt(lv['entry'])} ← 回测口径(次根开盘)",
|
||||
f"数量 {qty:.6f}(名义 {NOTIONAL:,.0f} USDT)",
|
||||
qty_line,
|
||||
f"距参考价成立 {age_s:.1f}s(含数据延迟 {lag_ms:.0f}ms,不可压缩)",
|
||||
"",
|
||||
f"止损 {_fmt(lv['stop'])} (2 ATR,stop-market)",
|
||||
f"止损 {_fmt(lv['stop'])} (2 ATR,stop-market,全仓)",
|
||||
f"减半 {_fmt(lv['scale'])} (3 ATR,限价 maker)",
|
||||
f"目标 {_fmt(lv['runner'])} (8 ATR,限价 maker)",
|
||||
f"目标 {_fmt(lv['runner'])} (8 ATR,限价 maker,剩余半仓)",
|
||||
f"超时 {MAXB} 分钟后市价平(剩余半仓止损仍在 2 ATR,不移成本)",
|
||||
"",
|
||||
f"ATR {atr_pct * 1e4:.1f}bp · 滑点预算 {budget_bp:.1f}bp",
|
||||
@@ -141,4 +212,6 @@ async def push_signal(sym: str, direction: int, entry: float, atr_pct: float,
|
||||
print(f" [TG] {sym} 无预算(当前环境不可做),不推", flush=True)
|
||||
return
|
||||
age = time.time() - kline_ts / 1000.0
|
||||
await send(build(sym, direction, entry, atr_pct, kline_ts, lag_ms, b, age))
|
||||
rule = (await load_rules()).get(sym.upper())
|
||||
await send(build(sym, direction, entry, atr_pct, kline_ts, lag_ms, b, age,
|
||||
rule))
|
||||
|
||||
Reference in New Issue
Block a user