推送按合约规则取整,减半腿精确一半

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:
jack
2026-08-28 15:42:17 +08:00
co-authored by Cursor
parent 97457b0518
commit c57adc8cb0
2 changed files with 175 additions and 6 deletions
+96
View File
@@ -0,0 +1,96 @@
"""100 USDT 的仓位在各币上能不能下出来——下单精度与最小量。
手工小额实盘的第一个坑不在策略,在交易规则。100 USDT 的仓位要拆成两半
3 ATR 减半 50 USDT、8 ATR 目标 50 USDT),任一半低于最小下单量就下不出去,
或者被精度取整到与计划偏差很大的数量。
取整偏差会直接扭曲收益结构:若 50 USDT 被取整到 40,减半那一腿实际只出了
40%,剩余 60% 暴露在 8 ATR 目标上。回测的收益结构假设是 50/50。
python research/live/probe_rules.py --notional 100
"""
from __future__ import annotations
import argparse
import asyncio
import os
import sys
from decimal import Decimal
from pathlib import Path
HERE = Path(__file__).resolve()
sys.path.insert(0, str(HERE.parents[1]))
sys.path.insert(0, str(HERE.parent))
SYMS = os.environ.get(
"SYMS", "BTC,ETH,SOL,BNB,XRP,DOGE,ADA,AVAX,LINK,LTC").split(",")
async def run(notional: float) -> None:
from hummingbot.connector.derivative.bitget_perpetual.bitget_perpetual_derivative import ( # noqa: E501
BitgetPerpetualDerivative,
)
pairs = [f"{s}-USDT" for s in SYMS]
conn = BitgetPerpetualDerivative(
bitget_perpetual_api_key="", bitget_perpetual_secret_key="",
bitget_perpetual_passphrase="", trading_pairs=pairs,
trading_required=False)
await conn.start_network()
for _ in range(60):
await asyncio.sleep(1)
if conn.trading_rules and all(p in conn.trading_rules for p in pairs):
break
print(f"仓位 {notional:.0f} USDT · 减半腿 {notional / 2:.0f} USDT\n")
print(f" {'':<6}{'现价':>11}{'最小量':>12}{'量步长':>12}"
f"{'最小名义':>10} 减半腿可行性")
bad = []
for s in SYMS:
p = f"{s}-USDT"
r = conn.trading_rules.get(p)
if r is None:
print(f" {s:<6}{'取不到规则':>11}")
continue
ob = conn.get_order_book(p)
px = float((ob.get_price(True) + ob.get_price(False)) / 2) if ob \
else float("nan")
min_amt = float(r.min_order_size)
step = float(r.min_base_amount_increment)
min_not = float(r.min_notional_size or 0)
half_base = (notional / 2) / px
# 按步长向下取整——交易所就是这么处理的,向上取会下不出去
q = Decimal(str(half_base)) // Decimal(str(step)) * Decimal(str(step))
got = float(q)
if got < min_amt or (min_not and got * px < min_not):
verdict = f"⛔ 下不出(需 ≥ {max(min_amt, min_not / px):.6f}"
bad.append(s)
else:
dev = abs(got * px - notional / 2) / (notional / 2) * 1e4
verdict = f"{got:.6f} 币,偏差 {dev:.0f}bp"
if dev > 100:
verdict += " ⚠ 取整偏差大"
bad.append(s)
print(f" {s:<6}{px:>11,.4f}{min_amt:>12.6f}{step:>12.6f}"
f"{min_not:>10.1f} {verdict}")
print()
if bad:
print(f"{notional:.0f} USDT 下这些币的减半腿有问题:{','.join(bad)}")
print(f" 要么提高仓位,要么这些币不做减半、单腿到 8 ATR 出场——但后者")
print(f" 改了回测的收益结构,不能直接套用原预算。")
else:
print(f" {notional:.0f} USDT 在全部 {len(SYMS)} 个币上都能拆成两半下出。")
await conn.stop_network()
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--notional", type=float, default=100.0)
a = ap.parse_args()
asyncio.run(run(a.notional))
if __name__ == "__main__":
main()
+79 -6
View File
@@ -42,6 +42,68 @@ ENABLED = bool(TOKEN and CHAT)
SL_ATR, SCALE_ATR, RUNNER_ATR, MAXB = 2.0, 3.0, 8.0, 48 SL_ATR, SCALE_ATR, RUNNER_ATR, MAXB = 2.0, 3.0, 8.0, 48
_sent: set = set() _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 USDT100 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: 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, def build(sym: str, direction: int, entry: float, atr_pct: float,
kline_ts: int, lag_ms: float, budget_bp: 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 atr = entry * atr_pct
lv = levels(entry, atr, direction) lv = levels(entry, atr, direction)
side = "做多 LONG" if direction > 0 else "做空 SHORT" side = "做多 LONG" if direction > 0 else "做空 SHORT"
qty = NOTIONAL / entry
stale = age_s > STALE_S 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 \ head = f"⛔ 已失效({age_s:.0f}s > {STALE_S:.0f}s)· 不要入场" if stale \
else f"{side} {sym}" else f"{side} {sym}"
lines = [ lines = [
head, head,
"", "",
f"参考成交价 {_fmt(lv['entry'])} ← 回测口径(次根开盘)", f"参考成交价 {_fmt(lv['entry'])} ← 回测口径(次根开盘)",
f"数量 {qty:.6f}(名义 {NOTIONAL:,.0f} USDT", qty_line,
f"距参考价成立 {age_s:.1f}s(含数据延迟 {lag_ms:.0f}ms,不可压缩)", f"距参考价成立 {age_s:.1f}s(含数据延迟 {lag_ms:.0f}ms,不可压缩)",
"", "",
f"止损 {_fmt(lv['stop'])} 2 ATRstop-market", f"止损 {_fmt(lv['stop'])} 2 ATRstop-market,全仓",
f"减半 {_fmt(lv['scale'])} 3 ATR,限价 maker", 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"超时 {MAXB} 分钟后市价平(剩余半仓止损仍在 2 ATR,不移成本)",
"", "",
f"ATR {atr_pct * 1e4:.1f}bp · 滑点预算 {budget_bp:.1f}bp", 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) print(f" [TG] {sym} 无预算(当前环境不可做),不推", flush=True)
return return
age = time.time() - kline_ts / 1000.0 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))