接 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>
231 lines
9.4 KiB
Python
231 lines
9.4 KiB
Python
"""把过全部滤网的信号推到 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 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()}
|
||
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 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:
|
||
"""推一条。传输层复用生产侧的 `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))
|