"""对冲计划开仓/平仓编排(可 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,
    }
