Files
Chan/research/live/tg_notify.py
T
jackandCursor c57adc8cb0 推送按合约规则取整,减半腿精确一半
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>
2026-08-28 15:42:17 +08:00

218 lines
8.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""把过全部滤网的信号推到 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 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:
"""按 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 ATRstop-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))