diff --git a/research/live/probe_rules.py b/research/live/probe_rules.py new file mode 100644 index 0000000..3514487 --- /dev/null +++ b/research/live/probe_rules.py @@ -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() diff --git a/research/live/tg_notify.py b/research/live/tg_notify.py index 9eb5086..d7d9d0b 100644 --- a/research/live/tg_notify.py +++ b/research/live/tg_notify.py @@ -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))