"""企业微信群机器人通知(独立模块)。""" 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 CLOSE_REASON_ZH: dict[str, str] = { "expiry": "到期结算全平", "target_perp_only": "净盈利达标·只平永续(期权归档到期)", "fixed_usdt": "固定净盈利达标·双腿全平", "premium_multiple": "权利金倍数达标·双腿全平", "emergency": "紧急全平", "emergency_perp": "紧急·只平永续", "manual": "手动全平", "perp_pending_retry": "续平永续", "liquidity_retry": "等待流动性后全平", "residual_premium_close": "残留期权·权利金回收中途平", "unknown": "未知原因", } BIAS_ZH: dict[str, str] = { "call_ask_gt_put": "买Call + 永续空", "put_ask_gt_call": "买Put + 永续多", "strike_below_spot": "买Call + 永续空", "strike_above_spot": "买Put + 永续多", "fixed_long_put": "固定方向·买Put + 永续多", "fixed_short_call": "固定方向·买Call + 永续空", "manual_call": "手动·买Call + 永续空", "manual_put": "手动·买Put + 永续多", } 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 wecom_machine_name() -> str: """多机推送区分用的机器名(设置页 / WECOM_MACHINE_NAME)。""" s = get_settings() name = (getattr(s, "wecom_machine_name", None) or "").strip() if name: return name[:64] try: return (get_db().get_setting("wecom_machine_name", "") or "").strip()[:64] except Exception: return "" def venue_label() -> str | None: """实盘时返回「实盘·交易所」;SIM 不展示盘口标签。""" s = get_settings() if s.is_sim: return None 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 "实盘·币安" if ex_u in ("gate", "gateio"): return "实盘·Gate" return "实盘·OKX" def close_reason_zh(reason: str | None) -> str: r = str(reason or "").strip() if not r: return "未知原因" return CLOSE_REASON_ZH.get(r, r) def direction_zh(extra: dict[str, Any]) -> str: bias = str(extra.get("bias") or "").strip() if bias in BIAS_ZH: return BIAS_ZH[bias] opt = str(extra.get("option_side") or "").strip().lower() perp = str(extra.get("perp_side") or "").strip().lower() if opt == "put" and perp == "long": return "买Put + 永续多" if opt == "call" and perp == "short": return "买Call + 永续空" if opt == "put": return "买Put" if opt == "call": return "买Call" if bias: return bias return "—" def _fmt_num(x: Any, digits: int = 2) -> str: try: if x is None or x == "": return "—" return f"{float(x):.{digits}f}" except (TypeError, ValueError): return "—" def _fmt_money(x: Any, *, signed: bool = False) -> str: try: if x is None or x == "": return "—" v = float(x) if signed: return f"{v:+.2f}U" return f"{v:.2f}U" except (TypeError, ValueError): return "—" def _pick_float(data: dict[str, Any], *keys: str) -> float | None: for k in keys: if k not in data or data[k] is None or data[k] == "": continue try: return float(data[k]) except (TypeError, ValueError): continue return None 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 [])) machine = wecom_machine_name() venue = venue_label() prefix_parts: list[str] = [] if machine: prefix_parts.append(f"【{machine}】") if venue: prefix_parts.append(f"【{venue}】") head = f"## {''.join(prefix_parts)}{title}" parts = [ head, f"> **标识**: `{tag}`", f"> **时间**: {time.strftime('%Y-%m-%d %H:%M:%S')}", ] if machine: parts.append(f"> **机器**: {machine}") 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 = dict(extra or {}) perp = extra.get("perp") if isinstance(extra.get("perp"), dict) else {} option = extra.get("option") if isinstance(extra.get("option"), dict) else {} perp_qty = _pick_float(extra, "perp_qty_eth") or _pick_float(perp, "qty_eth") opt_qty = _pick_float(extra, "option_qty_eth") or _pick_float(option, "qty_eth") premium = _pick_float(extra, "initial_premium", "premium") margin = _pick_float(extra, "perp_margin", "margin") leverage = _pick_float(extra, "leverage") perp_px = _pick_float(extra, "perp_entry_px") or _pick_float(perp, "fill_px") opt_px = _pick_float(extra, "option_entry_px") or _pick_float(option, "fill_px") # 缺保证金时用成交价×数量÷杠杆估算 if margin is None and perp_px is not None and perp_qty is not None: try: from ..sim.ledger import Ledger s = get_settings() lev = float(leverage) if leverage and leverage > 0 else float( Ledger().get_setting_float("leverage", s.leverage) or s.leverage or 1 ) if lev > 0: margin = abs(perp_px * perp_qty) / lev leverage = lev except Exception: pass lines = [ f"**组号**: `{group_id}`", f"**方向**: {direction_zh(extra)}", f"**期权合约**: `{extra.get('option_inst_id') or '—'}`", f"**行权价 / 到期**: {_fmt_num(extra.get('strike'), 0)} / {extra.get('expiry_ymd') or '—'}", f"**开仓数量**: 永续 {_fmt_num(perp_qty, 4)} ETH · 期权 {_fmt_num(opt_qty, 4)} ETH", f"**成交均价**: 永续 {_fmt_num(perp_px, 4)} · 期权 {_fmt_num(opt_px, 4)}", f"**权利金占用**: {_fmt_money(premium)}", f"**保证金占用**: {_fmt_money(margin)}" + (f"(杠杆 {_fmt_num(leverage, 0)}x)" if leverage else ""), ] # 说明仅在非模板英文码时展示 d = str(detail or "").strip() if d and d not in ("opened", "opened_live", "ok"): lines.append(f"**说明**: {d}") 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 = dict(data or {}) reason_zh = close_reason_zh(reason) gid = group_id or data.get("group_id") or "—" perp_pnl = _pick_float(data, "perp_pnl") opt_pnl = _pick_float(data, "option_pnl", "opt_pnl") net = _pick_float(data, "net", "net_pnl", "interim_net", "realized_pnl") # 只平永续时 interim_net 可能是净利口径 if data.get("option_abandoned") and opt_pnl is None: opt_note = "期权已归档,待到期结算(本组未计入期权最终盈亏)" else: opt_note = None lines = [ f"**组号**: `{gid}`", f"**平仓方式**: {reason_zh}", f"**永续盈亏**: {_fmt_money(perp_pnl, signed=True)}", f"**期权盈亏**: {_fmt_money(opt_pnl, signed=True)}", f"**净利润**: {_fmt_money(net, signed=True)}", ] if opt_note: lines.append(f"**备注**: {opt_note}") fees = _pick_float(data, "fees", "fees_total") if fees is None: fo = _pick_float(data, "fees_open") fc = _pick_float(data, "fees_close") if fo is not None or fc is not None: fees = (fo or 0.0) + (fc or 0.0) if fees is not None: lines.append(f"**手续费合计**: {_fmt_money(fees)}") d = str(detail or "").strip() if d and d not in ( "closed", "perp_closed_option_residual", "ok", "manual", ): lines.append(f"**说明**: {d}") 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]: venue = venue_label() machine = wecom_machine_name() lines = ["企业微信通知已连通。"] if machine: lines.append(f"机器名称: **{machine}**") if venue: lines.append(f"当前盘口标签: **{venue}**") content = build_markdown( tag=TAG_TEST, title="测试推送", lines=lines, ) return _post_markdown_sync(content)