diff --git a/docs/期权用法.md b/docs/期权用法.md index 7faec5d..6411761 100644 --- a/docs/期权用法.md +++ b/docs/期权用法.md @@ -74,10 +74,15 @@ OKX_OPTIONS_API_PASSPHRASE=... ## 5. 微信提醒 -当某笔持仓 **未实现盈亏 ≥ 已付权利金的 100%**(翻倍)时,会发 **一条** 企业微信提醒(同一笔只提醒一次). - 需已配置 `WECHAT_WEBHOOK`. +| 场景 | 标题 | 说明 | +|------|------|------| +| **开仓** | 【OKX期权·开仓】 | 下单成功并写入本地后必发(幂等) | +| **平仓** | 【OKX期权·平仓】 | 手动全平 / 目标位全平 / 到期或交易所平仓同步后必发(幂等) | +| 浮盈翻倍 | 【OKX期权·翻倍提醒】 | 未实现盈亏 ≥ 已付权利金约 100%,同一笔只提醒一次 | +| 挂单超时撤销 | 【OKX期权·挂单超时撤销】 | 平仓挂单超时被系统撤销 | + ## 6. 与永续 / 对冲计划的关系 | | 永续(子账户) | 期权(主账户) | diff --git a/lib/options/options_db.py b/lib/options/options_db.py index 2d68110..f89e252 100644 --- a/lib/options/options_db.py +++ b/lib/options/options_db.py @@ -95,6 +95,14 @@ def init_options_tables(conn: sqlite3.Connection) -> None: ON options_target_monitors(status) """ ) + for ddl in ( + "ALTER TABLE options_trades ADD COLUMN wechat_open_sent INTEGER DEFAULT 0", + "ALTER TABLE options_trades ADD COLUMN wechat_close_sent INTEGER DEFAULT 0", + ): + try: + conn.execute(ddl) + except Exception: + pass init_options_review_tables(conn) diff --git a/lib/options/options_monitor_lib.py b/lib/options/options_monitor_lib.py index 6dc43cb..1c25a67 100644 --- a/lib/options/options_monitor_lib.py +++ b/lib/options/options_monitor_lib.py @@ -237,6 +237,7 @@ def sync_open_options_trades( *, live_inst_ids: set[str], fetch_history_fn: Callable[[str], list[dict[str, Any]]], + notify_cfg: dict[str, Any] | None = None, ) -> int: """ 交易所已无持仓时,将本地 open 记录同步为 closed. @@ -319,6 +320,24 @@ def sync_open_options_trades( ), ) updated += 1 + if notify_cfg is not None: + try: + from lib.options.options_notify_lib import notify_options_close + + reason = "到期结算" if close_reason == "expired" else "交易所平仓" + notify_options_close( + notify_cfg, + conn, + inst_id=inst_id, + reason=reason, + trade_id=int(row["id"]), + premium_paid=paid, + premium_received=prem_recv, + realized_pnl=realized_pnl, + close_quote=close_quote, + ) + except Exception: + pass return updated @@ -414,6 +433,7 @@ def options_monitor_loop( close_fn=target_close_fn, send_wechat=send_wechat, account_label=account_label, + cfg={"send_wechat": send_wechat, "account_label": account_label}, ) if sync_trades_fn is not None: sync_trades_fn(conn) diff --git a/lib/options/options_notify_lib.py b/lib/options/options_notify_lib.py new file mode 100644 index 0000000..7b32721 --- /dev/null +++ b/lib/options/options_notify_lib.py @@ -0,0 +1,330 @@ +"""OKX 期权开仓/平仓企业微信推送(必发,幂等落库标记).""" +from __future__ import annotations + +import sqlite3 +from typing import Any, Callable, Optional + + +def _fmt(v: Any, d: int = 4) -> str: + try: + if v is None or v == "": + return "—" + return f"{float(v):.{d}f}" + except (TypeError, ValueError): + return str(v) + + +def _opt_type_label(opt_type: Any) -> str: + t = str(opt_type or "").strip().upper() + if t in ("C", "CALL"): + return "Call" + if t in ("P", "PUT"): + return "Put" + return t or "—" + + +def ensure_options_notify_columns(conn: sqlite3.Connection) -> None: + for ddl in ( + "ALTER TABLE options_trades ADD COLUMN wechat_open_sent INTEGER DEFAULT 0", + "ALTER TABLE options_trades ADD COLUMN wechat_close_sent INTEGER DEFAULT 0", + ): + try: + conn.execute(ddl) + except Exception: + pass + + +def notify_options_send(cfg: dict[str, Any], content: str) -> bool: + send: Optional[Callable[[str], Any]] = cfg.get("send_wechat") + if not callable(send): + return False + try: + send(content) + return True + except Exception: + return False + + +def build_options_open_message( + *, + account_label: str, + inst_id: str, + underlying: str = "", + opt_type: Any = None, + sheets: Any = None, + premium_paid: Any = None, + open_quote: Any = None, + target_index: Any = None, + signal_note: str = "", + trade_id: Any = None, +) -> str: + lines = [ + "【OKX期权·开仓】", + f"账户:{account_label or 'OKX期权'}", + ] + if trade_id is not None: + lines.append(f"本地单号:#{trade_id}") + lines.extend( + [ + 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", + ] + ) + if target_index is not None and str(target_index).strip() != "": + try: + lines.append(f"目标指数:{float(target_index):g}") + except (TypeError, ValueError): + lines.append(f"目标指数:{target_index}") + if signal_note: + lines.append(f"备注:{signal_note[:200]}") + return "\n".join(lines) + + +def build_options_close_message( + *, + account_label: str, + inst_id: str, + reason: str = "", + underlying: str = "", + opt_type: Any = None, + sheets: Any = None, + premium_paid: Any = None, + premium_received: Any = None, + realized_pnl: Any = None, + close_quote: Any = None, + target_index: Any = None, + trigger_idx: Any = None, + trade_id: Any = None, +) -> str: + lines = [ + "【OKX期权·平仓】", + f"账户:{account_label or 'OKX期权'}", + ] + if trade_id is not None: + lines.append(f"本地单号:#{trade_id}") + lines.extend( + [ + f"合约:{inst_id}", + 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", + ] + ) + if target_index is not None and str(target_index).strip() != "": + try: + lines.append(f"目标指数:{float(target_index):g}") + except (TypeError, ValueError): + lines.append(f"目标指数:{target_index}") + if trigger_idx is not None and str(trigger_idx).strip() != "": + try: + lines.append(f"触发指数:{float(trigger_idx):g}") + except (TypeError, ValueError): + lines.append(f"触发指数:{trigger_idx}") + return "\n".join(lines) + + +def notify_options_open( + cfg: dict[str, Any], + conn: sqlite3.Connection | None, + *, + trade_id: int | None, + inst_id: str, + underlying: str = "", + opt_type: Any = None, + sheets: Any = None, + premium_paid: Any = None, + open_quote: Any = None, + target_index: Any = None, + signal_note: str = "", +) -> bool: + ensure_options_notify_columns(conn) if conn is not None else None + if conn is not None and trade_id is not None: + row = conn.execute( + "SELECT wechat_open_sent FROM options_trades WHERE id=?", + (int(trade_id),), + ).fetchone() + if row and int(row["wechat_open_sent"] or 0): + return False + msg = build_options_open_message( + account_label=str(cfg.get("account_label") or "OKX期权"), + inst_id=inst_id, + underlying=underlying, + opt_type=opt_type, + sheets=sheets, + premium_paid=premium_paid, + open_quote=open_quote, + target_index=target_index, + signal_note=signal_note, + trade_id=trade_id, + ) + ok = notify_options_send(cfg, msg) + if ok and conn is not None and trade_id is not None: + conn.execute( + "UPDATE options_trades SET wechat_open_sent=1 WHERE id=?", + (int(trade_id),), + ) + try: + conn.commit() + except Exception: + pass + return ok + + +def _load_trade_row(conn: sqlite3.Connection, trade_id: int) -> dict[str, Any] | None: + row = conn.execute("SELECT * FROM options_trades WHERE id=?", (int(trade_id),)).fetchone() + return dict(row) if row else None + + +def notify_options_close( + cfg: dict[str, Any], + conn: sqlite3.Connection | None, + *, + inst_id: str, + reason: str = "平仓", + trade_id: int | None = None, + underlying: str = "", + opt_type: Any = None, + sheets: Any = None, + premium_paid: Any = None, + premium_received: Any = None, + realized_pnl: Any = None, + close_quote: Any = None, + target_index: Any = None, + trigger_idx: Any = None, + force: bool = False, +) -> bool: + """平仓必发.默认按 trade_id / 同合约未标记行幂等.""" + if conn is not None: + ensure_options_notify_columns(conn) + rows: list[dict[str, Any]] = [] + if conn is not None and trade_id is not None: + r = _load_trade_row(conn, int(trade_id)) + if r: + rows = [r] + elif conn is not None and inst_id: + q = conn.execute( + """ + SELECT * FROM options_trades + WHERE inst_id=? AND status='closed' + AND COALESCE(wechat_close_sent,0)=0 + ORDER BY id DESC + LIMIT 20 + """, + (inst_id,), + ).fetchall() + rows = [dict(x) for x in q] + if not rows and force: + q2 = conn.execute( + """ + SELECT * FROM options_trades + WHERE inst_id=? AND status='closed' + ORDER BY id DESC LIMIT 1 + """, + (inst_id,), + ).fetchone() + if q2: + rows = [dict(q2)] + + if rows: + # 同次平仓可能多腿:合并一条推送,逐条标记 + total_paid = sum(float(r.get("premium_paid") or 0) for r in rows) + total_recv = sum(float(r.get("premium_received") or 0) for r in rows if r.get("premium_received") is not None) + pnls = [float(r["realized_pnl"]) for r in rows if r.get("realized_pnl") is not None] + total_pnl = sum(pnls) if pnls else None + if total_pnl is None and (premium_received is not None or realized_pnl is not None): + total_pnl = realized_pnl + total_recv = premium_received if premium_received is not None else total_recv + total_paid = premium_paid if premium_paid is not None else total_paid + head = rows[0] + pending = [r for r in rows if not int(r.get("wechat_close_sent") or 0)] + if not pending and not force: + return False + msg = build_options_close_message( + account_label=str(cfg.get("account_label") or "OKX期权"), + inst_id=inst_id or str(head.get("inst_id") or ""), + reason=reason, + underlying=underlying or str(head.get("underlying") or ""), + opt_type=opt_type or head.get("opt_type"), + sheets=sheets if sheets is not None else sum(int(r.get("sheets") or 0) for r in rows), + premium_paid=total_paid, + premium_received=total_recv if rows else premium_received, + realized_pnl=total_pnl, + close_quote=close_quote if close_quote is not None else head.get("close_quote"), + target_index=target_index, + trigger_idx=trigger_idx, + trade_id=head.get("id") if len(rows) == 1 else None, + ) + ok = notify_options_send(cfg, msg) + if ok and conn is not None: + for r in pending or rows: + conn.execute( + "UPDATE options_trades SET wechat_close_sent=1 WHERE id=?", + (int(r["id"]),), + ) + try: + conn.commit() + except Exception: + pass + return ok + + # 无库行时仍发一条(尽量不丢提醒) + msg = build_options_close_message( + account_label=str(cfg.get("account_label") or "OKX期权"), + inst_id=inst_id, + reason=reason, + underlying=underlying, + opt_type=opt_type, + sheets=sheets, + premium_paid=premium_paid, + premium_received=premium_received, + realized_pnl=realized_pnl, + close_quote=close_quote, + target_index=target_index, + trigger_idx=trigger_idx, + trade_id=trade_id, + ) + return notify_options_send(cfg, msg) + + +def notify_options_close_trade_ids( + cfg: dict[str, Any], + conn: sqlite3.Connection, + trade_ids: list[int], + *, + reason: str, +) -> bool: + ids = [int(x) for x in trade_ids if x is not None] + if not ids: + return False + ensure_options_notify_columns(conn) + placeholders = ",".join("?" for _ in ids) + rows = conn.execute( + f""" + SELECT * FROM options_trades + WHERE id IN ({placeholders}) AND COALESCE(wechat_close_sent,0)=0 + """, + ids, + ).fetchall() + if not rows: + return False + first = dict(rows[0]) + return notify_options_close( + cfg, + conn, + inst_id=str(first.get("inst_id") or ""), + reason=reason, + trade_id=int(first["id"]) if len(rows) == 1 else None, + underlying=str(first.get("underlying") or ""), + opt_type=first.get("opt_type"), + sheets=sum(int(r["sheets"] or 0) for r in rows), + premium_paid=sum(float(r["premium_paid"] or 0) for r in rows), + 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"), + ) diff --git a/lib/options/options_register.py b/lib/options/options_register.py index 71a3079..7888b77 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -640,11 +640,15 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: conn = cfg["get_db"]() trade_id = None target_mon = None + open_underlying = "" + open_opt_type = None try: init_options_tables(conn) meta = q.get("meta") or {} u = str(meta.get("uly") or inst_id).split("-")[0] opt_type = meta.get("optType") + open_underlying = u + open_opt_type = opt_type cur = conn.execute( """ INSERT INTO options_trades @@ -683,9 +687,30 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: finally: conn.close() from lib.exchange.okx_options_lib import invalidate_option_positions_cache + from lib.options.options_notify_lib import notify_options_open invalidate_option_positions_cache() _sync_options_trades(cfg, force=True) + try: + conn_n = cfg["get_db"]() + try: + notify_options_open( + cfg, + conn_n, + trade_id=trade_id, + inst_id=inst_id, + underlying=open_underlying, + opt_type=open_opt_type, + sheets=sheets, + premium_paid=sizing.get("total_premium"), + open_quote=float(ask) if ask is not None else None, + target_index=target_index, + signal_note=signal_note, + ) + finally: + conn_n.close() + except Exception: + pass return jsonify( { "ok": True, @@ -938,11 +963,21 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: if result.get("fully_closed"): try: from lib.options.options_target_lib import cancel_target_monitor + from lib.options.options_notify_lib import notify_options_close conn2 = cfg["get_db"]() try: cancel_target_monitor(conn2, inst_id=inst_id) conn2.commit() + notify_options_close( + cfg, + conn2, + inst_id=inst_id, + reason="手动平仓", + sheets=result.get("submitted_sheets"), + premium_received=result.get("premium_received"), + close_quote=result.get("locked_bid_px") or result.get("bid"), + ) finally: conn2.close() except Exception: @@ -1253,6 +1288,7 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None: conn, live_inst_ids=live_ids, fetch_history_fn=lambda inst_id: fetch_option_position_history(ex, inst_id), + notify_cfg=cfg, ) def _target_close(inst_id: str) -> dict[str, Any]: diff --git a/lib/options/options_target_lib.py b/lib/options/options_target_lib.py index d20aef5..c7631c1 100644 --- a/lib/options/options_target_lib.py +++ b/lib/options/options_target_lib.py @@ -292,6 +292,7 @@ def close_option_by_bid_depth( def _notify_target_close( + cfg: dict[str, Any] | None, send_wechat: Callable[[str], None] | None, *, account_label: str, @@ -299,7 +300,28 @@ def _notify_target_close( target: float, idx: float, result: dict[str, Any], + conn: Any = None, ) -> None: + """目标位平仓推送:优先走统一平仓必发(幂等);无 cfg 时回退旧文案.""" + if result.get("fully_closed") or result.get("already_flat"): + if cfg is not None: + try: + from lib.options.options_notify_lib import notify_options_close + + notify_options_close( + cfg, + conn, + inst_id=inst_id, + reason="目标位平仓", + sheets=result.get("submitted_sheets"), + premium_received=result.get("premium_received"), + close_quote=result.get("locked_bid_px") or result.get("bid"), + target_index=target, + trigger_idx=idx, + ) + return + except Exception: + pass if not send_wechat: return try: @@ -313,6 +335,7 @@ def _notify_target_close( 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"状态:{'已全平' if (result.get('fully_closed') or result.get('already_flat')) else '挂单中/部分'}", ] ) ) @@ -339,6 +362,7 @@ def run_options_target_closes( index_fn: Callable[[dict[str, Any]], float | None] | None = None, send_wechat: Callable[[str], None] | None = None, account_label: str = "OKX期权", + cfg: dict[str, Any] | None = None, ) -> int: """ 扫描 active 目标委托;指数到位后限价平仓. @@ -433,11 +457,13 @@ def run_options_target_closes( _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, ) return triggered diff --git a/tests/test_options_notify_lib.py b/tests/test_options_notify_lib.py new file mode 100644 index 0000000..c821080 --- /dev/null +++ b/tests/test_options_notify_lib.py @@ -0,0 +1,48 @@ +"""期权开平仓微信文案.""" + +from __future__ import annotations + +import unittest + +from lib.options.options_notify_lib import ( + build_options_close_message, + build_options_open_message, +) + + +class TestOptionsNotify(unittest.TestCase): + def test_open_close_messages(self) -> None: + open_msg = build_options_open_message( + account_label="OKX期权", + inst_id="ETH-USD-250725-3200-C", + underlying="ETH", + opt_type="C", + sheets=2, + premium_paid=8.5, + open_quote=0.01, + target_index=3400, + signal_note="假突破", + trade_id=12, + ) + self.assertIn("【OKX期权·开仓】", open_msg) + self.assertIn("ETH-USD-250725-3200-C", open_msg) + self.assertIn("目标指数:3400", open_msg) + + close_msg = build_options_close_message( + account_label="OKX期权", + inst_id="ETH-USD-250725-3200-C", + reason="手动平仓", + underlying="ETH", + opt_type="C", + sheets=2, + premium_paid=8.5, + premium_received=12.0, + realized_pnl=3.5, + ) + self.assertIn("【OKX期权·平仓】", close_msg) + self.assertIn("手动平仓", close_msg) + self.assertIn("3.5000", close_msg) + + +if __name__ == "__main__": + unittest.main()