"""期权平仓执行:只锁买一限价卖出;永不市价.""" from __future__ import annotations import time from typing import Any from lib.options.options_close_gate_lib import ( clear_close_gate, is_close_gate_passed, mark_close_gate_passed, update_close_gate, ) from lib.options.options_pricing_lib import ( PREMIUM_DECIMALS_COIN, estimate_close_by_bids, fetch_option_mark_px, is_stub_bid_px, round_premium_amount, total_premium, ) def _safe_float(v: Any) -> float | None: if v is None or v == "": return None try: return float(v) except (TypeError, ValueError): return None def _open_premium_paid(cfg: dict[str, Any], inst_id: str) -> float | None: try: conn = cfg["get_db"]() try: from lib.options.options_db import init_options_tables, sum_open_premium_paid init_options_tables(conn) return sum_open_premium_paid(conn, inst_id) finally: conn.close() except Exception: pass return None def _pos_close_refs(ex: Any, pos: dict[str, Any], quote: dict[str, Any] | None = None) -> tuple[float | None, float | None]: from lib.exchange.okx_options_lib import option_fields_from_inst_id from lib.options.options_pricing_lib import close_ref_prices inst_id = str(pos.get("instId") or pos.get("inst_id") or "") mark = _safe_float(pos.get("markPx")) or _safe_float((quote or {}).get("mark_px") or (quote or {}).get("mark")) if mark is None: mark = fetch_option_mark_px(ex, inst_id) opt_type = pos.get("optType") or (quote or {}).get("opt_type") strike = _safe_float(pos.get("stk")) or _safe_float((quote or {}).get("strike")) if not opt_type or strike is None: pt, ps = option_fields_from_inst_id(inst_id) opt_type = opt_type or pt if strike is None: strike = ps idx = _safe_float(pos.get("idxPx")) or _safe_float((quote or {}).get("index_px")) return close_ref_prices( mark_px=mark, opt_type=str(opt_type or ""), strike=strike, index_px=idx, inst_id=inst_id, ) def _avail_sheets(pos: dict[str, Any]) -> int: avail = _safe_float(pos.get("availPos")) if avail is None or avail <= 0: avail = abs(_safe_float(pos.get("pos")) or 0) return max(0, int(avail or 0)) def _cancel_sell_pending(ex: Any, inst_id: str) -> None: try: pending = ex.private_get_trade_orders_pending({"instType": "OPTION", "instId": inst_id}) or {} for o in pending.get("data") or []: if str(o.get("side") or "").lower() != "sell": continue oid = o.get("ordId") if not oid: continue try: ex.private_post_trade_cancel_order({"instId": inst_id, "ordId": oid}) except Exception: pass except Exception: pass def close_option_by_bid1( cfg: dict[str, Any], ex: Any, inst_id: str, *, sheets: int | None = None, require_recycle_gate: bool = False, signal_note: str | None = None, ) -> dict[str, Any]: """ 本轮只吃买一深度: - 本批张数 = min(请求张数, 持仓, 买一深度) - 限价 = 校验通过时锁定的买一价 - 永不市价 - 始终校验有效流动性(残档买一禁止) - require_recycle_gate=True 时:首次还需可回收≥2×权利金并持续 hold 秒; 一旦通过后对同仓续批只验流动性 """ from lib.exchange.okx_options_lib import ( _pos_side_from_position, invalidate_option_positions_cache, ) inst_id = (inst_id or "").strip() if not inst_id: return {"ok": False, "msg": "缺少 inst_id"} q = cfg["quote_option_contract"](ex, inst_id) if not q.get("ok"): return {"ok": False, "msg": q.get("msg") or "报价失败"} tick_sz = q.get("tick_sz") ct_mult = float(q.get("ct_mult") or 0.01) raw_positions = cfg["fetch_option_positions"](ex) if raw_positions is None: return {"ok": False, "msg": "获取期权持仓失败"} pos = next((p for p in raw_positions if str(p.get("instId")) == inst_id), None) if not pos: clear_close_gate(inst_id) return {"ok": False, "msg": "未找到持仓", "already_flat": True} avail = _avail_sheets(pos) want = int(sheets) if sheets else avail want = min(want, avail) if want < 1: clear_close_gate(inst_id) return {"ok": False, "msg": "可平张数不足", "already_flat": True} td_mode = str(pos.get("mgnMode") or cfg.get("td_mode") or "isolated") pos_side = _pos_side_from_position(pos) or "net" mark_px, intrinsic_px = _pos_close_refs(ex, pos, q) premium_paid = _open_premium_paid(cfg, inst_id) if premium_paid is None: premium_paid = _safe_float(pos.get("premium_paid")) from lib.options.options_notify_lib import resolve_premium_ccy premium_ccy = resolve_premium_ccy(pos.get("premium_ccy"), inst_id=inst_id) index_px = _safe_float(pos.get("idxPx")) or _safe_float(q.get("index_px")) # 已有未成交卖平单:等成交,不撤不重挂 try: pending = ex.private_get_trade_orders_pending({"instType": "OPTION", "instId": inst_id}) or {} sell_pending = [ o for o in (pending.get("data") or []) if str(o.get("side") or "").lower() == "sell" and o.get("ordId") ] if sell_pending: time.sleep(0.5) invalidate_option_positions_cache() raw_positions = cfg["fetch_option_positions"](ex) if raw_positions is None: return {"ok": False, "msg": "获取期权持仓失败"} pos = next((p for p in raw_positions if str(p.get("instId")) == inst_id), None) if not pos or _avail_sheets(pos) < 1: clear_close_gate(inst_id) return { "ok": True, "already_flat": True, "msg": "已有限价卖单成交", "close_ord_id": ",".join(str(o.get("ordId")) for o in sell_pending), "fully_closed": True, "submitted_sheets": want, "remaining_sheets": 0, "mode": "bid1", } return { "ok": False, "msg": "等待已有买一限价卖单成交", "stopped_reason": "pending_close_order", "close_ord_id": ",".join(str(o.get("ordId")) for o in sell_pending), } except Exception: pass book = cfg["fetch_option_book_depth"](ex, inst_id, 1) preview = estimate_close_by_bids( book.get("bids") or [], want, ct_mult=ct_mult, premium_paid=premium_paid, mark_px=mark_px, intrinsic_px=intrinsic_px, max_levels=1, ) if preview.get("bid_invalid") or preview.get("auto_close_blocked"): # 不撤他人挂单:仅拒绝本轮下单 update_close_gate( inst_id, recycle_usdc=None, premium_paid=premium_paid, premium_ccy=premium_ccy, index_px=index_px, ) return { "ok": False, "msg": preview.get("bid_invalid_reason") or "暂无有效买盘,禁止平仓", "stopped_reason": "stub_bid", "auto_close_blocked": True, "liquidity_blocked": True, } levels = preview.get("levels") or [] if not levels: bid_px = _safe_float(q.get("bid")) stub, stub_reason = is_stub_bid_px(bid_px, mark_px=mark_px, intrinsic_px=intrinsic_px) if stub or bid_px is None or bid_px <= 0: update_close_gate( inst_id, recycle_usdc=None, premium_paid=premium_paid, premium_ccy=premium_ccy, index_px=index_px, ) return { "ok": False, "msg": stub_reason or "暂无买一,无法限价平仓", "stopped_reason": "stub_bid" if stub else "no_bid", "auto_close_blocked": True, "liquidity_blocked": True, } return { "ok": False, "msg": "暂无买一深度,无法平仓", "stopped_reason": "no_bid_depth", "liquidity_blocked": True, } level = levels[0] level_sheets = int(level.get("sheets") or 0) level_px = float(level.get("px") or 0) if level_sheets <= 0 or level_px <= 0: return {"ok": False, "msg": "买一深度无效", "stopped_reason": "invalid_bid_depth"} stub_lv, stub_lv_reason = is_stub_bid_px(level_px, mark_px=mark_px, intrinsic_px=intrinsic_px) if stub_lv: update_close_gate( inst_id, recycle_usdc=None, premium_paid=premium_paid, premium_ccy=premium_ccy, index_px=index_px, ) return { "ok": False, "msg": stub_lv_reason or "暂无有效买盘,禁止平仓", "stopped_reason": "stub_bid", "auto_close_blocked": True, "liquidity_blocked": True, } # 自动平仓:目标门控(首次);通过后同仓续批只验流动性 gate = update_close_gate( inst_id, recycle_usdc=_safe_float(preview.get("total_received")), premium_paid=premium_paid, premium_ccy=premium_ccy, index_px=index_px, ) if require_recycle_gate and not is_close_gate_passed(inst_id) and not gate.get("ready"): return { "ok": False, "msg": gate.get("msg") or "平仓门控未就绪(需满足目标门控并持续一段时间)", "stopped_reason": "close_gate", "auto_close_blocked": True, "close_gate": gate, } locked_bid_px = level_px before_avail = avail order = cfg["place_option_limit_order"]( ex, inst_id=inst_id, side="sell", sheets=level_sheets, price=locked_bid_px, td_mode=td_mode, tick_sz=tick_sz, reduce_only=True, pos_side=pos_side, ) if not order.get("ok"): return { "ok": False, "msg": order.get("msg") or "买一限价平仓失败", "stopped_reason": "order_failed", "locked_bid_px": locked_bid_px, "batch_sheets": level_sheets, } # 仅下单被接受后才记门控已通过,避免下单失败却跳过后续 2× 等待 if require_recycle_gate and gate.get("ready"): mark_close_gate_passed(inst_id) px = float(order.get("px", locked_bid_px)) oid = str((order.get("data") or {}).get("ordId") or "") # 本批预估收回(按挂单张数);成交后按实际减仓张数重算并入累计 prem_recv_submit = round_premium_amount( total_premium(px, level_sheets * ct_mult), decimals=PREMIUM_DECIMALS_COIN ) time.sleep(0.6) invalidate_option_positions_cache() raw2 = cfg["fetch_option_positions"](ex) if raw2 is None: return { "ok": False, "msg": "下单后获取持仓失败,未确认是否成交", "stopped_reason": "position_fetch_failed", "locked_bid_px": locked_bid_px, "batch_sheets": level_sheets, "close_ord_id": oid or None, "fully_closed": False, } after_pos = next((p for p in raw2 if str(p.get("instId")) == inst_id), None) after_avail = _avail_sheets(after_pos) if after_pos else 0 reduced = max(0, before_avail - after_avail) remaining_pos = after_avail fully_closed = remaining_pos < 1 filled_sheets = min(reduced, level_sheets) if reduced > 0 else 0 # 全平但减仓读数异常时回退挂单张数,避免漏记本批 if fully_closed and filled_sheets < 1: filled_sheets = level_sheets batch_recv = ( round_premium_amount(total_premium(px, filled_sheets * ct_mult), decimals=PREMIUM_DECIMALS_COIN) if filled_sheets > 0 else 0.0 ) total_recv = batch_recv close_px = px merged_ord = oid or None if fully_closed: clear_close_gate(inst_id) conn = cfg["get_db"]() try: from lib.options.options_db import ( add_open_close_accum, get_open_close_accum, init_options_tables, ) init_options_tables(conn) # 先并入本批,再读累计(含此前分批) if filled_sheets > 0: add_open_close_accum( conn, inst_id, recv=batch_recv, sheets=filled_sheets, px=px, ord_id=oid or None, ) accum = get_open_close_accum(conn, inst_id) total_recv = round_premium_amount( float(accum.get("recv") or 0), decimals=PREMIUM_DECIMALS_COIN ) or 0.0 accum_sheets = int(accum.get("sheets") or 0) accum_notional = float(accum.get("notional") or 0) if accum_sheets > 0 and accum_notional > 0: close_px = round(accum_notional / accum_sheets, 8) merged_ord = str(accum.get("close_ord_id") or "").strip() or (oid or None) open_rows = conn.execute( """ SELECT id, premium_paid, open_fee, premium_ccy, underlying, exchange_ord_id FROM options_trades WHERE inst_id = ? AND status = 'open' ORDER BY id ASC """, (inst_id,), ).fetchall() from lib.options.options_pnl_settle_lib import ( extract_order_fee, fee_to_premium_ccy, resolve_close_index_px, settle_closed_pnl, sum_option_order_fees_in_premium, ) from lib.options.options_time_lib import options_app_now_str row_ccy = premium_ccy if open_rows: row_ccy = str(open_rows[0]["premium_ccy"] or premium_ccy or "").strip() or premium_ccy underly = str((open_rows[0]["underlying"] if open_rows else "") or "").strip() close_idx = resolve_close_index_px( ex, inst_id=inst_id, underlying=underly, hint=index_px ) # 平仓手续费:优先累计订单号查单;查不到再用本笔下单 info close_fee_total = 0.0 try: close_fee_total = sum_option_order_fees_in_premium( ex, inst_id=inst_id, ord_ids=merged_ord, premium_ccy=row_ccy, index_px=close_idx, fetch_order_fn=cfg.get("fetch_option_order"), ) except Exception: close_fee_total = 0.0 if close_fee_total <= 0: fee_hint, fee_hint_ccy = extract_order_fee( order if isinstance(order, dict) else None ) if fee_hint > 0: close_fee_total = fee_to_premium_ccy( fee_hint, fee_hint_ccy or row_ccy, premium_ccy=row_ccy, index_px=close_idx, ) total_paid = sum(float(r["premium_paid"] or 0) for r in open_rows) total_open_fee = sum(float(r["open_fee"] or 0) for r in open_rows) # 开仓费未落库时按开仓单号补查 if total_open_fee <= 0: open_oids = [ str(r["exchange_ord_id"] or "").strip() for r in open_rows if str(r["exchange_ord_id"] or "").strip() ] try: total_open_fee = sum_option_order_fees_in_premium( ex, inst_id=inst_id, ord_ids=open_oids, premium_ccy=row_ccy, index_px=close_idx, fetch_order_fn=cfg.get("fetch_option_order"), ) except Exception: total_open_fee = 0.0 allocated = 0.0 allocated_cfee = 0.0 allocated_ofee = 0.0 for i, row in enumerate(open_rows): paid = float(row["premium_paid"] or 0) if i == len(open_rows) - 1: recv = round_premium_amount( total_recv - allocated, decimals=PREMIUM_DECIMALS_COIN ) or 0.0 row_cfee = round(close_fee_total - allocated_cfee, 8) stored_ofee = float(row["open_fee"] or 0) if stored_ofee > 0: row_ofee = stored_ofee elif total_paid > 0 and total_open_fee > 0: row_ofee = round(total_open_fee - allocated_ofee, 8) else: row_ofee = round(total_open_fee - allocated_ofee, 8) if total_open_fee > 0 else 0.0 elif total_paid > 0: share = paid / total_paid recv = round_premium_amount( total_recv * share, decimals=PREMIUM_DECIMALS_COIN ) or 0.0 allocated += recv row_cfee = round(close_fee_total * share, 8) allocated_cfee += row_cfee stored_ofee = float(row["open_fee"] or 0) if stored_ofee > 0: row_ofee = stored_ofee else: row_ofee = round(total_open_fee * share, 8) allocated_ofee += row_ofee else: recv = round_premium_amount( total_recv / len(open_rows), decimals=PREMIUM_DECIMALS_COIN ) or 0.0 allocated += recv row_cfee = round(close_fee_total / len(open_rows), 8) allocated_cfee += row_cfee stored_ofee = float(row["open_fee"] or 0) row_ofee = stored_ofee if stored_ofee > 0 else round( total_open_fee / len(open_rows), 8 ) if stored_ofee <= 0: allocated_ofee += row_ofee settled = settle_closed_pnl( premium_paid=paid, premium_received=recv, open_fee=row_ofee, close_fee=row_cfee, premium_ccy=str(row["premium_ccy"] or row_ccy or premium_ccy), index_px=close_idx, ) pnl = settled.get("realized_pnl") pnl_usdt = settled.get("realized_pnl_usdt") note_sql = "" # 占位: close_quote/recv/pnl/ord/closed_at/open_fee/close_fee/fee_ccy/idx/pnl_u/[note]/id params: list[Any] = [ close_px, recv, pnl, merged_ord, options_app_now_str(), row_ofee, row_cfee, str(row["premium_ccy"] or row_ccy or premium_ccy or ""), settled.get("close_index_px"), pnl_usdt, ] if signal_note and i == len(open_rows) - 1: note_sql = """, signal_note = CASE WHEN signal_note IS NULL OR TRIM(signal_note) = '' THEN ? ELSE signal_note END""" params.append(signal_note) params.append(int(row["id"])) conn.execute( f""" UPDATE options_trades SET status = 'closed', close_quote = ?, premium_received = ?, realized_pnl = ?, close_ord_id = ?, closed_at = ?, open_fee = ?, close_fee = ?, fee_ccy = ?, close_index_px = ?, realized_pnl_usdt = ?, close_recv_accum = 0, close_sheets_accum = 0, close_notional_accum = 0 {note_sql} WHERE id = ? """, tuple(params), ) conn.commit() finally: conn.close() elif filled_sheets > 0: # 部分成交:持久化本批收回,下次全平合计 conn = cfg["get_db"]() try: from lib.options.options_db import add_open_close_accum, init_options_tables init_options_tables(conn) add_open_close_accum( conn, inst_id, recv=batch_recv, sheets=filled_sheets, px=px, ord_id=oid or None, ) conn.commit() finally: conn.close() if require_recycle_gate: # 自动平已挂过单:同仓续批只验流动性 mark_close_gate_passed(inst_id) elif require_recycle_gate: # 自动平已挂过单:同仓续批只验流动性 mark_close_gate_passed(inst_id) out = { "ok": True, "mode": "bid1", "orders": [{"order": order, "px": px, "sheets": level_sheets}], "bid": px, "locked_bid_px": locked_bid_px, "submitted_sheets": level_sheets, "filled_or_reduced_sheets": filled_sheets, "remaining_sheets": remaining_pos, "premium_received": total_recv if fully_closed else (batch_recv or prem_recv_submit), "batch_premium_received": batch_recv, "stopped_reason": None if fully_closed else ("partial_bid1" if reduced > 0 else "order_not_filled"), "close_ord_id": merged_ord if fully_closed else (oid or None), "fully_closed": fully_closed, "msg": ( f"已按买一 {locked_bid_px:g} 提交 {level_sheets} 张" + ("" if fully_closed else f",剩余 {remaining_pos} 张待下次平仓") ), } if fully_closed: try: from lib.options.options_coin_open_lib import maybe_sell_spot_after_close spot_sell = maybe_sell_spot_after_close(cfg, ex, inst_id=inst_id, close_result=out) if spot_sell is not None: out["spot_sell"] = spot_sell if spot_sell.get("bridge_status") == "pending_sell_spot": out["msg"] = str(out.get("msg") or "") + ";卖回 USDT 失败,请重试卖回" except Exception as e: out["spot_sell"] = {"ok": False, "msg": str(e)} return out # 兼容旧名 def close_option_by_bid_depth( cfg: dict[str, Any], ex: Any, inst_id: str, *, sheets: int | None = None, ) -> dict[str, Any]: return close_option_by_bid1( cfg, ex, inst_id, sheets=sheets, require_recycle_gate=True, signal_note="目标位平仓", )