"""单独期权翻倍出场:盈利达权利金×倍数后按买一限价平仓. 1 倍 = 盈利金额等于初始权利金 ⇒ 买一可回收 ≥ 权利金 × (1 + 倍数). 与「目标位」并行;与仅微信提醒的 OKX_OPTIONS_PROFIT_ALERT_RATIO 独立. """ from __future__ import annotations import sqlite3 from typing import Any, Callable from lib.options.options_db import init_options_tables, sum_open_premium_paid def _safe_float(v: Any) -> float | None: if v is None or v == "": return None try: return float(v) except (TypeError, ValueError): return None def ensure_profit_exit_columns(conn: sqlite3.Connection) -> None: init_options_tables(conn) for ddl in ( "ALTER TABLE options_trades ADD COLUMN profit_exit_enabled INTEGER DEFAULT 0", "ALTER TABLE options_trades ADD COLUMN profit_exit_mult REAL DEFAULT 1.0", "ALTER TABLE options_trades ADD COLUMN profit_exit_state TEXT DEFAULT 'idle'", ): try: conn.execute(ddl) except Exception: pass def normalize_profit_exit_mult(raw: Any, *, default: float = 1.0) -> float: try: mult = float(raw) except (TypeError, ValueError): mult = float(default) if mult <= 0: mult = float(default) return round(mult, 4) def profit_exit_hit( *, premium_paid: float, recycle_usdc: float, mult: float, ) -> bool: """1倍:盈利=权利金 ⇒ recycle ≥ premium×(1+mult).""" prem = float(premium_paid or 0) recv = float(recycle_usdc or 0) m = float(mult or 0) if prem <= 0 or m <= 0 or recv <= 0: return False return recv + 1e-9 >= prem * (1.0 + m) def required_recycle_usdc(premium_paid: float, mult: float) -> float | None: prem = float(premium_paid or 0) m = float(mult or 0) if prem <= 0 or m <= 0: return None return round(prem * (1.0 + m), 4) def set_profit_exit( conn: sqlite3.Connection, *, inst_id: str, enabled: bool, mult: float | None = None, ) -> dict[str, Any]: ensure_profit_exit_columns(conn) inst = (inst_id or "").strip() if not inst: return {"ok": False, "msg": "缺少 inst_id"} m = normalize_profit_exit_mult(mult if mult is not None else 1.0) rows = conn.execute( """ SELECT id FROM options_trades WHERE inst_id = ? AND status = 'open' """, (inst,), ).fetchall() if not rows: return {"ok": False, "msg": "未找到该合约的本地开仓记录"} if enabled: conn.execute( """ UPDATE options_trades SET profit_exit_enabled = 1, profit_exit_mult = ?, profit_exit_state = 'active' WHERE inst_id = ? AND status = 'open' """, (m, inst), ) else: conn.execute( """ UPDATE options_trades SET profit_exit_enabled = 0, profit_exit_state = 'idle' WHERE inst_id = ? AND status = 'open' """, (inst,), ) return { "ok": True, "inst_id": inst, "profit_exit_enabled": bool(enabled), "profit_exit_mult": m if enabled else None, "updated": len(rows), } def profit_exit_by_inst(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]: """进行中(active/closing)的翻倍出场,按合约取最新一条规则.""" ensure_profit_exit_columns(conn) rows = conn.execute( """ SELECT inst_id, profit_exit_enabled, profit_exit_mult, profit_exit_state FROM options_trades WHERE status = 'open' AND ( CAST(COALESCE(profit_exit_enabled, 0) AS INTEGER) = 1 OR COALESCE(profit_exit_state, 'idle') IN ('active', 'closing') ) ORDER BY id DESC """ ).fetchall() out: dict[str, dict[str, Any]] = {} for r in rows: inst = str(r["inst_id"] or "").strip() if not inst or inst in out: continue enabled = int(r["profit_exit_enabled"] or 0) == 1 state = str(r["profit_exit_state"] or "idle") if not enabled and state not in ("active", "closing"): continue mult = normalize_profit_exit_mult(r["profit_exit_mult"], default=1.0) out[inst] = { "inst_id": inst, "profit_exit_enabled": enabled or state in ("active", "closing"), "profit_exit_mult": mult, "profit_exit_state": state if state in ("active", "closing") else ("active" if enabled else "idle"), "required_recycle": None, } for inst, info in out.items(): prem = sum_open_premium_paid(conn, inst) if prem is not None: info["premium_paid"] = prem info["required_recycle"] = required_recycle_usdc(prem, float(info["profit_exit_mult"])) return out def _mark_state(conn: sqlite3.Connection, inst_id: str, state: str) -> None: conn.execute( """ UPDATE options_trades SET profit_exit_state = ? WHERE inst_id = ? AND status = 'open' """, (state, inst_id), ) def _commit(conn: sqlite3.Connection) -> None: try: conn.commit() except Exception: pass def _result_fully_done(result: dict[str, Any]) -> bool: if result.get("already_flat"): return True if result.get("fully_closed"): return True remaining = result.get("remaining_sheets") if remaining is not None and int(remaining) <= 0 and result.get("ok"): return True return False def close_option_by_bid_profit_exit( cfg: dict[str, Any], ex: Any, inst_id: str, *, sheets: int | None = None, ) -> dict[str, Any]: from lib.options.options_close_exec_lib import close_option_by_bid1 return close_option_by_bid1( cfg, ex, inst_id, sheets=sheets, require_recycle_gate=False, signal_note="翻倍出场", ) def _estimate_recycle( cfg: dict[str, Any], ex: Any, pos: dict[str, Any], premium_paid: float | None, ) -> float | None: from lib.options.options_positions_lib import attach_close_preview row = dict(pos) attach_close_preview(cfg, ex, row, premium_paid=premium_paid) preview = row.get("close_preview") if isinstance(row.get("close_preview"), dict) else {} if preview.get("bid_invalid"): return None return _safe_float(preview.get("total_received")) def _notify_profit_exit_close( cfg: dict[str, Any] | None, send_wechat: Callable[[str], None] | None, *, account_label: str, inst_id: str, mult: float, premium_paid: float | None, recycle: float | None, 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: from lib.options.options_notify_lib import notify_options_close notify_options_close( cfg, conn, inst_id=inst_id, 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"权利金:{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 '挂单中/部分'}", ] ) ) except Exception: pass def run_options_profit_exits( conn: sqlite3.Connection, positions: list[dict[str, Any]], *, close_fn: Callable[[str], dict[str, Any]], recycle_fn: Callable[[dict[str, Any], float | None], float | None] | None = None, send_wechat: Callable[[str], None] | None = None, account_label: str = "OKX期权", cfg: dict[str, Any] | None = None, ex: Any = None, ) -> int: """扫描开启翻倍出场的 open 仓;买一可回收达标后限价平仓.返回本次新触发条数.""" ensure_profit_exit_columns(conn) pos_by_inst = {str(p.get("inst_id") or p.get("instId") or ""): p for p in positions} hedge_managed: set[str] = set() try: from lib.hedge_plan.hedge_plan_db import active_hedge_option_inst_ids, init_hedge_plan_tables init_hedge_plan_tables(conn) hedge_managed = active_hedge_option_inst_ids(conn) except Exception: return 0 rules = profit_exit_by_inst(conn) triggered = 0 for inst_id, info in list(rules.items()): if not inst_id: continue if inst_id in hedge_managed: _mark_state(conn, inst_id, "idle") conn.execute( """ UPDATE options_trades SET profit_exit_enabled = 0, profit_exit_state = 'idle' WHERE inst_id = ? AND status = 'open' """, (inst_id,), ) _commit(conn) continue pos = pos_by_inst.get(inst_id) if not pos: # 持仓已平:收尾 _mark_state(conn, inst_id, "done") _commit(conn) continue state = str(info.get("profit_exit_state") or "active") mult = normalize_profit_exit_mult(info.get("profit_exit_mult"), default=1.0) prem = sum_open_premium_paid(conn, inst_id) if prem is None or prem <= 0: continue if state == "closing": result = close_fn(inst_id) 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) continue if not info.get("profit_exit_enabled"): continue if recycle_fn is not None: recycle = recycle_fn(pos, prem) elif cfg is not None and ex is not None: recycle = _estimate_recycle(cfg, ex, pos, prem) else: continue if recycle is None: continue if not profit_exit_hit(premium_paid=prem, recycle_usdc=recycle, mult=mult): continue result = close_fn(inst_id) 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") _commit(conn) continue done = _result_fully_done(result) _mark_state(conn, inst_id, "done" if done else "closing") _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, ) return triggered