62c91d4bd0
Co-authored-by: Cursor <cursoragent@cursor.com>
385 lines
12 KiB
Python
385 lines
12 KiB
Python
"""企业微信群机器人通知(独立模块)。"""
|
||
|
||
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)
|