"""OKX 期权模块:Flask 路由注册.""" from __future__ import annotations import os import threading import time from typing import Any from flask import Flask, jsonify, redirect, request, url_for from jinja2 import ChoiceLoader, FileSystemLoader from lib.options.options_db import init_options_tables, sum_open_premium_paid, sum_open_sheets from lib.options.options_monitor_lib import options_monitor_loop from lib.exchange.okx_options_lib import _safe_float def _env_bool(key: str, default: bool = False) -> bool: raw = (os.getenv(key) or "").strip().lower() if not raw: return default return raw in ("1", "true", "yes", "on") def _env_float(key: str, default: float) -> float: try: return float(os.getenv(key) or default) except (TypeError, ValueError): return float(default) def _underly_blocked(underlying: str | None = None, *, inst_id: str | None = None) -> str | None: """热读白名单;返回拒因或 None.""" from lib.options.options_underly_lib import ( inst_underly_not_allowed_msg, underly_not_allowed_msg, ) if inst_id: return inst_underly_not_allowed_msg(inst_id) return underly_not_allowed_msg(underlying or "") def attach_options_templates(app: Flask, repo_root: str) -> None: tpl_dir = os.path.join(repo_root, "lib", "options", "templates") if not os.path.isdir(tpl_dir): return existing = app.jinja_loader loaders = [FileSystemLoader(tpl_dir)] if existing is not None: if isinstance(existing, ChoiceLoader): loaders = list(existing.loaders) + loaders else: loaders.insert(0, existing) app.jinja_loader = ChoiceLoader(loaders) def install_options_trading(app: Flask, repo_root: str, app_module: Any) -> None: enabled = _env_bool("OKX_OPTIONS_ENABLED", False) attach_options_templates(app, repo_root) cfg = _build_cfg(app_module) try: from lib.sim.hooks import patch_options_cfg patch_options_cfg(cfg) except Exception: pass try: from lib.options.options_notify_lib import bind_options_notify_cfg bind_options_notify_cfg(cfg) except Exception: pass app.extensions["options_cfg"] = cfg register_options_routes(app, cfg) _register_options_hub_bridge(app, cfg) if enabled: _start_monitor_thread(app, cfg) def _register_options_hub_bridge(app: Flask, cfg: dict[str, Any]) -> None: from lib.options.options_hub_lib import build_options_hub_snapshot def snapshot_fn(): return build_options_hub_snapshot(cfg) def close_fn(inst_id: str) -> dict[str, Any]: # 中控总览只全平(不改张数);无效买一在执行层拒绝 return _run_manual_bid_close(cfg, inst_id, sheets=None, reason="中控平仓") hub_ctx = dict(app.config.get("HUB_CTX") or {}) hub_ctx["options_snapshot_fn"] = snapshot_fn hub_ctx["options_close_fn"] = close_fn hub_ctx["options_cfg"] = cfg app.config["HUB_CTX"] = hub_ctx def _build_cfg(app_module: Any) -> dict[str, Any]: from lib.exchange.okx_options_lib import ( build_option_chain, estimate_usdt_to_usdc, execute_convert, fetch_option_book_depth, fetch_option_positions, fetch_options_balances, format_position_row, options_api_ready, cancel_option_order, fetch_option_order, fetch_option_pending_orders, place_option_limit_order, place_option_market_order, quote_option_contract, spot_market_swap_usdt_usdc, transfer_ccy, ) return { "enabled": _env_bool("OKX_OPTIONS_ENABLED", False), "get_db": app_module.get_db, "login_required": app_module.login_required, "exchange_options": getattr(app_module, "exchange_options", None), "send_wechat": app_module.send_wechat_msg, "render_main_page": app_module.render_main_page, "trade_budget": _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", 10.0), "budget_buffer": _env_float("OKX_OPTIONS_BUDGET_BUFFER", 1.0), "compound_full_enabled": _env_bool("OKX_OPTIONS_COMPOUND_FULL_ENABLED", True), "compound_full_cap_enabled": _env_bool("OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", False), "compound_full_cap_usdc": _env_float("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", 300.0), "margin_mode": (os.getenv("OKX_OPTIONS_MARGIN_MODE") or "coin").strip().lower(), "default_underly": (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper(), "max_dte_days": _env_float("OKX_OPTIONS_MAX_DTE_DAYS", 2.0), "chain_max_dte_days": _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", 14.0), "chain_ask_liq_filter": _env_bool("OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", True), "itm_max_dist": _env_float("OKX_OPTIONS_ITM_MAX_DIST_USD", 30.0), "td_mode": (os.getenv("OKX_OPTIONS_TD_MODE") or "isolated").strip(), # 市价平仓已硬关闭(忽略 env),仅买一限价 "allow_market_close": False, # 平仓限价挂单超时自动撤单(秒);默认 600=10 分钟,联调可设 60 "pending_ttl_seconds": _env_float("OKX_OPTIONS_PENDING_TTL_SECONDS", 600.0), "profit_ratio": _env_float("OKX_OPTIONS_PROFIT_ALERT_RATIO", 1.0), "poll_seconds": _env_float("OKX_OPTIONS_POLL_SECONDS", 15.0), "account_label": (os.getenv("OKX_OPTIONS_ACCOUNT_LABEL") or "OKX期权").strip(), "build_option_chain": build_option_chain, "quote_option_contract": quote_option_contract, "fetch_option_book_depth": fetch_option_book_depth, "place_option_limit_order": place_option_limit_order, "place_option_market_order": place_option_market_order, "fetch_option_order": fetch_option_order, "fetch_option_pending_orders": fetch_option_pending_orders, "cancel_option_order": cancel_option_order, "fetch_option_positions": fetch_option_positions, "fetch_options_balances": fetch_options_balances, "format_position_row": format_position_row, "estimate_usdt_to_usdc": estimate_usdt_to_usdc, "execute_convert": execute_convert, "transfer_ccy": transfer_ccy, "spot_market_swap_usdt_usdc": spot_market_swap_usdt_usdc, "options_api_ready": options_api_ready, "app_module": app_module, } def _mark_balances_stale(cfg: dict[str, Any]) -> None: from lib.exchange.okx_options_lib import invalidate_options_balance_cache from lib.instance.instance_live_push_lib import notify_instance_balance_changed invalidate_options_balance_cache() app_mod = cfg.get("app_module") if app_mod is not None and hasattr(app_mod, "invalidate_account_balance_cache"): app_mod.invalidate_account_balance_cache() try: notify_instance_balance_changed() except Exception: pass def _require_options_ex(cfg: dict[str, Any]): if not cfg.get("enabled"): return None, "期权模块未启用,请在 .env 设置 OKX_OPTIONS_ENABLED=true 并重启 PM2" ex = cfg.get("exchange_options") ok, reason = cfg["options_api_ready"](ex) if not ok: return None, reason or "期权 API 未配置" return ex, "" def _run_manual_bid_close( cfg: dict[str, Any], inst_id: str, *, sheets: int | None = None, reason: str = "手动平仓", ) -> dict[str, Any]: """手动买一限价平仓(全平或指定张数);残档/无效买一由 close_option_by_bid1 拒绝.""" ex, err = _require_options_ex(cfg) if ex is None: return {"ok": False, "msg": err} inst_id = (inst_id or "").strip() if not inst_id: return {"ok": False, "msg": "缺少 inst_id"} from lib.options.options_close_exec_lib import close_option_by_bid1 result = close_option_by_bid1( cfg, ex, inst_id, sheets=sheets, require_recycle_gate=False, ) if not result.get("ok"): return result from lib.exchange.okx_options_lib import invalidate_option_positions_cache invalidate_option_positions_cache() _sync_options_trades(cfg, force=True) try: from lib.options.options_notify_lib import notify_options_close from lib.options.options_target_lib import cancel_target_monitor conn2 = cfg["get_db"]() try: if result.get("fully_closed"): cancel_target_monitor(conn2, inst_id=inst_id) conn2.commit() note = reason if result.get("fully_closed") else f"{reason}(部分)" notify_options_close( cfg, conn2, inst_id=inst_id, reason=note, sheets=result.get("submitted_sheets"), premium_received=result.get("premium_received"), close_quote=result.get("locked_bid_px") or result.get("bid"), ) finally: conn2.close() except Exception: pass if result.get("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=result) if spot_sell is not None: result = dict(result) result["spot_sell"] = spot_sell if spot_sell.get("bridge_status") == "pending_sell_spot": result["msg"] = ( str(result.get("msg") or "平仓成功") + ";但卖回 USDT 失败,请点「重试卖回」" ) except Exception as e: result = dict(result) result["spot_sell"] = {"ok": False, "msg": str(e)} _mark_balances_stale(cfg) return result def _compound_full_enabled() -> bool: """USDC 全仓复利已下线;恒为 False,避免前端残留 compound_full 模式.""" return False def _normalize_size_mode(mode: str) -> tuple[str, str | None]: """币本位保留打满/复利;USDC 预算模式已下线,落到指定张数.""" m = (mode or "").strip() if m == "eth_amount": m = "eth" coin = False try: from lib.options.options_margin_mode_lib import is_coin_margin_mode coin = bool(is_coin_margin_mode()) except Exception: coin = False if not m: return ("budget_full" if coin else "sheets"), None if m in ("compound_full", "budget_full"): if coin: return m, None return "sheets", "USDC 期权模式已下线,已改用指定张数" return m, None def _open_premium_paid(cfg: dict[str, Any], inst_id: str) -> float | None: conn = cfg["get_db"]() try: init_options_tables(conn) return sum_open_premium_paid(conn, inst_id) finally: conn.close() def _position_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 _find_position(rows: list[dict[str, Any]] | None, inst_id: str) -> dict[str, Any] | None: return next((p for p in rows or [] if str(p.get("instId")) == inst_id), None) def _refresh_position_avail(cfg: dict[str, Any], ex: Any, inst_id: str) -> int | None: from lib.exchange.okx_options_lib import invalidate_option_positions_cache invalidate_option_positions_cache() raw = cfg["fetch_option_positions"](ex) if raw is None: return None pos = _find_position(raw, inst_id) if not pos: return 0 return _position_avail_sheets(pos) def _enrich_position_row_display( cfg: dict[str, Any], ex: Any, raw_pos: dict[str, Any], *, meta_cache: dict[str, dict[str, Any] | None] | None = None, premium_override: float | None = None, ) -> dict[str, Any]: from lib.options.options_history_lib import enrich_position_row_display return enrich_position_row_display( cfg, ex, raw_pos, meta_cache=meta_cache, premium_override=premium_override, ) def _attach_close_preview( cfg: dict[str, Any], ex: Any, row: dict[str, Any], *, sheets: int | None = None, premium_paid: float | None = None, ) -> dict[str, Any]: from lib.options.options_positions_lib import attach_close_preview return attach_close_preview( cfg, ex, row, sheets=sheets, premium_paid=premium_paid, ) _OPTIONS_SYNC_LOCK = threading.Lock() _OPTIONS_SYNC_LAST_AT = 0.0 _OPTIONS_SYNC_INTERVAL_SEC = 15.0 def _sync_options_trades( cfg: dict[str, Any], *, raw_positions: list[dict[str, Any]] | None = None, force: bool = False, ) -> None: global _OPTIONS_SYNC_LAST_AT ex = cfg.get("exchange_options") if ex is None: return now = time.time() with _OPTIONS_SYNC_LOCK: if not force and now - _OPTIONS_SYNC_LAST_AT < _OPTIONS_SYNC_INTERVAL_SEC: return _OPTIONS_SYNC_LAST_AT = now from lib.exchange.okx_options_lib import fetch_all_option_positions_history, fetch_option_position_history from lib.options.options_monitor_lib import ( backfill_closed_options_realized_pnl_from_history, reconcile_live_open_trades, sync_open_options_trades, ) if raw_positions is None: raw = cfg["fetch_option_positions"](ex) if raw is None: return else: raw = raw_positions live_ids = {str(p.get("instId") or "") for p in raw if str(p.get("instId") or "")} # 网络拉取历史必须在 DB 事务外,否则握锁期间卖币/中控快照会 database is locked pending_insts: list[str] = [] conn = cfg["get_db"]() try: init_options_tables(conn) rows = conn.execute( "SELECT inst_id FROM options_trades WHERE status = 'open'" ).fetchall() for r in rows: iid = str(r["inst_id"] or "").strip() if iid and iid not in live_ids: pending_insts.append(iid) try: conn.commit() except Exception: pass finally: conn.close() hist_by_inst: dict[str, list] = {} for iid in pending_insts: try: hist_by_inst[iid] = fetch_option_position_history(ex, iid) or [] except Exception: hist_by_inst[iid] = [] hist_all = None try: hist_all = fetch_all_option_positions_history(ex, limit=200) except Exception: hist_all = None conn = cfg["get_db"]() try: init_options_tables(conn) reconcile_live_open_trades(conn, live_inst_ids=live_ids) sync_open_options_trades( conn, live_inst_ids=live_ids, fetch_history_fn=lambda inst_id: hist_by_inst.get(str(inst_id) or [], []), notify_cfg=cfg, ) if hist_all is not None: try: backfill_closed_options_realized_pnl_from_history(conn, hist_all) except Exception: pass conn.commit() except Exception: try: conn.rollback() except Exception: pass finally: try: conn.close() except Exception: pass try: from lib.options.options_monitor_lib import sell_spot_after_options_sync sell_spot_after_options_sync(cfg) _mark_balances_stale(cfg) except Exception: pass def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: lr = cfg["login_required"] @app.route("/options/guide") @lr def options_trade_guide(): """期权开平仓与监控说明(独立页).""" from pathlib import Path from flask import render_template_string from lib.common.markdown_html_lib import render_markdown_html md_path = Path(__file__).resolve().parents[2] / "docs" / "期权开平仓与监控说明.md" try: md_text = md_path.read_text(encoding="utf-8") except OSError: md_text = "# 说明文档缺失\n\n未找到 `docs/期权开平仓与监控说明.md`." body = render_markdown_html(md_text) return render_template_string( """ 期权开平仓与监控说明

← 返回期权

{{ body|safe }} """, body=body, ) @app.route("/api/options/balances") @lr def api_options_balances(): ex, err = _require_options_ex(cfg) if ex is None: return jsonify({"ok": False, "msg": err}) force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes") bal = cfg["fetch_options_balances"](ex, force=force, scope="main") from lib.options.options_margin_mode_lib import is_coin_margin_mode, normalize_options_margin_mode margin_mode = normalize_options_margin_mode() payload = { "ok": True, **bal, "trade_budget": _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", float(cfg.get("trade_budget") or 10)), "compound_full_enabled": _compound_full_enabled(), "compound_full_cap_enabled": _env_bool("OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", False), "compound_full_cap_usdc": _env_float("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", 300.0), "options_margin_mode": margin_mode, "options_margin_mode_label": "币本位" if margin_mode == "coin" else "USDC", } if is_coin_margin_mode(): try: from lib.options.options_coin_open_lib import coin_budget_preview payload["coin_budget"] = coin_budget_preview(cfg, ex) except Exception as e: payload["coin_budget"] = {"ok": False, "msg": str(e)} return jsonify(payload) @app.route("/api/options/chain") @lr def api_options_chain(): ex, err = _require_options_ex(cfg) if ex is None: return jsonify({"ok": False, "msg": err}) from lib.options.options_underly_lib import resolve_default_underly u = (request.args.get("underlying") or resolve_default_underly()).upper() blocked = _underly_blocked(u) if blocked: return jsonify({"ok": False, "msg": blocked}) # 热更新:链展示天数每次读 env,保存后刷新链即可 chain_max_dte = _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", float(cfg.get("chain_max_dte_days") or 14)) try: from lib.options.options_margin_mode_lib import normalize_options_margin_mode margin_mode = normalize_options_margin_mode() chain = cfg["build_option_chain"]( ex, u, max_dte_days=chain_max_dte, itm_only=False, itm_max_dist_usd=cfg["itm_max_dist"], margin_mode=margin_mode, ) except Exception as e: return jsonify({"ok": False, "msg": f"加载期权链失败: {e}"}) expiries = chain.get("expiries") or [] chain_err = chain.get("chain_error") # 热更新:每次读 env,保存配置后刷新链即可生效 ask_liq_filter = _env_bool("OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", True) budget_buffer = _env_float("OKX_OPTIONS_BUDGET_BUFFER", 1.0) coin_budget = None try: from lib.options.options_margin_mode_lib import is_coin_margin_mode from lib.options.options_coin_open_lib import coin_budget_preview if is_coin_margin_mode(): coin_budget = coin_budget_preview(cfg, ex) except Exception: coin_budget = None if not expiries: return jsonify( { "ok": False, "msg": chain_err or "暂无到期日,请稍后点「刷新链」", **chain, "chain_max_dte_days": chain_max_dte, "ask_liq_filter_enabled": ask_liq_filter, "budget_buffer": budget_buffer, "trade_budget": cfg["trade_budget"], "options_margin_mode": chain.get("margin_mode") or margin_mode, "coin_budget": coin_budget, } ) return jsonify( { "ok": True, **chain, "chain_max_dte_days": chain_max_dte, "ask_liq_filter_enabled": ask_liq_filter, "budget_buffer": budget_buffer, "trade_budget": cfg["trade_budget"], "options_margin_mode": chain.get("margin_mode") or margin_mode, "coin_budget": coin_budget, } ) @app.route("/api/options/quote") @lr def api_options_quote(): ex, err = _require_options_ex(cfg) if ex is None: return jsonify({"ok": False, "msg": err}) inst_id = (request.args.get("inst_id") or "").strip() if not inst_id: return jsonify({"ok": False, "msg": "缺少 inst_id"}) blocked = _underly_blocked(inst_id=inst_id) if blocked: return jsonify({"ok": False, "msg": blocked, "can_open": False}) q = cfg["quote_option_contract"](ex, inst_id) if not q.get("ok"): return jsonify(q) ask = q.get("ask") ct_mult = q.get("ct_mult") or 0.01 min_sz = q.get("min_sz") or 1 mode = (request.args.get("mode") or "").strip() sheet_count = None try: if request.args.get("sheets"): sheet_count = int(request.args.get("sheets")) except (TypeError, ValueError): pass if mode == "close_preview": paid = _open_premium_paid(cfg, inst_id) target = sheet_count if sheet_count is not None else 0 from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode row_mode = margin_mode_from_inst_id(inst_id) prem_ccy = premium_ccy_for_mode(row_mode, (inst_id.split("-")[0] if inst_id else "ETH")) preview_row = { **q, "pos": target, "premium_paid": paid, "margin_mode": row_mode, "premium_ccy": prem_ccy, } out = _attach_close_preview(cfg, ex, preview_row, sheets=target, premium_paid=paid) out["options_margin_mode"] = row_mode out["premium_ccy"] = prem_ccy return jsonify(out) mode, mode_note = _normalize_size_mode(mode) # 币本位:报价预览走 USDT 预算→估币→张数,禁止再查 USDC try: from lib.options.options_margin_mode_lib import ( is_coin_margin_mode, margin_mode_from_inst_id, ) from lib.options.options_coin_open_lib import coin_budget_preview from lib.exchange.okx_options_lib import option_buy_liquidity_ok if is_coin_margin_mode(): ask = q.get("ask") ask_sz = q.get("ask_sz") can_open, block_msg = option_buy_liquidity_ok(ask, ask_sz) try: from lib.options.okx_trade_mode_lib import block_standalone_open_by_mode_msg mode_block = block_standalone_open_by_mode_msg() except Exception as e: return jsonify({"ok": False, "can_open": False, "msg": f"交易模式校验失败: {e}"}) if mode_block: return jsonify( { **q, "ok": True, "can_open": False, "msg": mode_block, "options_margin_mode": "coin", "sizing": {"ok": False, "msg": mode_block, "sheets": 0, "eth_amount": 0.0, "total_premium": 0.0}, } ) if margin_mode_from_inst_id(inst_id) != "coin": return jsonify( { **q, "ok": True, "can_open": False, "msg": "当前为币本位模式,请选择 ETH-USD / BTC-USD 合约(非 USD_UM)", "options_margin_mode": "coin", "sizing": { "ok": False, "msg": "合约非币本位", "sheets": 0, "eth_amount": 0.0, "total_premium": 0.0, }, } ) budget_info = coin_budget_preview(cfg, ex) if not can_open: return jsonify( { **q, "ok": True, "can_open": False, "msg": block_msg or q.get("open_block_msg") or "暂无卖一深度,无法买入", "options_margin_mode": "coin", "coin_budget": budget_info, "sizing": { "ok": False, "msg": block_msg or "暂无卖一深度,无法买入", "sheets": 0, "eth_amount": 0.0, "total_premium": 0.0, }, } ) if not budget_info.get("ok"): return jsonify( { **q, "ok": True, "can_open": False, "msg": budget_info.get("msg") or "交易账户 USDT 不足", "options_margin_mode": "coin", "coin_budget": budget_info, "sizing": { "ok": False, "msg": budget_info.get("msg") or "交易账户 USDT 不足", "sheets": 0, "eth_amount": 0.0, "total_premium": 0.0, }, } ) idx = _safe_float(q.get("index_px")) or _safe_float(q.get("idxPx")) budget_usdt = float(budget_info["budget_usdt"]) target_sheets = sheet_count if mode == "sheets" and sheet_count is not None else None if mode == "eth" and request.args.get("eth"): # 指定币量:按币量反推张数后再走统一规划 try: eth_want = float(request.args.get("eth")) except (TypeError, ValueError): eth_want = 0.0 if eth_want > 0 and float(ct_mult) > 0: import math target_sheets = max(int(min_sz), int(math.floor(eth_want / float(ct_mult) + 1e-12))) from lib.options.options_margin_mode_lib import plan_coin_open_by_budget sizing = plan_coin_open_by_budget( quote_per_unit=float(ask), ct_mult=float(ct_mult), min_sz=int(min_sz), budget_usdt=budget_usdt, index_px=float(idx or 0), ask_sz=ask_sz, target_sheets=target_sheets, ) if sizing.get("ok"): sizing["premium_ccy"] = (inst_id.split("-")[0] if inst_id else "ETH").upper() sizing["est_coin"] = sizing.get("buy_coin") q = _attach_close_preview( cfg, ex, q, sheets=int(sizing.get("sheets") or 0), premium_paid=_open_premium_paid(cfg, inst_id), ) return jsonify( { **q, "can_open": bool(sizing.get("ok")), "quote_per_unit": ask, "premium_per_sheet": round(float(ask) * float(ct_mult), 8), "sizing": sizing, "mode": mode, "mode_note": mode_note, "options_margin_mode": "coin", "coin_budget": budget_info, "compound_full_enabled": _compound_full_enabled(), } ) except Exception as e: return jsonify({"ok": False, "msg": f"币本位报价失败: {e}"}) return jsonify({"ok": False, "msg": "仅支持币本位期权报价", "can_open": False}) @app.route("/api/options/open", methods=["POST"]) @lr def api_options_open(): ex, err = _require_options_ex(cfg) if ex is None: return jsonify({"ok": False, "msg": err}) try: from lib.options.okx_trade_mode_lib import block_standalone_open_by_mode_msg mode_block = block_standalone_open_by_mode_msg() if mode_block: return jsonify({"ok": False, "msg": mode_block, "can_open": False}) except Exception as e: return jsonify({"ok": False, "msg": f"交易模式校验失败: {e}", "can_open": False}) data = request.get_json(silent=True) or {} inst_id = (data.get("inst_id") or "").strip() mode = (data.get("mode") or "").strip() mode, mode_note = _normalize_size_mode(mode) signal_note = (data.get("signal_note") or "").strip() # 中控条件单占用时禁止手动开仓 try: from lib.options.options_cond_order_lib import cond_order_blocks_manual_open_msg from lib.options.options_db import init_options_tables conn_blk = cfg["get_db"]() try: init_options_tables(conn_blk) block = cond_order_blocks_manual_open_msg(conn_blk) if block: return jsonify({"ok": False, "msg": block, "cond_order_locked": True}) finally: conn_blk.close() except Exception: pass if mode_note and mode == "sheets" and (data.get("mode") or "").strip() == "compound_full": # 前端残留全仓复利选中时,已自动改指定张数;继续开仓 pass target_index = None raw_target = data.get("target_index") if raw_target is not None and str(raw_target).strip() != "": try: target_index = float(raw_target) except (TypeError, ValueError): return jsonify({"ok": False, "msg": "目标位无效"}) if target_index <= 0: return jsonify({"ok": False, "msg": "目标位无效"}) profit_exit_enabled = bool(data.get("profit_exit_enabled")) profit_exit_mult = 1.0 if profit_exit_enabled: from lib.options.options_profit_exit_lib import normalize_profit_exit_mult profit_exit_mult = normalize_profit_exit_mult(data.get("profit_exit_mult"), default=1.0) if not inst_id: return jsonify({"ok": False, "msg": "缺少 inst_id"}) blocked = _underly_blocked(inst_id=inst_id) if blocked: return jsonify({"ok": False, "msg": blocked, "can_open": False}) try: from lib.options.options_margin_mode_lib import is_coin_margin_mode from lib.options.options_coin_open_lib import open_coin_option_buy_full if is_coin_margin_mode(): want_sheets = None if mode == "sheets": try: want_sheets = int(data.get("sheets") or 0) or None except (TypeError, ValueError): want_sheets = None elif mode == "eth": try: eth_want = float(data.get("eth") or 0) except (TypeError, ValueError): eth_want = 0.0 if eth_want > 0: q0 = cfg["quote_option_contract"](ex, inst_id) ct0 = float((q0 or {}).get("ct_mult") or 0.01) min0 = int((q0 or {}).get("min_sz") or 1) if ct0 > 0: import math want_sheets = max(min0, int(math.floor(eth_want / ct0 + 1e-12))) result = open_coin_option_buy_full( cfg, ex, inst_id=inst_id, signal_note=signal_note, target_index=target_index, profit_exit_enabled=profit_exit_enabled, profit_exit_mult=profit_exit_mult, target_sheets=want_sheets, ) if result.get("ok"): from lib.exchange.okx_options_lib import invalidate_option_positions_cache invalidate_option_positions_cache() _mark_balances_stale(cfg) return jsonify(result) except Exception as e: return jsonify({"ok": False, "msg": f"币本位开仓失败: {e}"}) return jsonify({"ok": False, "msg": "仅支持币本位期权开仓"}) @app.route("/api/options/orders/pending") @lr def api_options_orders_pending(): ex, err = _require_options_ex(cfg) if ex is None: return jsonify({"ok": False, "msg": err}) inst_id = (request.args.get("inst_id") or "").strip() or None try: orders = cfg["fetch_option_pending_orders"](ex, inst_id) except Exception as e: return jsonify({"ok": False, "msg": f"获取委托失败: {e}"}) from lib.options.options_pending_lib import enrich_pending_orders ttl = float(cfg.get("pending_ttl_seconds") or 600.0) enriched = enrich_pending_orders(orders, ttl_seconds=ttl) return jsonify( { "ok": True, "orders": enriched, "count": len(enriched), "pending_ttl_seconds": ttl, } ) @app.route("/api/options/orders/cancel", methods=["POST"]) @lr def api_options_orders_cancel(): ex, err = _require_options_ex(cfg) if ex is None: return jsonify({"ok": False, "msg": err}) data = request.get_json(silent=True) or {} inst_id = (data.get("inst_id") or "").strip() ord_id = (data.get("ord_id") or "").strip() if not inst_id or not ord_id: return jsonify({"ok": False, "msg": "缺少 inst_id 或 ord_id"}) out = cfg["cancel_option_order"](ex, inst_id=inst_id, ord_id=ord_id) if out.get("ok"): from lib.exchange.okx_options_lib import invalidate_option_positions_cache invalidate_option_positions_cache() # 本地未成交开仓记录标记取消,避免假 open try: conn = cfg["get_db"]() try: from lib.options.options_time_lib import options_app_now_str init_options_tables(conn) conn.execute( """ UPDATE options_trades SET status = 'cancelled', signal_note = CASE WHEN signal_note IS NULL OR TRIM(signal_note) = '' THEN '委托撤销' ELSE signal_note END, closed_at = ? WHERE inst_id = ? AND exchange_ord_id = ? AND status = 'open' """, (options_app_now_str(), inst_id, ord_id), ) conn.commit() finally: conn.close() except Exception: pass _sync_options_trades(cfg, force=True) return jsonify(out), (200 if out.get("ok") else 400) @app.route("/api/options/positions") @lr def api_options_positions(): ex, err = _require_options_ex(cfg) if ex is None: return jsonify({"ok": False, "msg": err}) raw = cfg["fetch_option_positions"](ex) if raw is None: return jsonify({"ok": False, "msg": "获取期权持仓失败"}) _sync_options_trades(cfg, raw_positions=raw) meta_cache: dict[str, dict[str, Any] | None] = {} conn = cfg["get_db"]() try: from lib.options.options_target_lib import targets_by_inst from lib.options.options_profit_exit_lib import profit_exit_by_inst tgt_map = targets_by_inst(conn) profit_exit_map = profit_exit_by_inst(conn) rows = [] for p in raw: inst = str(p.get("instId") or "").strip() premium_override = sum_open_premium_paid(conn, inst) if inst else None row = _enrich_position_row_display( cfg, ex, p, meta_cache=meta_cache, premium_override=premium_override, ) _attach_close_preview(cfg, ex, row, premium_paid=_safe_float(row.get("premium_paid"))) mon = tgt_map.get(inst) if mon: row["target_index"] = mon.get("target_index") row["target_monitor_id"] = mon.get("id") row["target_monitor"] = mon pe = profit_exit_map.get(inst) if pe: row["profit_exit_enabled"] = pe.get("profit_exit_enabled") row["profit_exit_mult"] = pe.get("profit_exit_mult") row["profit_exit_state"] = pe.get("profit_exit_state") row["profit_exit_required_recycle"] = pe.get("required_recycle") try: from lib.instance.instance_dashboard_lib import _resolve_options_source source_key, source_label, source_plan_id = _resolve_options_source(conn, inst) row["source"] = source_key row["source_label"] = source_label row["source_plan_id"] = source_plan_id except Exception: row.setdefault("source", "option") row.setdefault("source_label", "纯期权") row.setdefault("source_plan_id", None) rows.append(row) finally: conn.close() spot_sell_hint = None try: from lib.options.options_margin_mode_lib import is_coin_margin_mode if is_coin_margin_mode(): from lib.options.options_spot_bridge_lib import list_open_bridges conn_b = cfg["get_db"]() try: from lib.options.options_db import init_options_tables init_options_tables(conn_b) open_bridges = list_open_bridges(conn_b) bal = cfg["fetch_options_balances"](ex, scope="main") bridge = open_bridges[0] if open_bridges else None from lib.options.options_spot_bridge_lib import ( display_sellable_coin, spot_min_sell_sz, ) raw_eth = bal.get("trading_eth_avail") or bal.get("trading_eth") raw_btc = bal.get("trading_btc_avail") or bal.get("trading_btc") from lib.options.options_underly_lib import underly_cfg_snapshot _uly_snap = underly_cfg_snapshot() spot_sell_hint = { "bridge_status": str(bridge.get("status") or "") if bridge else None, "bridge_underlying": str(bridge.get("underlying") or "").upper() if bridge else None, # 粉尘归零:不够现货最小可卖则前端不展示、不提示重试 "trading_eth": display_sellable_coin(ex, "ETH", raw_eth), "trading_btc": display_sellable_coin(ex, "BTC", raw_btc), "min_sz_eth": spot_min_sell_sz(ex, "ETH"), "min_sz_btc": spot_min_sell_sz(ex, "BTC"), "default_underly": _uly_snap["default_underly"], "allowed_underlys": _uly_snap["allowed_underlys"], } finally: conn_b.close() except Exception: spot_sell_hint = None cond_locked = False cond_msg = "" cond_order = None try: from lib.options.options_cond_order_lib import has_active_cond_order, list_cond_orders conn_c = cfg["get_db"]() try: init_options_tables(conn_c) cond_locked = has_active_cond_order(conn_c) if cond_locked: act = list_cond_orders(conn_c, active_only=True) if act: cond_order = act[0] cond_msg = str(act[0].get("message") or act[0].get("status") or "") finally: conn_c.close() except Exception: pass transfer_blocked = False transfer_block_msg = "" try: from lib.options.options_account_pnl_lib import transfer_blocked_by_position_msg conn_tb = cfg["get_db"]() try: tmsg = transfer_blocked_by_position_msg(conn_tb, ex) if tmsg: transfer_blocked = True transfer_block_msg = tmsg finally: conn_tb.close() except Exception: pass return jsonify( { "ok": True, "positions": rows, "spot_sell": spot_sell_hint, "cond_order_locked": cond_locked, "cond_order_msg": cond_msg, "cond_order": cond_order, "transfer_blocked": transfer_blocked, "transfer_block_msg": transfer_block_msg, } ) @app.route("/api/options/targets") @lr def api_options_targets(): conn = cfg["get_db"]() try: from lib.options.options_target_lib import list_active_targets, list_closing_targets return jsonify({"ok": True, "targets": list_active_targets(conn) + list_closing_targets(conn)}) finally: conn.close() @app.route("/api/options/target", methods=["POST"]) @lr def api_options_target_set(): ex, err = _require_options_ex(cfg) if ex is None: return jsonify({"ok": False, "msg": err}) data = request.get_json(silent=True) or {} inst_id = (data.get("inst_id") or "").strip() if not inst_id: return jsonify({"ok": False, "msg": "缺少 inst_id"}) try: target_index = float(data.get("target_index")) except (TypeError, ValueError): return jsonify({"ok": False, "msg": "目标位无效"}) if target_index <= 0: return jsonify({"ok": False, "msg": "目标位无效"}) raw = cfg["fetch_option_positions"](ex) if raw is None: return jsonify({"ok": False, "msg": "获取期权持仓失败"}) pos = _find_position(raw, inst_id) if not pos: return jsonify({"ok": False, "msg": "未找到持仓"}) from lib.options.options_target_lib import upsert_target_monitor fmt = cfg["format_position_row"](pos) conn = cfg["get_db"]() try: trade = conn.execute( """ SELECT id, opt_type, underlying FROM options_trades WHERE inst_id = ? AND status = 'open' ORDER BY id DESC LIMIT 1 """, (inst_id,), ).fetchone() trade_id = int(trade["id"]) if trade else None sheets_sum = sum_open_sheets(conn, inst_id) sheets = sheets_sum if sheets_sum is not None else int(fmt.get("pos") or 0) opt_type = (trade["opt_type"] if trade else None) or fmt.get("opt_type") underlying = (trade["underlying"] if trade else None) or fmt.get("underlying") out = upsert_target_monitor( conn, inst_id=inst_id, target_index=target_index, underlying=str(underlying) if underlying else None, opt_type=str(opt_type) if opt_type else None, trade_id=trade_id, sheets=sheets, ) conn.commit() return jsonify(out) finally: conn.close() @app.route("/api/options/target/cancel", methods=["POST"]) @lr def api_options_target_cancel(): data = request.get_json(silent=True) or {} inst_id = (data.get("inst_id") or "").strip() or None monitor_id = data.get("id") try: mid = int(monitor_id) if monitor_id is not None and str(monitor_id).strip() != "" else None except (TypeError, ValueError): return jsonify({"ok": False, "msg": "监控 id 无效"}) if not inst_id and mid is None: return jsonify({"ok": False, "msg": "缺少 inst_id 或 id"}) from lib.options.options_target_lib import cancel_target_monitor conn = cfg["get_db"]() try: n = cancel_target_monitor(conn, inst_id=inst_id, monitor_id=mid) conn.commit() return jsonify({"ok": True, "cancelled": n}) finally: conn.close() @app.route("/api/options/profit-exit", methods=["POST"]) @lr def api_options_profit_exit_set(): ex, err = _require_options_ex(cfg) if ex is None: return jsonify({"ok": False, "msg": err}) data = request.get_json(silent=True) or {} inst_id = (data.get("inst_id") or "").strip() if not inst_id: return jsonify({"ok": False, "msg": "缺少 inst_id"}) enabled_raw = data.get("enabled") if enabled_raw is None: enabled_raw = data.get("profit_exit_enabled") enabled = bool(enabled_raw) and str(enabled_raw).strip().lower() not in ( "0", "false", "off", "no", ) from lib.options.options_profit_exit_lib import normalize_profit_exit_mult, set_profit_exit mult = normalize_profit_exit_mult(data.get("mult", data.get("profit_exit_mult")), default=1.0) raw = cfg["fetch_option_positions"](ex) if raw is None: return jsonify({"ok": False, "msg": "获取期权持仓失败"}) if not _find_position(raw, inst_id): return jsonify({"ok": False, "msg": "未找到持仓"}) conn = cfg["get_db"]() try: out = set_profit_exit(conn, inst_id=inst_id, enabled=enabled, mult=mult) if out.get("ok"): conn.commit() return jsonify(out) finally: conn.close() @app.route("/api/options/close", methods=["POST"]) @lr def api_options_close(): data = request.get_json(silent=True) or {} inst_id = (data.get("inst_id") or "").strip() if not inst_id: return jsonify({"ok": False, "msg": "缺少 inst_id"}) if data.get("market"): return jsonify({"ok": False, "msg": "已禁用市价平仓,仅支持买一限价"}) sheets = data.get("sheets") try: sheets_i = int(sheets) if sheets is not None and str(sheets).strip() != "" else None except (TypeError, ValueError): return jsonify({"ok": False, "msg": "张数无效"}) # 手动买一平仓:只验有效流动性;2×门控仅用于自动/目标位平仓 return jsonify(_run_manual_bid_close(cfg, inst_id, sheets=sheets_i, reason="手动平仓")) @app.route("/api/options/spot-bridge/retry-sell", methods=["POST"]) @lr def api_options_spot_bridge_retry_sell(): """币本位:重试把残留标的币市价卖回 USDT.""" ex, err = _require_options_ex(cfg) if ex is None: return jsonify({"ok": False, "msg": err}) data = request.get_json(silent=True) or {} underlying = (data.get("underlying") or cfg.get("default_underly") or "ETH").strip().upper() inst_id = (data.get("inst_id") or "").strip() or None from lib.options.options_spot_bridge_lib import sell_residual_after_option_flat out = sell_residual_after_option_flat( cfg["get_db"], ex, underlying=underlying, inst_id=inst_id, ) if out.get("ok") and out.get("sell_complete", True) and not out.get("incomplete"): try: from lib.options.options_account_pnl_lib import try_settle_after_sellback bid = out.get("bridge_id") try_settle_after_sellback( cfg["get_db"], ex, bridge_id=int(bid) if bid is not None else None, cfg=cfg, underlying=underlying, sell_complete=True, ) except Exception: pass _mark_balances_stale(cfg) elif out.get("ok"): _mark_balances_stale(cfg) return jsonify(out) @app.route("/api/options/convert/quote", methods=["POST"]) @lr def api_options_convert_quote(): return jsonify({"ok": False, "msg": "USDC 兑换已下线,仅支持币本位期权"}) @app.route("/api/options/convert/execute", methods=["POST"]) @lr def api_options_convert_execute(): return jsonify({"ok": False, "msg": "USDC 兑换已下线,仅支持币本位期权"}) @app.route("/api/options/transfer", methods=["POST"]) @lr def api_options_transfer(): ex, err = _require_options_ex(cfg) if ex is None: return jsonify({"ok": False, "msg": err}) del ex # 执行层自行取 exchange_options data = request.get_json(silent=True) or {} from lib.options.options_transfer_exec_lib import execute_options_internal_transfer # 前端传 ccy;缺省 USDT(币本位) ccy = (data.get("ccy") or "USDT").upper() return jsonify( execute_options_internal_transfer( cfg, amount=data.get("amount"), from_acct=(data.get("from") or "funding"), to_acct=(data.get("to") or "trading"), ccy=ccy, ) ) @app.route("/api/options/spot/swap", methods=["POST"]) @lr def api_options_spot_swap(): return jsonify({"ok": False, "msg": "USDT↔USDC 兑换已下线,仅支持币本位期权"}) @app.route("/api/options/history") @lr def api_options_history(): ex, err = _require_options_ex(cfg) if ex is None: return jsonify({"ok": False, "msg": err}) from lib.options.options_history_lib import load_options_history raw_live = cfg["fetch_option_positions"](ex) if raw_live is None: return jsonify({"ok": False, "msg": "获取期权持仓失败"}) history = load_options_history(ex, cfg) live_ids = {str(x.get("inst_id") or "") for x in history if x.get("status") == "open"} return jsonify({"ok": True, "history": history, "live_inst_ids": sorted(live_ids)}) @app.route("/api/options/stats") @lr def api_options_stats(): ex, err = _require_options_ex(cfg) if ex is None: return jsonify({"ok": False, "msg": err}) from lib.options.options_margin_mode_lib import is_coin_margin_mode from lib.options.options_positions_lib import sum_options_net_pnl_usdc from lib.options.options_stats_lib import compute_options_stats_auto raw_live = cfg["fetch_option_positions"](ex) if raw_live is None: return jsonify({"ok": False, "msg": "获取期权持仓失败"}) try: # 实盘:期权历史交易所原生盈亏;模拟盘:本地结算 stats = compute_options_stats_auto(cfg, ex) except Exception as e: return jsonify({"ok": False, "msg": f"统计失败: {e}"}) open_float = sum_options_net_pnl_usdc(cfg, ex, raw_live) net_realized = _safe_float(stats.get("net_realized_pnl")) or 0.0 total_pnl = None if open_float is not None: total_pnl = round(net_realized + float(open_float), 4) elif stats.get("total_closed"): total_pnl = round(net_realized, 4) # 币本位统计统一标 U;USDC 模式仍标 USDC pnl_unit = "U" if is_coin_margin_mode() else "USDC" return jsonify( { "ok": True, **stats, "open_float_pnl": open_float, "total_pnl": total_pnl, "pnl_unit": pnl_unit, } ) @app.route("/api/options/history/", methods=["DELETE"]) @lr def api_options_history_delete(history_key: str): ex, err = _require_options_ex(cfg) if ex is None: return jsonify({"ok": False, "msg": err}) key = (history_key or "").strip() if not key: return jsonify({"ok": False, "msg": "缺少 history_key"}) data = request.get_json(silent=True) or {} inst_id = str(data.get("inst_id") or request.args.get("inst_id") or "").strip() or None closed_at = str(data.get("closed_at") or request.args.get("closed_at") or "").strip() or None conn = cfg["get_db"]() try: init_options_tables(conn) conn.execute( "INSERT OR IGNORE INTO options_history_hidden (history_key) VALUES (?)", (key,), ) # 同步隐藏期权复盘,避免本地已平记录刷新后又出现 try: from lib.options.options_review_lib import hide_review_keys hide_review_keys( conn, history_key=key, inst_id=inst_id, closed_at=closed_at, ) if inst_id: # 去掉已导入的复盘快照(按合约+平仓时间) if closed_at: rows = conn.execute( """ SELECT id, history_key FROM options_review_trades WHERE inst_id = ? AND substr(COALESCE(closed_at,''),1,16) = substr(?,1,16) """, (inst_id, closed_at), ).fetchall() else: rows = conn.execute( """ SELECT id, history_key FROM options_review_trades WHERE inst_id = ? """, (inst_id,), ).fetchall() for r in rows: conn.execute( "DELETE FROM options_review_entries WHERE trade_id=?", (int(r["id"]),), ) conn.execute( "DELETE FROM options_review_trades WHERE id=?", (int(r["id"]),), ) conn.execute( "INSERT OR IGNORE INTO options_review_hidden(history_key, inst_id, closed_at) VALUES (?,?,?)", (str(r["history_key"]), inst_id, (closed_at or "")[:19] or None), ) fps = [] if closed_at: fps.append(f"inst_close:{inst_id}:{closed_at[:16]}") fps.append(f"inst:{inst_id}") for fp in fps: conn.execute( "INSERT OR IGNORE INTO options_review_hidden(history_key, inst_id, closed_at) VALUES (?,?,?)", (fp, inst_id, (closed_at or "")[:19] or None), ) except Exception: pass conn.commit() finally: conn.close() return jsonify({"ok": True}) def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None: if app.extensions.get("options_monitor_started"): return app.extensions["options_monitor_started"] = True def _bid(inst_id: str) -> float | None: ex = cfg.get("exchange_options") if ex is None: return None try: q = cfg["quote_option_contract"](ex, inst_id) return q.get("bid") except Exception: return None def _positions(): ex = cfg.get("exchange_options") if ex is None: return [] raw = cfg["fetch_option_positions"](ex) if raw is None: return [] return [cfg["format_position_row"](p) for p in raw] def _sync(_conn=None): # 不用监控线程传入的连接;force=False 走 15s 节流,卖币由监控循环在关闭连接后另扫 try: _sync_options_trades(cfg, force=False) return 1 except Exception: return 0 def _target_close(inst_id: str) -> dict[str, Any]: from lib.options.options_target_lib import close_option_by_bid_depth ex = cfg.get("exchange_options") if ex is None: return {"ok": False, "msg": "期权 exchange 未就绪"} result = close_option_by_bid_depth(cfg, ex, inst_id) if result.get("ok"): try: _sync_options_trades(cfg, force=True) except Exception: pass try: _mark_balances_stale(cfg) except Exception: pass return result def _profit_exit_close(inst_id: str) -> dict[str, Any]: from lib.options.options_profit_exit_lib import close_option_by_bid_profit_exit ex = cfg.get("exchange_options") if ex is None: return {"ok": False, "msg": "期权 exchange 未就绪"} result = close_option_by_bid_profit_exit(cfg, ex, inst_id) if result.get("ok"): try: _sync_options_trades(cfg, force=True) except Exception: pass try: _mark_balances_stale(cfg) except Exception: pass return result def _stale_pending() -> dict[str, Any]: from lib.exchange.okx_options_lib import invalidate_option_positions_cache from lib.options.options_pending_lib import cancel_stale_close_pending_orders ex = cfg.get("exchange_options") if ex is None: return {"ok": False, "msg": "期权 exchange 未就绪"} ttl = float(cfg.get("pending_ttl_seconds") or 600.0) out = cancel_stale_close_pending_orders( fetch_pending=lambda _ex: cfg["fetch_option_pending_orders"](_ex), cancel_order=lambda _ex, inst_id, ord_id: cfg["cancel_option_order"]( _ex, inst_id=inst_id, ord_id=ord_id ), ttl_seconds=ttl, ex=ex, ) if out.get("cancelled"): try: invalidate_option_positions_cache() except Exception: pass try: send = cfg.get("send_wechat") if callable(send): parts = [ "【OKX期权·挂单超时撤销】", f"账户:{cfg.get('account_label') or 'OKX期权'}", f"超时:{ttl:g}s", f"撤销:{out.get('cancelled')} 笔", ] for o in out.get("orders") or []: parts.append(f"- {o.get('inst_id')} #{o.get('ord_id')}") send("\n".join(parts)) except Exception: pass return out def _cond_order_tick(_conn=None) -> None: from lib.options.options_cond_order_lib import run_cond_orders_tick # 传 get_db:条件单开仓前会释放连接,避免嵌套锁库 run_cond_orders_tick( cfg["get_db"], cfg, send_wechat=cfg.get("send_wechat"), account_label=str(cfg.get("account_label") or ""), ) t = threading.Thread( target=options_monitor_loop, kwargs={ "enabled": True, "poll_seconds": cfg["poll_seconds"], "get_db": cfg["get_db"], "fetch_positions": _positions, "ticker_bid_fn": _bid, "send_wechat": cfg["send_wechat"], "account_label": cfg["account_label"], "profit_ratio": cfg["profit_ratio"], "sync_trades_fn": _sync, "target_close_fn": _target_close, "profit_exit_close_fn": _profit_exit_close, "profit_exit_cfg": cfg, "stale_pending_fn": _stale_pending, "cond_order_tick_fn": _cond_order_tick, }, daemon=True, name="options-monitor", ) t.start()