"""对冲计划开仓/平仓编排(可 dry_run 校验下单路径).""" from __future__ import annotations import json import os from datetime import datetime, timezone from typing import Any, Callable, Optional def _now() -> str: return datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S") def _env_bool(key: str, default: bool = False) -> bool: raw = (os.getenv(key) or "").strip().lower() if not raw: return default return raw in ("1", "true", "yes", "on") def open_order_mode() -> str: v = (os.getenv("HEDGE_PLAN_OPEN_ORDER") or "options_first").strip().lower() return v if v in ("options_first", "perp_first") else "options_first" def manual_complete_on_partial() -> bool: """半腿失败后挂 partial 并手动补开(默认 true).""" return _env_bool("HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL", True) def partial_auto_close_enabled() -> bool: """手动补开开启时强制关闭自动平,避免吃买卖价差.""" if manual_complete_on_partial(): return False return _env_bool("HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION", True) def build_po_path_plan(body: dict[str, Any]) -> list[dict[str, Any]]: """永期下单路径清单(不交易).""" from lib.hedge_plan.hedge_plan_option_primary_lib import ( is_option_primary, perp_direction_for_view, ) opt_primary = is_option_primary(body) mode = "options_first" if opt_primary else open_order_mode() view = str(body.get("direction") or "long") perp_dir = perp_direction_for_view(view) if opt_primary else view opt = { "step": "options_buy_limit", "account": "options", "inst_id": body.get("opt_inst_id"), "sheets": float(body.get("sheets") or 1), "side": "buy", "price_hint": "ask", } perp = { "step": "perp_market_open", "account": "swap", "symbol": body.get("exchange_symbol"), "direction": perp_dir, "contracts": float(body.get("contracts") or 0), "tp": None if opt_primary else body.get("tp"), "sl": None if opt_primary else body.get("sl"), "attach_tpsl": False if opt_primary else True, "option_primary": opt_primary, "view_side": view, } return [opt, perp] if mode == "options_first" else [perp, opt] def build_oo_path_plan(body: dict[str, Any]) -> list[dict[str, Any]]: return [ { "step": "options_buy_limit", "account": "options", "leg": "a", "inst_id": (body.get("leg_a") or {}).get("inst_id"), "sheets": float((body.get("leg_a") or {}).get("sheets") or 1), "side": "buy", "price_hint": "ask", }, { "step": "options_buy_limit", "account": "options", "leg": "b", "inst_id": (body.get("leg_b") or {}).get("inst_id"), "sheets": float((body.get("leg_b") or {}).get("sheets") or 1), "side": "buy", "price_hint": "ask", }, ] def _option_open_fill_timeout_sec() -> float: try: return max(2.0, float(os.getenv("OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC") or "12")) except (TypeError, ValueError): return 12.0 def _buy_option( cfg: dict[str, Any], *, inst_id: str, sheets: float, dry_run: bool, ) -> dict[str, Any]: from lib.exchange.okx_options_lib import ( cap_option_buy_sheets_to_ask_depth, option_buy_liquidity_ok, wait_option_order_full_fill, ) ex = cfg.get("exchange_options") quote_fn = cfg.get("quote_option_contract") place_fn = cfg.get("place_option_limit_order") td_buy = cfg.get("td_mode_for_option_buy") if not inst_id: return {"ok": False, "msg": "缺少期权合约"} if not callable(quote_fn) or ex is None: return {"ok": False, "msg": "期权报价能力未就绪"} q = quote_fn(ex, inst_id) if not q.get("ok"): return {"ok": False, "msg": q.get("msg") or "期权报价失败", "quote": q} ask = q.get("ask") ask_sz = q.get("ask_sz") can_open, block_msg = option_buy_liquidity_ok(ask, ask_sz) if not can_open: return { "ok": False, "msg": block_msg or q.get("open_block_msg") or "暂无卖一深度,无法买入", "quote": q, "mark": q.get("mark"), "ref_ask": q.get("ref_ask"), "can_open": False, } from lib.options.options_position_limit_lib import option_position_limit_block_msg pos_limit_msg = option_position_limit_block_msg( ex, opening_inst_id=inst_id, fetch_positions=cfg.get("fetch_option_positions"), ) if pos_limit_msg: return {"ok": False, "msg": pos_limit_msg, "quote": q, "can_open": False} sheets_i = max(1, int(round(float(sheets)))) requested_sheets = sheets_i capped, cap_msg = cap_option_buy_sheets_to_ask_depth(sheets_i, ask_sz, min_sz=1) if capped is None: return {"ok": False, "msg": cap_msg or "卖一深度不足,无法买入", "quote": q} if int(capped) < requested_sheets: return { "ok": False, "msg": f"卖一深度仅 {int(capped)} 张,不足请求 {requested_sheets} 张,拒绝缩量成交", "quote": q, "can_open": False, "requested_sheets": requested_sheets, "ask_sz": ask_sz, } sheets_i = int(capped) ct_mult = float(q.get("ct_mult") or 0.01) premium = float(ask) * sheets_i * ct_mult if dry_run: return { "ok": True, "dry_run": True, "inst_id": inst_id, "sheets": sheets_i, "ask": float(ask), "ask_sz": float(ask_sz), "premium": premium, "ct_mult": ct_mult, "tick_sz": q.get("tick_sz"), "meta": q.get("meta") or {}, "strike": q.get("strike"), "exp_time": q.get("exp_time"), "opt_type": (q.get("meta") or {}).get("optType") or q.get("opt_type"), "can_open": True, } if not callable(place_fn): return {"ok": False, "msg": "期权限价下单未注入"} td = "isolated" if callable(td_buy): td = td_buy(cfg.get("options_td_mode") or "isolated") # IOC:能成交多少成交多少,剩余立即撤销;再校验是否完全成交 order = place_fn( ex, inst_id=inst_id, side="buy", sheets=sheets_i, price=float(ask), td_mode=td, tick_sz=q.get("tick_sz"), ord_type="ioc", ) if not order.get("ok"): return order ord_id = str((order.get("data") or {}).get("ordId") or "").strip() if not ord_id: return {"ok": False, "msg": "下单成功但未返回订单号", "order": order} fill = wait_option_order_full_fill( ex, inst_id=inst_id, ord_id=ord_id, need_sheets=sheets_i, timeout_sec=_option_open_fill_timeout_sec(), cancel_on_timeout=True, ) if not fill.get("ok"): filled_n = int(fill.get("filled_sheets") or 0) orphan_close = None if filled_n > 0 and not dry_run: # 部分成交后撤单:尝试立刻平掉已成交,避免孤儿多头 try: orphan_close = _sell_option(cfg, inst_id=inst_id, sheets=float(filled_n)) except Exception as e: orphan_close = {"ok": False, "msg": str(e)} return { "ok": False, "msg": fill.get("msg") or "未完全成交,开仓失败", "inst_id": inst_id, "sheets": sheets_i, "ask": float(ask), "exchange_ord_id": ord_id, "filled_sheets": filled_n, "orphan_close": orphan_close, "order": order, "fill": fill, "can_open": False, } fill_px = float(fill.get("avg_px") or ask) filled_n = int(fill.get("filled_sheets") or sheets_i) premium = fill_px * filled_n * ct_mult return { "ok": True, "inst_id": inst_id, "sheets": filled_n, "ask": fill_px, "ask_sz": float(ask_sz), "premium": premium, "ct_mult": ct_mult, "tick_sz": q.get("tick_sz"), "meta": q.get("meta") or {}, "strike": q.get("strike"), "exp_time": q.get("exp_time"), "opt_type": (q.get("meta") or {}).get("optType") or q.get("opt_type"), "exchange_ord_id": ord_id, "order": order, "fill": fill, "can_open": True, } def _open_perp( cfg: dict[str, Any], *, symbol: str, direction: str, contracts: float, leverage: int, tp: Optional[float], sl: Optional[float], dry_run: bool, attach_tpsl: bool = True, ) -> dict[str, Any]: if not symbol or contracts <= 0: return {"ok": False, "msg": "永续符号或张数无效"} amount = float(contracts) to_prec = cfg.get("amount_to_precision") ex = cfg.get("exchange") if callable(to_prec) and ex is not None: try: amount = float(to_prec(symbol, amount)) except Exception: pass if amount <= 0: return {"ok": False, "msg": "张数经精度舍入后为 0"} use_tpsl = bool(attach_tpsl) and tp is not None and sl is not None tp_v = float(tp) if use_tpsl else None sl_v = float(sl) if use_tpsl else None if dry_run: return { "ok": True, "dry_run": True, "symbol": symbol, "direction": direction, "contracts": amount, "leverage": leverage, "tp": tp_v, "sl": sl_v, "attach_tpsl": use_tpsl, } ensure = cfg.get("ensure_okx_live_ready") if callable(ensure): ok, msg = ensure() if not ok: return {"ok": False, "msg": msg or "实盘未就绪"} place = cfg.get("place_exchange_order") if not callable(place): return {"ok": False, "msg": "永续下单函数未注入"} try: order = place( symbol, direction, amount, leverage, stop_loss=sl_v, take_profit=tp_v, ) except Exception as e: return {"ok": False, "msg": f"永续开仓失败: {e}"} return { "ok": True, "symbol": symbol, "direction": direction, "contracts": amount, "leverage": leverage, "tp": tp_v, "sl": sl_v, "attach_tpsl": use_tpsl, "order": order, "exchange_ord_id": str((order or {}).get("id") or (order or {}).get("info", {}).get("ordId") or ""), } def _close_perp( cfg: dict[str, Any], *, symbol: str, direction: str, contracts: float, dry_run: bool = False, ) -> dict[str, Any]: """市价平永续(reduce-only);优先用注入的 close_exchange_order.""" if not symbol: return {"ok": False, "msg": "永续符号无效"} if dry_run: return { "ok": True, "dry_run": True, "symbol": symbol, "direction": direction, "contracts": float(contracts or 0), } close_fn = cfg.get("close_exchange_order") if callable(close_fn): try: order = close_fn( { "exchange_symbol": symbol, "direction": direction, "order_amount": float(contracts or 0), "symbol": symbol, } ) return {"ok": True, "symbol": symbol, "direction": direction, "order": order} except Exception as e: return {"ok": False, "msg": f"永续平仓失败: {e}"} # 回退:对向市价 reduce-only(若注入了 place + 支持) place = cfg.get("place_exchange_order") if not callable(place): return {"ok": False, "msg": "永续平仓函数未注入"} try: # 无 TP/SL 的对向单;依赖交易所 reduceOnly 由 place 实现不保证,优先 close_exchange_order side_dir = "short" if str(direction).lower() == "long" else "long" order = place(symbol, side_dir, float(contracts or 0), int(cfg.get("alt_leverage") or 5), None, None) return {"ok": True, "symbol": symbol, "direction": direction, "order": order, "note": "fallback_place"} except Exception as e: return {"ok": False, "msg": f"永续平仓失败: {e}"} def _sell_option( cfg: dict[str, Any], *, inst_id: str, sheets: float, dry_run: bool = False, ) -> dict[str, Any]: """平期权:走买一限价 + 验仓;仅 fully_closed/already_flat 视为成功. 对冲强平/目标平仓不启用 2× 回收门控(require_recycle_gate=False). """ from lib.exchange.okx_options_lib import fetch_option_book_depth, fetch_option_positions from lib.options.options_close_exec_lib import close_option_by_bid1 ex = cfg.get("exchange_options") quote_fn = cfg.get("quote_option_contract") if not inst_id: return {"ok": False, "msg": "缺少期权合约"} if not callable(quote_fn) or ex is None: return {"ok": False, "msg": "期权报价能力未就绪"} q = quote_fn(ex, inst_id) bid = q.get("bid") if q.get("ok") else None if bid is None or float(bid) <= 0: return {"ok": False, "msg": "暂无买一价,无法平期权"} sheets_i = max(1, int(round(float(sheets)))) if dry_run: return { "ok": True, "dry_run": True, "inst_id": inst_id, "sheets": sheets_i, "bid": float(bid), "fully_closed": True, } if not callable(cfg.get("place_option_limit_order")): return {"ok": False, "msg": "期权平仓未注入"} close_cfg = dict(cfg) if not callable(close_cfg.get("fetch_option_positions")): close_cfg["fetch_option_positions"] = fetch_option_positions if not callable(close_cfg.get("fetch_option_book_depth")): close_cfg["fetch_option_book_depth"] = fetch_option_book_depth if "td_mode" not in close_cfg: close_cfg["td_mode"] = close_cfg.get("options_td_mode") or "isolated" result = close_option_by_bid1( close_cfg, ex, inst_id, sheets=sheets_i, require_recycle_gate=False, ) out = dict(result or {}) if out.get("already_flat"): # 二次验仓,避免一次空列表误判已平 import time as _time _time.sleep(0.35) try: from lib.exchange.okx_options_lib import invalidate_option_positions_cache invalidate_option_positions_cache() except Exception: pass rows2 = close_cfg["fetch_option_positions"](ex) if rows2 is None: return {"ok": False, "msg": "二次验仓失败,未确认是否已平", "fully_closed": False} still = next((p for p in rows2 if str(p.get("instId")) == inst_id), None) still_sz = 0.0 if still is not None: try: still_sz = abs(float(still.get("availPos") or still.get("pos") or 0)) except (TypeError, ValueError): still_sz = 0.0 if still is not None and still_sz >= 1: return { "ok": False, "msg": "二次验仓仍有持仓,拒绝 already_flat", "fully_closed": False, } out["ok"] = True out["fully_closed"] = True out.setdefault("bid", float(bid)) return out if not out.get("ok"): out.setdefault("bid", float(bid)) return out if not out.get("fully_closed"): return { "ok": False, "msg": out.get("msg") or "期权尚未完全平仓,将下轮重试", "bid": out.get("locked_bid_px") or float(bid), "fully_closed": False, "partial": True, "close": out, } out["bid"] = out.get("locked_bid_px") or float(bid) out["fully_closed"] = True return out def _notify_partial(cfg: dict[str, Any], plan_type: str, msg: str, results: list[dict[str, Any]]) -> None: try: from lib.hedge_plan.hedge_plan_notify_lib import notify_partial_fail notify_partial_fail(cfg, plan_type=plan_type, msg=msg, results=results) except Exception: pass def _park_partial( cfg: dict[str, Any], *, plan_type: str, body: dict[str, Any], missing_leg: str, msg: str, path: list[dict[str, Any]], results: list[dict[str, Any]], persist: Optional[Callable[..., Any]], dry_run: bool, **filled: Any, ) -> dict[str, Any]: """半腿失败:保留已成腿,挂 partial 供手动补开.""" if not dry_run: _notify_partial(cfg, plan_type, msg, results) out: dict[str, Any] = { "ok": True, "partial": True, "status": "partial", "dry_run": dry_run, "plan_type": plan_type, "missing_leg": missing_leg, "msg": msg, "path": path, "results": results, "opened_at": _now(), **filled, } if persist and not dry_run: out["plan_id"] = persist(out, body) return out def _hedge_budget_buffer(cfg: dict[str, Any] | None = None) -> float: """对冲专用预算缓冲;默认 0.95.与 OKX_OPTIONS_BUDGET_BUFFER 独立.""" raw = None if cfg is not None: raw = cfg.get("budget_buffer") if raw is None or raw == "": raw = os.getenv("HEDGE_PLAN_BUDGET_BUFFER") or "0.95" try: buf = float(raw) except (TypeError, ValueError): buf = 0.95 if buf <= 0: buf = 0.95 if buf > 1: buf = 1.0 return float(buf) def _oo_bias_settings(cfg: dict[str, Any] | None = None) -> tuple[str, float]: from lib.hedge_plan.hedge_plan_calc_lib import _clamp_oo_bias_ratio, _normalize_oo_bias_split_by split = None ratio = None if cfg is not None: split = cfg.get("oo_bias_split_by") ratio = cfg.get("oo_bias_ratio") if split in (None, ""): split = os.getenv("HEDGE_PLAN_OO_BIAS_SPLIT_BY") or "budget" if ratio in (None, ""): ratio = os.getenv("HEDGE_PLAN_OO_BIAS_RATIO") or "0.7" return _normalize_oo_bias_split_by(split), _clamp_oo_bias_ratio(ratio) def refresh_oo_sizing_before_start(cfg: dict[str, Any], body: dict[str, Any]) -> dict[str, Any]: """启动前再拉两腿卖一,按对冲预算缓冲重算张数;就地写回 body.leg_*. 方案 A:成交价与张数均基于点击启动瞬间的最新卖一/余额. """ from lib.exchange.okx_options_lib import fetch_options_trading_usdc, option_buy_liquidity_ok from lib.hedge_plan.hedge_plan_calc_lib import resolve_oo_budget_usdc, suggest_oo_sheets leg_a = dict(body.get("leg_a") or {}) leg_b = dict(body.get("leg_b") or {}) inst_a = str(leg_a.get("inst_id") or "").strip() inst_b = str(leg_b.get("inst_id") or "").strip() if not inst_a or not inst_b: return {"ok": False, "msg": "缺少期权合约"} quote_fn = cfg.get("quote_option_contract") ex = cfg.get("exchange_options") if not callable(quote_fn) or ex is None: return {"ok": False, "msg": "期权报价能力未就绪"} qa = quote_fn(ex, inst_a) if not qa.get("ok"): return {"ok": False, "msg": qa.get("msg") or "腿A报价失败", "quote_a": qa} qb = quote_fn(ex, inst_b) if not qb.get("ok"): return {"ok": False, "msg": qb.get("msg") or "腿B报价失败", "quote_b": qb} for tag, q in (("A", qa), ("B", qb)): can_open, block_msg = option_buy_liquidity_ok(q.get("ask"), q.get("ask_sz")) if not can_open: return { "ok": False, "msg": f"腿{tag}: {block_msg or '暂无卖一深度,无法买入'}", "quote_a": qa, "quote_b": qb, } trading = fetch_options_trading_usdc(ex) buf = _hedge_budget_buffer(cfg) budget_info = resolve_oo_budget_usdc( trading_usdc=trading, trade_budget_usdc=cfg.get("trade_budget_usdc"), buffer_ratio=buf, ) if not budget_info.get("ok"): return { "ok": False, "msg": budget_info.get("msg") or "可用预算不足", "budget": budget_info, "quote_a": qa, "quote_b": qb, } mode = str(body.get("oo_sheets_mode") or "same_sheets") split_by, bias_ratio = _oo_bias_settings(cfg) opt_a = str( leg_a.get("opt_type") or (qa.get("meta") or {}).get("optType") or qa.get("opt_type") or "" ) opt_b = str( leg_b.get("opt_type") or (qb.get("meta") or {}).get("optType") or qb.get("opt_type") or "" ) sug = suggest_oo_sheets( mode=mode, budget_usdc=float(budget_info["budget_usdc"]), ask_a=float(qa["ask"]), ct_mult_a=float(qa.get("ct_mult") or leg_a.get("ct_mult") or 0.01), ask_sz_a=qa.get("ask_sz"), opt_type_a=opt_a, ask_b=float(qb["ask"]), ct_mult_b=float(qb.get("ct_mult") or leg_b.get("ct_mult") or 0.01), ask_sz_b=qb.get("ask_sz"), opt_type_b=opt_b, bias_split_by=split_by, bias_ratio=bias_ratio, ) if not sug.get("ok"): return { "ok": False, "msg": sug.get("msg") or "按最新卖一无法建议张数", "sizing": sug, "budget": budget_info, "quote_a": qa, "quote_b": qb, } prev_a = leg_a.get("sheets") prev_b = leg_b.get("sheets") leg_a["sheets"] = int(sug["sheets_a"]) leg_a["ask"] = float(qa["ask"]) leg_a["ask_sz"] = qa.get("ask_sz") leg_a["ct_mult"] = float(qa.get("ct_mult") or leg_a.get("ct_mult") or 0.01) if opt_a: leg_a["opt_type"] = opt_a leg_b["sheets"] = int(sug["sheets_b"]) leg_b["ask"] = float(qb["ask"]) leg_b["ask_sz"] = qb.get("ask_sz") leg_b["ct_mult"] = float(qb.get("ct_mult") or leg_b.get("ct_mult") or 0.01) if opt_b: leg_b["opt_type"] = opt_b body["leg_a"] = leg_a body["leg_b"] = leg_b return { "ok": True, "buffer_ratio": buf, "budget": budget_info, "sizing": sug, "quote_a": qa, "quote_b": qb, "prev_sheets_a": prev_a, "prev_sheets_b": prev_b, "sheets_a": int(sug["sheets_a"]), "sheets_b": int(sug["sheets_b"]), "ask_a": float(qa["ask"]), "ask_b": float(qb["ask"]), "premium_est": sug.get("premium_est"), "msg": ( f"已按最新卖一重算: A {sug['sheets_a']}张@{qa['ask']} + " f"B {sug['sheets_b']}张@{qb['ask']} · 预估 {sug.get('premium_est')}U" ), } def refresh_po_option_quote_before_start(cfg: dict[str, Any], body: dict[str, Any]) -> dict[str, Any]: """永期启动前再拉卖一;保险模式张数沿用页面;期权为主时按权利金×0.95重算定仓.""" from lib.exchange.okx_options_lib import option_buy_liquidity_ok from lib.hedge_plan.hedge_plan_option_primary_lib import is_option_primary, size_from_premium inst = str(body.get("opt_inst_id") or "").strip() if not inst: return {"ok": False, "msg": "缺少期权合约"} quote_fn = cfg.get("quote_option_contract") ex = cfg.get("exchange_options") if not callable(quote_fn) or ex is None: return {"ok": False, "msg": "期权报价能力未就绪"} q = quote_fn(ex, inst) if not q.get("ok"): return {"ok": False, "msg": q.get("msg") or "期权报价失败", "quote": q} can_open, block_msg = option_buy_liquidity_ok(q.get("ask"), q.get("ask_sz")) if not can_open: return { "ok": False, "msg": block_msg or "暂无卖一深度,无法买入", "quote": q, } body["ask"] = float(q["ask"]) body["ask_sz"] = q.get("ask_sz") if q.get("ct_mult") is not None: body["ct_mult"] = float(q.get("ct_mult") or 0.01) if is_option_primary(body): cs = float(body.get("contract_size") or 0.01) get_cs = cfg.get("get_contract_size") sym = str(body.get("exchange_symbol") or "") if callable(get_cs) and sym: try: cs = float(get_cs(sym) or cs) except Exception: pass sized = size_from_premium( premium_budget=float(body.get("premium_budget") or 0), ask=float(body["ask"]), ct_mult=float(body.get("ct_mult") or 0.01), ratio=float(body.get("option_perp_ratio") or 2), contract_size=cs, ) if not sized.get("ok"): return {"ok": False, "msg": sized.get("msg") or "定仓失败", "quote": q, "sizing": sized} body["sheets"] = sized["sheets"] body["contracts"] = sized["contracts"] body["eth_qty"] = sized["eth_qty"] body["contract_size"] = cs # 深度不足则缩量 ask_sz = float(q.get("ask_sz") or 0) if ask_sz > 0 and float(body["sheets"]) > ask_sz: body["sheets"] = float(int(ask_sz)) if body["sheets"] <= 0: return {"ok": False, "msg": "卖一深度不足 1 张", "quote": q, "sizing": sized} eth = round(float(body["sheets"]) * float(body.get("ct_mult") or 0.01), 2) body["eth_qty"] = eth body["contracts"] = (eth / float(body.get("option_perp_ratio") or 2)) / cs return { "ok": True, "ask": float(q["ask"]), "ask_sz": q.get("ask_sz"), "sheets": body.get("sheets"), "contracts": body.get("contracts"), "eth_qty": body.get("eth_qty"), "sizing": sized, "quote": q, "msg": ( f"期权为主定仓: 权利金×0.95→{body.get('eth_qty')}ETH / " f"{body.get('sheets')}张期权 / {float(body.get('contracts') or 0):.4f}张永续 @{q['ask']}" ), } return { "ok": True, "ask": float(q["ask"]), "ask_sz": q.get("ask_sz"), "sheets": body.get("sheets"), "quote": q, "msg": f"已按最新卖一: {body.get('sheets')}张@{q['ask']}", } def execute_perp_options_start( cfg: dict[str, Any], body: dict[str, Any], *, dry_run: bool = False, persist: Optional[Callable[..., Any]] = None, ) -> dict[str, Any]: refresh = refresh_po_option_quote_before_start(cfg, body) if not refresh.get("ok"): return {"ok": False, "msg": refresh.get("msg") or "启动前刷新卖一失败", "refresh": refresh} path = build_po_path_plan(body) results: list[dict[str, Any]] = [] opt_res: Optional[dict[str, Any]] = None perp_res: Optional[dict[str, Any]] = None for step in path: if step["step"] == "options_buy_limit": opt_res = _buy_option( cfg, inst_id=str(body.get("opt_inst_id") or ""), sheets=float(body.get("sheets") or 1), dry_run=dry_run, ) results.append({"step": step["step"], **opt_res}) if not opt_res.get("ok"): # 永续已成、期权失败 → 可挂 partial 等补开期权 if ( perp_res and perp_res.get("ok") and not dry_run and manual_complete_on_partial() and persist ): return _park_partial( cfg, plan_type="perp_options", body=body, missing_leg="option_hedge", msg="永续已开、期权失败。计划已挂半腿待补,请在「进行中」补开期权", path=path, results=results, persist=persist, dry_run=dry_run, option=None, perp=perp_res, ) return {"ok": False, "msg": opt_res.get("msg") or "期权开仓失败", "path": path, "results": results} else: from lib.hedge_plan.hedge_plan_option_primary_lib import ( is_option_primary, perp_direction_for_view, ) opt_primary = is_option_primary(body) view = str(body.get("direction") or "long") perp_dir = str(step.get("direction") or ( perp_direction_for_view(view) if opt_primary else view )) attach = bool(step.get("attach_tpsl", not opt_primary)) tp_v = None if not attach else body.get("tp") sl_v = None if not attach else body.get("sl") if attach: tp_v = float(body["tp"]) sl_v = float(body["sl"]) perp_res = _open_perp( cfg, symbol=str(body.get("exchange_symbol") or ""), direction=perp_dir, contracts=float(body.get("contracts") or 0), leverage=int(body.get("leverage") or (100 if opt_primary else 10)), tp=tp_v, sl=sl_v, dry_run=dry_run, attach_tpsl=attach, ) results.append({"step": step["step"], **perp_res}) if not perp_res.get("ok"): if opt_res and opt_res.get("ok") and not dry_run and partial_auto_close_enabled(): close_r = _sell_option( cfg, inst_id=str(opt_res.get("inst_id") or body.get("opt_inst_id") or ""), sheets=float(opt_res.get("sheets") or body.get("sheets") or 1), ) results.append({"step": "options_auto_close_on_perp_fail", **close_r}) msg = perp_res.get("msg") or "永续开仓失败" _notify_partial(cfg, "perp_options", msg, results) return { "ok": False, "msg": msg, "path": path, "results": results, "partial": True, } if ( opt_res and opt_res.get("ok") and not dry_run and manual_complete_on_partial() and persist ): return _park_partial( cfg, plan_type="perp_options", body=body, missing_leg="perp", msg="期权已开、永续失败。计划已挂半腿待补,请在「进行中」补开永续", path=path, results=results, persist=persist, dry_run=dry_run, option=opt_res, perp=None, ) msg = perp_res.get("msg") or "永续开仓失败" if not dry_run: _notify_partial(cfg, "perp_options", msg, results) return { "ok": False, "msg": msg, "path": path, "results": results, "partial": True, } out = { "ok": True, "dry_run": dry_run, "plan_type": "perp_options", "path": path, "results": results, "option": opt_res, "perp": perp_res, "refresh": refresh, "opened_at": _now(), } if persist and not dry_run: out["plan_id"] = persist(out, body) return out def execute_options_options_start( cfg: dict[str, Any], body: dict[str, Any], *, dry_run: bool = False, persist: Optional[Callable[..., Any]] = None, ) -> dict[str, Any]: refresh = refresh_oo_sizing_before_start(cfg, body) if not refresh.get("ok"): return {"ok": False, "msg": refresh.get("msg") or "启动前刷新卖一/张数失败", "refresh": refresh} path = build_oo_path_plan(body) results: list[dict[str, Any]] = [] leg_a = body.get("leg_a") or {} leg_b = body.get("leg_b") or {} inst_a = str(leg_a.get("inst_id") or "") inst_b = str(leg_b.get("inst_id") or "") from lib.options.options_position_limit_lib import option_position_limit_block_msg pos_limit_msg = option_position_limit_block_msg( cfg.get("exchange_options"), opening_inst_ids=[inst_a, inst_b], fetch_positions=cfg.get("fetch_option_positions"), ) if pos_limit_msg: return { "ok": False, "msg": pos_limit_msg, "path": path, "results": [], "refresh": refresh, } a_res = _buy_option(cfg, inst_id=inst_a, sheets=float(leg_a.get("sheets") or 1), dry_run=dry_run) results.append({"step": "options_buy_limit", "leg": "a", **a_res}) if not a_res.get("ok"): return { "ok": False, "msg": a_res.get("msg") or "腿A开仓失败", "path": path, "results": results, "refresh": refresh, } b_res = _buy_option(cfg, inst_id=inst_b, sheets=float(leg_b.get("sheets") or 1), dry_run=dry_run) results.append({"step": "options_buy_limit", "leg": "b", **b_res}) if not b_res.get("ok"): if not dry_run and partial_auto_close_enabled(): close_r = _sell_option(cfg, inst_id=str(a_res.get("inst_id") or ""), sheets=float(a_res.get("sheets") or 1)) results.append({"step": "options_auto_close_leg_a", **close_r}) msg = b_res.get("msg") or "腿B开仓失败" _notify_partial(cfg, "options_options", msg, results) return { "ok": False, "msg": msg, "path": path, "results": results, "partial": True, "refresh": refresh, } if not dry_run and manual_complete_on_partial() and persist: out_p = _park_partial( cfg, plan_type="options_options", body=body, missing_leg="option_b", msg="腿A已开、腿B失败。计划已挂半腿待补,请在「进行中」补开腿B", path=path, results=results, persist=persist, dry_run=dry_run, leg_a=a_res, leg_b=None, ) out_p["refresh"] = refresh return out_p msg = b_res.get("msg") or "腿B开仓失败" if not dry_run: _notify_partial(cfg, "options_options", msg, results) return { "ok": False, "msg": msg, "path": path, "results": results, "partial": True, "refresh": refresh, } out = { "ok": True, "dry_run": dry_run, "plan_type": "options_options", "path": path, "results": results, "leg_a": a_res, "leg_b": b_res, "refresh": refresh, "opened_at": _now(), } if persist and not dry_run: out["plan_id"] = persist(out, body) return out def execute_complete_missing_leg( cfg: dict[str, Any], plan: dict[str, Any], legs: list[dict[str, Any]], start_body: dict[str, Any], *, dry_run: bool = False, ) -> dict[str, Any]: """对 partial 计划补开缺失腿;成功后由调用方把计划升为 active.""" missing = None for leg in legs: if str(leg.get("status") or "").lower() == "pending": missing = leg break if not missing: return {"ok": False, "msg": "没有待补开的腿"} role = str(missing.get("leg_role") or "") results: list[dict[str, Any]] = [] if role == "perp": from lib.hedge_plan.hedge_plan_option_primary_lib import ( is_option_primary, perp_direction_for_view, ) opt_primary = is_option_primary(start_body) view = str(start_body.get("direction") or "long") perp_dir = perp_direction_for_view(view) if opt_primary else view attach = not opt_primary res = _open_perp( cfg, symbol=str(start_body.get("exchange_symbol") or missing.get("symbol") or ""), direction=perp_dir, contracts=float(start_body.get("contracts") or missing.get("size") or 0), leverage=int(start_body.get("leverage") or (100 if opt_primary else 10)), tp=None if not attach else float(start_body["tp"]), sl=None if not attach else float(start_body["sl"]), dry_run=dry_run, attach_tpsl=attach, ) results.append({"step": "perp_market_open", "complete": True, **res}) if not res.get("ok"): return {"ok": False, "msg": res.get("msg") or "补开永续失败", "results": results, "leg_role": role} return { "ok": True, "leg_role": role, "leg_id": missing.get("id"), "results": results, "fill": res, "opened_at": _now(), } if role in ("option_hedge", "option_b", "option_a"): if role == "option_b": src = start_body.get("leg_b") or {} inst = str(src.get("inst_id") or missing.get("inst_id") or "") sheets = float(src.get("sheets") or missing.get("size") or 1) elif role == "option_a": src = start_body.get("leg_a") or {} inst = str(src.get("inst_id") or missing.get("inst_id") or "") sheets = float(src.get("sheets") or missing.get("size") or 1) else: inst = str(start_body.get("opt_inst_id") or missing.get("inst_id") or "") sheets = float(start_body.get("sheets") or missing.get("size") or 1) res = _buy_option(cfg, inst_id=inst, sheets=sheets, dry_run=dry_run) results.append({"step": "options_buy_limit", "complete": True, "leg_role": role, **res}) if not res.get("ok"): return {"ok": False, "msg": res.get("msg") or "补开期权失败", "results": results, "leg_role": role} return { "ok": True, "leg_role": role, "leg_id": missing.get("id"), "results": results, "fill": res, "opened_at": _now(), } return {"ok": False, "msg": f"未知待补腿: {role}"} def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]: pt = (plan_type or "").strip().lower() if pt == "perp_options": from lib.hedge_plan.hedge_plan_option_primary_lib import ( is_option_primary, validate_option_primary_start, ) if is_option_primary(body): from lib.hedge_plan.hedge_plan_option_primary_lib import validate_option_primary_watch # 以期权为主默认盯盘启动(非现场开仓);显式 watch_entry=0 才走即开校验 watch = body.get("watch_entry") if watch in (None, "", True, 1, "1", "true", "yes", "on"): return validate_option_primary_watch(body) return validate_option_primary_start(body) need = ("direction", "entry", "tp", "sl", "contracts", "opt_inst_id", "sheets", "exchange_symbol") for k in need: if body.get(k) in (None, ""): return f"缺少字段: {k}" try: if float(body["contracts"]) <= 0 or float(body["sheets"]) <= 0: return "张数必须大于 0" entry = float(body["entry"]) tp = float(body["tp"]) sl = float(body["sl"]) if tp <= 0 or sl <= 0 or entry <= 0: return "止盈/止损/入场无效" except (TypeError, ValueError): return "数值字段无效" direction = str(body.get("direction") or "").strip().lower() if direction not in ("long", "short"): return "方向须为 long 或 short" opt_type = str(body.get("opt_type") or "").strip().upper() if not opt_type: # 允许从合约名推断 ETH-USD-...-P / -C inst = str(body.get("opt_inst_id") or "") if inst.upper().endswith("-P"): opt_type = "P" elif inst.upper().endswith("-C"): opt_type = "C" if opt_type not in ("P", "C"): return "缺少期权类型(Put/Call)" if direction == "long" and opt_type != "P": return "做多永期对冲须用 Put" if direction == "short" and opt_type != "C": return "做空永期对冲须用 Call" if direction == "long" and not (sl < entry < tp): return "做多须满足 止损 < 入场 < 止盈" if direction == "short" and not (tp < entry < sl): return "做空须满足 止盈 < 入场 < 止损" from lib.hedge_plan.hedge_plan_moneyness_lib import ( parse_strike_from_inst, validate_po_option_moneyness, ) strike = body.get("strike") if strike in (None, ""): strike = parse_strike_from_inst(str(body.get("opt_inst_id") or "")) index_px = body.get("index_px") if index_px in (None, ""): index_px = entry money_err = validate_po_option_moneyness( opt_type=opt_type, strike=strike, index_px=index_px, ask=body.get("ask"), hours_to_expiry=body.get("hours_to_expiry"), ) if money_err: return money_err return None if pt == "options_options": a = body.get("leg_a") or {} b = body.get("leg_b") or {} if not a.get("inst_id") or not b.get("inst_id"): return "请选用两条期权腿" rr_raw = body.get("profit_rr") if rr_raw not in (None, ""): try: rr = float(rr_raw) except (TypeError, ValueError): return "盈亏比无效" if rr <= 0: return "盈亏比须大于0" else: # 兼容旧上/下破 up = body.get("target_price_up") down = body.get("target_price_down") legacy = body.get("target_price") if up in (None, "") and legacy not in (None, ""): up = legacy if down in (None, "") and legacy not in (None, ""): down = legacy if up in (None, "") or down in (None, ""): return "请填写盈亏比" try: if float(up) <= float(down): return "上破目标价必须大于下破目标价" except (TypeError, ValueError): return "目标价无效" from lib.hedge_plan.hedge_plan_moneyness_lib import ( parse_strike_from_inst, validate_oo_legs_moneyness, ) def _leg_for_money(leg: dict) -> dict: strike = leg.get("strike") if strike in (None, ""): strike = parse_strike_from_inst(str(leg.get("inst_id") or "")) opt_type = leg.get("opt_type") if not opt_type: inst = str(leg.get("inst_id") or "").upper() if inst.endswith("-C"): opt_type = "C" elif inst.endswith("-P"): opt_type = "P" return {"opt_type": opt_type, "strike": strike} index_px = body.get("index_px") money_err = validate_oo_legs_moneyness( _leg_for_money(a), _leg_for_money(b), index_px=index_px, ) if money_err: return money_err return None return "未知计划类型" def dump_preview(preview: Any) -> str: try: return json.dumps(preview, ensure_ascii=False)[:8000] except Exception: return "" def _live_option_pos_sheets(ex: Any, inst_id: str) -> float: from lib.exchange.okx_options_lib import fetch_option_positions inst_id = (inst_id or "").strip() if not inst_id or ex is None: return 0.0 rows = fetch_option_positions(ex) if rows is None: return -1.0 # API 失败:未知 for r in rows: if str(r.get("instId") or "").strip() != inst_id: continue try: return abs(float(r.get("pos") or 0)) except (TypeError, ValueError): return 0.0 return 0.0 def _sync_plan_status_after_leg_fix(conn: Any, plan_id: int) -> None: """腿状态校正后:有 open + pending → partial.""" from lib.hedge_plan.hedge_plan_db import get_plan, get_plan_legs, update_plan plan = get_plan(conn, int(plan_id)) if not plan: return pst = str(plan.get("status") or "") if pst not in ("opening", "active", "partial"): return legs = get_plan_legs(conn, int(plan_id)) statuses = [str(l.get("status") or "").lower() for l in legs] n_open = sum(1 for s in statuses if s == "open") n_pending = sum(1 for s in statuses if s == "pending") if n_pending and n_open: update_plan(conn, int(plan_id), status="partial", close_reason="partial_fail") def reconcile_unfilled_option_legs(cfg: dict[str, Any], conn: Any, plan_id: int) -> list[str]: """未成交却标 open 的期权腿 → pending(可补开);不显示成持仓.""" from lib.hedge_plan.hedge_plan_db import get_plan_legs, update_leg from lib.exchange.okx_options_lib import fetch_option_order ex = cfg.get("exchange_options") notes: list[str] = [] legs = get_plan_legs(conn, int(plan_id)) for leg in legs: role = str(leg.get("leg_role") or "") if not role.startswith("option"): continue st = str(leg.get("status") or "").lower() if st != "open": continue inst = str(leg.get("inst_id") or "").strip() oid = str(leg.get("exchange_ord_id") or "").strip() leg_id = int(leg["id"]) sheets = _live_option_pos_sheets(ex, inst) if sheets < 0: continue # 查仓失败不改 if sheets >= 1: continue # 无实仓:再看订单是否已成交(仍挂单只改 pending,不撤单) if ex is not None and inst and oid: od = fetch_option_order(ex, inst_id=inst, ord_id=oid) if od.get("ok"): acc = float(od.get("acc_fill_sz") or 0) ostate = str(od.get("state") or "") if acc >= 1 or ostate == "filled": continue # 有成交但仓位暂未同步,暂不改 update_leg( conn, leg_id, status="pending", close_reason=None, closed_at=None, avg_open=None, premium=0, ) notes.append(f"{inst} 无成交却标open→pending") if notes: _sync_plan_status_after_leg_fix(conn, int(plan_id)) return notes def execute_manual_end_plan(cfg: dict[str, Any], conn: Any, plan_id: int) -> dict[str, Any]: """人工结束进行中计划:不自动平仓;未成交腿标 cancelled.""" from lib.hedge_plan.hedge_plan_db import get_plan, get_plan_legs, update_leg, update_plan from lib.hedge_plan.hedge_plan_notify_lib import notify_plan_end from lib.exchange.okx_options_lib import cancel_option_order plan = get_plan(conn, int(plan_id)) if not plan: return {"ok": False, "msg": "计划不存在"} st = str(plan.get("status") or "") if st not in ("opening", "active", "partial", "watching"): return {"ok": False, "msg": f"当前状态 {st or '—'} 不可结束"} notes = reconcile_unfilled_option_legs(cfg, conn, int(plan_id)) ex = cfg.get("exchange_options") legs = get_plan_legs(conn, int(plan_id)) for leg in legs: lst = str(leg.get("status") or "").lower() inst = str(leg.get("inst_id") or "").strip() oid = str(leg.get("exchange_ord_id") or "").strip() if lst == "pending": if ex is not None and inst and oid: cancel_option_order(ex, inst_id=inst, ord_id=oid) update_leg( conn, int(leg["id"]), status="cancelled", close_reason="manual_end", closed_at=_now(), avg_open=None, premium=0, ) notes.append(f"{inst or leg.get('leg_role')} 待补→cancelled") update_plan( conn, int(plan_id), status="closed", close_reason="manual", closed_at=_now(), note=((plan.get("note") or "") + " · 人工结束(不平仓)").strip(" ·")[:500], ) plan2 = get_plan(conn, int(plan_id)) if plan2: try: notify_plan_end(cfg, conn, plan2) except Exception: pass return { "ok": True, "plan_id": int(plan_id), "msg": "计划已结束(未自动平仓;有持仓请自行平掉)", "notes": notes, }