"""本地模拟撮合：永续市价 + 期权只买开/卖平。"""

from __future__ import annotations

import logging
import time
from dataclasses import dataclass
from typing import Any

from ..config import get_settings
from ..exchange import get_exchange
from ..models.db import Database, get_db
from ..strategy.session import get_session
from .ledger import Ledger
from .liquidity import bid_covers_eth, bid_mark_ok, contracts_for_eth, eth_from_contracts
from .pricing import (
    is_deep_otm,
    option_expiry_settle,
    option_fill,
    option_intrinsic,
    perp_fill,
    resolve_option_close_bid,
)

logger = logging.getLogger(__name__)

# 禁止新开仓的本地仓位状态（实盘防卡）
BLOCKING_STATUSES = frozenset(
    {"open", "half_open", "option_closed_perp_pending", "opening"}
)


@dataclass(slots=True)
class OpenResult:
    ok: bool
    group_id: str | None = None
    detail: str = ""
    data: dict[str, Any] | None = None


@dataclass(slots=True)
class CloseResult:
    ok: bool
    detail: str = ""
    liquidity_wait: bool = False
    data: dict[str, Any] | None = None


class Matcher:
    def __init__(self, db: Database | None = None) -> None:
        self.db = db or get_db()
        self.ledger = Ledger(self.db)

    def _fee_rate(self) -> float:
        return self.ledger.get_setting_float("fee_rate", get_settings().fee_rate)

    def _ct_mult(self, option_inst_id: str) -> float:
        try:
            from ..exchange.runtime import load_runtime_settings

            s = load_runtime_settings()
        except Exception:
            s = get_settings()
        try:
            return get_exchange().get_ct_mult(
                option_inst_id, s.option_inst_family, s.option_ct_mult_default
            )
        except Exception:
            pass
        return float(s.option_ct_mult_default)

    def current_position(self) -> dict[str, Any]:
        row = self.db.fetchone("SELECT * FROM positions WHERE id=1")
        assert row is not None
        return dict(row)

    def has_open_position(self) -> bool:
        """是否禁止新开：含 open / half_open / option_closed_perp_pending / opening。"""
        pos = self.current_position()
        st = str(pos.get("status") or "")
        if st not in BLOCKING_STATUSES:
            return False
        if st == "opening":
            return True
        return bool(pos.get("group_id") or pos.get("option_inst_id"))

    def position_status(self) -> str:
        return str(self.current_position().get("status") or "flat")

    def _liquidity_wait(self, group_id: str, detail: str) -> CloseResult:
        note = f"liquidity_wait:{int(time.time())}:{detail[:80]}"
        self.db.execute(
            "UPDATE groups SET note=? WHERE group_id=? AND status='open'",
            (note, group_id),
        )
        return CloseResult(ok=False, detail=detail, liquidity_wait=True)

    def _group_strike(self, group_id: str, option_inst_id: str) -> float | None:
        g = self.db.fetchone(
            "SELECT strike FROM groups WHERE group_id=?", (group_id,)
        )
        if g is not None and g["strike"] is not None:
            try:
                return float(g["strike"])
            except (TypeError, ValueError):
                pass
        try:
            from ..exchange.okx.parse import parse_option_inst_id

            _, stk, _ = parse_option_inst_id(option_inst_id)
            if stk is not None:
                return float(stk)
        except Exception:
            pass
        try:
            from ..exchange.binance.parse import parse_option_symbol

            _, stk, _ = parse_option_symbol(option_inst_id)
            if stk is not None:
                return float(stk)
        except Exception:
            pass
        return None

    def _close_spot_px(self, snap: Any) -> float | None:
        if getattr(snap, "index_px", None) is not None:
            try:
                px = float(snap.index_px)
                if px > 0:
                    return px
            except (TypeError, ValueError):
                pass
        perp = getattr(snap, "perp", None)
        if not perp:
            return None
        for attr in ("mark_px", "last"):
            v = getattr(perp, attr, None)
            if v is not None:
                try:
                    px = float(v)
                    if px > 0:
                        return px
                except (TypeError, ValueError):
                    pass
        if perp.bid is not None and perp.ask is not None:
            return (float(perp.bid) + float(perp.ask)) / 2.0
        if perp.bid is not None:
            return float(perp.bid)
        if perp.ask is not None:
            return float(perp.ask)
        return None

    def open_group(
        self,
        *,
        group_id: str,
        bias: str,
        option_side: str,  # call|put
        perp_side: str,  # long|short
        option_inst_id: str,
        entry_index_px: float,
        strike: float | None = None,
        expiry_ymd: str | None = None,
    ) -> OpenResult:
        s = get_settings()
        pos = self.current_position()
        st = str(pos.get("status") or "flat")
        if st in BLOCKING_STATUSES and (
            st == "opening" or bool(pos.get("group_id") or pos.get("option_inst_id"))
        ):
            return OpenResult(ok=False, detail=f"已有持仓状态({st})，请先平仓")
        if pos.get("status") == "open" and pos.get("group_id"):
            return OpenResult(ok=False, detail="已有持仓组，请先平仓")

        sess = get_session()
        snap = sess.snapshot()
        if not snap.perp or snap.perp.bid is None or snap.perp.ask is None:
            return OpenResult(ok=False, detail="永续盘口不可用")
        oq = snap.call if option_side == "call" else snap.put
        # 若 ATM 对与持仓合约不一致，直接取持仓合约盘口
        held = get_exchange().quote(option_inst_id)
        if held and held.ask is not None:
            oq = held
        if not oq or oq.ask is None:
            return OpenResult(ok=False, detail="期权卖一不可用")

        fee_rate = self._fee_rate()
        s = get_settings()
        perp_qty = self.ledger.get_setting_float("perp_qty_eth", s.perp_qty_eth)
        opt_qty = self.ledger.get_setting_float("option_qty_eth", s.option_qty_eth)
        ct_mult = self._ct_mult(option_inst_id)
        opt_contracts = contracts_for_eth(opt_qty, ct_mult)

        # 1) 先成交期权（吃卖一）；失败则整组不开
        of = option_fill(
            action="open",
            bid=float(oq.bid or 0),
            ask=float(oq.ask),
            qty_eth=opt_qty,
            fee_rate=fee_rate,
        )
        initial_premium = of.fill_px * opt_qty  # 锁定口径：成交价×名义，不含费
        premium_cost = of.notional + of.fee
        try:
            self.ledger.apply_cash(
                -premium_cost,
                kind="open_option",
                group_id=group_id,
                note=f"open option {group_id}",
            )
        except RuntimeError as e:
            return OpenResult(ok=False, detail=str(e))

        # 2) 期权确认后再市价成交永续（重新取盘口）
        snap2 = sess.snapshot()
        if not snap2.perp or snap2.perp.bid is None or snap2.perp.ask is None:
            self.ledger.apply_cash(
                premium_cost,
                kind="open_option_rollback",
                group_id=group_id,
                note=f"rollback option {group_id}: perp book missing",
            )
            return OpenResult(
                ok=False,
                detail="期权已成交但永续盘口不可用，已回滚期权",
            )
        pf = perp_fill(
            side=perp_side,
            action="open",
            bid=float(snap2.perp.bid),
            ask=float(snap2.perp.ask),
            qty_eth=perp_qty,
            fee_rate=fee_rate,
        )
        try:
            self.ledger.apply_cash(
                -pf.fee,
                kind="open_perp_fee",
                group_id=group_id,
                note=f"open perp {group_id}",
            )
        except RuntimeError as e:
            self.ledger.apply_cash(
                premium_cost,
                kind="open_option_rollback",
                group_id=group_id,
                note=f"rollback option {group_id}: {e}",
            )
            return OpenResult(ok=False, detail=f"期权已成交但永续扣费失败并已回滚: {e}")

        now = int(time.time() * 1000)
        with self.db._lock:
            self.db._conn.execute(
                """INSERT INTO groups(
                    group_id, status, bias, option_side, perp_side, option_inst_id, perp_inst_id,
                    strike, expiry_ymd, entry_index_px, initial_premium, open_at_ms, fees, slip_cost,
                    exec_mode
                ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
                (
                    group_id,
                    "open",
                    bias,
                    option_side,
                    perp_side,
                    option_inst_id,
                    s.perp_inst_id,
                    strike,
                    expiry_ymd,
                    entry_index_px,
                    initial_premium,
                    now,
                    pf.fee + of.fee,
                    pf.slip + of.slip,
                    "SIM",
                ),
            )
            # 成交顺序：期权先、永续后（时间戳差 1ms 便于审计）
            self.db._conn.execute(
                """INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts,
                   base_px, fill_px, fee, slip, notional, ts_ms, exec_mode)
                   VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
                (
                    group_id,
                    "option",
                    "open",
                    "long",
                    option_inst_id,
                    opt_qty,
                    opt_contracts,
                    of.base_px,
                    of.fill_px,
                    of.fee,
                    of.slip,
                    of.notional,
                    now,
                    "SIM",
                ),
            )
            self.db._conn.execute(
                """INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts,
                   base_px, fill_px, fee, slip, notional, ts_ms, exec_mode)
                   VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
                (
                    group_id,
                    "perp",
                    "open",
                    perp_side,
                    s.perp_inst_id,
                    perp_qty,
                    None,
                    pf.base_px,
                    pf.fill_px,
                    pf.fee,
                    pf.slip,
                    pf.notional,
                    now + 1,
                    "SIM",
                ),
            )
            self.db._conn.execute(
                """UPDATE positions SET
                    group_id=?, perp_side=?, perp_qty_eth=?, perp_entry_px=?,
                    option_inst_id=?, option_side=?, option_qty_eth=?, option_qty_contracts=?,
                    option_entry_px=?, entry_index_px=?, initial_premium=?, status=?
                    WHERE id=1""",
                (
                    group_id,
                    perp_side,
                    perp_qty,
                    pf.fill_px,
                    option_inst_id,
                    option_side,
                    opt_qty,
                    opt_contracts,
                    of.fill_px,
                    entry_index_px,
                    initial_premium,
                    "open",
                ),
            )
            self.db._conn.commit()

        try:
            from ..strategy.exits import lock_trade_exit_target

            lock_trade_exit_target(
                self.db, group_id=group_id, initial_premium=initial_premium
            )
        except Exception:
            logger.exception("lock exit target failed group=%s", group_id)

        leverage = self.ledger.get_setting_float("leverage", s.leverage)
        perp_margin = (
            abs(float(pf.fill_px) * float(perp_qty)) / float(leverage)
            if leverage and float(leverage) > 0
            else None
        )
        return OpenResult(
            ok=True,
            group_id=group_id,
            detail="opened",
            data={
                "group_id": group_id,
                "bias": bias,
                "option_side": option_side,
                "perp_side": perp_side,
                "option_inst_id": option_inst_id,
                "strike": strike,
                "expiry_ymd": expiry_ymd,
                "perp": pf.to_dict(),
                "option": of.to_dict(),
                "perp_qty_eth": float(perp_qty),
                "option_qty_eth": float(opt_qty),
                "perp_entry_px": float(pf.fill_px),
                "option_entry_px": float(of.fill_px),
                "initial_premium": initial_premium,
                "perp_margin": perp_margin,
                "leverage": float(leverage) if leverage else None,
                "fees": pf.fee + of.fee,
                "open_sequence": ["option", "perp"],
            },
        )

    def open_oo_group(
        self,
        *,
        group_id: str,
        call_inst_id: str,
        put_inst_id: str,
        call_strike: float,
        put_strike: float,
        entry_index_px: float,
        expiry_ymd: str | None = None,
    ) -> OpenResult:
        """期期：买 Call 再买 Put，无永续。"""
        if not get_settings().is_sim:
            return OpenResult(
                ok=False,
                detail="LIVE 期期开仓须走 LiveExecutor.open_oo_group",
            )
        pos = self.current_position()
        st = str(pos.get("status") or "flat")
        if st in BLOCKING_STATUSES and (
            st == "opening" or bool(pos.get("group_id") or pos.get("option_inst_id"))
        ):
            return OpenResult(ok=False, detail=f"已有持仓状态({st})，请先平仓")
        if pos.get("status") == "open" and pos.get("group_id"):
            return OpenResult(ok=False, detail="已有持仓组，请先平仓")

        ex = get_exchange()
        cq = ex.quote(call_inst_id)
        pq = ex.quote(put_inst_id)
        if cq is None or cq.ask is None:
            _, asks, _ = ex.fetch_book(call_inst_id, depth=5)
            if asks:
                from types import SimpleNamespace

                cq = SimpleNamespace(ask=asks[0].px, bid=None)
        if pq is None or pq.ask is None:
            _, asks, _ = ex.fetch_book(put_inst_id, depth=5)
            if asks:
                from types import SimpleNamespace

                pq = SimpleNamespace(ask=asks[0].px, bid=None)
        if not cq or cq.ask is None or not pq or pq.ask is None:
            return OpenResult(ok=False, detail="期期 Call/Put 卖一不可用")

        fee_rate = self._fee_rate()
        call_qty = self.ledger.get_setting_float("option_qty_eth", 0.1)
        put_qty = self.ledger.get_setting_float("oo_put_qty_eth", call_qty)
        if call_qty < 0.1 - 1e-12 or put_qty < 0.1 - 1e-12:
            return OpenResult(ok=False, detail="期期名义 qty 无效")
        call_ct = self._ct_mult(call_inst_id)
        put_ct = self._ct_mult(put_inst_id)
        call_contracts = contracts_for_eth(call_qty, call_ct)
        put_contracts = contracts_for_eth(put_qty, put_ct)

        cf = option_fill(
            action="open",
            bid=float(getattr(cq, "bid", None) or 0),
            ask=float(cq.ask),
            qty_eth=call_qty,
            fee_rate=fee_rate,
        )
        call_prem = cf.fill_px * call_qty
        call_cost = cf.notional + cf.fee
        try:
            self.ledger.apply_cash(
                -call_cost,
                kind="open_option",
                group_id=group_id,
                note=f"open oo call {group_id}",
            )
        except RuntimeError as e:
            return OpenResult(ok=False, detail=str(e))

        # 再买 Put；失败则尝试卖回 Call
        pq2 = ex.quote(put_inst_id) or pq
        ask2 = float(pq2.ask) if pq2 and pq2.ask else float(pq.ask)
        pf = option_fill(
            action="open",
            bid=float(getattr(pq2, "bid", None) or 0),
            ask=ask2,
            qty_eth=put_qty,
            fee_rate=fee_rate,
        )
        put_prem = pf.fill_px * put_qty
        put_cost = pf.notional + pf.fee
        try:
            self.ledger.apply_cash(
                -put_cost,
                kind="open_option",
                group_id=group_id,
                note=f"open oo put {group_id}",
            )
        except RuntimeError as e:
            # 回滚 Call：按买一卖出估算
            bid = float(getattr(cq, "bid", None) or cf.fill_px)
            rb = option_fill(
                action="close",
                bid=bid,
                ask=float(cq.ask),
                qty_eth=call_qty,
                fee_rate=fee_rate,
            )
            self.ledger.apply_cash(
                rb.notional - rb.fee,
                kind="open_option_rollback",
                group_id=group_id,
                note=f"rollback oo call {group_id}: {e}",
            )
            return OpenResult(ok=False, detail=f"Call 已成交但 Put 扣费失败并已回滚: {e}")

        now = int(time.time() * 1000)
        total_prem = call_prem + put_prem
        with self.db._lock:
            self.db._conn.execute(
                """INSERT INTO groups(
                    group_id, status, bias, option_side, perp_side, option_inst_id, perp_inst_id,
                    strike, expiry_ymd, entry_index_px, initial_premium, open_at_ms, fees, slip_cost,
                    exec_mode, hedge_mode, option2_inst_id, option2_side, strike2, initial_premium2
                ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
                (
                    group_id,
                    "open",
                    "option_option",
                    "call",
                    None,
                    call_inst_id,
                    None,
                    float(call_strike),
                    expiry_ymd,
                    entry_index_px,
                    call_prem,
                    now,
                    cf.fee + pf.fee,
                    cf.slip + pf.slip,
                    "SIM",
                    "option_option",
                    put_inst_id,
                    "put",
                    float(put_strike),
                    put_prem,
                ),
            )
            self.db._conn.execute(
                """INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts,
                   base_px, fill_px, fee, slip, notional, ts_ms, exec_mode)
                   VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
                (
                    group_id,
                    "option",
                    "open",
                    "long",
                    call_inst_id,
                    call_qty,
                    call_contracts,
                    cf.base_px,
                    cf.fill_px,
                    cf.fee,
                    cf.slip,
                    cf.notional,
                    now,
                    "SIM",
                ),
            )
            self.db._conn.execute(
                """INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts,
                   base_px, fill_px, fee, slip, notional, ts_ms, exec_mode)
                   VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
                (
                    group_id,
                    "option2",
                    "open",
                    "long",
                    put_inst_id,
                    put_qty,
                    put_contracts,
                    pf.base_px,
                    pf.fill_px,
                    pf.fee,
                    pf.slip,
                    pf.notional,
                    now + 1,
                    "SIM",
                ),
            )
            self.db._conn.execute(
                """UPDATE positions SET
                    group_id=?, perp_side=NULL, perp_qty_eth=0, perp_entry_px=NULL,
                    option_inst_id=?, option_side=?, option_qty_eth=?, option_qty_contracts=?,
                    option_entry_px=?, entry_index_px=?, initial_premium=?, status=?,
                    hedge_mode=?, option2_inst_id=?, option2_side=?, option2_qty_eth=?,
                    option2_qty_contracts=?, option2_entry_px=?, strike2=?, initial_premium2=?
                    WHERE id=1""",
                (
                    group_id,
                    call_inst_id,
                    "call",
                    call_qty,
                    call_contracts,
                    cf.fill_px,
                    entry_index_px,
                    call_prem,
                    "open",
                    "option_option",
                    put_inst_id,
                    "put",
                    put_qty,
                    put_contracts,
                    pf.fill_px,
                    float(put_strike),
                    put_prem,
                ),
            )
            self.db._conn.commit()

        try:
            from ..strategy.exits import lock_trade_exit_target

            lock_trade_exit_target(
                self.db, group_id=group_id, initial_premium=total_prem
            )
        except Exception:
            logger.exception("lock exit target failed oo group=%s", group_id)

        return OpenResult(
            ok=True,
            group_id=group_id,
            detail="opened_oo",
            data={
                "group_id": group_id,
                "hedge_mode": "option_option",
                "call_inst_id": call_inst_id,
                "put_inst_id": put_inst_id,
                "call_strike": float(call_strike),
                "put_strike": float(put_strike),
                "option_qty_eth": float(call_qty),
                "option2_qty_eth": float(put_qty),
                "initial_premium": total_prem,
                "fees": cf.fee + pf.fee,
                "open_sequence": ["call", "put"],
            },
        )

    def close_winning_oo_leave_residual(
        self, *, reason: str = "target_oo_win", skip_market: bool = False
    ) -> CloseResult:
        """期期达标：平盈利腿，亏损腿进 residual。skip_market=True 时假定已在交易所卖掉盈利腿。"""
        pos = self.current_position()
        st = str(pos.get("status") or "")
        if st not in ("open", "closing") or not pos.get("group_id"):
            return CloseResult(ok=False, detail="无期期持仓可平")
        if str(pos.get("hedge_mode") or "") != "option_option":
            # 兼容：有 option2 即视为期期
            if not pos.get("option2_inst_id"):
                return CloseResult(ok=False, detail="非期期持仓")
        if st == "closing" and not skip_market:
            skip_market = True

        group_id = str(pos["group_id"])
        call_id = str(pos.get("option_inst_id") or "")
        put_id = str(pos.get("option2_inst_id") or "")
        qty = float(pos.get("option_qty_eth") or 0)
        qty2 = float(pos.get("option2_qty_eth") or qty)
        if not call_id or not put_id or qty <= 0:
            return CloseResult(ok=False, detail="期期腿不完整")

        upl = self.unrealized()
        call_upl = float(upl.get("option_upl") or 0)
        put_upl = float(upl.get("option2_upl") or 0)
        # 盈利腿：UPL 更高且 > 0
        if call_upl >= put_upl and call_upl > 0:
            win_leg, lose_leg = "option", "option2"
            win_id, lose_id = call_id, put_id
            win_side, lose_side = "call", "put"
            win_qty = qty
            lose_qty = qty2
            win_entry = float(pos.get("option_entry_px") or 0)
            lose_entry = float(pos.get("option2_entry_px") or 0)
            lose_strike = float(pos.get("strike2") or 0)
            lose_prem = float(pos.get("initial_premium2") or 0)
            win_contracts = float(pos.get("option_qty_contracts") or 0)
            lose_contracts = float(pos.get("option2_qty_contracts") or 0)
        elif put_upl > call_upl and put_upl > 0:
            win_leg, lose_leg = "option2", "option"
            win_id, lose_id = put_id, call_id
            win_side, lose_side = "put", "call"
            win_qty = qty2
            lose_qty = qty
            win_entry = float(pos.get("option2_entry_px") or 0)
            lose_entry = float(pos.get("option_entry_px") or 0)
            g = self.db.fetchone(
                "SELECT strike FROM groups WHERE group_id=?", (group_id,)
            )
            lose_strike = float(g["strike"] or 0) if g else 0.0
            lose_prem = float(pos.get("initial_premium") or 0)
            win_contracts = float(pos.get("option2_qty_contracts") or 0)
            lose_contracts = float(pos.get("option_qty_contracts") or 0)
        else:
            return CloseResult(ok=False, detail="无明确盈利腿，暂不平")

        fee_rate = self._fee_rate()
        if skip_market:
            oq = self._quote_held_option(win_id)
            fill_px = float(oq.bid) if oq and oq.bid else float(win_entry)
            of = option_fill(
                action="close",
                bid=fill_px,
                ask=fill_px,
                qty_eth=win_qty,
                fee_rate=fee_rate,
            )
        else:
            oq = self._quote_held_option(win_id)
            if oq is None or oq.bid is None or float(oq.bid) <= 0:
                return CloseResult(ok=False, detail="盈利腿买一不可用")
            gate = self._residual_bid_gate(
                {
                    "option_inst_id": win_id,
                    "option_qty_eth": win_qty,
                    "initial_premium": win_entry * win_qty,
                },
                bid=float(oq.bid),
                oq=oq,
                require_premium_ratio=False,
            )
            if gate:
                return CloseResult(ok=False, detail=f"盈利腿流动性不足: {gate}")
            of = option_fill(
                action="close",
                bid=float(oq.bid),
                ask=float(oq.ask or oq.bid),
                qty_eth=win_qty,
                fee_rate=fee_rate,
            )
        cash = of.notional - of.fee
        if get_settings().is_sim or not skip_market:
            self.ledger.apply_cash(
                cash, kind="close_option", group_id=group_id, note=f"oo win {win_leg}"
            )
        elif skip_market:
            # LIVE：交易所已成交，仍记本地账本现金（与其它 LIVE 平仓一致）
            try:
                self.ledger.apply_cash(
                    cash,
                    kind="close_option",
                    group_id=group_id,
                    note=f"oo win live {win_leg}",
                )
            except Exception:
                logger.exception("oo win live ledger cash failed")

        now = int(time.time() * 1000)
        expiry_ymd = None
        expiry_ms = None
        g = self.db.fetchone("SELECT * FROM groups WHERE group_id=?", (group_id,))
        if g:
            expiry_ymd = g["expiry_ymd"]
            try:
                from ..exchange.expiry import expiry_ms_from_ymd

                if expiry_ymd:
                    expiry_ms = int(expiry_ms_from_ymd(str(expiry_ymd)))
            except Exception:
                expiry_ms = None

        with self.db._lock:
            self.db._conn.execute(
                """INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts,
                   base_px, fill_px, fee, slip, notional, ts_ms, exec_mode)
                   VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
                (
                    group_id,
                    win_leg,
                    "close",
                    "sell",
                    win_id,
                    win_qty,
                    win_contracts,
                    of.base_px,
                    of.fill_px,
                    of.fee,
                    of.slip,
                    of.notional,
                    now,
                    "SIM",
                ),
            )
            self.db._conn.execute(
                """INSERT INTO residual_options(
                    group_id, option_inst_id, option_side, option_qty_eth, option_qty_contracts,
                    option_entry_px, strike, expiry_ymd, expiry_ms, entry_index_px,
                    initial_premium, status, created_at_ms, note
                ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
                (
                    group_id,
                    lose_id,
                    lose_side,
                    lose_qty,
                    lose_contracts,
                    lose_entry,
                    lose_strike,
                    expiry_ymd,
                    expiry_ms,
                    float(pos.get("entry_index_px") or 0),
                    lose_prem,
                    "pending",
                    now,
                    f"oo losing leg after {reason}; win={win_leg}",
                ),
            )
            # 组：记部分实现盈亏（赢腿），状态 residual
            win_pnl = (of.fill_px - win_entry) * win_qty - of.fee
            self.db._conn.execute(
                """UPDATE groups SET status=?, close_at_ms=?, close_reason=?, realized_pnl=?,
                   fees=COALESCE(fees,0)+?, note=?
                   WHERE group_id=?""",
                (
                    "option_residual",
                    now,
                    reason,
                    float(win_pnl),
                    float(of.fee),
                    f"oo win closed {win_leg}; lose {lose_leg} residual",
                    group_id,
                ),
            )
            self.db._conn.execute(
                """UPDATE positions SET
                    group_id=NULL, perp_side=NULL, perp_qty_eth=0, perp_entry_px=NULL,
                    option_inst_id=NULL, option_side=NULL, option_qty_eth=0,
                    option_qty_contracts=0, option_entry_px=NULL, entry_index_px=NULL,
                    initial_premium=0, exit_target_usdt=NULL, status='flat',
                    hedge_mode=NULL, option2_inst_id=NULL, option2_side=NULL,
                    option2_qty_eth=0, option2_qty_contracts=0, option2_entry_px=NULL,
                    strike2=NULL, initial_premium2=NULL
                    WHERE id=1"""
            )
            self.db._conn.commit()

        return CloseResult(
            ok=True,
            detail="oo_win_closed_lose_residual",
            data={
                "group_id": group_id,
                "reason": reason,
                "win_leg": win_leg,
                "lose_leg": lose_leg,
                "win_pnl": win_pnl,
            },
        )

    def close_oo_full(
        self, *, reason: str = "expiry", bypass_liquidity: bool = False
    ) -> CloseResult:
        """期期全平两腿（到期/紧急）；无永续。"""
        pos = self.current_position()
        if str(pos.get("status") or "") != "open" or not pos.get("group_id"):
            return CloseResult(ok=False, detail="无期期持仓可平")
        if not (
            str(pos.get("hedge_mode") or "") == "option_option"
            or pos.get("option2_inst_id")
        ):
            return CloseResult(ok=False, detail="非期期持仓")

        group_id = str(pos["group_id"])
        legs = [
            (
                "option",
                str(pos.get("option_inst_id") or ""),
                float(pos.get("option_qty_eth") or 0),
                float(pos.get("option_qty_contracts") or 0),
                float(pos.get("option_entry_px") or 0),
                float(pos.get("initial_premium") or 0),
            ),
            (
                "option2",
                str(pos.get("option2_inst_id") or ""),
                float(pos.get("option2_qty_eth") or 0),
                float(pos.get("option2_qty_contracts") or 0),
                float(pos.get("option2_entry_px") or 0),
                float(pos.get("initial_premium2") or 0),
            ),
        ]
        fee_rate = self._fee_rate()
        now = int(time.time() * 1000)
        total_pnl = 0.0
        total_fees = 0.0
        for leg, inst, qty, contracts, entry, prem in legs:
            if not inst or qty <= 0:
                continue
            oq = self._quote_held_option(inst)
            if reason == "expiry":
                # 到期：尽量用买一，否则按 0 权利金结算
                bid = float(oq.bid) if oq and oq.bid is not None else 0.0
                ask = float(oq.ask) if oq and oq.ask is not None else bid
            else:
                if oq is None or oq.bid is None or float(oq.bid) <= 0:
                    if not bypass_liquidity:
                        return CloseResult(
                            ok=False, detail=f"期期全平缺买一: {inst}"
                        )
                    bid = float(entry)
                    ask = bid
                else:
                    if not bypass_liquidity:
                        gate = self._residual_bid_gate(
                            {
                                "option_inst_id": inst,
                                "option_qty_eth": qty,
                                "initial_premium": prem or entry * qty,
                            },
                            bid=float(oq.bid),
                            oq=oq,
                            require_premium_ratio=False,
                        )
                        if gate:
                            return CloseResult(
                                ok=False, detail=f"期期全平流动性: {gate}"
                            )
                    bid = float(oq.bid)
                    ask = float(oq.ask or oq.bid)
            of = option_fill(
                action="close",
                bid=bid,
                ask=ask if ask > 0 else bid,
                qty_eth=qty,
                fee_rate=fee_rate,
            )
            cash = of.notional - of.fee
            if get_settings().is_sim:
                self.ledger.apply_cash(
                    cash,
                    kind="close_option",
                    group_id=group_id,
                    note=f"oo full {leg}",
                )
            else:
                try:
                    self.ledger.apply_cash(
                        cash,
                        kind="close_option",
                        group_id=group_id,
                        note=f"oo full {leg}",
                    )
                except Exception:
                    logger.exception("oo full ledger cash failed leg=%s", leg)
            total_pnl += (of.fill_px * qty - (prem or entry * qty)) - of.fee
            total_fees += of.fee
            with self.db._lock:
                self.db._conn.execute(
                    """INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts,
                       base_px, fill_px, fee, slip, notional, ts_ms, exec_mode)
                       VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
                    (
                        group_id,
                        leg,
                        "close",
                        "sell",
                        inst,
                        qty,
                        contracts,
                        of.base_px,
                        of.fill_px,
                        of.fee,
                        of.slip,
                        of.notional,
                        now,
                        "SIM" if get_settings().is_sim else "LIVE",
                    ),
                )
                self.db._conn.commit()
            now += 1

        with self.db._lock:
            self.db._conn.execute(
                """UPDATE groups SET status=?, close_at_ms=?, close_reason=?, realized_pnl=?,
                   fees=COALESCE(fees,0)+?, note=?
                   WHERE group_id=?""",
                (
                    "closed",
                    int(time.time() * 1000),
                    reason,
                    float(total_pnl),
                    float(total_fees),
                    f"oo full close {reason}",
                    group_id,
                ),
            )
            self.db._conn.execute(
                """UPDATE positions SET
                    group_id=NULL, perp_side=NULL, perp_qty_eth=0, perp_entry_px=NULL,
                    option_inst_id=NULL, option_side=NULL, option_qty_eth=0,
                    option_qty_contracts=0, option_entry_px=NULL, entry_index_px=NULL,
                    initial_premium=0, exit_target_usdt=NULL, status='flat',
                    hedge_mode=NULL, option2_inst_id=NULL, option2_side=NULL,
                    option2_qty_eth=0, option2_qty_contracts=0, option2_entry_px=NULL,
                    strike2=NULL, initial_premium2=NULL
                    WHERE id=1"""
            )
            self.db._conn.commit()
        return CloseResult(
            ok=True,
            detail="oo_full_closed",
            data={"group_id": group_id, "reason": reason, "net": total_pnl},
        )

    def close_group(self, *, reason: str, bypass_liquidity: bool = False) -> CloseResult:
        """
        全平一组。默认校验期权买一深度 + 买一/标记偏差（默认≤30%）。
        reason=expiry：对齐实盘，期权按标的结算价的内在价值入账（不吃盘口）。
        bypass_liquidity=True（紧急）：绕过深度/偏差闸门，成交价仍按买一（对齐 OKX 市价卖，不用标记）。
        成交顺序：先平期权 → 再瞬时平永续；永续盘口失败则回滚期权入账。
        """
        s = get_settings()
        pos = self.current_position()
        if pos.get("status") != "open" or not pos.get("group_id"):
            return CloseResult(ok=False, detail="无持仓可平")

        group_id = str(pos["group_id"])
        sess = get_session()
        snap = sess.snapshot()
        if not snap.perp or snap.perp.bid is None or snap.perp.ask is None:
            return CloseResult(ok=False, detail="永续盘口不可用")

        option_inst_id = str(pos["option_inst_id"])
        option_side = str(pos["option_side"])
        # 严禁回退到 ATM 对：持仓行权价可能已偏离当前 ATM
        oq = self._quote_held_option(option_inst_id)
        if oq is None and reason != "expiry":
            return CloseResult(
                ok=False,
                detail=f"持仓期权盘口不可用: {option_inst_id}",
            )

        ct_mult = self._ct_mult(option_inst_id)
        need_eth = float(pos["option_qty_eth"] or s.option_qty_eth)
        max_dev = self.ledger.get_setting_float(
            "close_bid_mark_max_pct", s.close_bid_mark_max_pct
        )

        strike = self._group_strike(group_id, option_inst_id)
        spot = self._close_spot_px(snap)
        intrinsic: float | None = None
        if strike is not None and spot is not None:
            intrinsic = option_intrinsic(
                option_side=option_side, strike=strike, spot=spot
            )

        fee_rate = self._fee_rate()
        is_expiry = reason == "expiry"

        if is_expiry:
            # 实盘到期：直接按内在价值结算，不依赖盘口
            if intrinsic is None:
                return CloseResult(
                    ok=False,
                    detail="到期结算失败：缺少行权价或标的结算价",
                )
            of = option_expiry_settle(
                intrinsic=float(intrinsic),
                qty_eth=float(pos["option_qty_eth"]),
                fee_rate=fee_rate,
            )
            close_bid = float(intrinsic)
        else:
            if not oq:
                return CloseResult(
                    ok=False,
                    detail="期权盘口不可用",
                    liquidity_wait=not bypass_liquidity,
                )
            close_bid = oq.bid
            if not bypass_liquidity:
                if close_bid is None:
                    return self._liquidity_wait(group_id, "期权买一不可用")
                if not bid_covers_eth(
                    bid_sz_contracts=oq.bid_sz,
                    ct_mult=ct_mult,
                    need_eth=need_eth,
                ):
                    return self._liquidity_wait(group_id, "期权买一流动性不足")
                ok_dev, why = bid_mark_ok(
                    bid=close_bid, mark=oq.mark_px, max_dev_pct=max_dev
                )
                if not ok_dev:
                    return self._liquidity_wait(group_id, why)
                resolved = resolve_option_close_bid(
                    bid=float(close_bid),
                    mark=oq.mark_px,
                    intrinsic=intrinsic,
                    bypass_liquidity=False,
                )
                if resolved is None:
                    return self._liquidity_wait(group_id, "期权平仓价不可用")
                close_bid = resolved
            else:
                resolved = resolve_option_close_bid(
                    bid=close_bid,
                    mark=oq.mark_px,
                    intrinsic=intrinsic,
                    bypass_liquidity=True,
                )
                if resolved is None:
                    return CloseResult(
                        ok=False,
                        detail="紧急全平失败：无买一（对齐 OKX，不能用标记价平仓）",
                    )
                close_bid = resolved
            of = option_fill(
                action="close",
                bid=float(close_bid),
                ask=float(oq.ask or close_bid),
                qty_eth=float(pos["option_qty_eth"]),
                fee_rate=fee_rate,
            )

        perp_side = str(pos["perp_side"])
        perp_qty = float(pos["perp_qty_eth"])
        opt_qty = float(pos["option_qty_eth"])
        perp_entry = float(pos["perp_entry_px"])
        opt_entry = float(pos["option_entry_px"])

        # 1) 先平期权；永续对冲暂留
        opt_pnl = (of.fill_px - opt_entry) * opt_qty
        opt_cash = of.notional - of.fee
        self.ledger.apply_cash(
            opt_cash,
            kind="close_option",
            group_id=group_id,
            note=f"close option {reason}",
        )

        # 2) 期权确认后再瞬时平永续（重取盘口）
        snap2 = sess.snapshot()
        if not snap2.perp or snap2.perp.bid is None or snap2.perp.ask is None:
            self.ledger.apply_cash(
                -opt_cash,
                kind="close_option_rollback",
                group_id=group_id,
                note=f"rollback option close {group_id}: perp book missing",
            )
            return CloseResult(
                ok=False,
                detail="期权已平但永续盘口不可用，已回滚期权入账",
            )

        pf = perp_fill(
            side=perp_side,
            action="close",
            bid=float(snap2.perp.bid),
            ask=float(snap2.perp.ask),
            qty_eth=perp_qty,
            fee_rate=fee_rate,
        )
        if perp_side == "long":
            perp_pnl = (pf.fill_px - perp_entry) * perp_qty
        else:
            perp_pnl = (perp_entry - pf.fill_px) * perp_qty
        self.ledger.apply_cash(
            perp_pnl - pf.fee,
            kind="close_perp",
            group_id=group_id,
            note=f"close perp {reason}",
        )

        net = perp_pnl + opt_pnl - pf.fee - of.fee
        # 组已累计开仓手续费；实现净盈亏扣开+平全部手续费
        open_fees = float(
            (
                self.db.fetchone(
                    "SELECT fees FROM groups WHERE group_id=?", (group_id,)
                )
                or {"fees": 0}
            )["fees"]
            or 0
        )
        net_after_all_fees = perp_pnl + opt_pnl - open_fees - pf.fee - of.fee

        now = int(time.time() * 1000)
        with self.db._lock:
            # 成交顺序：期权先、永续后
            self.db._conn.execute(
                """INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts,
                   base_px, fill_px, fee, slip, notional, ts_ms)
                   VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
                (
                    group_id,
                    "option",
                    "close",
                    "flat",
                    option_inst_id,
                    opt_qty,
                    float(pos["option_qty_contracts"] or 0),
                    of.base_px,
                    of.fill_px,
                    of.fee,
                    of.slip,
                    of.notional,
                    now,
                ),
            )
            self.db._conn.execute(
                """INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts,
                   base_px, fill_px, fee, slip, notional, ts_ms)
                   VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
                (
                    group_id,
                    "perp",
                    "close",
                    "flat",
                    s.perp_inst_id,
                    perp_qty,
                    None,
                    pf.base_px,
                    pf.fill_px,
                    pf.fee,
                    pf.slip,
                    pf.notional,
                    now + 1,
                ),
            )
            g = self.db._conn.execute(
                "SELECT fees, slip_cost FROM groups WHERE group_id=?", (group_id,)
            ).fetchone()
            fees = float(g["fees"] or 0) + pf.fee + of.fee
            slip = float(g["slip_cost"] or 0) + pf.slip + of.slip
            settle_index = float(spot) if spot is not None else None
            self.db._conn.execute(
                """UPDATE groups SET status=?, close_at_ms=?, close_reason=?, realized_pnl=?,
                   fees=?, slip_cost=?, note=NULL, settle_index_px=? WHERE group_id=?""",
                (
                    "closed",
                    now,
                    reason,
                    net_after_all_fees,
                    fees,
                    slip,
                    settle_index,
                    group_id,
                ),
            )
            self.db._conn.execute(
                """UPDATE positions SET
                    group_id=NULL, perp_side=NULL, perp_qty_eth=0, perp_entry_px=NULL,
                    option_inst_id=NULL, option_side=NULL, option_qty_eth=0, option_qty_contracts=0,
                    option_entry_px=NULL, entry_index_px=NULL, initial_premium=0,
                    exit_target_usdt=NULL, status='flat'
                    WHERE id=1"""
            )
            self.db._conn.commit()

        return CloseResult(
            ok=True,
            detail="closed",
            data={
                "group_id": group_id,
                "reason": reason,
                "perp_pnl": perp_pnl,
                "option_pnl": opt_pnl,
                "net": net_after_all_fees,
                "net_pnl": net_after_all_fees,
                "fees_open": open_fees,
                "fees_close": pf.fee + of.fee,
                "fees": float(open_fees) + float(pf.fee) + float(of.fee),
                "close_sequence": ["option", "perp"],
                "cash_delta": opt_cash + perp_pnl - pf.fee,
                "option_close_bid": float(close_bid),
                "option_intrinsic": intrinsic,
                "settle_spot": spot,
            },
        )

    def option_is_deep_otm(self) -> bool:
        """活跃组期权是否远虚（内在价值≈0）。"""
        pos = self.current_position()
        if pos.get("status") != "open" or not pos.get("group_id"):
            return False
        group_id = str(pos["group_id"])
        option_inst_id = str(pos.get("option_inst_id") or "")
        option_side = str(pos.get("option_side") or "")
        strike = self._group_strike(group_id, option_inst_id)
        spot = self._close_spot_px(get_session().snapshot())
        if strike is None or spot is None:
            return False
        return is_deep_otm(
            option_side=option_side, strike=float(strike), spot=float(spot)
        )

    def close_perp_abandon_option(
        self, *, reason: str = "target_perp_only", require_deep_otm: bool = True
    ) -> CloseResult:
        """
        目标平仓 B：只平永续，期权归档为到期残留（不再盯盘、不挡新开）。
        """
        s = get_settings()
        pos = self.current_position()
        if pos.get("status") != "open" or not pos.get("group_id"):
            return CloseResult(ok=False, detail="无持仓可平")

        group_id = str(pos["group_id"])
        sess = get_session()
        snap = sess.snapshot()
        if not snap.perp or snap.perp.bid is None or snap.perp.ask is None:
            return CloseResult(ok=False, detail="永续盘口不可用")

        option_inst_id = str(pos["option_inst_id"])
        option_side = str(pos["option_side"])
        strike = self._group_strike(group_id, option_inst_id)
        spot = self._close_spot_px(snap)
        if strike is None or spot is None:
            return CloseResult(ok=False, detail="无法判断远虚：缺行权价或标的价")
        if require_deep_otm and not is_deep_otm(
            option_side=option_side, strike=float(strike), spot=float(spot)
        ):
            return CloseResult(ok=False, detail="期权非远虚，应走双腿全平")

        fee_rate = self._fee_rate()
        perp_side = str(pos["perp_side"])
        perp_qty = float(pos["perp_qty_eth"])
        perp_entry = float(pos["perp_entry_px"])
        pf = perp_fill(
            side=perp_side,
            action="close",
            bid=float(snap.perp.bid),
            ask=float(snap.perp.ask),
            qty_eth=perp_qty,
            fee_rate=fee_rate,
        )
        if perp_side == "long":
            perp_pnl = (pf.fill_px - perp_entry) * perp_qty
        else:
            perp_pnl = (perp_entry - pf.fill_px) * perp_qty
        self.ledger.apply_cash(
            perp_pnl - pf.fee,
            kind="close_perp",
            group_id=group_id,
            note=f"close perp {reason} abandon option",
        )

        g = self.db.fetchone("SELECT * FROM groups WHERE group_id=?", (group_id,))
        expiry_ymd = str(g["expiry_ymd"]) if g and g["expiry_ymd"] else None
        expiry_ms = None
        if expiry_ymd:
            try:
                from ..exchange.expiry import expiry_ms_from_ymd

                expiry_ms = int(expiry_ms_from_ymd(expiry_ymd))
            except Exception:
                expiry_ms = None

        now = int(time.time() * 1000)
        open_fees = float((g["fees"] if g else 0) or 0)
        fees = open_fees + pf.fee
        slip = float((g["slip_cost"] if g else 0) or 0) + pf.slip
        # 暂记永续段实现盈亏；期权到期结算后再按全部成交重算
        interim_net = perp_pnl - open_fees - pf.fee

        with self.db._lock:
            self.db._conn.execute(
                """INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts,
                   base_px, fill_px, fee, slip, notional, ts_ms)
                   VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
                (
                    group_id,
                    "perp",
                    "close",
                    "flat",
                    s.perp_inst_id,
                    perp_qty,
                    None,
                    pf.base_px,
                    pf.fill_px,
                    pf.fee,
                    pf.slip,
                    pf.notional,
                    now,
                ),
            )
            self.db._conn.execute(
                """INSERT INTO residual_options(
                    group_id, option_inst_id, option_side, option_qty_eth, option_qty_contracts,
                    option_entry_px, strike, expiry_ymd, expiry_ms, entry_index_px,
                    initial_premium, status, created_at_ms, note
                ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
                (
                    group_id,
                    option_inst_id,
                    option_side,
                    float(pos["option_qty_eth"]),
                    float(pos["option_qty_contracts"] or 0),
                    float(pos["option_entry_px"]),
                    float(strike),
                    expiry_ymd,
                    expiry_ms,
                    float(pos["entry_index_px"] or 0),
                    float(pos["initial_premium"] or 0),
                    "pending",
                    now,
                    f"abandoned after {reason}; deep_otm spot={spot:.4f} K={strike}",
                ),
            )
            self.db._conn.execute(
                """UPDATE groups SET status=?, close_at_ms=?, close_reason=?, realized_pnl=?,
                   fees=?, slip_cost=?, note=? WHERE group_id=?""",
                (
                    "option_residual",
                    now,
                    reason,
                    interim_net,
                    fees,
                    slip,
                    f"perp_closed; option residual until expiry",
                    group_id,
                ),
            )
            self.db._conn.execute(
                """UPDATE positions SET
                    group_id=NULL, perp_side=NULL, perp_qty_eth=0, perp_entry_px=NULL,
                    option_inst_id=NULL, option_side=NULL, option_qty_eth=0, option_qty_contracts=0,
                    option_entry_px=NULL, entry_index_px=NULL, initial_premium=0,
                    exit_target_usdt=NULL, status='flat'
                    WHERE id=1"""
            )
            self.db._conn.commit()

        return CloseResult(
            ok=True,
            detail="perp_closed_option_residual",
            data={
                "group_id": group_id,
                "reason": reason,
                "mode": "target_perp_only",
                "perp_pnl": perp_pnl,
                "option_pnl": None,
                "interim_net": interim_net,
                "net": interim_net,
                "net_pnl": interim_net,
                "option_abandoned": True,
                "strike": float(strike),
                "spot": float(spot),
            },
        )

    def list_residual_options(self, *, pending_only: bool = True) -> list[dict[str, Any]]:
        if pending_only:
            rows = self.db.fetchall(
                "SELECT * FROM residual_options WHERE status='pending' ORDER BY created_at_ms ASC"
            )
        else:
            rows = self.db.fetchall(
                "SELECT * FROM residual_options ORDER BY created_at_ms DESC LIMIT 100"
            )
        return [dict(r) for r in rows]

    def _residual_min_premium_pct(self) -> float:
        s = get_settings()
        return float(
            self.ledger.get_setting_float(
                "residual_min_premium_pct", s.residual_min_premium_pct
            )
        )

    def _residual_bid_gate(
        self,
        row: dict[str, Any],
        *,
        bid: float,
        oq: Any,
        require_premium_ratio: bool = True,
    ) -> str | None:
        """深度 + 买一/标记偏差；可选权利金比例。通过返回 None。"""
        s = get_settings()
        initial_premium = float(row.get("initial_premium") or 0)
        opt_qty = float(row.get("option_qty_eth") or 0)
        if opt_qty <= 0:
            return "invalid_qty"
        if bid <= 0:
            return "option_bid_unavailable"
        if require_premium_ratio:
            if initial_premium <= 0:
                return "invalid_initial_premium_or_qty"
            current_premium = float(bid) * opt_qty
            min_pct = self._residual_min_premium_pct()
            threshold = initial_premium * (min_pct / 100.0)
            if current_premium + 1e-12 < threshold:
                return (
                    f"premium_below_threshold curr={current_premium:.4f} "
                    f"need>={threshold:.4f} ({min_pct:g}%)"
                )
        option_inst_id = str(row.get("option_inst_id") or "")
        ct_mult = self._ct_mult(option_inst_id)
        if not bid_covers_eth(
            bid_sz_contracts=getattr(oq, "bid_sz", None),
            ct_mult=ct_mult,
            need_eth=opt_qty,
        ):
            return "option_bid_liquidity_insufficient"
        max_dev = self.ledger.get_setting_float(
            "close_bid_mark_max_pct", s.close_bid_mark_max_pct
        )
        ok_dev, why = bid_mark_ok(
            bid=float(bid),
            mark=getattr(oq, "mark_px", None),
            max_dev_pct=max_dev,
        )
        if not ok_dev:
            return why or "bid_mark_deviation"
        return None

    def _evaluate_residual_premium_close(
        self, row: dict[str, Any], *, require_premium_ratio: bool = True
    ) -> tuple[str | None, float | None, Any]:
        """
        残留平前置：买一流动性；自动路径另加权利金比例。
        返回 (skip_reason, close_bid, option_quote)；skip_reason 非空则本轮不卖。
        """
        option_inst_id = str(row.get("option_inst_id") or "")
        if not option_inst_id:
            return ("missing_option_inst", None, None)
        oq = self._quote_held_option(option_inst_id)
        if oq is None or oq.bid is None:
            return ("option_bid_unavailable", None, None)
        close_bid = float(oq.bid)
        skip = self._residual_bid_gate(
            row,
            bid=close_bid,
            oq=oq,
            require_premium_ratio=require_premium_ratio,
        )
        if skip:
            return (skip, None, None)
        return (None, close_bid, oq)

    def list_residual_options_enriched(self) -> list[dict[str, Any]]:
        """pending 残留 + 买一价/量、买一权利金、回收占比、流动性是否可手动平。"""
        out: list[dict[str, Any]] = []
        for row in self.list_residual_options(pending_only=True):
            d = dict(row)
            option_inst_id = str(d.get("option_inst_id") or "")
            opt_qty = float(d.get("option_qty_eth") or 0)
            init = float(d.get("initial_premium") or 0)
            oq = self._quote_held_option(option_inst_id) if option_inst_id else None
            bid = float(oq.bid) if oq is not None and oq.bid is not None else None
            bid_sz = None
            bid_sz_eth = None
            if oq is not None and getattr(oq, "bid_sz", None) is not None:
                try:
                    bid_sz = float(oq.bid_sz)
                except (TypeError, ValueError):
                    bid_sz = None
                if bid_sz is not None and bid_sz >= 0:
                    ct = self._ct_mult(option_inst_id) if option_inst_id else 0.01
                    bid_sz_eth = eth_from_contracts(bid_sz, ct)
            # 权利金 = 最新买一价 × 持仓数量；占比 = 买一权利金 / 开仓权利金
            cur = float(bid) * opt_qty if bid is not None else None
            ratio = (cur / init * 100.0) if cur is not None and init > 1e-12 else None
            liq_detail: str | None
            if bid is None or oq is None:
                liq_detail = "option_bid_unavailable"
            else:
                liq_detail = self._residual_bid_gate(
                    d, bid=bid, oq=oq, require_premium_ratio=False
                )
            d.update(
                {
                    "bid_px": bid,
                    "bid_sz": bid_sz,
                    "bid_sz_eth": bid_sz_eth,
                    "current_premium": cur,
                    "recovery_pct": ratio,
                    "liquidity_ok": liq_detail is None,
                    "liquidity_detail": liq_detail,
                }
            )
            out.append(d)
        return out

    def close_residual_manual(self, group_id: str) -> CloseResult:
        """中控手动平单条残留：只验流动性，不验权利金比例。"""
        gid = str(group_id or "").strip()
        if not gid:
            return CloseResult(ok=False, detail="缺少 group_id")
        row = self.db.fetchone(
            "SELECT * FROM residual_options WHERE group_id=? AND status='pending'",
            (gid,),
        )
        if row is None:
            return CloseResult(ok=False, detail="无该组 pending 残留")
        d = dict(row)
        skip, _bid, _oq = self._evaluate_residual_premium_close(
            d, require_premium_ratio=False
        )
        if skip:
            return CloseResult(ok=False, detail=skip, liquidity_wait=True)
        booked = self.try_close_one_residual(d, skip_premium_ratio=True)
        if not booked:
            return CloseResult(
                ok=False,
                detail="平残留失败（流动性变化或下单未成交）",
                liquidity_wait=True,
            )
        return CloseResult(ok=True, detail="residual_manual_closed", data=booked)

    def _book_residual_market_close(
        self,
        row: dict[str, Any],
        *,
        fill_px: float,
        fee: float,
        notional: float,
        slip: float,
        now_ms: int,
        note: str,
        exec_mode: str | None = None,
        filled_contracts: float | None = None,
        remaining_contracts: float | None = None,
        close_reason: str = "residual_premium_close",
    ) -> dict[str, Any] | None:
        """买一卖出残留后的入账（与 pending 状态同事务）。支持部分成交扣减数量。"""
        group_id = str(row["group_id"])
        option_inst_id = str(row["option_inst_id"])
        ct_mult = self._ct_mult(option_inst_id)
        local_c = float(row.get("option_qty_contracts") or 0)
        local_eth = float(row.get("option_qty_eth") or 0)
        zero_fill_ok = (
            filled_contracts is not None
            and float(filled_contracts) <= 1e-12
            and remaining_contracts is not None
            and float(remaining_contracts) <= 1e-12
        )
        if filled_contracts is not None and float(filled_contracts) > 0:
            fill_c = float(filled_contracts)
            fill_eth = eth_from_contracts(fill_c, ct_mult)
        elif zero_fill_ok:
            fill_c = 0.0
            fill_eth = 0.0
        else:
            fill_eth = local_eth
            fill_c = local_c if local_c > 0 else contracts_for_eth(fill_eth, ct_mult)
        if fill_eth <= 0 and not zero_fill_ok:
            return None
        fill_notional = (
            float(notional)
            if float(notional) > 0
            else float(fill_px) * fill_eth
        )
        opt_entry = float(row["option_entry_px"])
        opt_pnl = (float(fill_px) - opt_entry) * fill_eth if fill_eth > 0 else 0.0
        opt_cash = fill_notional - float(fee)
        allow_neg = not get_settings().is_sim

        if remaining_contracts is not None:
            rem_c = max(0.0, float(remaining_contracts))
        else:
            rem_c = max(0.0, local_c - fill_c) if local_c > 0 else 0.0
        rem_eth = eth_from_contracts(rem_c, ct_mult) if rem_c > 0 else 0.0
        fully_done = rem_c <= 1e-8

        with self.db._lock:
            pending = self.db._conn.execute(
                "SELECT * FROM residual_options WHERE group_id=? AND status='pending'",
                (group_id,),
            ).fetchone()
            if pending is None:
                logger.warning(
                    "residual book skip %s: not pending (already settled?)", group_id
                )
                return None

            if abs(opt_cash) > 1e-12:
                self.ledger.apply_cash(
                    opt_cash,
                    kind="close_option",
                    group_id=group_id,
                    note=note,
                    allow_negative=allow_neg,
                    commit=False,
                )
                if get_settings().is_sim:
                    try:
                        from .funds_wallets import SimFundsWallets

                        SimFundsWallets(self.db).mirror_cash(
                            float(opt_cash), kind="close_option"
                        )
                    except Exception:
                        pass

            if fill_eth > 1e-12 or float(fee) > 1e-12:
                fill_cols = (
                    "group_id, leg, action, side, inst_id, qty_eth, qty_contracts, "
                    "base_px, fill_px, fee, slip, notional, ts_ms"
                )
                fill_vals: list[Any] = [
                    group_id,
                    "option",
                    "close",
                    "flat",
                    option_inst_id,
                    fill_eth,
                    fill_c,
                    float(fill_px),
                    float(fill_px),
                    float(fee),
                    float(slip),
                    fill_notional,
                    now_ms,
                ]
                if exec_mode:
                    fill_cols += ", exec_mode"
                    fill_vals.append(exec_mode)
                self.db._conn.execute(
                    f"""INSERT INTO fills({fill_cols})
                       VALUES ({",".join("?" for _ in fill_vals)})""",
                    tuple(fill_vals),
                )
            fills = self.db._conn.execute(
                "SELECT * FROM fills WHERE group_id=? ORDER BY id ASC", (group_id,)
            ).fetchall()
            from ..sim.pnl import summarize_fills_pnl

            summary = summarize_fills_pnl(list(fills))
            net = summary.get("net_pnl")
            if net is None:
                net = opt_pnl - float(fee)
            g = self.db._conn.execute(
                "SELECT fees, slip_cost FROM groups WHERE group_id=?", (group_id,)
            ).fetchone()
            fees = float(g["fees"] or 0) + float(fee) if g else float(fee)
            slip_total = float(g["slip_cost"] or 0) + float(slip) if g else float(slip)

            if fully_done:
                cur = self.db._conn.execute(
                    """UPDATE residual_options SET status=?, settled_at_ms=?, settle_px=?, settle_pnl=?, note=?
                       WHERE group_id=? AND status='pending'""",
                    (
                        "settled",
                        now_ms,
                        float(fill_px),
                        opt_pnl,
                        note,
                        group_id,
                    ),
                )
                if cur.rowcount != 1:
                    self.db._conn.rollback()
                    logger.warning("residual settle race %s", group_id)
                    return None
                self.db._conn.execute(
                    """UPDATE groups SET status=?, close_at_ms=COALESCE(close_at_ms, ?),
                       close_reason=?, realized_pnl=?, fees=?, slip_cost=?
                       WHERE group_id=?""",
                    (
                        "closed",
                        now_ms,
                        close_reason,
                        float(net),
                        fees,
                        slip_total,
                        group_id,
                    ),
                )
            else:
                # 按初始权利金比例缩减门槛基准，避免部分成交后永远达不到原 20%
                init_prem = float(pending["initial_premium"] or 0)
                if local_eth > 1e-12 and rem_eth > 0:
                    init_prem = init_prem * (rem_eth / local_eth)
                cur = self.db._conn.execute(
                    """UPDATE residual_options SET
                       option_qty_eth=?, option_qty_contracts=?, initial_premium=?, note=?
                       WHERE group_id=? AND status='pending'""",
                    (
                        rem_eth,
                        rem_c,
                        init_prem,
                        f"{note}; partial rem_c={rem_c}",
                        group_id,
                    ),
                )
                if cur.rowcount != 1:
                    self.db._conn.rollback()
                    return None
                self.db._conn.execute(
                    """UPDATE groups SET realized_pnl=?, fees=?, slip_cost=? WHERE group_id=?""",
                    (float(net), fees, slip_total, group_id),
                )
            self.db._conn.commit()

        return {
            "group_id": group_id,
            "option_pnl": opt_pnl,
            "settle_px": float(fill_px),
            "net_pnl": float(net),
            "reason": close_reason,
            "current_premium": float(fill_px) * fill_eth,
            "initial_premium": float(row.get("initial_premium") or 0),
            "filled_contracts": fill_c,
            "remaining_contracts": rem_c,
            "fully_done": fully_done,
        }

    def try_close_one_residual(
        self, row: dict[str, Any], *, skip_premium_ratio: bool = False
    ) -> dict[str, Any] | None:
        """SIM：流动性通过则吃买一平残留；自动路径另要求权利金比例。"""
        require_ratio = not skip_premium_ratio
        skip, close_bid, oq = self._evaluate_residual_premium_close(
            row, require_premium_ratio=require_ratio
        )
        if skip or close_bid is None or oq is None:
            if skip:
                logger.debug(
                    "residual close skip %s: %s",
                    row.get("group_id"),
                    skip,
                )
            return None
        option_inst_id = str(row.get("option_inst_id") or "")
        oq2 = self._quote_held_option(option_inst_id) or oq
        bid2 = float(oq2.bid) if oq2.bid is not None else float(close_bid)
        skip2 = self._residual_bid_gate(
            row, bid=bid2, oq=oq2, require_premium_ratio=require_ratio
        )
        if skip2:
            logger.debug(
                "residual close recheck skip %s: %s",
                row.get("group_id"),
                skip2,
            )
            return None
        of = option_fill(
            action="close",
            bid=float(bid2),
            ask=float(oq2.ask or bid2),
            qty_eth=float(row["option_qty_eth"]),
            fee_rate=self._fee_rate(),
        )
        now_ms = int(time.time() * 1000)
        note = (
            f"residual manual close at bid px={bid2}"
            if skip_premium_ratio
            else f"residual mid-close at bid px={bid2}"
        )
        return self._book_residual_market_close(
            row,
            fill_px=of.fill_px,
            fee=of.fee,
            notional=of.notional,
            slip=of.slip,
            now_ms=now_ms,
            note=note,
            filled_contracts=float(row.get("option_qty_contracts") or 0) or None,
            remaining_contracts=0.0,
            close_reason=(
                "residual_manual_close"
                if skip_premium_ratio
                else "residual_premium_close"
            ),
        )

    def try_close_pending_residuals(self) -> list[dict[str, Any]]:
        """巡检全部 pending 残留，尝试权利金回收平仓。"""
        out: list[dict[str, Any]] = []
        for row in self.list_residual_options(pending_only=True):
            try:
                r = self.try_close_one_residual(row)
            except Exception:
                logger.exception(
                    "try_close_one_residual failed group=%s", row.get("group_id")
                )
                continue
            if r:
                out.append(r)
        return out

    def settle_due_residuals(self, *, now_ms: int | None = None) -> list[dict[str, Any]]:
        """到期结算所有 pending 残留期权（不扫描进活跃组平仓）。"""
        now = int(now_ms if now_ms is not None else time.time() * 1000)
        pending = self.db.fetchall(
            "SELECT * FROM residual_options WHERE status='pending' ORDER BY id ASC"
        )
        out: list[dict[str, Any]] = []
        for row in pending:
            ems = row["expiry_ms"]
            if ems is None:
                ymd = row["expiry_ymd"]
                if ymd:
                    try:
                        from ..exchange.expiry import expiry_ms_from_ymd

                        ems = int(expiry_ms_from_ymd(str(ymd)))
                    except Exception:
                        continue
                else:
                    continue
            if now < int(ems):
                continue
            r = self._settle_one_residual(dict(row), now_ms=now)
            if r:
                out.append(r)
        return out

    def settle_all_residuals_now(self) -> list[dict[str, Any]]:
        """紧急：立即按内在价值结算全部残留（不等到期）。"""
        pending = self.db.fetchall(
            "SELECT * FROM residual_options WHERE status='pending' ORDER BY id ASC"
        )
        now = int(time.time() * 1000)
        out: list[dict[str, Any]] = []
        for row in pending:
            r = self._settle_one_residual(dict(row), now_ms=now, force=True)
            if r:
                out.append(r)
        return out

    def _try_exchange_flatten_residual(
        self, row: dict[str, Any], *, force: bool = False
    ) -> dict[str, Any] | None:
        """LIVE 覆盖：尽量在交易所卖掉残留。成功返回 fill 字段字典。"""
        return None

    def _settle_one_residual(
        self, row: dict[str, Any], *, now_ms: int, force: bool = False
    ) -> dict[str, Any] | None:
        group_id = str(row["group_id"])
        # LIVE：优先交易所卖出再入账
        ex_fill = self._try_exchange_flatten_residual(row, force=force)
        if ex_fill is not None:
            booked = self._book_residual_market_close(
                row,
                fill_px=float(ex_fill["fill_px"]),
                fee=float(ex_fill.get("fee") or 0),
                notional=float(ex_fill["notional"]),
                slip=float(ex_fill.get("slip") or 0),
                now_ms=now_ms,
                note=str(ex_fill.get("note") or "residual exchange settle"),
                exec_mode=ex_fill.get("exec_mode"),
                filled_contracts=ex_fill.get("filled_contracts"),
                remaining_contracts=float(ex_fill.get("remaining_contracts") or 0),
                close_reason=str(
                    ex_fill.get("close_reason")
                    or ("emergency" if force else "expiry")
                ),
            )
            if booked is not None:
                booked["forced"] = force
            return booked

        sess = get_session()
        snap = sess.snapshot()
        spot = self._close_spot_px(snap)
        strike = row["strike"]
        if strike is None or spot is None:
            logger.warning("residual settle skip %s: no strike/spot", group_id)
            return None
        fee_rate = self._fee_rate()
        intrinsic = option_intrinsic(
            option_side=str(row["option_side"]),
            strike=float(strike),
            spot=float(spot),
        )
        of = option_expiry_settle(
            intrinsic=float(intrinsic),
            qty_eth=float(row["option_qty_eth"]),
            fee_rate=fee_rate,
        )
        return self._book_residual_market_close(
            row,
            fill_px=of.fill_px,
            fee=of.fee,
            notional=of.notional,
            slip=of.slip,
            now_ms=now_ms,
            note=f"residual option expiry settle{' force' if force else ''}",
            filled_contracts=float(row.get("option_qty_contracts") or 0) or None,
            remaining_contracts=0.0,
            close_reason="expiry" if not force else "emergency",
        )

    def _quote_held_option(self, option_inst_id: str):
        """只取持仓合约盘口；缺失时 REST 补一次，绝不借用 ATM 对。"""
        if not option_inst_id:
            return None
        ex = get_exchange()
        oq = ex.quote(option_inst_id)
        if oq is not None and (oq.bid is not None or oq.ask is not None or oq.mark_px is not None):
            return oq
        try:
            bids, asks, ts = ex.fetch_book(option_inst_id, depth=5)
            cache = getattr(ex, "cache", None)
            if cache is not None and (bids or asks):
                cache.upsert_book(option_inst_id, bids=bids, asks=asks, ts_ms=ts)
                try:
                    mp = ex.fetch_mark(option_inst_id)
                    if mp:
                        cache.set_mark_px(option_inst_id, mp)
                except Exception:
                    pass
            return ex.quote(option_inst_id)
        except Exception:
            return ex.quote(option_inst_id)

    def unrealized(self) -> dict[str, Any]:
        pos = self.current_position()
        if pos.get("status") != "open":
            return {
                "has_position": False,
                "perp_upl": 0.0,
                "option_upl": 0.0,
                "option2_upl": 0.0,
                "net_pnl": 0.0,
                "est_close_fees": 0.0,
                "index_px": None,
                "move_points": 0.0,
                "move_pct": 0.0,
                "premium_gap": None,
            }
        if str(pos.get("hedge_mode") or "") == "option_option" or pos.get(
            "option2_inst_id"
        ):
            return self._unrealized_oo(pos)
        return self._unrealized_perp(pos)

    def _unrealized_oo(self, pos: dict[str, Any]) -> dict[str, Any]:
        sess = get_session()
        snap = sess.snapshot()
        fee_rate = self._fee_rate()
        index_px = snap.index_px
        if index_px is None and snap.perp:
            index_px = snap.perp.mark_px
        qty = float(pos.get("option_qty_eth") or 0)
        raw_q2 = pos.get("option2_qty_eth")
        try:
            qty2 = float(raw_q2) if raw_q2 is not None else 0.0
        except (TypeError, ValueError):
            qty2 = 0.0
        if qty2 <= 0:
            qty2 = qty
        contracts1 = float(pos.get("option_qty_contracts") or 0)
        raw_c2 = pos.get("option2_qty_contracts")
        try:
            contracts2 = float(raw_c2) if raw_c2 is not None else 0.0
        except (TypeError, ValueError):
            contracts2 = 0.0
        prem1 = float(pos.get("initial_premium") or 0)
        prem2 = float(pos.get("initial_premium2") or 0)
        call_id = str(pos.get("option_inst_id") or "")
        put_id = str(pos.get("option2_inst_id") or "")
        entry1 = float(pos.get("option_entry_px") or 0)
        entry2 = float(pos.get("option2_entry_px") or 0)
        oq1 = self._quote_held_option(call_id) if call_id else None
        oq2 = self._quote_held_option(put_id) if put_id else None
        # 盘口缓存未订阅到 Put 时，回退 session 监控腿（与「持仓虚值」同数据源）
        if (oq2 is None or (oq2.bid is None and oq2.mark_px is None)) and put_id:
            try:
                if snap.put and str(getattr(snap.put, "inst_id", "") or "") == put_id:
                    oq2 = snap.put
            except Exception:
                pass
        if (oq1 is None or (oq1.bid is None and oq1.mark_px is None)) and call_id:
            try:
                if snap.call and str(getattr(snap.call, "inst_id", "") or "") == call_id:
                    oq1 = snap.call
            except Exception:
                pass
        option_upl = 0.0
        option2_upl = 0.0
        fees = 0.0
        opt_mark = None
        opt_bid_sz = None
        opt2_mark = None
        opt2_bid_sz = None
        if oq1 and oq1.bid is not None and qty > 0:
            bid = float(oq1.bid)
            of = option_fill(
                action="close",
                bid=bid,
                ask=float(oq1.ask or bid),
                qty_eth=qty,
                fee_rate=fee_rate,
            )
            fees += of.fee
            option_upl = bid * qty - prem1
            opt_mark = bid
            opt_bid_sz = float(oq1.bid_sz) if oq1.bid_sz is not None else None
        elif oq1:
            opt_mark = oq1.bid or oq1.mark_px
            opt_bid_sz = float(oq1.bid_sz) if oq1.bid_sz is not None else None
            if opt_mark is not None and qty > 0:
                option_upl = float(opt_mark) * qty - prem1
        if oq2 and oq2.bid is not None and qty2 > 0:
            bid = float(oq2.bid)
            of = option_fill(
                action="close",
                bid=bid,
                ask=float(oq2.ask or bid),
                qty_eth=qty2,
                fee_rate=fee_rate,
            )
            fees += of.fee
            option2_upl = bid * qty2 - prem2
            opt2_mark = bid
            opt2_bid_sz = float(oq2.bid_sz) if oq2.bid_sz is not None else None
        elif oq2:
            opt2_mark = oq2.bid or oq2.mark_px
            opt2_bid_sz = float(oq2.bid_sz) if oq2.bid_sz is not None else None
            if opt2_mark is not None and qty2 > 0:
                option2_upl = float(opt2_mark) * qty2 - prem2
        g = None
        gid = pos.get("group_id")
        if gid:
            g = self.db.fetchone("SELECT * FROM groups WHERE group_id=?", (gid,))
        paid = float(g["fees"] or 0) if g else 0.0
        # SIM：浮盈 − 估平仓费；有入场费时离场按入场估算（对齐永期口径）
        if paid > 1e-12:
            est_close = paid
            net = option_upl + option2_upl - paid * 2.0
        else:
            est_close = fees
            net = option_upl + option2_upl - fees
        entry_idx = float(pos.get("entry_index_px") or 0)
        move = abs(float(index_px) - entry_idx) if index_px is not None and entry_idx else 0.0
        move_pct = (move / entry_idx * 100.0) if entry_idx > 0 else 0.0
        from ..strategy.selection import option_leverage as _opt_lev

        opt_lev = (
            _opt_lev(entry_idx, entry1) if entry_idx > 0 and entry1 > 0 else None
        )
        opt2_lev = (
            _opt_lev(entry_idx, entry2) if entry_idx > 0 and entry2 > 0 else None
        )
        if opt_lev is not None:
            opt_lev = round(float(opt_lev), 1)
        if opt2_lev is not None:
            opt2_lev = round(float(opt2_lev), 1)
        expiry_ymd = str(g["expiry_ymd"]) if g and g["expiry_ymd"] else None
        expiry_ms = None
        if expiry_ymd and len(expiry_ymd) == 6:
            try:
                from ..exchange.expiry import expiry_ms_from_ymd

                expiry_ms = expiry_ms_from_ymd(expiry_ymd)
            except Exception:
                expiry_ms = None
        strike1 = float(g["strike"]) if g and g["strike"] is not None else None
        strike2 = float(pos.get("strike2") or 0) or None
        return {
            "has_position": True,
            "hedge_mode": "option_option",
            "group_id": gid,
            "status": "open",
            "perp_upl": 0.0,
            "option_upl": option_upl,
            "option2_upl": option2_upl,
            "net_pnl": net,
            "fees_paid": paid,
            "est_close_fees": est_close,
            "index_px": float(index_px) if index_px is not None else None,
            "entry_index_px": entry_idx,
            "move_points": move,
            "move_pct": move_pct,
            "initial_premium": prem1,
            "initial_premium2": prem2,
            "option_inst_id": call_id,
            "option2_inst_id": put_id,
            "option_side": "call",
            "option2_side": "put",
            "option_qty_eth": qty,
            "option2_qty_eth": qty2,
            "option_qty_contracts": contracts1,
            "option2_qty_contracts": contracts2,
            "option_entry_px": entry1,
            "option2_entry_px": entry2,
            "option_mark_px": float(opt_mark) if opt_mark is not None else None,
            "option_bid_sz": float(opt_bid_sz) if opt_bid_sz is not None else None,
            "option2_mark_px": float(opt2_mark) if opt2_mark is not None else None,
            "option2_bid_sz": float(opt2_bid_sz) if opt2_bid_sz is not None else None,
            "option_leverage": float(opt_lev) if opt_lev is not None else None,
            "option2_leverage": float(opt2_lev) if opt2_lev is not None else None,
            "strike": strike1,
            "strike2": strike2,
            "expiry_ymd": expiry_ymd,
            "expiry_ms": expiry_ms,
            "open_at_ms": int(g["open_at_ms"]) if g and g["open_at_ms"] else None,
            "perp_side": None,
            "perp_qty_eth": 0.0,
            "premium_gap": None,
        }

    def _unrealized_perp(self, pos: dict[str, Any]) -> dict[str, Any]:
        sess = get_session()
        snap = sess.snapshot()
        s = get_settings()
        fee_rate = self._fee_rate()
        index_px = snap.index_px
        if index_px is None and snap.perp:
            index_px = snap.perp.mark_px
        perp_side = str(pos["perp_side"])
        perp_entry = float(pos["perp_entry_px"])
        perp_qty = float(pos["perp_qty_eth"])
        opt_qty = float(pos["option_qty_eth"] or 0)
        opt_entry = float(pos["option_entry_px"] or 0)

        # 与平仓一致：用对手价估算可平盈亏 + 手续费
        perp_upl = 0.0
        est_perp_close_fee = 0.0
        mark = None
        if snap.perp and snap.perp.bid is not None and snap.perp.ask is not None:
            pf = perp_fill(
                side=perp_side,
                action="close",
                bid=float(snap.perp.bid),
                ask=float(snap.perp.ask),
                qty_eth=perp_qty,
                fee_rate=fee_rate,
            )
            if perp_side == "long":
                perp_upl = (pf.fill_px - perp_entry) * perp_qty
            else:
                perp_upl = (perp_entry - pf.fill_px) * perp_qty
            est_perp_close_fee = pf.fee
            mark = pf.fill_px
        elif snap.perp:
            if perp_side == "long":
                mark = snap.perp.bid or snap.perp.mark_px
            else:
                mark = snap.perp.ask or snap.perp.mark_px
            if mark is not None:
                if perp_side == "long":
                    perp_upl = (float(mark) - perp_entry) * perp_qty
                else:
                    perp_upl = (perp_entry - float(mark)) * perp_qty

        option_side = str(pos["option_side"])
        opt_inst = str(pos.get("option_inst_id") or "")
        oq = self._quote_held_option(opt_inst)
        initial_premium = float(pos["initial_premium"] or 0)
        option_upl = 0.0
        est_opt_close_fee = 0.0
        opt_mark = None
        opt_bid_sz = None
        if oq and oq.bid is not None:
            bid = float(oq.bid)
            of = option_fill(
                action="close",
                bid=bid,
                ask=float(oq.ask or bid),
                qty_eth=opt_qty,
                fee_rate=fee_rate,
            )
            est_opt_close_fee = of.fee
            opt_mark = bid
            opt_bid_sz = float(oq.bid_sz) if oq.bid_sz is not None else None
            # 浮盈亏：买一×数量 − 初始权利金（对齐可市价卖出）
            option_upl = bid * opt_qty - initial_premium
        elif oq:
            opt_mark = oq.bid or oq.mark_px
            opt_bid_sz = float(oq.bid_sz) if oq.bid_sz is not None else None
            if opt_mark is not None:
                option_upl = float(opt_mark) * opt_qty - initial_premium

        book_close_fees = est_perp_close_fee + est_opt_close_fee

        entry_idx = float(pos["entry_index_px"] or 0)
        move = abs(float(index_px) - entry_idx) if index_px is not None and entry_idx else 0.0
        move_pct = (move / entry_idx * 100.0) if entry_idx > 0 else 0.0
        premium_gap = initial_premium - perp_upl
        leverage = self.ledger.get_setting_float("leverage", s.leverage)
        notional = abs(perp_entry * perp_qty)
        margin = notional / leverage if leverage > 0 else None
        from ..strategy.selection import option_leverage as _opt_lev

        opt_lev = _opt_lev(entry_idx, opt_entry) if entry_idx > 0 and opt_entry > 0 else None
        if opt_lev is not None:
            opt_lev = round(float(opt_lev), 1)

        group_id = pos.get("group_id")
        g = (
            self.db.fetchone("SELECT * FROM groups WHERE group_id=?", (group_id,))
            if group_id
            else None
        )
        strike = float(g["strike"]) if g and g["strike"] is not None else None
        expiry_ymd = str(g["expiry_ymd"]) if g and g["expiry_ymd"] else None
        open_at_ms = int(g["open_at_ms"]) if g and g["open_at_ms"] else None
        open_fees = abs(float(g["fees"] or 0)) if g else 0.0
        # 净盈利：浮盈 − 入场手续费×2（离场费按入场估算）；无入场费时退回盘口估平仓费
        if open_fees > 1e-12:
            est_close_fees = open_fees
            net_pnl = perp_upl + option_upl - open_fees * 2.0
        else:
            est_close_fees = book_close_fees
            net_pnl = perp_upl + option_upl - est_close_fees
        expiry_ms = None
        if expiry_ymd and len(expiry_ymd) == 6:
            try:
                from ..exchange.expiry import expiry_ms_from_ymd

                expiry_ms = expiry_ms_from_ymd(expiry_ymd)
            except Exception:
                expiry_ms = None
        perp_inst_id = (
            str(g["perp_inst_id"])
            if g and g["perp_inst_id"]
            else s.perp_inst_id
        )

        return {
            "has_position": True,
            "group_id": group_id,
            "open_at_ms": open_at_ms,
            "perp_side": perp_side,
            "option_side": option_side,
            "perp_inst_id": perp_inst_id,
            "perp_entry_px": perp_entry,
            "perp_qty_eth": perp_qty,
            "perp_mark_px": float(mark) if mark is not None else None,
            "perp_notional": notional,
            "perp_margin": margin,
            "leverage": leverage,
            "option_inst_id": pos.get("option_inst_id"),
            "option_entry_px": opt_entry,
            "option_qty_eth": opt_qty,
            "option_qty_contracts": float(pos["option_qty_contracts"] or 0),
            "option_mark_px": float(opt_mark) if opt_mark is not None else None,
            "option_bid_sz": float(opt_bid_sz) if opt_bid_sz is not None else None,
            "option_leverage": float(opt_lev) if opt_lev is not None else None,
            "strike": strike,
            "expiry_ymd": expiry_ymd,
            "expiry_ms": expiry_ms,
            "perp_upl": perp_upl,
            "option_upl": option_upl,
            "fees_paid": open_fees,
            "est_close_fees": est_close_fees,
            "net_pnl": net_pnl,
            "index_px": index_px,
            "entry_index_px": entry_idx,
            "move_points": move,
            "move_pct": move_pct,
            "initial_premium": initial_premium,
            "exit_target_usdt": (
                float(pos["exit_target_usdt"])
                if pos.get("exit_target_usdt") is not None
                else None
            ),
            "premium_gap": premium_gap,
            "status": pos.get("status"),
        }
