过滤网信号推 Telegram,供手工小额实盘

自动执行链一行都还没写(下单/持仓状态/跨重启持久化/对账/熔断),而过全部
滤网的信号只有约 5.3 笔/天——低到人手能接。先手工跑一批,就能在写自动化
**之前**拿到真实费率档、真实成交价、真实出场行为,让执行链的每个假设都有
实测对照,而不是写完再发现出场模型不对。

推送内容按手工执行需要给全:参考成交价(回测口径的次根开盘)、按 2/3/8 ATR
换算的绝对价位、下单数量、该币滑点预算,以及一句「偏离超过预算就不值得做」。
剩余半仓止损保持 2ATR 不移成本,这是回测参数,移了就不是同一个收益结构。

时效是这条路最大的风险,所以起点取 kline_ts 而不是信号产生时刻——参考价就是
在 kline_ts 那一刻存在的,从信号时刻起算会漏掉数据延迟加计算那 0.5~1.5s,而
那段不可压缩。超过 TG_STALE_S 直接标记已失效,不让人自己判断:宁可漏做,不
要在偏离预算之外入场。

三条防线:lag 退化时不推(与「停开新仓」同一条规则,不能只在自动化里执行);
按 (币, K线时刻, 方向) 去重,避免补根或池重建重放导致开两次仓;无预算的币
(如 TRX)不推。推送任何失败只打日志,不连坐采集——已验假 token 下降级为
HTTP 401 日志而非抛异常。

凭据走 tg.env(已 gitignore),给了 tg.env.example 说明怎么拿 token 和 chat id。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jack
2026-08-28 15:35:17 +08:00
co-authored by Cursor
parent 0745e73b4f
commit 97457b0518
5 changed files with 179 additions and 0 deletions
+3
View File
@@ -46,3 +46,6 @@ research/out/*.jsonl.gz
research/out/penetration.csv
research/out/shadow_*.csv
research/out/run_meta_*.json
# Telegram 凭据。**不要提交**
research/live/deploy/tg.env
+5
View File
@@ -113,6 +113,11 @@ docker run -d --name "$NAME" -w /home/hummingbot \
-e PYTHONPATH=/home/hummingbot:/repo/research:/repo/research/live:/repo \
-e SHADOW_SITE="$SHADOW_SITE" \
-e SHADOW_LEAN="$SHADOW_LEAN" \
-e SHADOW_INCR="${SHADOW_INCR:-1}" \
-e TG_TOKEN="${TG_TOKEN:-}" \
-e TG_CHAT="${TG_CHAT:-}" \
-e TG_NOTIONAL="${TG_NOTIONAL:-200}" \
-e TG_STALE_S="${TG_STALE_S:-90}" \
-v "$REPO_ROOT:/repo:ro" \
-v "$OUT:/out" \
--entrypoint /opt/conda/envs/hummingbot/bin/python \
+13
View File
@@ -0,0 +1,13 @@
# 复制成 tg.env 再填。tg.env 已在 .gitignore 里,不会被提交。
#
# 拿 tokenTelegram 里找 @BotFather → /newbot → 按提示起名
# 拿 chat id:给你的 bot 随便发一句,然后打开
# https://api.telegram.org/bot<TOKEN>/getUpdates
# 返回的 result[0].message.chat.id 就是
export TG_TOKEN=""
export TG_CHAT=""
# 每笔名义额(USDT)。小额实盘先用小的,它只影响推送里的下单数量
export TG_NOTIONAL="200"
# 距「参考价成立」超过这么多秒就标为已失效。参考价是次根开盘价,
# 过了就不是回测那个成交价了
export TG_STALE_S="90"
+14
View File
@@ -71,6 +71,8 @@ import pandas as pd
from lib.shadow_budget import LAG_ALARM_MS, LAG_WINDOW, lag_healthy
import tg_notify
# 站点标识。跨地对比时两台机器的 CSV 要能合起来读,没有这一列就分不清哪行
# 来自哪台。默认取主机名,部署脚本会显式传 SHADOW_SITE(如 sg-hetzner
SITE = os.environ.get("SHADOW_SITE") or socket.gethostname()
@@ -646,6 +648,18 @@ class Shadow:
self._record_later(sym, kline_ts, h, t_data, t_signal,
baseline, atr_pct, lag_ok),
f"record {sym}")
# 手工执行的推送。只推过全部滤网的,且 lag 退化时不推——那与
# 「停开新仓」是同一条规则,不能只在自动化里执行
if h["pass_all"] and atr_pct:
if not lag_ok:
print(f" [TG] {sym} lag 退化,按停开新仓规则不推",
flush=True)
else:
self._spawn(
tg_notify.push_signal(
sym, h["direction"], float(baseline),
float(atr_pct), kline_ts, t_data - kline_ts),
f"tg {sym}")
def _probe_lag(self, sym: str, lag_ms: int) -> tuple[float, bool]:
"""记一根的到达延迟,返回 (滚动中位数, 该币是否健康)。
+144
View File
@@ -0,0 +1,144 @@
"""把过全部滤网的信号推到 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()
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) -> 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
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",
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
await send(build(sym, direction, entry, atr_pct, kline_ts, lag_ms, b, age))