Enrich WeCom open/close notifies with Chinese qty, margin, and PnL.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-02 08:58:49 +08:00
parent aab0b9bdf6
commit 0e3b9e53ac
8 changed files with 373 additions and 32 deletions
+2
View File
@@ -161,9 +161,11 @@ async def sim_open_group(
extra={
"bias": bias,
"option_side": option_side,
"perp_side": perp_side,
"option_inst_id": option_inst,
"strike": pick.pair.strike,
"expiry_ymd": pick.pair.expiry_ymd,
**(r.data or {}),
},
)
except Exception:
+48 -1
View File
@@ -371,6 +371,12 @@ class BinanceLiveExecutor(Matcher):
except Exception:
logger.exception("lock exit target failed group=%s", group_id)
leverage = self.ledger.get_setting_float("leverage", get_settings().leverage)
perp_margin = (
abs(float(pf_px) * float(perp_qty)) / float(leverage)
if leverage and float(leverage) > 0
else None
)
return OpenResult(
ok=True,
group_id=group_id,
@@ -379,9 +385,21 @@ class BinanceLiveExecutor(Matcher):
"group_id": group_id,
"exec_mode": "LIVE",
"exchange": "binance",
"bias": bias,
"option_side": option_side,
"perp_side": perp_side,
"option_inst_id": option_inst_id,
"strike": strike,
"expiry_ymd": expiry_ymd,
"option_ord": opt_fill.ord_id,
"perp_ord": perp_fill_live.ord_id,
"perp_qty_eth": float(perp_qty),
"option_qty_eth": float(opt_qty),
"perp_entry_px": float(pf_px),
"option_entry_px": float(of_px),
"initial_premium": initial_premium,
"perp_margin": perp_margin,
"leverage": float(leverage) if leverage else None,
"fees": of_fee + pf_fee,
},
)
@@ -1101,13 +1119,31 @@ class BinanceLiveExecutor(Matcher):
local_net=float(net) if net is not None else None,
)
fills_summary = None
try:
from ..sim.pnl import summarize_fills_pnl
fill_rows = self.db.fetchall(
"SELECT * FROM fills WHERE group_id=? ORDER BY id ASC", (group_id,)
)
fills_summary = summarize_fills_pnl(list(fill_rows))
except Exception:
fills_summary = None
return CloseResult(
ok=True,
detail="closed_live_binance",
data={
"group_id": group_id,
"reason": reason,
"perp_pnl": (
fills_summary.get("perp_pnl") if fills_summary else None
),
"option_pnl": (
fills_summary.get("option_pnl") if fills_summary else None
),
"net": net,
"net_pnl": net,
"fees": fills_summary.get("fees_total") if fills_summary else None,
"exec_mode": "LIVE",
"pnl_source": "live_exchange",
},
@@ -1306,5 +1342,16 @@ class BinanceLiveExecutor(Matcher):
return CloseResult(
ok=True,
detail="perp_closed_option_residual_live_binance",
data={"group_id": group_id, "reason": reason, "mode": "target_perp_only", "exec_mode": "LIVE"},
data={
"group_id": group_id,
"reason": reason,
"mode": "target_perp_only",
"perp_pnl": perp_pnl,
"option_pnl": None,
"interim_net": interim_net,
"net": interim_net,
"net_pnl": interim_net,
"option_abandoned": True,
"exec_mode": "LIVE",
},
)
+52 -1
View File
@@ -389,6 +389,12 @@ class OkxLiveExecutor(Matcher):
except Exception:
logger.exception("lock exit target failed group=%s", group_id)
leverage = self.ledger.get_setting_float("leverage", get_settings().leverage)
perp_margin = (
abs(float(pf_px) * float(perp_qty)) / float(leverage)
if leverage and float(leverage) > 0
else None
)
return OpenResult(
ok=True,
group_id=group_id,
@@ -396,9 +402,21 @@ class OkxLiveExecutor(Matcher):
data={
"group_id": group_id,
"exec_mode": "LIVE",
"bias": bias,
"option_side": option_side,
"perp_side": perp_side,
"option_inst_id": option_inst_id,
"strike": strike,
"expiry_ymd": expiry_ymd,
"option_ord": opt_fill.ord_id,
"perp_ord": perp_fill_live.ord_id,
"perp_qty_eth": float(perp_qty),
"option_qty_eth": float(opt_qty),
"perp_entry_px": float(pf_px),
"option_entry_px": float(of_px),
"initial_premium": initial_premium,
"perp_margin": perp_margin,
"leverage": float(leverage) if leverage else None,
"fees": of_fee + pf_fee,
},
)
@@ -1140,13 +1158,35 @@ class OkxLiveExecutor(Matcher):
local_net=float(net) if net is not None else None,
)
fills_summary = None
try:
from ..sim.pnl import summarize_fills_pnl
fill_rows = self.db.fetchall(
"SELECT * FROM fills WHERE group_id=? ORDER BY id ASC", (group_id,)
)
fills_summary = summarize_fills_pnl(list(fill_rows))
except Exception:
fills_summary = None
return CloseResult(
ok=True,
detail="closed_live",
data={
"group_id": group_id,
"reason": reason,
"perp_pnl": (
fills_summary.get("perp_pnl")
if fills_summary
else None
),
"option_pnl": (
fills_summary.get("option_pnl")
if fills_summary
else None
),
"net": net,
"net_pnl": net,
"fees": fills_summary.get("fees_total") if fills_summary else None,
"exec_mode": "LIVE",
"pnl_source": "live_exchange",
},
@@ -1330,7 +1370,18 @@ class OkxLiveExecutor(Matcher):
return CloseResult(
ok=True,
detail="perp_closed_option_residual_live",
data={"group_id": group_id, "reason": reason, "mode": "target_perp_only", "exec_mode": "LIVE"},
data={
"group_id": group_id,
"reason": reason,
"mode": "target_perp_only",
"perp_pnl": perp_pnl,
"option_pnl": None,
"interim_net": interim_net,
"net": interim_net,
"net_pnl": interim_net,
"option_abandoned": True,
"exec_mode": "LIVE",
},
)
+158 -25
View File
@@ -27,6 +27,30 @@ _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": "等待流动性后全平",
"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 == "":
@@ -84,6 +108,64 @@ def venue_label() -> str | None:
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()
@@ -175,15 +257,48 @@ def notify_pause() -> None:
def notify_open(*, group_id: str, detail: str = "", extra: dict[str, Any] | None = None) -> None:
extra = extra or {}
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"**方向**: {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 ''}",
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 ""),
]
if detail:
lines.append(f"**说明**: {detail}")
# 说明仅在非模板英文码时展示
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))
@@ -194,26 +309,44 @@ def notify_close(
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)
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"**原因**: {reason_zh} (`{reason}`)",
f"****: `{group_id or data.get('group_id') or ''}`",
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 detail:
lines.append(f"**说明**: {detail}")
net = data.get("net_pnl")
if net is not None:
lines.append(f"**净盈亏**: {net}")
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))
+23
View File
@@ -349,15 +349,33 @@ class Matcher:
except Exception:
logger.exception("lock exit target failed group=%s", group_id)
leverage = self.ledger.get_setting_float("leverage", s.leverage)
perp_margin = (
abs(float(pf.fill_px) * float(perp_qty)) / float(leverage)
if leverage and float(leverage) > 0
else None
)
return OpenResult(
ok=True,
group_id=group_id,
detail="opened",
data={
"group_id": group_id,
"bias": bias,
"option_side": option_side,
"perp_side": perp_side,
"option_inst_id": option_inst_id,
"strike": strike,
"expiry_ymd": expiry_ymd,
"perp": pf.to_dict(),
"option": of.to_dict(),
"perp_qty_eth": float(perp_qty),
"option_qty_eth": float(opt_qty),
"perp_entry_px": float(pf.fill_px),
"option_entry_px": float(of.fill_px),
"initial_premium": initial_premium,
"perp_margin": perp_margin,
"leverage": float(leverage) if leverage else None,
"fees": pf.fee + of.fee,
"open_sequence": ["option", "perp"],
},
@@ -617,8 +635,10 @@ class Matcher:
"perp_pnl": perp_pnl,
"option_pnl": opt_pnl,
"net": net_after_all_fees,
"net_pnl": net_after_all_fees,
"fees_open": open_fees,
"fees_close": pf.fee + of.fee,
"fees": float(open_fees) + float(pf.fee) + float(of.fee),
"close_sequence": ["option", "perp"],
"cash_delta": opt_cash + perp_pnl - pf.fee,
"option_close_bid": float(close_bid),
@@ -788,7 +808,10 @@ class Matcher:
"reason": reason,
"mode": "target_perp_only",
"perp_pnl": perp_pnl,
"option_pnl": None,
"interim_net": interim_net,
"net": interim_net,
"net_pnl": interim_net,
"option_abandoned": True,
"strike": float(strike),
"spot": float(spot),
+2
View File
@@ -953,9 +953,11 @@ class StrategyEngine:
extra={
"bias": pick.bias,
"option_side": pick.option_side,
"perp_side": pick.perp_side,
"option_inst_id": option_inst,
"strike": pick.pair.strike,
"expiry_ymd": pick.pair.expiry_ymd,
**(r.data or {}),
},
)
except Exception:
+87 -4
View File
@@ -1,12 +1,21 @@
from __future__ import annotations
from app.notify.wecom import TAG_OPEN, build_markdown, venue_label
from app.notify.wecom import (
TAG_CLOSE,
TAG_OPEN,
build_markdown,
close_reason_zh,
direction_zh,
notify_close,
notify_open,
venue_label,
)
def test_build_markdown_has_tag_and_title(monkeypatch):
monkeypatch.setattr("app.notify.wecom.venue_label", lambda: "模拟盘")
monkeypatch.setattr("app.notify.wecom.venue_label", lambda: "实盘·OKX")
md = build_markdown(tag=TAG_OPEN, title="开仓成功", lines=["组: G-1"])
assert "模拟盘】开仓成功" in md
assert "实盘·OKX】开仓成功" in md
assert "`OPEN`" in md
assert "组: G-1" in md
@@ -17,4 +26,78 @@ def test_venue_label_sim(monkeypatch):
exchange = "okx"
monkeypatch.setattr("app.notify.wecom.get_settings", lambda: S())
assert venue_label() == "模拟盘"
assert venue_label() is None
def test_direction_and_close_reason_zh() -> None:
assert "Put" in direction_zh({"option_side": "put", "perp_side": "long"})
assert close_reason_zh("liquidity_retry") == "等待流动性后全平"
assert close_reason_zh("fixed_usdt") == "固定净盈利达标·双腿全平"
def test_notify_open_close_markdown(monkeypatch) -> None:
captured: list[str] = []
monkeypatch.setattr("app.notify.wecom.wecom_enabled", lambda: True)
monkeypatch.setattr("app.notify.wecom.wecom_webhook_url", lambda: "http://example.test")
monkeypatch.setattr("app.notify.wecom.venue_label", lambda: None)
monkeypatch.setattr("app.notify.wecom.wecom_machine_name", lambda: "")
def _capture(content: str):
captured.append(content)
return True, "ok"
monkeypatch.setattr("app.notify.wecom._post_markdown_sync", _capture)
monkeypatch.setattr(
"app.notify.wecom.notify_async",
lambda content: captured.append(content),
)
notify_open(
group_id="G-20260802-01",
detail="opened",
extra={
"bias": "put_ask_gt_call",
"option_side": "put",
"perp_side": "long",
"option_inst_id": "ETH-USD-260802-1850-P",
"strike": 1850,
"expiry_ymd": "260802",
"perp_qty_eth": 1.0,
"option_qty_eth": 2.0,
"perp_entry_px": 1860.5,
"option_entry_px": 12.3,
"initial_premium": 24.6,
"perp_margin": 620.0,
"leverage": 3,
},
)
assert captured
open_md = captured[-1]
assert "开仓成功" in open_md
assert "`OPEN`" in open_md or TAG_OPEN in open_md
assert "权利金占用" in open_md
assert "保证金占用" in open_md
assert "开仓数量" in open_md
assert "买Put" in open_md
captured.clear()
notify_close(
reason="liquidity_retry",
detail="closed",
data={
"group_id": "G-20260802-01",
"perp_pnl": -10.5,
"option_pnl": 40.2,
"net": 25.0,
"fees": 4.5,
},
)
close_md = captured[-1]
assert "平仓" in close_md
assert TAG_CLOSE in close_md or "`CLOSE`" in close_md
assert "等待流动性后全平" in close_md
assert "永续盈亏" in close_md
assert "期权盈亏" in close_md
assert "净利润" in close_md
assert "+25.00U" in close_md or "25.00U" in close_md
+1 -1
View File
@@ -30,7 +30,7 @@ const CLOSE_REASON_ZH: Record<string, string> = {
expiry: "到期结算",
emergency: "紧急全平",
manual: "手动平仓",
liquidity_retry: "流动性等待后续平仓",
liquidity_retry: "等待流动性后全平",
unknown: "未知",
};