Files
jackandCursor 15f4aa088e 加 Telegram 通知,并修好一道死掉的闸
接 Telegram 时查出 Guard.realized() 定义了但全仓库没有调用点——pnl_day
恒为 0,MAX_DAY_LOSS 完全不生效。三条硬约束里最重要的一条是死的。

根因是止损与止盈都挂在交易所侧成交,本进程收不到通知,而 sweep 只在 48
分钟到点才查持仓。连带第二个后果:execs 条目不清,MAX_OPEN 把已出场的
仓位继续算在场,新信号被白挡到截止时刻。

补 watch() 循环(10s):持仓消失即判出场,去 history-position 取
netProfit(= pnl + 资金费 + 开平手续费)记回闸并释放名额。盈亏取交易所的
数而不自己按标记价估——估会漏掉费用且方向总偏乐观。历史未落库时留到下轮,
不会漏记。字段名按文档与官方 TS 类型的差异同时兼容 ctime/cTime。

Telegram(live/tg.py,stdlib + aiohttp):推开仓、平仓带已实现盈亏、被硬
约束挡住、报错、对账平仓、跨日结算、启动与停机。不推信号过期跳过(常态,
搬运重连会重放旧信号)与心跳,否则真事会被淹掉。启动那条兼作通道自检。
研究侧 tg_notify.send 改为复用生产的传输层,方向与 signal_bus 一致。

status.sh 增加一条判读:开过仓但 pnl 仍为 0 就是 watch() 出了问题。

实测:8 类消息渲染、_match_hist 的过早/方向不符/币不符/驼峰字段/取最近
五种情形、100 USDT 下 SOL 与 ADA 的端到端空跑(两腿等量,50/50 精确)。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 17:30:11 +08:00

231 lines
9.4 KiB
Python
Raw Permalink 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:
"""推一条。传输层复用生产侧的 `live/tg.py`,这里不再维护第二份。
方向与 `signal_bus` 一致:**生产持有实现,研究侧反过来 import**。反过来
写成生产 import 研究侧,就等于把研究侧的依赖树绑到实盘进程上。
"""
from pathlib import Path
import sys
p = str(Path(__file__).resolve().parents[2] / "live")
if p not in sys.path:
sys.path.insert(0, p)
import tg
await tg.send(text)
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))