diff --git a/lib/options/options_coin_open_lib.py b/lib/options/options_coin_open_lib.py index 48db734..eeb2e48 100644 --- a/lib/options/options_coin_open_lib.py +++ b/lib/options/options_coin_open_lib.py @@ -378,6 +378,8 @@ def open_coin_option_buy_full( open_quote=float(ask), target_index=target_index, signal_note=signal_note, + premium_ccy=premium_ccy, + margin_mode="coin", ) except Exception: pass diff --git a/lib/options/options_monitor_lib.py b/lib/options/options_monitor_lib.py index e6e5093..f2a6194 100644 --- a/lib/options/options_monitor_lib.py +++ b/lib/options/options_monitor_lib.py @@ -30,16 +30,23 @@ def build_profit_alert_message( upl: float, upl_ratio: float | None, bid: float | None, + premium_ccy: str | None = None, ) -> str: + from lib.options.options_notify_lib import resolve_options_premium_ccy + + ccy = resolve_options_premium_ccy(inst_id=inst_id, premium_ccy=premium_ccy) + d = 6 if ccy in ("ETH", "BTC") else 4 pct = f"{upl_ratio * 100:.1f}%" if upl_ratio is not None else "—" - bid_txt = f"{bid:.4f}" if bid is not None else "—" + bid_txt = f"{bid:.{d}f}" if bid is not None else "—" + mode = "币本位" if ccy != "USDC" else "USDC" return "\n".join( [ "【OKX期权·翻倍提醒】", f"账户:{account_label}", + f"本位:{mode}", f"合约:{inst_id}", - f"已付权利金:{premium_paid:.4f} USDC", - f"未实现盈亏:{upl:+.4f} USDC({pct})", + f"已付权利金:{premium_paid:.{d}f} {ccy}", + f"未实现盈亏:{upl:+.{d}f} {ccy}({pct})", f"当前买一:{bid_txt}(可考虑限价平仓锁利)", ] ) diff --git a/lib/options/options_notify_lib.py b/lib/options/options_notify_lib.py index 7b32721..66329a0 100644 --- a/lib/options/options_notify_lib.py +++ b/lib/options/options_notify_lib.py @@ -1,4 +1,4 @@ -"""OKX 期权开仓/平仓企业微信推送(必发,幂等落库标记).""" +"""OKX 期权开仓/平仓企业微信推送(必发,幂等落库标记).支持 USDC / 币本位(ETH/BTC).""" from __future__ import annotations import sqlite3 @@ -23,6 +23,63 @@ def _opt_type_label(opt_type: Any) -> str: return t or "—" +def resolve_options_premium_ccy( + *, + inst_id: str = "", + underlying: str = "", + premium_ccy: Any = None, + margin_mode: Any = None, + row: dict[str, Any] | None = None, +) -> str: + """权利金计价币种:USDC 或 ETH/BTC.""" + raw = premium_ccy + if (raw is None or str(raw).strip() == "") and row: + raw = row.get("premium_ccy") + ccy = str(raw or "").strip().upper() + if ccy: + return ccy + try: + from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode + + mid = str(inst_id or (row or {}).get("inst_id") or "").strip() + mode = margin_mode if margin_mode is not None else (row or {}).get("margin_mode") + if mode is None and mid: + mode = margin_mode_from_inst_id(mid) + u = str( + underlying + or (row or {}).get("underlying") + or (mid.split("-")[0] if mid else "ETH") + or "ETH" + ).strip().upper() or "ETH" + return premium_ccy_for_mode(str(mode or "usdc"), u) + except Exception: + return "USDC" + + +def _amount_decimals(ccy: str) -> int: + c = (ccy or "USDC").strip().upper() + if c in ("ETH", "BTC"): + return 6 + return 4 + + +def _mode_tag(*, inst_id: str = "", premium_ccy: str = "", margin_mode: Any = None) -> str: + ccy = (premium_ccy or "").strip().upper() + if ccy and ccy != "USDC": + return "币本位" + try: + from lib.options.options_margin_mode_lib import is_coin_margin_mode, margin_mode_from_inst_id + + mode = margin_mode + if mode is None and inst_id: + mode = margin_mode_from_inst_id(inst_id) + if is_coin_margin_mode(mode): + return "币本位" + except Exception: + pass + return "USDC" + + def ensure_options_notify_columns(conn: sqlite3.Connection) -> None: for ddl in ( "ALTER TABLE options_trades ADD COLUMN wechat_open_sent INTEGER DEFAULT 0", @@ -57,10 +114,21 @@ def build_options_open_message( target_index: Any = None, signal_note: str = "", trade_id: Any = None, + premium_ccy: Any = None, + margin_mode: Any = None, ) -> str: + ccy = resolve_options_premium_ccy( + inst_id=inst_id, + underlying=underlying, + premium_ccy=premium_ccy, + margin_mode=margin_mode, + ) + d = _amount_decimals(ccy) + mode = _mode_tag(inst_id=inst_id, premium_ccy=ccy, margin_mode=margin_mode) lines = [ "【OKX期权·开仓】", f"账户:{account_label or 'OKX期权'}", + f"本位:{mode}", ] if trade_id is not None: lines.append(f"本地单号:#{trade_id}") @@ -69,8 +137,8 @@ def build_options_open_message( f"合约:{inst_id}", f"标的:{(underlying or '—')} · {_opt_type_label(opt_type)}", f"张数:{sheets if sheets is not None else '—'}", - f"开仓报价:{_fmt(open_quote)} USDC", - f"权利金:{_fmt(premium_paid)} USDC", + f"开仓报价:{_fmt(open_quote, d)} {ccy}", + f"权利金:{_fmt(premium_paid, d)} {ccy}", ] ) if target_index is not None and str(target_index).strip() != "": @@ -98,10 +166,21 @@ def build_options_close_message( target_index: Any = None, trigger_idx: Any = None, trade_id: Any = None, + premium_ccy: Any = None, + margin_mode: Any = None, ) -> str: + ccy = resolve_options_premium_ccy( + inst_id=inst_id, + underlying=underlying, + premium_ccy=premium_ccy, + margin_mode=margin_mode, + ) + d = _amount_decimals(ccy) + mode = _mode_tag(inst_id=inst_id, premium_ccy=ccy, margin_mode=margin_mode) lines = [ "【OKX期权·平仓】", f"账户:{account_label or 'OKX期权'}", + f"本位:{mode}", ] if trade_id is not None: lines.append(f"本地单号:#{trade_id}") @@ -111,9 +190,9 @@ def build_options_close_message( f"标的:{(underlying or '—')} · {_opt_type_label(opt_type)}", f"原因:{(reason or '平仓').strip()}", f"张数:{sheets if sheets is not None else '—'}", - f"平仓报价:{_fmt(close_quote)} USDC", - f"已付/收回:{_fmt(premium_paid)} / {_fmt(premium_received)} USDC", - f"实现盈亏:{_fmt(realized_pnl, 4)} USDC", + f"平仓报价:{_fmt(close_quote, d)} {ccy}", + f"已付/收回:{_fmt(premium_paid, d)} / {_fmt(premium_received, d)} {ccy}", + f"实现盈亏:{_fmt(realized_pnl, d)} {ccy}", ] ) if target_index is not None and str(target_index).strip() != "": @@ -142,15 +221,26 @@ def notify_options_open( open_quote: Any = None, target_index: Any = None, signal_note: str = "", + premium_ccy: Any = None, + margin_mode: Any = None, ) -> bool: ensure_options_notify_columns(conn) if conn is not None else None + row_ccy = premium_ccy + row_mode = margin_mode if conn is not None and trade_id is not None: row = conn.execute( - "SELECT wechat_open_sent FROM options_trades WHERE id=?", + "SELECT wechat_open_sent, premium_ccy, margin_mode, underlying FROM options_trades WHERE id=?", (int(trade_id),), ).fetchone() if row and int(row["wechat_open_sent"] or 0): return False + if row: + if row_ccy is None: + row_ccy = row["premium_ccy"] if "premium_ccy" in row.keys() else None + if row_mode is None: + row_mode = row["margin_mode"] if "margin_mode" in row.keys() else None + if not underlying: + underlying = str(row["underlying"] or "") if "underlying" in row.keys() else underlying msg = build_options_open_message( account_label=str(cfg.get("account_label") or "OKX期权"), inst_id=inst_id, @@ -162,6 +252,8 @@ def notify_options_open( target_index=target_index, signal_note=signal_note, trade_id=trade_id, + premium_ccy=row_ccy, + margin_mode=row_mode, ) ok = notify_options_send(cfg, msg) if ok and conn is not None and trade_id is not None: @@ -197,6 +289,8 @@ def notify_options_close( close_quote: Any = None, target_index: Any = None, trigger_idx: Any = None, + premium_ccy: Any = None, + margin_mode: Any = None, force: bool = False, ) -> bool: """平仓必发.默认按 trade_id / 同合约未标记行幂等.""" @@ -230,6 +324,18 @@ def notify_options_close( ).fetchone() if q2: rows = [dict(q2)] + if not rows: + # 已有平仓记录且均已推送:幂等跳过,避免再走「无库行」重复推 + exists = conn.execute( + """ + SELECT 1 FROM options_trades + WHERE inst_id=? AND status='closed' + LIMIT 1 + """, + (inst_id,), + ).fetchone() + if exists: + return False if rows: # 同次平仓可能多腿:合并一条推送,逐条标记 @@ -259,6 +365,8 @@ def notify_options_close( target_index=target_index, trigger_idx=trigger_idx, trade_id=head.get("id") if len(rows) == 1 else None, + premium_ccy=premium_ccy or head.get("premium_ccy"), + margin_mode=margin_mode or head.get("margin_mode"), ) ok = notify_options_send(cfg, msg) if ok and conn is not None: @@ -288,6 +396,8 @@ def notify_options_close( target_index=target_index, trigger_idx=trigger_idx, trade_id=trade_id, + premium_ccy=premium_ccy, + margin_mode=margin_mode, ) return notify_options_send(cfg, msg) @@ -327,4 +437,6 @@ def notify_options_close_trade_ids( premium_received=sum(float(r["premium_received"] or 0) for r in rows if r["premium_received"] is not None), realized_pnl=sum(float(r["realized_pnl"]) for r in rows if r["realized_pnl"] is not None), close_quote=first.get("close_quote"), + premium_ccy=first.get("premium_ccy"), + margin_mode=first.get("margin_mode"), ) diff --git a/lib/options/options_profit_exit_lib.py b/lib/options/options_profit_exit_lib.py index c71c986..be1d60f 100644 --- a/lib/options/options_profit_exit_lib.py +++ b/lib/options/options_profit_exit_lib.py @@ -233,6 +233,12 @@ def _notify_profit_exit_close( result: dict[str, Any], conn: Any = None, ) -> None: + from lib.options.options_notify_lib import resolve_options_premium_ccy + + ccy = resolve_options_premium_ccy(inst_id=inst_id) + d = 6 if ccy in ("ETH", "BTC") else 4 + mode = "币本位" if ccy != "USDC" else "USDC" + reason = f"翻倍出场({mult:g}倍)" if result.get("fully_closed") or result.get("already_flat"): if cfg is not None: try: @@ -242,26 +248,31 @@ def _notify_profit_exit_close( cfg, conn, inst_id=inst_id, - reason=f"翻倍出场({mult:g}倍)", + reason=reason, sheets=result.get("submitted_sheets"), premium_received=result.get("premium_received"), close_quote=result.get("locked_bid_px") or result.get("bid"), + premium_ccy=ccy, ) + # 无论首次/幂等跳过,全平路径不再走下方 fallback,避免重复推 return except Exception: pass if not send_wechat: return try: + prem_txt = f"{float(premium_paid):.{d}f}" if premium_paid is not None else "—" + recv_txt = f"{float(recycle):.{d}f}" if recycle is not None else "—" send_wechat( "\n".join( [ "【OKX期权·翻倍出场】", f"账户:{account_label}", + f"本位:{mode}", f"合约:{inst_id}", f"倍数:{mult:g}(1倍=盈利=权利金)", - f"权利金:{premium_paid if premium_paid is not None else '—'}", - f"可回收:{recycle if recycle is not None else '—'}", + f"权利金:{prem_txt} {ccy}", + f"可回收:{recv_txt} {ccy}", f"提交张数:{result.get('submitted_sheets') or '—'}", f"状态:{'已全平' if (result.get('fully_closed') or result.get('already_flat')) else '挂单中/部分'}", ] @@ -330,6 +341,17 @@ def run_options_profit_exits( if result.get("already_flat") or _result_fully_done(result): _mark_state(conn, inst_id, "done") _commit(conn) + _notify_profit_exit_close( + cfg, + send_wechat, + account_label=account_label, + inst_id=inst_id, + mult=mult, + premium_paid=prem, + recycle=None, + result={**result, "fully_closed": True}, + conn=conn, + ) else: _mark_state(conn, inst_id, "closing") _commit(conn) @@ -353,6 +375,18 @@ def run_options_profit_exits( if result.get("already_flat"): _mark_state(conn, inst_id, "done") _commit(conn) + triggered += 1 + _notify_profit_exit_close( + cfg, + send_wechat, + account_label=account_label, + inst_id=inst_id, + mult=mult, + premium_paid=prem, + recycle=recycle, + result=result, + conn=conn, + ) continue if not result.get("ok"): _mark_state(conn, inst_id, "active") diff --git a/lib/options/options_target_lib.py b/lib/options/options_target_lib.py index fe84344..2b84017 100644 --- a/lib/options/options_target_lib.py +++ b/lib/options/options_target_lib.py @@ -309,6 +309,11 @@ def _notify_target_close( conn: Any = None, ) -> None: """目标位平仓推送:优先走统一平仓必发(幂等);无 cfg 时回退旧文案.""" + from lib.options.options_notify_lib import resolve_options_premium_ccy + + ccy = resolve_options_premium_ccy(inst_id=inst_id) + d = 6 if ccy in ("ETH", "BTC") else 4 + mode = "币本位" if ccy != "USDC" else "USDC" if result.get("fully_closed") or result.get("already_flat"): if cfg is not None: try: @@ -324,23 +329,28 @@ def _notify_target_close( close_quote=result.get("locked_bid_px") or result.get("bid"), target_index=target, trigger_idx=idx, + premium_ccy=ccy, ) + # 无论首次/幂等跳过,全平路径不再走下方 fallback,避免重复推 return except Exception: pass if not send_wechat: return try: + recv = result.get("premium_received") + recv_txt = f"{float(recv):.{d}f} {ccy}" if recv is not None else f"— {ccy}" send_wechat( "\n".join( [ "【OKX期权·目标位平仓】", f"账户:{account_label}", + f"本位:{mode}", f"合约:{inst_id}", f"目标指数:{target:g}", f"触发指数:{idx:g}", f"提交张数:{result.get('submitted_sheets') or '—'}", - f"预估收回:{result.get('premium_received') if result.get('premium_received') is not None else '—'} USDC", + f"预估收回:{recv_txt}", f"状态:{'已全平' if (result.get('fully_closed') or result.get('already_flat')) else '挂单中/部分'}", ] ) @@ -391,7 +401,7 @@ def run_options_target_closes( cancel_orphans_without_position(conn, live_inst_ids=live_ids) _commit_monitor(conn) - # 先处理已挂单等待成交的,绝不再发微信 + # 先处理已挂单等待成交的;首次触发已推过「挂单中」,此处仅在全平时走幂等平仓推送 for mon in list_closing_targets(conn): inst_id = str(mon.get("inst_id") or "") if not inst_id: @@ -408,6 +418,18 @@ def run_options_target_closes( if inst_id not in pos_by_inst: mark_monitor(conn, int(mon["id"]), status="expired", message="持仓已平") _commit_monitor(conn) + target = _safe_float(mon.get("target_index")) + idx = _safe_float(mon.get("trigger_idx")) + _notify_target_close( + cfg, + send_wechat, + account_label=account_label, + inst_id=inst_id, + target=float(target) if target is not None else 0.0, + idx=float(idx) if idx is not None else 0.0, + result={"already_flat": True, "fully_closed": True, "ok": True}, + conn=conn, + ) continue result = close_fn(inst_id) idx = _safe_float(pos_by_inst[inst_id].get("idx_px") or pos_by_inst[inst_id].get("idxPx")) @@ -421,6 +443,17 @@ def run_options_target_closes( message="目标位限价平仓完成", ) _commit_monitor(conn) + target = _safe_float(mon.get("target_index")) + _notify_target_close( + cfg, + send_wechat, + account_label=account_label, + inst_id=inst_id, + target=float(target) if target is not None else 0.0, + idx=float(idx) if idx is not None else 0.0, + result={**result, "fully_closed": True}, + conn=conn, + ) continue mark_monitor( conn, @@ -464,6 +497,17 @@ def run_options_target_closes( if result.get("already_flat"): mark_monitor(conn, int(mon["id"]), status="expired", trigger_idx=idx, message="持仓已平") _commit_monitor(conn) + triggered += 1 + _notify_target_close( + cfg, + send_wechat, + account_label=account_label, + inst_id=inst_id, + target=target, + idx=idx, + result=result, + conn=conn, + ) continue if not result.get("ok"): mark_monitor( diff --git a/tests/test_options_notify_lib.py b/tests/test_options_notify_lib.py index c821080..2d1d557 100644 --- a/tests/test_options_notify_lib.py +++ b/tests/test_options_notify_lib.py @@ -11,10 +11,10 @@ from lib.options.options_notify_lib import ( class TestOptionsNotify(unittest.TestCase): - def test_open_close_messages(self) -> None: + def test_open_close_messages_usdc(self) -> None: open_msg = build_options_open_message( account_label="OKX期权", - inst_id="ETH-USD-250725-3200-C", + inst_id="ETH-USD_UM-250725-3200-C", underlying="ETH", opt_type="C", sheets=2, @@ -23,14 +23,17 @@ class TestOptionsNotify(unittest.TestCase): target_index=3400, signal_note="假突破", trade_id=12, + premium_ccy="USDC", + margin_mode="usdc", ) self.assertIn("【OKX期权·开仓】", open_msg) - self.assertIn("ETH-USD-250725-3200-C", open_msg) + self.assertIn("ETH-USD_UM-250725-3200-C", open_msg) self.assertIn("目标指数:3400", open_msg) + self.assertIn("USDC", open_msg) close_msg = build_options_close_message( account_label="OKX期权", - inst_id="ETH-USD-250725-3200-C", + inst_id="ETH-USD_UM-250725-3200-C", reason="手动平仓", underlying="ETH", opt_type="C", @@ -38,10 +41,45 @@ class TestOptionsNotify(unittest.TestCase): premium_paid=8.5, premium_received=12.0, realized_pnl=3.5, + premium_ccy="USDC", ) self.assertIn("【OKX期权·平仓】", close_msg) self.assertIn("手动平仓", close_msg) self.assertIn("3.5000", close_msg) + self.assertIn("USDC", close_msg) + + def test_open_close_messages_coin(self) -> None: + open_msg = build_options_open_message( + account_label="OKX期权", + inst_id="ETH-USD-250725-3200-C", + underlying="ETH", + opt_type="C", + sheets=1, + premium_paid=0.001234, + open_quote=0.01234, + trade_id=99, + premium_ccy="ETH", + margin_mode="coin", + ) + self.assertIn("本位:币本位", open_msg) + self.assertIn("ETH", open_msg) + self.assertNotIn("USDC", open_msg) + + close_msg = build_options_close_message( + account_label="OKX期权", + inst_id="ETH-USD-250725-3200-C", + reason="翻倍出场(1倍)", + underlying="ETH", + sheets=1, + premium_paid=0.001234, + premium_received=0.0025, + realized_pnl=0.001266, + premium_ccy="ETH", + margin_mode="coin", + ) + self.assertIn("本位:币本位", close_msg) + self.assertIn("翻倍出场", close_msg) + self.assertIn("ETH", close_msg) if __name__ == "__main__":