From 1642b1bbfcdf621f08d687e47b04b37c42b78362 Mon Sep 17 00:00:00 2001 From: jack Date: Fri, 28 Aug 2026 17:12:41 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E4=B8=8A=E5=81=9C=E6=9C=BA=E5=A4=84?= =?UTF-8?q?=E7=90=86=EF=BC=9ASIGTERM=20=E6=92=A4=E6=8C=82=E5=8D=95?= =?UTF-8?q?=E5=B9=B3=E4=BB=93=E5=86=8D=E9=80=80=EF=BC=8C=E5=B9=B6=E5=85=B3?= =?UTF-8?q?=E6=8E=89=20aiohttp=20=E4=BC=9A=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README 里原先写「执行器收到 SIGINT 会先平掉在场仓位再退出」,但代码里 完全没有信号处理——asyncio.run 外面连 except KeyboardInterrupt 都没有。 断言了一个不存在的行为,现在把它实现。 为什么停机要平仓而崩溃不用:崩溃后 systemd/docker 几秒内重启,reconcile 接着清掉遗留仓位,空窗期有交易所侧止损兜着。而主动停机后没人重启,仓位会 一直挂到止损或止盈,48 分钟超时腿丢了,跑的就不是回测那个出场结构。 必须显式挂 SIGTERM:docker stop 与 systemd stop 默认发的都是它,而 Python 对 SIGTERM 不抛 KeyboardInterrupt,不挂就是直接消失、没有任何清理。挂上后 systemd 单元里 KillSignal=SIGINT 那个绕法也去掉了。 主循环因异常退出时同样走停机流程,不把仓位留给已经没人管的进程。 顺带修掉 "Unclosed client session"(bitget_rest 本有 close() 但没人调, Restart=always 下会漏 socket)。 实测:发 SIGTERM 后走完停机流程、无泄漏警告。 Co-authored-by: Cursor --- live/deploy/chan-live-exec.service | 5 +-- live/live_exec.py | 53 +++++++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/live/deploy/chan-live-exec.service b/live/deploy/chan-live-exec.service index 05ae66f..dc34d8c 100644 --- a/live/deploy/chan-live-exec.service +++ b/live/deploy/chan-live-exec.service @@ -21,8 +21,9 @@ ExecStart=/opt/chan/.venv/bin/python /opt/chan/live/live_exec.py Restart=always RestartSec=5 -# 收到 stop 时给足时间:执行器要平掉在场仓位再退出 -KillSignal=SIGINT +# 收到 stop 时给足时间:执行器接到 SIGTERM 会撤挂单 + 平掉在场仓位再退出 +# (live_exec.shutdown(),内部平仓上限 60s)。默认的 90s 停机窗口留了余量。 +# 不需要 KillSignal=SIGINT——SIGTERM 已在代码里显式挂了处理器 TimeoutStopSec=90 StandardOutput=journal diff --git a/live/live_exec.py b/live/live_exec.py index d0ff93c..bc1e488 100644 --- a/live/live_exec.py +++ b/live/live_exec.py @@ -66,6 +66,7 @@ import argparse import asyncio import json import os +import signal import sys import time from decimal import Decimal @@ -478,9 +479,59 @@ class Exec: f"当日 PnL {self.guard.pnl_day:+.2f}/-{MAX_DAY_LOSS}", flush=True) + async def shutdown(self) -> None: + """收到停机信号:撤挂单 + 平掉在场仓位,然后退出。 + + 为什么停机要平仓,而崩溃不需要:崩溃后 systemd/docker 会在几秒内重启, + `reconcile` 接着就把遗留仓位清掉,空窗期有交易所侧止损兜着。而**主动 + 停机后没人重启**,仓位会一直挂到止损或止盈——48 分钟超时腿丢了,跑的 + 就不是回测那个出场结构了。 + + 直接复用 reconcile:它做的正是"撤挂单 + 市价平掉一切"。 + """ + print("\n 收到停机信号,撤挂单并平掉在场仓位", flush=True) + try: + if self.dry: + print(" 空跑模式,无仓位可平", flush=True) + else: + await asyncio.wait_for(self.reconcile(), timeout=60.0) + except asyncio.TimeoutError: + print(" ⛔ 平仓超过 60s 未完成。仓位仍有交易所侧止损," + "但超时腿已丢,去交易所确认", flush=True) + except Exception as e: # noqa: BLE001 + print(f" ⛔ 停机平仓失败 {type(e).__name__}: {e},需人工介入", + flush=True) + finally: + # 不关会话会在日志里留 "Unclosed client session",且反复重启 + # (Restart=always)会漏 socket + try: + await self.api.close() + except Exception: # noqa: BLE001 + pass + async def run(self) -> None: await self.start() - await asyncio.gather(self.poll(), self.sweep(), self.heartbeat()) + stop = asyncio.Event() + loop = asyncio.get_running_loop() + # 必须显式挂 SIGTERM:docker stop 与 systemd stop 默认发的都是它, + # 而 Python 对 SIGTERM 不抛 KeyboardInterrupt,不挂就是直接消失、 + # 没有任何清理。SIGINT 一并挂上,省得依赖 KillSignal= 那种绕法 + 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())] + done, _ = await asyncio.wait( + [*work, asyncio.create_task(stop.wait())], + return_when=asyncio.FIRST_COMPLETED) + for t in work: + t.cancel() + await asyncio.gather(*work, return_exceptions=True) + # 任一主循环自己退出(异常)也走这里:仓位不能留给没人管的进程 + for t in done: + if t in work and (exc := t.exception()) is not None: + print(f" ⛔ 主循环异常退出 {type(exc).__name__}: {exc}", + flush=True) + await self.shutdown() def main() -> None: