补上停机处理:SIGTERM 撤挂单平仓再退,并关掉 aiohttp 会话

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 <cursoragent@cursor.com>
This commit is contained in:
jack
2026-08-28 17:12:41 +08:00
co-authored by Cursor
parent 335d891478
commit 1642b1bbfc
2 changed files with 55 additions and 3 deletions
+3 -2
View File
@@ -21,8 +21,9 @@ ExecStart=/opt/chan/.venv/bin/python /opt/chan/live/live_exec.py
Restart=always Restart=always
RestartSec=5 RestartSec=5
# 收到 stop 时给足时间:执行器平掉在场仓位再退出 # 收到 stop 时给足时间:执行器接到 SIGTERM 会撤挂单 + 平掉在场仓位再退出
KillSignal=SIGINT # live_exec.shutdown(),内部平仓上限 60s)。默认的 90s 停机窗口留了余量。
# 不需要 KillSignal=SIGINT——SIGTERM 已在代码里显式挂了处理器
TimeoutStopSec=90 TimeoutStopSec=90
StandardOutput=journal StandardOutput=journal
+52 -1
View File
@@ -66,6 +66,7 @@ import argparse
import asyncio import asyncio
import json import json
import os import os
import signal
import sys import sys
import time import time
from decimal import Decimal from decimal import Decimal
@@ -478,9 +479,59 @@ class Exec:
f"当日 PnL {self.guard.pnl_day:+.2f}/-{MAX_DAY_LOSS}", f"当日 PnL {self.guard.pnl_day:+.2f}/-{MAX_DAY_LOSS}",
flush=True) 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: async def run(self) -> None:
await self.start() await self.start()
await asyncio.gather(self.poll(), self.sweep(), self.heartbeat()) stop = asyncio.Event()
loop = asyncio.get_running_loop()
# 必须显式挂 SIGTERMdocker 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: def main() -> None: