Add WeCom markdown alerts and keep target exits while strategy is paused.

Notify open/close/start/pause/fault with SIM vs LIVE venue labels; configure webhook in Settings.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-26 23:19:39 +08:00
parent 4d331da962
commit 56a4007130
12 changed files with 602 additions and 16 deletions
+139 -15
View File
@@ -146,6 +146,12 @@ class StrategyEngine:
async def pause(self) -> dict[str, Any]:
self._set_state(running=0, phase="paused", last_error=None)
try:
from ..notify import wecom
wecom.notify_pause()
except Exception:
logger.exception("wecom notify_pause failed")
return self.state()
async def start(self) -> dict[str, Any]:
@@ -155,13 +161,25 @@ class StrategyEngine:
ok, reason = live_ready()
if not ok:
self._set_state(running=0, phase="paused", last_error=reason)
try:
from ..notify import wecom
wecom.notify_fault(title="启动失败", detail=reason, dedupe_key="start_fail")
except Exception:
pass
return self.state()
self._set_state(running=1, last_error=None, phase="idle")
self.ensure_loop()
try:
from ..notify import wecom
wecom.notify_start()
except Exception:
logger.exception("wecom notify_start failed")
return self.state()
def ensure_loop(self) -> None:
"""保证后台循环在跑(即使策略暂停,也要盯到期全平)。"""
"""保证后台循环在跑(暂停时仍盯目标/到期,不新开)。"""
if self._task is None or self._task.done():
self._task = asyncio.create_task(self._loop(), name="strategy-engine")
@@ -204,6 +222,17 @@ class StrategyEngine:
self.enter_rest_after_close()
residuals = self.matcher.settle_all_residuals_now()
try:
if ok:
from ..notify import wecom
wecom.notify_close(
reason="emergency",
detail=detail,
data=close_data or {},
)
except Exception:
logger.exception("wecom emergency notify failed")
return {
"close": {
"ok": ok,
@@ -298,6 +327,16 @@ class StrategyEngine:
last_error=None,
phase="resting",
)
try:
from ..notify import wecom
wecom.notify_close(
reason="target_perp_only",
detail=r.detail,
data=r.data or {},
)
except Exception:
logger.exception("wecom notify_close failed")
else:
self._note_retry_result(kind, ok=False, detail=r.detail)
self._set_state(phase="closing", last_error=r.detail)
@@ -311,6 +350,16 @@ class StrategyEngine:
if r.ok:
self._note_retry_result(kind, ok=True)
self._after_close()
try:
from ..notify import wecom
wecom.notify_close(
reason=reason,
detail=r.detail,
data=r.data or {},
)
except Exception:
logger.exception("wecom notify_close failed")
elif r.liquidity_wait and not bypass_liquidity:
# 等待期间若已变成远虚,下一 tick 走归档
if self.matcher.option_is_deep_otm():
@@ -321,6 +370,16 @@ class StrategyEngine:
if r2.ok:
self._note_retry_result(kind, ok=True)
self._after_close()
try:
from ..notify import wecom
wecom.notify_close(
reason="target_perp_only",
detail=r2.detail,
data=r2.data or {},
)
except Exception:
logger.exception("wecom notify_close failed")
return
self._note_retry_result("liquidity", ok=False, detail=r.detail)
self._set_state(phase="liquidity_wait", last_error=r.detail)
@@ -360,9 +419,9 @@ class StrategyEngine:
row = self.db.fetchone("SELECT running FROM strategy_state WHERE id=1")
running = bool(row and int(row["running"]))
if not running:
# 暂停时仍执行到期全平,避免拖过期
# 暂停:不新开仓;仍盯目标平仓 + 到期平仓
async with self._lock:
await self._maybe_expiry_close()
await self._tick_manage_positions()
await asyncio.sleep(1)
continue
async with self._lock:
@@ -382,21 +441,25 @@ class StrategyEngine:
continue
logger.exception("strategy tick failed")
self._set_state(last_error=err)
try:
from ..notify import wecom
wecom.notify_fault(
title="策略循环异常",
detail=err,
dedupe_key=f"tick:{err[:80]}",
)
except Exception:
pass
sleep_for = 1.0 + max(0.0, self._extra_sleep_sec)
self._extra_sleep_sec = 0.0
await asyncio.sleep(min(sleep_for, 60.0))
async def _tick_async(self) -> None:
# 残留期权到期结算(与活跃组隔离,不挡开仓)
async def _tick_manage_positions(self) -> None:
"""有仓时的盯盘:残留结算 / 半仓修复 / 目标平 / 到期平。不新开仓。"""
await self._settle_residuals()
s = get_settings()
st = self.db.fetchone("SELECT * FROM strategy_state WHERE id=1")
assert st is not None
wkey = window_key()
if st["window_key"] != wkey:
self._set_state(window_key=wkey, phase="idle")
st = self.db.fetchone("SELECT * FROM strategy_state WHERE id=1")
assert st is not None
exit_mode = self.ledger.get_setting_str("exit_mode", s.exit_mode)
@@ -409,7 +472,6 @@ class StrategyEngine:
pos = self.matcher.current_position()
st_pos = str(pos.get("status") or "flat")
# 实盘半仓修复:禁止新开;失败指数退避,避免每秒砸期权
if st_pos == "half_open":
allowed, left = self._retry_allowed("half_open")
if not allowed:
@@ -425,12 +487,21 @@ class StrategyEngine:
self._note_retry_result("half_open", ok=True)
self._after_close()
self._set_state(phase="resting", last_error=None)
try:
from ..notify import wecom
wecom.notify_close(
reason="emergency",
detail=r.detail or "half_open repaired",
data=r.data or {},
)
except Exception:
pass
else:
self._note_retry_result("half_open", ok=False, detail=r.detail)
self._set_state(phase="closing", last_error=r.detail)
return
# 期权已平、永续待平:只续平永续(带退避)
if st_pos == "option_closed_perp_pending":
await self._close_open_position(
reason="perp_pending_retry",
@@ -441,7 +512,6 @@ class StrategyEngine:
)
return
# 有活跃持仓:只盯当前组平仓;残留期权不在此扫描
if st_pos == "open":
upl = self.matcher.unrealized()
expired = check_expiry_close(expiry_ms=self._position_expiry_ms(upl))
@@ -462,7 +532,6 @@ class StrategyEngine:
else:
reason = decision.reason or "liquidity_retry"
bypass = False
# 目标达标(或流动性等待重试)时:远虚走只平永续
abandon = bool(decision.should_close or pending_close)
rkind = "liquidity" if pending_close else "close"
await self._close_open_position(
@@ -474,6 +543,24 @@ class StrategyEngine:
)
else:
self._set_state(phase="open", last_error=None)
async def _tick_async(self) -> None:
# 先盯仓(平仓路径)
await self._tick_manage_positions()
s = get_settings()
st = self.db.fetchone("SELECT * FROM strategy_state WHERE id=1")
assert st is not None
wkey = window_key()
if st["window_key"] != wkey:
self._set_state(window_key=wkey, phase="idle")
st = self.db.fetchone("SELECT * FROM strategy_state WHERE id=1")
assert st is not None
pos = self.matcher.current_position()
st_pos = str(pos.get("status") or "flat")
# 仍有活跃仓或半仓:本 tick 不再开新仓
if st_pos in ("open", "half_open", "option_closed_perp_pending", "opening"):
return
if st["phase"] == "resting" and st["rest_until_ms"]:
@@ -514,6 +601,7 @@ class StrategyEngine:
return
self._set_state(phase="opening", last_error=None)
wkey = window_key()
count = self._count_groups_for_day(wkey)
gid = next_group_id(count)
option_inst = (
@@ -526,6 +614,16 @@ class StrategyEngine:
safe, safe_msg = assert_safe_to_open_live(self.matcher)
if not safe:
self._set_state(phase="idle", last_error=safe_msg)
try:
from ..notify import wecom
wecom.notify_fault(
title="开仓前对账拒绝",
detail=safe_msg,
dedupe_key=f"recon:{safe_msg[:80]}",
)
except Exception:
pass
return
r = await asyncio.to_thread(
self.matcher.open_group,
@@ -540,8 +638,34 @@ class StrategyEngine:
)
if r.ok:
self._set_state(phase="open", last_error=None)
try:
from ..notify import wecom
wecom.notify_open(
group_id=gid,
detail=r.detail,
extra={
"bias": pick.bias,
"option_side": pick.option_side,
"option_inst_id": option_inst,
"strike": pick.pair.strike,
"expiry_ymd": pick.pair.expiry_ymd,
},
)
except Exception:
logger.exception("wecom notify_open failed")
else:
self._set_state(phase="idle", last_error=r.detail)
try:
from ..notify import wecom
wecom.notify_fault(
title="开仓失败",
detail=r.detail,
dedupe_key=f"open_fail:{r.detail[:80]}",
)
except Exception:
pass
_engine: StrategyEngine | None = None