Files
Chan/research/live/tg_notify.py
T
jackandCursor f716ddd149 推送带杠杆与保证金,名义额默认抬到 500
杠杆只影响占用保证金,**不改**名义敞口、手续费、滑点、盈亏绝对值——费与滑点
都按名义额收。所以「用杠杆所以可以很小额」这个方向要说清:杠杆省的是本金
占用,不是成本。

它真正的用处是让「抬名义额」几乎免费,而抬名义额正好压掉步长取整。实测
取整到步长偶数倍后最差币的名义偏差:100U → 6.7%(SOL)、500U → 1.8%、
1000U → 0.6%。所以默认名义改 500、杠杆 10x,每笔占 50 USDT 保证金。

止损在 2 ATR ≈ 0.2%,而 10x 的强平约需逆向 10% = 100 个 ATR,差 50 倍。
这个止损紧到杠杆几乎不引入强平风险,所以这里没有「杠杆换风险」的取舍。
仍建议逐仓,让每笔最大损失被保证金封住。

推送里加上占用保证金与「触止损亏多少 USDT / 占保证金百分之几」,手工执行时
不必自己换算。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 15:44:39 +08:00

233 lines
9.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", "")
# 名义额。杠杆不改手续费与滑点(都按名义额收),所以抬名义额是有真实成本的;
# 抬它的理由只有一个:把步长取整压下去。实测最差币的偏差
# 100U → 6.7%(SOL)、500U → 1.8%、1000U → 0.6%。
NOTIONAL = float(os.environ.get("TG_NOTIONAL", "500"))
# 杠杆只影响占用保证金,不影响名义敞口、手续费、滑点、盈亏绝对值。
# 止损在 2 ATR ≈ 0.2%,而 10x 的强平约需逆向 10% = 100 个 ATR,差 50 倍,
# 所以这里的杠杆几乎不引入强平风险。逐仓,让每笔的最大损失被保证金封住。
LEVERAGE = float(os.environ.get("TG_LEVERAGE", "10"))
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()}
notional = qty * entry
qty_line = (f"入场 {qty:.6g} 币 ≈ {notional:,.1f} USDT"
f" · 减半腿 {half:.6g} 币(正好一半)")
else:
qty = NOTIONAL / entry
notional = NOTIONAL
qty_line = f"入场 {qty:.6g} 币 ≈ {NOTIONAL:,.0f} USDT(未取整)"
margin = notional / LEVERAGE if LEVERAGE > 0 else notional
# 止损距入场 2 ATR,换成保证金的百分比才是「这笔最多亏多少本金」
loss_pct = SL_ATR * atr_pct * LEVERAGE * 100
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"{LEVERAGE:.0f}x 逐仓 → 占用保证金 {margin:,.1f} USDT"
f" · 触止损亏 {notional * SL_ATR * atr_pct:,.2f} USDT"
f"(保证金的 {loss_pct:.1f}%",
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))