加 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>
This commit is contained in:
+113
-4
@@ -78,6 +78,7 @@ sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
import signal_bus # noqa: E402
|
||||
from bitget_rest import Bitget # noqa: E402
|
||||
import tg # noqa: E402
|
||||
from exit_params import MAXB, RUNNER, RUNNER_STOP, SCALE_AT, SL # noqa: E402
|
||||
|
||||
# 生产状态的根目录。默认放 ~/chan-live,**不落在仓库里**:仓库会被 git
|
||||
@@ -99,6 +100,9 @@ MAX_DAY = int(os.environ.get("LIVE_MAX_DAY", "15"))
|
||||
MAX_DAY_LOSS = float(os.environ.get("LIVE_MAX_DAY_LOSS", "20"))
|
||||
# 信号超过这么久就不做了。参考成交价是次根开盘价,过期后跑的不是回测那个价
|
||||
STALE_S = float(os.environ.get("LIVE_STALE_S", "20"))
|
||||
# 盯交易所侧出场的轮询间隔。10s 足够:出场后要做的只是记账与放开 MAX_OPEN
|
||||
# 名额,不涉及下单时效。太密会白耗 API 配额
|
||||
WATCH_S = float(os.environ.get("LIVE_WATCH_S", "10"))
|
||||
|
||||
STATE = Path(os.environ.get("LIVE_STATE", LIVE_HOME / "state" / "live_state.json"))
|
||||
TRADES = Path(os.environ.get("LIVE_TRADES", LIVE_HOME / "state" / "live_trades.jsonl"))
|
||||
@@ -135,6 +139,7 @@ class Guard:
|
||||
self.n_day = 0
|
||||
self.pnl_day = 0.0
|
||||
self.done: set = set()
|
||||
self.pending_roll: tuple | None = None
|
||||
self._load()
|
||||
|
||||
def _load(self) -> None:
|
||||
@@ -165,6 +170,8 @@ class Guard:
|
||||
print(f" [guard] 跨日 {self.day} → {today},"
|
||||
f"当日 {self.n_day} 笔 / PnL {self.pnl_day:+.2f} USDT",
|
||||
flush=True)
|
||||
# roll() 是同步的,推送要 await,所以只留个待发件,由心跳取走
|
||||
self.pending_roll = (self.day, self.n_day, self.pnl_day)
|
||||
self.day, self.n_day, self.pnl_day = today, 0, 0.0
|
||||
self.save()
|
||||
|
||||
@@ -248,6 +255,9 @@ class Exec:
|
||||
self.api = Bitget(dry=self.dry)
|
||||
self.rules = await self.api.contracts()
|
||||
print(f" 合约规则 {len(self.rules)} 个", flush=True)
|
||||
# 启动推送兼作"通道通不通"的自检:配错了这里就收不到,而不是等到
|
||||
# 几小时后第一个真信号来时才发现
|
||||
await tg.started(NOTIONAL, LEVERAGE, len(SYMS), self.dry)
|
||||
if self.dry:
|
||||
print(" ⚠ 空跑模式:不下真单", flush=True)
|
||||
return
|
||||
@@ -294,6 +304,9 @@ class Exec:
|
||||
return
|
||||
print(f" ⚠ 对账:发现 {len(pos)} 个遗留持仓,撤挂单后市价平掉",
|
||||
flush=True)
|
||||
await tg.error(f"对账:发现 {len(pos)} 个遗留持仓,撤挂单后市价平掉",
|
||||
"、".join(f"{p['symbol']} {p['holdSide']} {p['total']}"
|
||||
for p in pos))
|
||||
for p in pos:
|
||||
sym, hs, sz = p["symbol"], p["holdSide"], p["total"]
|
||||
print(f" {sym} {hs} {sz} @ {p.get('openPriceAvg')}",
|
||||
@@ -339,6 +352,10 @@ class Exec:
|
||||
print(f" ⊘ {r['key']} 跳过:{why}", flush=True)
|
||||
log_trade({"ev": "skip", "key": r["key"], "why": why,
|
||||
"age_s": round(age, 2)})
|
||||
# 只有被硬约束挡住才推。过期跳过是常态(搬运重连会重放旧信号),
|
||||
# 推了会把真事淹掉
|
||||
if "过期" not in why and "已做过" not in why:
|
||||
await tg.blocked(r["key"], why)
|
||||
return
|
||||
|
||||
self.guard.took(r["key"])
|
||||
@@ -428,12 +445,95 @@ class Exec:
|
||||
print(f" {x['tag']:<7}{half} 币 · 止损 {stop_px} · "
|
||||
f"止盈 {tp_px}", flush=True)
|
||||
|
||||
log_trade({"ev": "opened", "key": r["key"], "legs": opened,
|
||||
"stop_px": stop_px})
|
||||
if opened:
|
||||
self.execs[r["key"]] = {
|
||||
"sym": sym, "pair": pair, "hold": hold,
|
||||
"deadline": time.time() + MAXB * 60, "legs": opened}
|
||||
log_trade({"ev": "opened", "key": r["key"], "legs": opened,
|
||||
"stop_px": stop_px})
|
||||
"deadline": time.time() + MAXB * 60, "legs": opened,
|
||||
# watch() 靠这个时间戳去 history-position 里认领对应的平仓记录
|
||||
"opened_ms": int(time.time() * 1000),
|
||||
"side": "LONG" if d > 0 else "SHORT"}
|
||||
await tg.opened(sym, self.execs[r["key"]]["side"], entry,
|
||||
r["atr_pct"], NOTIONAL, LEVERAGE, stop_px,
|
||||
opened, time.time() - r["emit_ms"] / 1000.0)
|
||||
|
||||
async def watch(self) -> None:
|
||||
"""盯交易所侧的出场,把已实现盈亏记回闸。
|
||||
|
||||
为什么必须有这个循环:止损与止盈都挂在交易所侧,成交时本进程收不到
|
||||
任何通知。缺了它有两个后果,都是静默的:
|
||||
|
||||
1. `Guard.realized()` 没人调用 → `pnl_day` 恒为 0 →
|
||||
**MAX_DAY_LOSS 这道闸完全不生效**。三条硬约束里最重要的一条。
|
||||
2. `self.execs` 的条目要挂到 48 分钟截止才清 → `MAX_OPEN` 把已经
|
||||
出场的仓位继续算在场 → 新信号被白挡掉。方向保守但不是本意。
|
||||
|
||||
盈亏取交易所的 `netProfit`(= pnl + 资金费 + 开平手续费),不自己按
|
||||
标记价估——估会漏掉费用,而且方向总是偏乐观。
|
||||
"""
|
||||
while True:
|
||||
await asyncio.sleep(WATCH_S)
|
||||
if self.dry or not self.execs:
|
||||
continue
|
||||
try:
|
||||
live = {p["symbol"] for p in await self.api.positions()}
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" ⚠ 盯仓读持仓失败 {type(e).__name__}: {e}", flush=True)
|
||||
continue
|
||||
gone = [k for k, st in self.execs.items()
|
||||
if st["pair"] not in live]
|
||||
if not gone:
|
||||
continue
|
||||
# 两个半仓在同一 symbol 上会被交易所净成一个仓位,所以一个 key
|
||||
# 对应一条历史记录。按最早的入场时间取一次历史,够覆盖全部
|
||||
since = min(self.execs[k]["opened_ms"] for k in gone) - 60_000
|
||||
try:
|
||||
hist = await self.api.history_positions(start_ms=since)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" ⚠ 读历史持仓失败 {type(e).__name__}: {e},"
|
||||
f"本轮不结算(下轮重试,不会漏)", flush=True)
|
||||
continue
|
||||
for key in gone:
|
||||
st = self.execs[key]
|
||||
rec = self._match_hist(hist, st)
|
||||
if rec is None:
|
||||
# 常见于刚平掉、历史还没落库。留着下轮再试;真丢了也有
|
||||
# 48 分钟截止那条路兜住 execs 的清理
|
||||
print(f" … {key} 已出场但历史未就绪,下轮再结算",
|
||||
flush=True)
|
||||
continue
|
||||
pnl = float(rec.get("netProfit") or 0.0)
|
||||
self.guard.realized(pnl)
|
||||
self.guard.save()
|
||||
self.execs.pop(key, None)
|
||||
print(f" ◀ {key} 交易所侧出场 · 已实现 {pnl:+.2f} USDT "
|
||||
f"· 当日累计 {self.guard.pnl_day:+.2f}", flush=True)
|
||||
log_trade({"ev": "closed", "key": key, "net_profit": pnl,
|
||||
"open_px": rec.get("openAvgPrice"),
|
||||
"close_px": rec.get("closeAvgPrice")})
|
||||
await tg.closed(st["sym"], st["side"], pnl,
|
||||
float(rec.get("openAvgPrice") or 0),
|
||||
float(rec.get("closeAvgPrice") or 0),
|
||||
self.guard.pnl_day, self.guard.n_day)
|
||||
|
||||
@staticmethod
|
||||
def _match_hist(hist: list, st: dict) -> dict | None:
|
||||
"""在历史持仓里认领属于这一笔的记录。
|
||||
|
||||
按 symbol + holdSide 匹配,并要求收盘时间不早于入场时间(减 60s 容差,
|
||||
两边时钟与落库都有抖动)。同一 symbol 有多条时取最近的一条。
|
||||
"""
|
||||
best, best_t = None, -1.0
|
||||
for r in hist:
|
||||
if r.get("symbol") != st["pair"] or r.get("holdSide") != st["hold"]:
|
||||
continue
|
||||
t = float(r.get("utime") or r.get("uTime") or 0)
|
||||
if t < st["opened_ms"] - 60_000:
|
||||
continue
|
||||
if t > best_t:
|
||||
best, best_t = r, t
|
||||
return best
|
||||
|
||||
async def sweep(self) -> None:
|
||||
"""超时腿:48 分钟到点市价平。
|
||||
@@ -472,6 +572,11 @@ class Exec:
|
||||
async def heartbeat(self) -> None:
|
||||
while True:
|
||||
await asyncio.sleep(300)
|
||||
# 跨日结算是 roll() 里同步留下的,在这里发出去
|
||||
self.guard.roll()
|
||||
if self.guard.pending_roll:
|
||||
await tg.day_rolled(*self.guard.pending_roll)
|
||||
self.guard.pending_roll = None
|
||||
n_open = sum(1 for v in self.execs.values() if v)
|
||||
print(f" [心跳] 见信号 {self.n_seen} · 已做 {self.n_took} · "
|
||||
f"跳过 {self.n_skip} · 在场 {n_open}/{MAX_OPEN} · "
|
||||
@@ -490,6 +595,7 @@ class Exec:
|
||||
直接复用 reconcile:它做的正是"撤挂单 + 市价平掉一切"。
|
||||
"""
|
||||
print("\n 收到停机信号,撤挂单并平掉在场仓位", flush=True)
|
||||
await tg.stopping(len(self.execs))
|
||||
try:
|
||||
if self.dry:
|
||||
print(" 空跑模式,无仓位可平", flush=True)
|
||||
@@ -519,7 +625,8 @@ class Exec:
|
||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||
loop.add_signal_handler(sig, stop.set)
|
||||
work = [asyncio.create_task(c)
|
||||
for c in (self.poll(), self.sweep(), self.heartbeat())]
|
||||
for c in (self.poll(), self.watch(), self.sweep(),
|
||||
self.heartbeat())]
|
||||
done, _ = await asyncio.wait(
|
||||
[*work, asyncio.create_task(stop.wait())],
|
||||
return_when=asyncio.FIRST_COMPLETED)
|
||||
@@ -531,6 +638,8 @@ class Exec:
|
||||
if t in work and (exc := t.exception()) is not None:
|
||||
print(f" ⛔ 主循环异常退出 {type(exc).__name__}: {exc}",
|
||||
flush=True)
|
||||
await tg.error("主循环异常退出,正在平仓并退出",
|
||||
f"{type(exc).__name__}: {exc}")
|
||||
await self.shutdown()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user