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
|
||||
PERP_QTY_ETH=1
|
||||
OPTION_QTY_ETH=2
|
||||
|
||||
# 企业微信群机器人(系统设置页可改)
|
||||
WECOM_ENABLED=0
|
||||
WECOM_WEBHOOK_URL=
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]:
|
||||
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
|
||||
|
||||
@@ -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 — 一键部署环境检测 / 卸载删目录
|
||||
|
||||
### 变更
|
||||
|
||||
+2
-1
@@ -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)
|
||||
|
||||
@@ -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<void> {
|
||||
await ensureFreshToken();
|
||||
const token = getToken();
|
||||
|
||||
@@ -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<NotifySettings | null>(null);
|
||||
const [wecomOk, setWecomOk] = useState("");
|
||||
|
||||
const [backup, setBackup] = useState<BackupStatus | null>(null);
|
||||
const [bakAuto, setBakAuto] = useState(true);
|
||||
@@ -106,6 +111,12 @@ export default function SettingsPage() {
|
||||
setMode(r.mode === "LIVE" ? "LIVE" : "SIM");
|
||||
})
|
||||
.catch(() => undefined);
|
||||
apiFetch<NotifySettings>("/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<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 (
|
||||
<div className="settings-page">
|
||||
<h2 style={{ marginTop: 0 }}>系统设置</h2>
|
||||
@@ -880,6 +931,65 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</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
|
||||
open={runtimeRulesOpen}
|
||||
onToggle={() => setRuntimeRulesOpen((v) => !v)}
|
||||
@@ -905,6 +1015,10 @@ export default function SettingsPage() {
|
||||
密钥留空保存 = 不覆盖已有配置。交易所选币安且 LIVE
|
||||
时用币安密钥真下单(无 Passphrase)。
|
||||
</li>
|
||||
<li>
|
||||
企业微信:在群里添加「自定义机器人」复制 Webhook。留空保存不覆盖已有
|
||||
URL。故障同类消息 5 分钟内去重。
|
||||
</li>
|
||||
</ul>
|
||||
</RulesFold>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user