diff --git a/backend/app/api/sim.py b/backend/app/api/sim.py index f966a9c..acdd4d5 100644 --- a/backend/app/api/sim.py +++ b/backend/app/api/sim.py @@ -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: diff --git a/backend/app/live/binance_executor.py b/backend/app/live/binance_executor.py index 270aec8..9b80dbd 100644 --- a/backend/app/live/binance_executor.py +++ b/backend/app/live/binance_executor.py @@ -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", + }, ) diff --git a/backend/app/live/executor.py b/backend/app/live/executor.py index b652302..a615059 100644 --- a/backend/app/live/executor.py +++ b/backend/app/live/executor.py @@ -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", + }, ) diff --git a/backend/app/notify/wecom.py b/backend/app/notify/wecom.py index bb515b5..30f3ee5 100644 --- a/backend/app/notify/wecom.py +++ b/backend/app/notify/wecom.py @@ -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)) diff --git a/backend/app/sim/matcher.py b/backend/app/sim/matcher.py index c475a5b..614b323 100644 --- a/backend/app/sim/matcher.py +++ b/backend/app/sim/matcher.py @@ -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), diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index 0bcfb6e..305217c 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -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: diff --git a/backend/tests/test_wecom_notify.py b/backend/tests/test_wecom_notify.py index db0e8e0..a7b4285 100644 --- a/backend/tests/test_wecom_notify.py +++ b/backend/tests/test_wecom_notify.py @@ -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 diff --git a/frontend/src/labels.ts b/frontend/src/labels.ts index 5b0d037..a8b9a53 100644 --- a/frontend/src/labels.ts +++ b/frontend/src/labels.ts @@ -30,7 +30,7 @@ const CLOSE_REASON_ZH: Record = { expiry: "到期结算", emergency: "紧急全平", manual: "手动平仓", - liquidity_retry: "流动性等待后续平仓", + liquidity_retry: "等待流动性后全平", unknown: "未知", };