diff --git a/.env.example b/.env.example index be68644..f477036 100644 --- a/.env.example +++ b/.env.example @@ -55,3 +55,7 @@ CLOSE_BID_MARK_MAX_PCT=30 REST_SECONDS=300 PERP_QTY_ETH=1 OPTION_QTY_ETH=2 + +# 企业微信群机器人(系统设置页可改) +WECOM_ENABLED=0 +WECOM_WEBHOOK_URL= diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 6892886..5b3c208 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -329,3 +329,54 @@ async def put_runtime_settings( pass 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()} diff --git a/backend/app/api/sim.py b/backend/app/api/sim.py index d719a57..d51e107 100644 --- a/backend/app/api/sim.py +++ b/backend/app/api/sim.py @@ -115,6 +115,22 @@ async def sim_open_group( ) if not r.ok: 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 { "ok": True, **(r.data or {}), @@ -141,6 +157,16 @@ async def sim_close_group(_user: Annotated[str, Depends(require_user)]) -> dict: if r.ok: # 与自动/紧急全平一致:成功全平后进入组间休息 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 { "ok": r.ok, "liquidity_wait": r.liquidity_wait, diff --git a/backend/app/config.py b/backend/app/config.py index 79c8d47..2640aa6 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -77,6 +77,10 @@ class Settings(BaseSettings): option_qty_eth: float = 2.0 db_path: str = "" # empty -> backend/data/hedge.db + # 企业微信群机器人 + wecom_enabled: bool = False + wecom_webhook_url: str = "" + @property def is_sim(self) -> bool: return self.mode.strip().upper() != "LIVE" diff --git a/backend/app/notify/__init__.py b/backend/app/notify/__init__.py new file mode 100644 index 0000000..d700495 --- /dev/null +++ b/backend/app/notify/__init__.py @@ -0,0 +1,5 @@ +"""通知子系统。""" + +from . import wecom + +__all__ = ["wecom"] diff --git a/backend/app/notify/wecom.py b/backend/app/notify/wecom.py new file mode 100644 index 0000000..e9f0fc9 --- /dev/null +++ b/backend/app/notify/wecom.py @@ -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) diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index a0a9153..e2f20af 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -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 diff --git a/backend/tests/test_wecom_notify.py b/backend/tests/test_wecom_notify.py new file mode 100644 index 0000000..db0e8e0 --- /dev/null +++ b/backend/tests/test_wecom_notify.py @@ -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() == "模拟盘" diff --git a/docs/更新说明.md b/docs/更新说明.md index 5a12f00..d2223bf 100644 --- a/docs/更新说明.md +++ b/docs/更新说明.md @@ -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 — 一键部署环境检测 / 卸载删目录 ### 变更 diff --git a/docs/策略说明.md b/docs/策略说明.md index 31d2e81..54e2f99 100644 --- a/docs/策略说明.md +++ b/docs/策略说明.md @@ -92,6 +92,7 @@ | 周六/周日 | 默认 **跳过新开仓**(上海时区,`skip_weekends`);持仓仍可平 | | 每日轮次上限 | **不限制**;波动大可能多轮,波动小可能一轮都难 | | 组间休息 | 全平后默认休息 **300 秒**(`rest_seconds`)再开下一组;**自动达标/到期、手动全平、紧急全平**成功后均进入休息 | +| 暂停时 | **不新开仓**;仍执行 **目标平仓 + 到期平仓**(防重启/暂停漏平) | | 同时持仓 | 最多 1 组 | ### 3.4 费用(SIM) @@ -160,7 +161,7 @@ - 到达期权到期时刻(OKX:UTC 08:00 = **上海 16:00**)→ 原因 `expiry`。 - **活跃组**:期权按标的结算价的 **内在价值** 入账;若永续仍在则市价平掉。 - **残留组**(曾走 4.1.B):永续早已平掉,**只结算归档期权**,不碰当前活跃仓、不改下方 ATM。 -- 策略 **暂停时仍执行到期结算**,避免拖过期。 +- 策略 **暂停时仍执行目标平仓与到期结算**,不新开仓;避免重启/暂停后漏平。 - 波动小、拖到到期:权利金亏损视为 **预算内成本**,可接受。 ### 4.3 平仓流动性闸门(仅 4.1.A) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 74dc316..b20a29a 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -299,6 +299,13 @@ export type RuntimeSettings = { 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 { await ensureFreshToken(); const token = getToken(); diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 89c7b92..d10bea8 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -6,6 +6,7 @@ import { apiFetch, StrategySettings, RuntimeSettings, + NotifySettings, BackupStatus, downloadBackup, uploadRestoreBackup, @@ -89,6 +90,10 @@ export default function SettingsPage() { const [bnKey, setBnKey] = useState(""); const [bnSecret, setBnSecret] = useState(""); const [runtimeOk, setRuntimeOk] = useState(""); + const [wecomEnabled, setWecomEnabled] = useState(false); + const [wecomWebhook, setWecomWebhook] = useState(""); + const [wecomMeta, setWecomMeta] = useState(null); + const [wecomOk, setWecomOk] = useState(""); const [backup, setBackup] = useState(null); const [bakAuto, setBakAuto] = useState(true); @@ -106,6 +111,12 @@ export default function SettingsPage() { setMode(r.mode === "LIVE" ? "LIVE" : "SIM"); }) .catch(() => undefined); + apiFetch("/api/settings/notify") + .then((n) => { + setWecomMeta(n); + setWecomEnabled(n.enabled === true); + }) + .catch(() => undefined); } function loadBackup() { @@ -351,6 +362,46 @@ export default function SettingsPage() { } } + async function onSaveWecom() { + setErr(""); + setWecomOk(""); + try { + const body: Record = { enabled: wecomEnabled }; + if (wecomWebhook.trim()) body.webhook_url = wecomWebhook.trim(); + const n = await apiFetch("/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( + "/api/settings/notify/test", + { method: "POST", body: "{}" }, + ); + setWecomMeta(n); + setWecomOk(n.detail || "测试消息已发送,请查看企业微信群"); + } catch (ex) { + setErr(ex instanceof Error ? ex.message : String(ex)); + } + } + return (

系统设置

@@ -880,6 +931,65 @@ export default function SettingsPage() {
+
+

企业微信通知

+

+ 群机器人 Webhook。推送 Markdown,带标识(OPEN/CLOSE/START/PAUSE/FAULT)。 + 当前标签: + + {" "} + {wecomMeta?.venue_label || + (runtime?.mode === "LIVE" + ? `实盘·${(runtime.exchange || "okx").toUpperCase()}` + : "模拟盘")} + + 。开仓/平仓/启动/暂停/故障均推送;模拟盘标题为「模拟盘」,实盘为「实盘·交易所」。 +

+
+
+ + +
+
+ + setWecomWebhook(e.target.value)} + /> +
+
+ {wecomOk ?
{wecomOk}
: null} +
+ + +
+
+ setRuntimeRulesOpen((v) => !v)} @@ -905,6 +1015,10 @@ export default function SettingsPage() { 密钥留空保存 = 不覆盖已有配置。交易所选币安且 LIVE 时用币安密钥真下单(无 Passphrase)。 +
  • + 企业微信:在群里添加「自定义机器人」复制 Webhook。留空保存不覆盖已有 + URL。故障同类消息 5 分钟内去重。 +