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:
@@ -55,3 +55,7 @@ CLOSE_BID_MARK_MAX_PCT=30
|
|||||||
REST_SECONDS=300
|
REST_SECONDS=300
|
||||||
PERP_QTY_ETH=1
|
PERP_QTY_ETH=1
|
||||||
OPTION_QTY_ETH=2
|
OPTION_QTY_ETH=2
|
||||||
|
|
||||||
|
# 企业微信群机器人(系统设置页可改)
|
||||||
|
WECOM_ENABLED=0
|
||||||
|
WECOM_WEBHOOK_URL=
|
||||||
|
|||||||
@@ -329,3 +329,54 @@ async def put_runtime_settings(
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
return _runtime_payload()
|
return _runtime_payload()
|
||||||
|
|
||||||
|
|
||||||
|
class NotifySettingsBody(BaseModel):
|
||||||
|
enabled: bool | None = None
|
||||||
|
webhook_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _notify_payload() -> dict:
|
||||||
|
from ..notify import wecom
|
||||||
|
from ..env_store import mask_secret
|
||||||
|
|
||||||
|
s = get_settings()
|
||||||
|
url = wecom.wecom_webhook_url()
|
||||||
|
return {
|
||||||
|
"enabled": wecom.wecom_enabled(),
|
||||||
|
"webhook_configured": bool(url),
|
||||||
|
"webhook_url_masked": mask_secret(url) if url else None,
|
||||||
|
"venue_label": wecom.venue_label(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/notify")
|
||||||
|
async def get_notify_settings(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||||||
|
return _notify_payload()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/notify")
|
||||||
|
async def put_notify_settings(
|
||||||
|
body: NotifySettingsBody,
|
||||||
|
_user: Annotated[str, Depends(require_user)],
|
||||||
|
) -> dict:
|
||||||
|
updates: dict[str, str] = {}
|
||||||
|
if body.enabled is not None:
|
||||||
|
updates["WECOM_ENABLED"] = "1" if body.enabled else "0"
|
||||||
|
get_db().set_setting("wecom_enabled", "1" if body.enabled else "0")
|
||||||
|
if body.webhook_url is not None and body.webhook_url.strip():
|
||||||
|
updates["WECOM_WEBHOOK_URL"] = body.webhook_url.strip()
|
||||||
|
get_db().set_setting("wecom_webhook_url", body.webhook_url.strip())
|
||||||
|
if updates:
|
||||||
|
upsert_env_keys(updates)
|
||||||
|
return _notify_payload()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/notify/test")
|
||||||
|
async def test_notify(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||||||
|
from ..notify import wecom
|
||||||
|
|
||||||
|
ok, msg = wecom.notify_test()
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(status_code=400, detail=f"推送失败: {msg}")
|
||||||
|
return {"ok": True, "detail": "测试消息已发送", **_notify_payload()}
|
||||||
|
|||||||
@@ -115,6 +115,22 @@ async def sim_open_group(
|
|||||||
)
|
)
|
||||||
if not r.ok:
|
if not r.ok:
|
||||||
raise HTTPException(status_code=400, detail=r.detail)
|
raise HTTPException(status_code=400, detail=r.detail)
|
||||||
|
try:
|
||||||
|
from ..notify import wecom
|
||||||
|
|
||||||
|
wecom.notify_open(
|
||||||
|
group_id=gid,
|
||||||
|
detail=r.detail,
|
||||||
|
extra={
|
||||||
|
"bias": bias,
|
||||||
|
"option_side": option_side,
|
||||||
|
"option_inst_id": option_inst,
|
||||||
|
"strike": pick.pair.strike,
|
||||||
|
"expiry_ymd": pick.pair.expiry_ymd,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
**(r.data or {}),
|
**(r.data or {}),
|
||||||
@@ -141,6 +157,16 @@ async def sim_close_group(_user: Annotated[str, Depends(require_user)]) -> dict:
|
|||||||
if r.ok:
|
if r.ok:
|
||||||
# 与自动/紧急全平一致:成功全平后进入组间休息
|
# 与自动/紧急全平一致:成功全平后进入组间休息
|
||||||
get_engine().enter_rest_after_close()
|
get_engine().enter_rest_after_close()
|
||||||
|
try:
|
||||||
|
from ..notify import wecom
|
||||||
|
|
||||||
|
wecom.notify_close(
|
||||||
|
reason="manual",
|
||||||
|
detail=r.detail,
|
||||||
|
data=r.data or {},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return {
|
return {
|
||||||
"ok": r.ok,
|
"ok": r.ok,
|
||||||
"liquidity_wait": r.liquidity_wait,
|
"liquidity_wait": r.liquidity_wait,
|
||||||
|
|||||||
@@ -77,6 +77,10 @@ class Settings(BaseSettings):
|
|||||||
option_qty_eth: float = 2.0
|
option_qty_eth: float = 2.0
|
||||||
db_path: str = "" # empty -> backend/data/hedge.db
|
db_path: str = "" # empty -> backend/data/hedge.db
|
||||||
|
|
||||||
|
# 企业微信群机器人
|
||||||
|
wecom_enabled: bool = False
|
||||||
|
wecom_webhook_url: str = ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_sim(self) -> bool:
|
def is_sim(self) -> bool:
|
||||||
return self.mode.strip().upper() != "LIVE"
|
return self.mode.strip().upper() != "LIVE"
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""通知子系统。"""
|
||||||
|
|
||||||
|
from . import wecom
|
||||||
|
|
||||||
|
__all__ = ["wecom"]
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
"""企业微信群机器人通知(独立模块)。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from ..config import get_settings
|
||||||
|
from ..exchange.runtime import load_runtime_settings
|
||||||
|
from ..models.db import get_db
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 消息标识(Markdown 内展示,便于检索)
|
||||||
|
TAG_OPEN = "OPEN"
|
||||||
|
TAG_CLOSE = "CLOSE"
|
||||||
|
TAG_START = "START"
|
||||||
|
TAG_PAUSE = "PAUSE"
|
||||||
|
TAG_FAULT = "FAULT"
|
||||||
|
TAG_TEST = "TEST"
|
||||||
|
|
||||||
|
_last_fault_key: str | None = None
|
||||||
|
_last_fault_ms: float = 0.0
|
||||||
|
_FAULT_DEDUP_SEC = 300.0
|
||||||
|
|
||||||
|
|
||||||
|
def _as_bool(raw: str | None, default: bool = False) -> bool:
|
||||||
|
if raw is None or raw == "":
|
||||||
|
return default
|
||||||
|
return str(raw).strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
|
||||||
|
def wecom_enabled() -> bool:
|
||||||
|
s = get_settings()
|
||||||
|
if getattr(s, "wecom_enabled", False):
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
return _as_bool(get_db().get_setting("wecom_enabled", "0"), False)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def wecom_webhook_url() -> str:
|
||||||
|
s = get_settings()
|
||||||
|
url = (getattr(s, "wecom_webhook_url", None) or "").strip()
|
||||||
|
if url:
|
||||||
|
return url
|
||||||
|
try:
|
||||||
|
return (get_db().get_setting("wecom_webhook_url", "") or "").strip()
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def venue_label() -> str:
|
||||||
|
"""模拟盘 / 实盘·OKX / 实盘·BINANCE。"""
|
||||||
|
s = get_settings()
|
||||||
|
if s.is_sim:
|
||||||
|
return "模拟盘"
|
||||||
|
try:
|
||||||
|
ex = load_runtime_settings().exchange
|
||||||
|
except Exception:
|
||||||
|
ex = s.exchange or "okx"
|
||||||
|
ex_u = str(ex).strip().lower()
|
||||||
|
if ex_u in ("binance", "bn"):
|
||||||
|
return "实盘·币安"
|
||||||
|
return "实盘·OKX"
|
||||||
|
|
||||||
|
|
||||||
|
def build_markdown(*, tag: str, title: str, lines: list[str] | None = None) -> str:
|
||||||
|
body = "\n".join(f"> {ln}" if not ln.startswith(">") else ln for ln in (lines or []))
|
||||||
|
parts = [
|
||||||
|
f"## 【{venue_label()}】{title}",
|
||||||
|
f"> **标识**: `{tag}`",
|
||||||
|
f"> **时间**: {time.strftime('%Y-%m-%d %H:%M:%S')}",
|
||||||
|
]
|
||||||
|
if body:
|
||||||
|
parts.append("")
|
||||||
|
parts.append(body)
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _post_markdown_sync(content: str) -> tuple[bool, str]:
|
||||||
|
if not wecom_enabled():
|
||||||
|
return False, "未开启企业微信通知"
|
||||||
|
url = wecom_webhook_url()
|
||||||
|
if not url:
|
||||||
|
return False, "未配置 Webhook"
|
||||||
|
# 企业微信 markdown 上限约 4096 字节
|
||||||
|
raw = content.encode("utf-8")
|
||||||
|
if len(raw) > 4000:
|
||||||
|
content = raw[:3900].decode("utf-8", errors="ignore") + "\n…"
|
||||||
|
payload = {"msgtype": "markdown", "markdown": {"content": content}}
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=8.0) as client:
|
||||||
|
r = client.post(url, json=payload)
|
||||||
|
data = r.json() if r.content else {}
|
||||||
|
if r.status_code != 200 or int(data.get("errcode") or 0) != 0:
|
||||||
|
return False, str(data.get("errmsg") or r.text or r.status_code)
|
||||||
|
return True, "ok"
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_markdown(content: str) -> tuple[bool, str]:
|
||||||
|
return await asyncio.to_thread(_post_markdown_sync, content)
|
||||||
|
|
||||||
|
|
||||||
|
def notify_async(content: str) -> None:
|
||||||
|
"""火忘:不阻塞策略循环。"""
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
ok, msg = _post_markdown_sync(content)
|
||||||
|
if not ok:
|
||||||
|
logger.warning("wecom sync send failed: %s", msg)
|
||||||
|
return
|
||||||
|
|
||||||
|
async def _run() -> None:
|
||||||
|
ok, msg = await send_markdown(content)
|
||||||
|
if not ok:
|
||||||
|
logger.warning("wecom send failed: %s", msg)
|
||||||
|
|
||||||
|
loop.create_task(_run())
|
||||||
|
|
||||||
|
|
||||||
|
def notify_start() -> None:
|
||||||
|
notify_async(
|
||||||
|
build_markdown(
|
||||||
|
tag=TAG_START,
|
||||||
|
title="策略启动",
|
||||||
|
lines=["策略已启动,可新开仓并盯目标平仓。"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def notify_pause() -> None:
|
||||||
|
notify_async(
|
||||||
|
build_markdown(
|
||||||
|
tag=TAG_PAUSE,
|
||||||
|
title="策略暂停",
|
||||||
|
lines=[
|
||||||
|
"策略已暂停:**不再新开仓**。",
|
||||||
|
"仍会盯盘:**目标平仓 + 到期平仓**。",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def notify_open(*, group_id: str, detail: str = "", extra: dict[str, Any] | None = None) -> None:
|
||||||
|
extra = extra or {}
|
||||||
|
lines = [
|
||||||
|
f"**组**: `{group_id}`",
|
||||||
|
f"**方向**: {extra.get('bias') or extra.get('option_side') or '—'}",
|
||||||
|
f"**期权**: `{extra.get('option_inst_id') or '—'}`",
|
||||||
|
f"**行权/到期**: {extra.get('strike') or '—'} / {extra.get('expiry_ymd') or '—'}",
|
||||||
|
]
|
||||||
|
if detail:
|
||||||
|
lines.append(f"**说明**: {detail}")
|
||||||
|
notify_async(build_markdown(tag=TAG_OPEN, title="开仓成功", lines=lines))
|
||||||
|
|
||||||
|
|
||||||
|
def notify_close(
|
||||||
|
*,
|
||||||
|
reason: str,
|
||||||
|
detail: str = "",
|
||||||
|
group_id: str | None = None,
|
||||||
|
data: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
data = data or {}
|
||||||
|
reason_zh = {
|
||||||
|
"expiry": "到期平仓",
|
||||||
|
"target_perp_only": "目标平仓·只平永续",
|
||||||
|
"fixed_usdt": "目标平仓·双腿",
|
||||||
|
"premium_multiple": "目标平仓·双腿",
|
||||||
|
"emergency": "紧急全平",
|
||||||
|
"emergency_perp": "紧急·只平永续",
|
||||||
|
"manual": "手动全平",
|
||||||
|
"perp_pending_retry": "续平永续",
|
||||||
|
}.get(reason, reason)
|
||||||
|
lines = [
|
||||||
|
f"**原因**: {reason_zh} (`{reason}`)",
|
||||||
|
f"**组**: `{group_id or data.get('group_id') or '—'}`",
|
||||||
|
]
|
||||||
|
if detail:
|
||||||
|
lines.append(f"**说明**: {detail}")
|
||||||
|
net = data.get("net_pnl")
|
||||||
|
if net is not None:
|
||||||
|
lines.append(f"**净盈亏**: {net}")
|
||||||
|
notify_async(build_markdown(tag=TAG_CLOSE, title=f"平仓 · {reason_zh}", lines=lines))
|
||||||
|
|
||||||
|
|
||||||
|
def notify_fault(*, title: str, detail: str, dedupe_key: str | None = None) -> None:
|
||||||
|
global _last_fault_key, _last_fault_ms
|
||||||
|
key = dedupe_key or f"{title}:{detail[:120]}"
|
||||||
|
now = time.time()
|
||||||
|
if key == _last_fault_key and now - _last_fault_ms < _FAULT_DEDUP_SEC:
|
||||||
|
return
|
||||||
|
_last_fault_key = key
|
||||||
|
_last_fault_ms = now
|
||||||
|
notify_async(
|
||||||
|
build_markdown(
|
||||||
|
tag=TAG_FAULT,
|
||||||
|
title=f"故障 · {title}",
|
||||||
|
lines=[f"**详情**: {detail[:500]}"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def notify_test() -> tuple[bool, str]:
|
||||||
|
content = build_markdown(
|
||||||
|
tag=TAG_TEST,
|
||||||
|
title="测试推送",
|
||||||
|
lines=["企业微信通知已连通。", f"当前盘口标签: **{venue_label()}**"],
|
||||||
|
)
|
||||||
|
return _post_markdown_sync(content)
|
||||||
+139
-15
@@ -146,6 +146,12 @@ class StrategyEngine:
|
|||||||
|
|
||||||
async def pause(self) -> dict[str, Any]:
|
async def pause(self) -> dict[str, Any]:
|
||||||
self._set_state(running=0, phase="paused", last_error=None)
|
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()
|
return self.state()
|
||||||
|
|
||||||
async def start(self) -> dict[str, Any]:
|
async def start(self) -> dict[str, Any]:
|
||||||
@@ -155,13 +161,25 @@ class StrategyEngine:
|
|||||||
ok, reason = live_ready()
|
ok, reason = live_ready()
|
||||||
if not ok:
|
if not ok:
|
||||||
self._set_state(running=0, phase="paused", last_error=reason)
|
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()
|
return self.state()
|
||||||
self._set_state(running=1, last_error=None, phase="idle")
|
self._set_state(running=1, last_error=None, phase="idle")
|
||||||
self.ensure_loop()
|
self.ensure_loop()
|
||||||
|
try:
|
||||||
|
from ..notify import wecom
|
||||||
|
|
||||||
|
wecom.notify_start()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("wecom notify_start failed")
|
||||||
return self.state()
|
return self.state()
|
||||||
|
|
||||||
def ensure_loop(self) -> None:
|
def ensure_loop(self) -> None:
|
||||||
"""保证后台循环在跑(即使策略暂停,也要盯到期全平)。"""
|
"""保证后台循环在跑(暂停时仍盯目标/到期,不新开)。"""
|
||||||
if self._task is None or self._task.done():
|
if self._task is None or self._task.done():
|
||||||
self._task = asyncio.create_task(self._loop(), name="strategy-engine")
|
self._task = asyncio.create_task(self._loop(), name="strategy-engine")
|
||||||
|
|
||||||
@@ -204,6 +222,17 @@ class StrategyEngine:
|
|||||||
self.enter_rest_after_close()
|
self.enter_rest_after_close()
|
||||||
|
|
||||||
residuals = self.matcher.settle_all_residuals_now()
|
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 {
|
return {
|
||||||
"close": {
|
"close": {
|
||||||
"ok": ok,
|
"ok": ok,
|
||||||
@@ -298,6 +327,16 @@ class StrategyEngine:
|
|||||||
last_error=None,
|
last_error=None,
|
||||||
phase="resting",
|
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:
|
else:
|
||||||
self._note_retry_result(kind, ok=False, detail=r.detail)
|
self._note_retry_result(kind, ok=False, detail=r.detail)
|
||||||
self._set_state(phase="closing", last_error=r.detail)
|
self._set_state(phase="closing", last_error=r.detail)
|
||||||
@@ -311,6 +350,16 @@ class StrategyEngine:
|
|||||||
if r.ok:
|
if r.ok:
|
||||||
self._note_retry_result(kind, ok=True)
|
self._note_retry_result(kind, ok=True)
|
||||||
self._after_close()
|
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:
|
elif r.liquidity_wait and not bypass_liquidity:
|
||||||
# 等待期间若已变成远虚,下一 tick 走归档
|
# 等待期间若已变成远虚,下一 tick 走归档
|
||||||
if self.matcher.option_is_deep_otm():
|
if self.matcher.option_is_deep_otm():
|
||||||
@@ -321,6 +370,16 @@ class StrategyEngine:
|
|||||||
if r2.ok:
|
if r2.ok:
|
||||||
self._note_retry_result(kind, ok=True)
|
self._note_retry_result(kind, ok=True)
|
||||||
self._after_close()
|
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
|
return
|
||||||
self._note_retry_result("liquidity", ok=False, detail=r.detail)
|
self._note_retry_result("liquidity", ok=False, detail=r.detail)
|
||||||
self._set_state(phase="liquidity_wait", last_error=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")
|
row = self.db.fetchone("SELECT running FROM strategy_state WHERE id=1")
|
||||||
running = bool(row and int(row["running"]))
|
running = bool(row and int(row["running"]))
|
||||||
if not running:
|
if not running:
|
||||||
# 暂停时仍执行到期全平,避免拖过期
|
# 暂停:不新开仓;仍盯目标平仓 + 到期平仓
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
await self._maybe_expiry_close()
|
await self._tick_manage_positions()
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
continue
|
continue
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
@@ -382,21 +441,25 @@ class StrategyEngine:
|
|||||||
continue
|
continue
|
||||||
logger.exception("strategy tick failed")
|
logger.exception("strategy tick failed")
|
||||||
self._set_state(last_error=err)
|
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)
|
sleep_for = 1.0 + max(0.0, self._extra_sleep_sec)
|
||||||
self._extra_sleep_sec = 0.0
|
self._extra_sleep_sec = 0.0
|
||||||
await asyncio.sleep(min(sleep_for, 60.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()
|
await self._settle_residuals()
|
||||||
|
|
||||||
s = get_settings()
|
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")
|
st = self.db.fetchone("SELECT * FROM strategy_state WHERE id=1")
|
||||||
assert st is not None
|
assert st is not None
|
||||||
exit_mode = self.ledger.get_setting_str("exit_mode", s.exit_mode)
|
exit_mode = self.ledger.get_setting_str("exit_mode", s.exit_mode)
|
||||||
@@ -409,7 +472,6 @@ class StrategyEngine:
|
|||||||
pos = self.matcher.current_position()
|
pos = self.matcher.current_position()
|
||||||
st_pos = str(pos.get("status") or "flat")
|
st_pos = str(pos.get("status") or "flat")
|
||||||
|
|
||||||
# 实盘半仓修复:禁止新开;失败指数退避,避免每秒砸期权
|
|
||||||
if st_pos == "half_open":
|
if st_pos == "half_open":
|
||||||
allowed, left = self._retry_allowed("half_open")
|
allowed, left = self._retry_allowed("half_open")
|
||||||
if not allowed:
|
if not allowed:
|
||||||
@@ -425,12 +487,21 @@ class StrategyEngine:
|
|||||||
self._note_retry_result("half_open", ok=True)
|
self._note_retry_result("half_open", ok=True)
|
||||||
self._after_close()
|
self._after_close()
|
||||||
self._set_state(phase="resting", last_error=None)
|
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:
|
else:
|
||||||
self._note_retry_result("half_open", ok=False, detail=r.detail)
|
self._note_retry_result("half_open", ok=False, detail=r.detail)
|
||||||
self._set_state(phase="closing", last_error=r.detail)
|
self._set_state(phase="closing", last_error=r.detail)
|
||||||
return
|
return
|
||||||
|
|
||||||
# 期权已平、永续待平:只续平永续(带退避)
|
|
||||||
if st_pos == "option_closed_perp_pending":
|
if st_pos == "option_closed_perp_pending":
|
||||||
await self._close_open_position(
|
await self._close_open_position(
|
||||||
reason="perp_pending_retry",
|
reason="perp_pending_retry",
|
||||||
@@ -441,7 +512,6 @@ class StrategyEngine:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# 有活跃持仓:只盯当前组平仓;残留期权不在此扫描
|
|
||||||
if st_pos == "open":
|
if st_pos == "open":
|
||||||
upl = self.matcher.unrealized()
|
upl = self.matcher.unrealized()
|
||||||
expired = check_expiry_close(expiry_ms=self._position_expiry_ms(upl))
|
expired = check_expiry_close(expiry_ms=self._position_expiry_ms(upl))
|
||||||
@@ -462,7 +532,6 @@ class StrategyEngine:
|
|||||||
else:
|
else:
|
||||||
reason = decision.reason or "liquidity_retry"
|
reason = decision.reason or "liquidity_retry"
|
||||||
bypass = False
|
bypass = False
|
||||||
# 目标达标(或流动性等待重试)时:远虚走只平永续
|
|
||||||
abandon = bool(decision.should_close or pending_close)
|
abandon = bool(decision.should_close or pending_close)
|
||||||
rkind = "liquidity" if pending_close else "close"
|
rkind = "liquidity" if pending_close else "close"
|
||||||
await self._close_open_position(
|
await self._close_open_position(
|
||||||
@@ -474,6 +543,24 @@ class StrategyEngine:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self._set_state(phase="open", last_error=None)
|
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
|
return
|
||||||
|
|
||||||
if st["phase"] == "resting" and st["rest_until_ms"]:
|
if st["phase"] == "resting" and st["rest_until_ms"]:
|
||||||
@@ -514,6 +601,7 @@ class StrategyEngine:
|
|||||||
return
|
return
|
||||||
|
|
||||||
self._set_state(phase="opening", last_error=None)
|
self._set_state(phase="opening", last_error=None)
|
||||||
|
wkey = window_key()
|
||||||
count = self._count_groups_for_day(wkey)
|
count = self._count_groups_for_day(wkey)
|
||||||
gid = next_group_id(count)
|
gid = next_group_id(count)
|
||||||
option_inst = (
|
option_inst = (
|
||||||
@@ -526,6 +614,16 @@ class StrategyEngine:
|
|||||||
safe, safe_msg = assert_safe_to_open_live(self.matcher)
|
safe, safe_msg = assert_safe_to_open_live(self.matcher)
|
||||||
if not safe:
|
if not safe:
|
||||||
self._set_state(phase="idle", last_error=safe_msg)
|
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
|
return
|
||||||
r = await asyncio.to_thread(
|
r = await asyncio.to_thread(
|
||||||
self.matcher.open_group,
|
self.matcher.open_group,
|
||||||
@@ -540,8 +638,34 @@ class StrategyEngine:
|
|||||||
)
|
)
|
||||||
if r.ok:
|
if r.ok:
|
||||||
self._set_state(phase="open", last_error=None)
|
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:
|
else:
|
||||||
self._set_state(phase="idle", last_error=r.detail)
|
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
|
_engine: StrategyEngine | None = None
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.notify.wecom import TAG_OPEN, build_markdown, venue_label
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_markdown_has_tag_and_title(monkeypatch):
|
||||||
|
monkeypatch.setattr("app.notify.wecom.venue_label", lambda: "模拟盘")
|
||||||
|
md = build_markdown(tag=TAG_OPEN, title="开仓成功", lines=["组: G-1"])
|
||||||
|
assert "【模拟盘】开仓成功" in md
|
||||||
|
assert "`OPEN`" in md
|
||||||
|
assert "组: G-1" in md
|
||||||
|
|
||||||
|
|
||||||
|
def test_venue_label_sim(monkeypatch):
|
||||||
|
class S:
|
||||||
|
is_sim = True
|
||||||
|
exchange = "okx"
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.notify.wecom.get_settings", lambda: S())
|
||||||
|
assert venue_label() == "模拟盘"
|
||||||
@@ -5,6 +5,17 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-07-26 — 企业微信通知 + 暂停盯目标
|
||||||
|
|
||||||
|
### 变更
|
||||||
|
|
||||||
|
1. **企业微信**:独立模块 `backend/app/notify/wecom.py`;系统设置「运行模式」可配 Webhook / 开关 / 测试推送。
|
||||||
|
2. **推送事件**:开仓、平仓、策略启动、暂停、故障(同类 5 分钟去重)。Markdown + 标识 `OPEN/CLOSE/START/PAUSE/FAULT`。
|
||||||
|
3. **标题盘口**:模拟盘 →「模拟盘」;实盘 →「实盘·OKX」或「实盘·币安」。
|
||||||
|
4. **暂停策略**:仍执行目标平仓 + 到期平仓,**不新开仓**(策略说明已同步)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-07-26 — 一键部署环境检测 / 卸载删目录
|
## 2026-07-26 — 一键部署环境检测 / 卸载删目录
|
||||||
|
|
||||||
### 变更
|
### 变更
|
||||||
|
|||||||
+2
-1
@@ -92,6 +92,7 @@
|
|||||||
| 周六/周日 | 默认 **跳过新开仓**(上海时区,`skip_weekends`);持仓仍可平 |
|
| 周六/周日 | 默认 **跳过新开仓**(上海时区,`skip_weekends`);持仓仍可平 |
|
||||||
| 每日轮次上限 | **不限制**;波动大可能多轮,波动小可能一轮都难 |
|
| 每日轮次上限 | **不限制**;波动大可能多轮,波动小可能一轮都难 |
|
||||||
| 组间休息 | 全平后默认休息 **300 秒**(`rest_seconds`)再开下一组;**自动达标/到期、手动全平、紧急全平**成功后均进入休息 |
|
| 组间休息 | 全平后默认休息 **300 秒**(`rest_seconds`)再开下一组;**自动达标/到期、手动全平、紧急全平**成功后均进入休息 |
|
||||||
|
| 暂停时 | **不新开仓**;仍执行 **目标平仓 + 到期平仓**(防重启/暂停漏平) |
|
||||||
| 同时持仓 | 最多 1 组 |
|
| 同时持仓 | 最多 1 组 |
|
||||||
|
|
||||||
### 3.4 费用(SIM)
|
### 3.4 费用(SIM)
|
||||||
@@ -160,7 +161,7 @@
|
|||||||
- 到达期权到期时刻(OKX:UTC 08:00 = **上海 16:00**)→ 原因 `expiry`。
|
- 到达期权到期时刻(OKX:UTC 08:00 = **上海 16:00**)→ 原因 `expiry`。
|
||||||
- **活跃组**:期权按标的结算价的 **内在价值** 入账;若永续仍在则市价平掉。
|
- **活跃组**:期权按标的结算价的 **内在价值** 入账;若永续仍在则市价平掉。
|
||||||
- **残留组**(曾走 4.1.B):永续早已平掉,**只结算归档期权**,不碰当前活跃仓、不改下方 ATM。
|
- **残留组**(曾走 4.1.B):永续早已平掉,**只结算归档期权**,不碰当前活跃仓、不改下方 ATM。
|
||||||
- 策略 **暂停时仍执行到期结算**,避免拖过期。
|
- 策略 **暂停时仍执行目标平仓与到期结算**,不新开仓;避免重启/暂停后漏平。
|
||||||
- 波动小、拖到到期:权利金亏损视为 **预算内成本**,可接受。
|
- 波动小、拖到到期:权利金亏损视为 **预算内成本**,可接受。
|
||||||
|
|
||||||
### 4.3 平仓流动性闸门(仅 4.1.A)
|
### 4.3 平仓流动性闸门(仅 4.1.A)
|
||||||
|
|||||||
@@ -299,6 +299,13 @@ export type RuntimeSettings = {
|
|||||||
sim: boolean;
|
sim: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type NotifySettings = {
|
||||||
|
enabled: boolean;
|
||||||
|
webhook_configured: boolean;
|
||||||
|
webhook_url_masked: string | null;
|
||||||
|
venue_label: string;
|
||||||
|
};
|
||||||
|
|
||||||
export async function downloadBackup(name: string): Promise<void> {
|
export async function downloadBackup(name: string): Promise<void> {
|
||||||
await ensureFreshToken();
|
await ensureFreshToken();
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
apiFetch,
|
apiFetch,
|
||||||
StrategySettings,
|
StrategySettings,
|
||||||
RuntimeSettings,
|
RuntimeSettings,
|
||||||
|
NotifySettings,
|
||||||
BackupStatus,
|
BackupStatus,
|
||||||
downloadBackup,
|
downloadBackup,
|
||||||
uploadRestoreBackup,
|
uploadRestoreBackup,
|
||||||
@@ -89,6 +90,10 @@ export default function SettingsPage() {
|
|||||||
const [bnKey, setBnKey] = useState("");
|
const [bnKey, setBnKey] = useState("");
|
||||||
const [bnSecret, setBnSecret] = useState("");
|
const [bnSecret, setBnSecret] = useState("");
|
||||||
const [runtimeOk, setRuntimeOk] = useState("");
|
const [runtimeOk, setRuntimeOk] = useState("");
|
||||||
|
const [wecomEnabled, setWecomEnabled] = useState(false);
|
||||||
|
const [wecomWebhook, setWecomWebhook] = useState("");
|
||||||
|
const [wecomMeta, setWecomMeta] = useState<NotifySettings | null>(null);
|
||||||
|
const [wecomOk, setWecomOk] = useState("");
|
||||||
|
|
||||||
const [backup, setBackup] = useState<BackupStatus | null>(null);
|
const [backup, setBackup] = useState<BackupStatus | null>(null);
|
||||||
const [bakAuto, setBakAuto] = useState(true);
|
const [bakAuto, setBakAuto] = useState(true);
|
||||||
@@ -106,6 +111,12 @@ export default function SettingsPage() {
|
|||||||
setMode(r.mode === "LIVE" ? "LIVE" : "SIM");
|
setMode(r.mode === "LIVE" ? "LIVE" : "SIM");
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
|
apiFetch<NotifySettings>("/api/settings/notify")
|
||||||
|
.then((n) => {
|
||||||
|
setWecomMeta(n);
|
||||||
|
setWecomEnabled(n.enabled === true);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadBackup() {
|
function loadBackup() {
|
||||||
@@ -351,6 +362,46 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onSaveWecom() {
|
||||||
|
setErr("");
|
||||||
|
setWecomOk("");
|
||||||
|
try {
|
||||||
|
const body: Record<string, unknown> = { enabled: wecomEnabled };
|
||||||
|
if (wecomWebhook.trim()) body.webhook_url = wecomWebhook.trim();
|
||||||
|
const n = await apiFetch<NotifySettings>("/api/settings/notify", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
setWecomMeta(n);
|
||||||
|
setWecomEnabled(n.enabled === true);
|
||||||
|
setWecomWebhook("");
|
||||||
|
setWecomOk(
|
||||||
|
n.enabled
|
||||||
|
? n.webhook_configured
|
||||||
|
? "企业微信通知已保存并开启"
|
||||||
|
: "已开启,但尚未配置 Webhook"
|
||||||
|
: "企业微信通知已关闭",
|
||||||
|
);
|
||||||
|
} catch (ex) {
|
||||||
|
setErr(ex instanceof Error ? ex.message : String(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onTestWecom() {
|
||||||
|
setErr("");
|
||||||
|
setWecomOk("");
|
||||||
|
try {
|
||||||
|
const n = await apiFetch<NotifySettings & { detail?: string }>(
|
||||||
|
"/api/settings/notify/test",
|
||||||
|
{ method: "POST", body: "{}" },
|
||||||
|
);
|
||||||
|
setWecomMeta(n);
|
||||||
|
setWecomOk(n.detail || "测试消息已发送,请查看企业微信群");
|
||||||
|
} catch (ex) {
|
||||||
|
setErr(ex instanceof Error ? ex.message : String(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="settings-page">
|
<div className="settings-page">
|
||||||
<h2 style={{ marginTop: 0 }}>系统设置</h2>
|
<h2 style={{ marginTop: 0 }}>系统设置</h2>
|
||||||
@@ -880,6 +931,65 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="settings-section">
|
||||||
|
<h3>企业微信通知</h3>
|
||||||
|
<p style={{ color: "var(--muted)", marginTop: 0 }}>
|
||||||
|
群机器人 Webhook。推送 Markdown,带标识(OPEN/CLOSE/START/PAUSE/FAULT)。
|
||||||
|
当前标签:
|
||||||
|
<span className="mono">
|
||||||
|
{" "}
|
||||||
|
{wecomMeta?.venue_label ||
|
||||||
|
(runtime?.mode === "LIVE"
|
||||||
|
? `实盘·${(runtime.exchange || "okx").toUpperCase()}`
|
||||||
|
: "模拟盘")}
|
||||||
|
</span>
|
||||||
|
。开仓/平仓/启动/暂停/故障均推送;模拟盘标题为「模拟盘」,实盘为「实盘·交易所」。
|
||||||
|
</p>
|
||||||
|
<div className="settings-fields">
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="wecomEn">启用通知</label>
|
||||||
|
<select
|
||||||
|
id="wecomEn"
|
||||||
|
className="mono"
|
||||||
|
value={wecomEnabled ? "1" : "0"}
|
||||||
|
onChange={(e) => setWecomEnabled(e.target.value === "1")}
|
||||||
|
>
|
||||||
|
<option value="0">关闭</option>
|
||||||
|
<option value="1">开启</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="wecomUrl">Webhook URL</label>
|
||||||
|
<input
|
||||||
|
id="wecomUrl"
|
||||||
|
className="mono"
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder={
|
||||||
|
wecomMeta?.webhook_configured
|
||||||
|
? `已配置 ${wecomMeta.webhook_url_masked || "********"}`
|
||||||
|
: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..."
|
||||||
|
}
|
||||||
|
value={wecomWebhook}
|
||||||
|
onChange={(e) => setWecomWebhook(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{wecomOk ? <div className="settings-ok">{wecomOk}</div> : null}
|
||||||
|
<div className="settings-actions" style={{ gap: 8 }}>
|
||||||
|
<button className="btn ghost" type="button" onClick={() => void onSaveWecom()}>
|
||||||
|
保存企业微信
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn ghost"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void onTestWecom()}
|
||||||
|
>
|
||||||
|
发送测试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<RulesFold
|
<RulesFold
|
||||||
open={runtimeRulesOpen}
|
open={runtimeRulesOpen}
|
||||||
onToggle={() => setRuntimeRulesOpen((v) => !v)}
|
onToggle={() => setRuntimeRulesOpen((v) => !v)}
|
||||||
@@ -905,6 +1015,10 @@ export default function SettingsPage() {
|
|||||||
密钥留空保存 = 不覆盖已有配置。交易所选币安且 LIVE
|
密钥留空保存 = 不覆盖已有配置。交易所选币安且 LIVE
|
||||||
时用币安密钥真下单(无 Passphrase)。
|
时用币安密钥真下单(无 Passphrase)。
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
企业微信:在群里添加「自定义机器人」复制 Webhook。留空保存不覆盖已有
|
||||||
|
URL。故障同类消息 5 分钟内去重。
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</RulesFold>
|
</RulesFold>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user