补上停机处理: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:
@@ -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
@@ -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()
|
||||||
|
# 必须显式挂 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:
|
def main() -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user