Files
dekun 4585dba3c3 Add 永期半自动: human arm, machine open/close, then stop.
Settings toggle, Plan panel, Fleet monitor, exit locks and armed TOCTOU gates; docs and dual audits.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 11:20:11 +08:00

387 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""企业微信群机器人通知(独立模块)。"""
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": "净盈利达标·只平永续(期权归档到期)",
"semi_target_points": "半自动·标的到点且组合净利>0",
"semi_perp_exit": "半自动·净利基数达标",
"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)