"""把过全部滤网的信号推到 Telegram,供手工执行。 ## 为什么要这个 自动执行链一行都还没写(下单 / 持仓状态 / 跨重启持久化 / 对账 / 熔断),而 过全部滤网的信号只有约 5.3 笔/天——低到人手能接。先手工跑一批,就能在写 自动化**之前**拿到真实费率档、真实成交价、真实出场行为,让执行链的每个假设 都有实测对照,而不是写完再发现出场模型不对。 ## 时效是这条路最大的风险 回测的成交价是**信号根的次根开盘价**。信号在收盘瞬间产生,人看到推送、解锁 手机、下单,几十秒就过去了,成交价已经不是那个开盘价。所以推送里必须带: - 参考开盘价(回测口径的成交价) - 该币的滑点预算(还能容忍多少偏离) - 距信号产生已过多久 并且**超过 TG_STALE_S 就直接标记为已失效**,而不是让人自己判断。宁可漏做, 不要在偏离预算之外入场——那等于在负期望上开仓。 ## 环境变量 TG_TOKEN BotFather 给的 token(缺失则整个推送静默关闭) TG_CHAT chat id TG_NOTIONAL 每笔名义额,默认 200 USDT(小额实盘) TG_STALE_S 超过多少秒算失效,默认 90 """ from __future__ import annotations import os import time TOKEN = os.environ.get("TG_TOKEN", "") CHAT = os.environ.get("TG_CHAT", "") NOTIONAL = float(os.environ.get("TG_NOTIONAL", "200")) STALE_S = float(os.environ.get("TG_STALE_S", "90")) ENABLED = bool(TOKEN and CHAT) # 出场参数。必须与 step43_fill_aware_budget 的口径一致,否则推的价位和 # 预算所依据的收益结构不是一回事 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: """按 2/3/8 ATR 算出绝对价位。 direction=+1 做多、-1 做空。剩余半仓的止损**保持在 2ATR**、不移到成本, 这是回测参数(RUNNER_STOP=2.0),移了就不是同一个收益结构。 """ s = 1.0 if direction > 0 else -1.0 return {"entry": entry, "stop": entry - s * SL_ATR * atr, "scale": entry + s * SCALE_ATR * atr, "runner": entry + s * RUNNER_ATR * atr} def _fmt(px: float) -> str: # 币价跨度从 DOGE 的 0.2 到 BTC 的 10 万,固定小数位会把小价币截成 0 if px >= 1000: return f"{px:,.1f}" if px >= 10: return f"{px:,.3f}" return f"{px:.6f}" def build(sym: str, direction: int, entry: float, atr_pct: float, kline_ts: int, lag_ms: float, budget_bp: float, age_s: float, rule: dict | None = None) -> str: atr = entry * atr_pct lv = levels(entry, atr, direction) side = "做多 LONG" if direction > 0 else "做空 SHORT" 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'])} ← 回测口径(次根开盘)", qty_line, f"距参考价成立 {age_s:.1f}s(含数据延迟 {lag_ms:.0f}ms,不可压缩)", "", f"止损 {_fmt(lv['stop'])} (2 ATR,stop-market,全仓)", f"减半 {_fmt(lv['scale'])} (3 ATR,限价 maker)", f"目标 {_fmt(lv['runner'])} (8 ATR,限价 maker,剩余半仓)", f"超时 {MAXB} 分钟后市价平(剩余半仓止损仍在 2 ATR,不移成本)", "", f"ATR {atr_pct * 1e4:.1f}bp · 滑点预算 {budget_bp:.1f}bp", f"→ 实际成交偏离参考价超过 {budget_bp:.1f}bp 就不值得做", ] if stale: lines.append("") lines.append("时效已过:成交价已不是回测那个价,宁可漏做。") return "\n".join(lines) async def send(text: str) -> None: """推一条。任何失败都只打日志——推送挂了不能连坐采集。""" if not ENABLED: return try: import aiohttp url = f"https://api.telegram.org/bot{TOKEN}/sendMessage" async with aiohttp.ClientSession() as s: async with s.post(url, json={"chat_id": CHAT, "text": text}, timeout=aiohttp.ClientTimeout(total=10)) as r: if r.status != 200: print(f" [TG] 推送失败 HTTP {r.status} " f"{(await r.text())[:200]}", flush=True) except Exception as e: print(f" [TG] 推送异常 {type(e).__name__}: {e}", flush=True) async def push_signal(sym: str, direction: int, entry: float, atr_pct: float, kline_ts: int, lag_ms: float) -> None: """去重后推一条信号。 去重键取 (币, K线时刻, 方向):同一根被重复处理(补根、池重建后重放)不该 推两次,否则人会开两次仓。 时效的起点是 `kline_ts` 而不是信号产生时刻——参考成交价(次根开盘)就是 在 kline_ts 那一刻存在的。从信号时刻起算会漏掉数据延迟加计算那 0.5~1.5s, 而那段是无法压缩的固定成本,必须计入。 """ if not ENABLED: return key = (sym, int(kline_ts), int(direction)) if key in _sent: return _sent.add(key) if len(_sent) > 5000: _sent.clear() from lib.shadow_budget import budget_of b = budget_of(sym) if not (b == b): # nan:该币当前环境不可做(如 TRX) print(f" [TG] {sym} 无预算(当前环境不可做),不推", flush=True) return age = time.time() - kline_ts / 1000.0 rule = (await load_rules()).get(sym.upper()) await send(build(sym, direction, entry, atr_pct, kline_ts, lag_ms, b, age, rule))