推送按合约规则取整,减半腿精确一半
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:
@@ -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()
|
||||||
@@ -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 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:
|
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 ATR,stop-market)",
|
f"止损 {_fmt(lv['stop'])} (2 ATR,stop-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))
|
||||||
|
|||||||
Reference in New Issue
Block a user