From 2854d94e6b30d41168eb2d8bef0e78cd94ba97a2 Mon Sep 17 00:00:00 2001 From: dekun Date: Wed, 14 Oct 2026 12:33:59 +0800 Subject: [PATCH] Remove USDC product path; CoinOX is coin-margined only. Drop USDC open/quote/swap APIs and env knobs; retarget hedge/settings copy to USDT/coin. Co-authored-by: Cursor --- .env.example | 12 +- lib/common/static/hedge_plan.js | 22 +- lib/env/env_schema.py | 1 - lib/env/env_ui_manifest.py | 77 +- lib/exchange/okx_options_lib.py | 2 +- lib/hedge_plan/hedge_plan_register.py | 8 +- .../templates/hedge_plan_panel.html | 10 +- lib/instance/instance_display_prefs_lib.py | 4 +- lib/options/options_margin_mode_lib.py | 5 +- lib/options/options_register.py | 3619 +++++++---------- .../templates/options_settings_transfer.html | 5 +- scripts/_tmp_deploy_cloud.py | 30 + scripts/_tmp_strip_usdc.py | 70 + 13 files changed, 1682 insertions(+), 2183 deletions(-) create mode 100644 scripts/_tmp_deploy_cloud.py create mode 100644 scripts/_tmp_strip_usdc.py diff --git a/.env.example b/.env.example index 374dd51..b466ff0 100644 --- a/.env.example +++ b/.env.example @@ -120,21 +120,16 @@ OKX_OPTIONS_ENABLED=true # OKX_OPTIONS_API_SECRET= # OKX_OPTIONS_API_PASSPHRASE= OKX_OPTIONS_ACCOUNT_LABEL=账户·期权 -# 单笔期权本位: coin(默认,币本位+USDT买币桥) | usdc(权利金USDC;对冲仍仅USDC) +# CoinOX 固定币本位(USDT 买币桥);勿再配置 USDC 权利金模式 OKX_OPTIONS_MARGIN_MODE=coin -OKX_OPTIONS_TRADE_BUDGET_USDC=10 OKX_OPTIONS_BUDGET_BUFFER=0.95 -# 币本位:按交易户USDT×缓冲复利;上限开关默认关(靠人工转走) +# 按交易户USDT×缓冲复利;上限开关默认关(靠人工转走) OKX_OPTIONS_COIN_COMPOUND=true OKX_OPTIONS_COIN_BUDGET_USDT=10 OKX_OPTIONS_COIN_MAX_USDT_ENABLED=false OKX_OPTIONS_COIN_MAX_USDT=50 # 现货买入相对权利金缓冲:1.10=多买10%;也可写 0.10。按最大可开张数×权利金×缓冲买币,不全额兑换 OKX_OPTIONS_COIN_SPOT_BUY_BUFFER=1.10 -# 全仓复利:开启时隐藏单笔预算且不可用打满;关闭后恢复单笔预算 -OKX_OPTIONS_COMPOUND_FULL_ENABLED=true -OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED=false -OKX_OPTIONS_COMPOUND_FULL_CAP_USDC=300 # 交易模式三选一(热更):options=单独期权 / perp_options=永期对冲 / options_options=期期对冲 # 选单独期权时隐藏对冲导航与对冲配置;选对冲时不可单独开期权,仓位按「对冲组数上限」 OKX_TRADE_MODE=perp_options @@ -152,10 +147,8 @@ OKX_OPTIONS_TD_MODE=isolated OKX_OPTIONS_ALLOW_MARKET_CLOSE=false # 目标平仓门控(目标触达后自动平才校验;比较口径均为 USDT 估值) OKX_OPTIONS_CLOSE_GATE_MODE=premium -# 全局倍数可选;留空则按本位用下方 COIN/USDC OKX_OPTIONS_CLOSE_RECYCLE_MULT= OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN=1.05 -OKX_OPTIONS_CLOSE_RECYCLE_MULT_USDC=2 OKX_OPTIONS_CLOSE_NET_PNL_MIN_U=0 OKX_OPTIONS_CLOSE_HOLD_SECONDS=120 # 对冲买期权等成交超时(秒);超时撤未成交部分,未完全成交则开仓失败 @@ -337,5 +330,4 @@ APP_TIMEZONE=Asia/Shanghai # 切实盘:填 OKX_API_* , LIVE_TRADING_ENABLED=true , SIM_DEFAULT_MODE=live , 并在 OKX 白名单加云主机出口 IP SIM_DEFAULT_MODE=sim SIM_INITIAL_EQUITY_USDT=10000 -SIM_INITIAL_USDC=0 SIM_FEE_RATE=0.0005 diff --git a/lib/common/static/hedge_plan.js b/lib/common/static/hedge_plan.js index 4c88c12..ad2bcc9 100644 --- a/lib/common/static/hedge_plan.js +++ b/lib/common/static/hedge_plan.js @@ -694,16 +694,16 @@ } const line = label + - " · 交易 USDC " + - fmt(acct.trading_usdc, 2) + - " · 资金 USDC " + - fmt(acct.funding_usdc, 2); + " · 交易 USDT " + + fmt(acct.trading_usdt != null ? acct.trading_usdt : acct.trading_usdc, 2) + + " · 资金 USDT " + + fmt(acct.funding_usdt != null ? acct.funding_usdt : acct.funding_usdc, 2); const el = $("hp-opt-bal-line"); if (el) el.textContent = line; const fundEl = $("hp-oo-funding-usdc"); const tradeEl = $("hp-oo-trading-usdc"); - if (fundEl) fundEl.textContent = fmt(acct.funding_usdc, 2); - if (tradeEl) tradeEl.textContent = fmt(acct.trading_usdc, 2); + if (fundEl) fundEl.textContent = fmt(acct.funding_usdt != null ? acct.funding_usdt : acct.funding_usdc, 2); + if (tradeEl) tradeEl.textContent = fmt(acct.trading_usdt != null ? acct.trading_usdt : acct.trading_usdc, 2); autoFillOoSheets(); } @@ -730,7 +730,7 @@ const d = await apiJson("/api/options/transfer", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ccy: "USDC", from: from, to: to, amount: amount }), + body: JSON.stringify({ ccy: "USDT", from: from, to: to, amount: amount }), }); if (!d.ok) { setOoXferMsg(d.msg || "划转失败", "err"); @@ -1312,7 +1312,7 @@ const ct = Number(state.selected.ct_mult || 0.01); const ask = Number(state.selected.ask || 0); const prem = ask * sheets * ct; - line.textContent = "预估权利金 ≈ " + fmt(prem, 4) + " USDC(期权账户)"; + line.textContent = "预估权利金 ≈ " + fmt(prem, 4) + " 币(币本位)"; } function buildStraddleRows(contracts) { @@ -1561,7 +1561,7 @@ fmt(b, 4) + " ≈ " + fmt(a + b, 4) + - " USDC"; + " 币"; } function legPayload(c, sheets) { @@ -2051,7 +2051,7 @@ } const amt = Math.floor(max * 100) / 100; const label = dir === "trading_to_funding" ? "交易 → 资金" : "资金 → 交易"; - if (!window.confirm("确认全部划转?\n方向:" + label + "\n数量:" + amt + " USDC")) return; + if (!window.confirm("确认全部划转?\n方向:" + label + "\n数量:" + amt + " USDT")) return; const amtEl = $("hp-oo-xfer-amount"); if (amtEl) { amtEl.removeAttribute("readonly"); @@ -2435,7 +2435,7 @@ html += "
权利金合计 " + fmt(p.premium_total, 4) + - " USDC
"; + " 币"; html += "
合约摘要 " + (d.contracts_summary || "—") + diff --git a/lib/env/env_schema.py b/lib/env/env_schema.py index 52cef47..9d4e5f3 100644 --- a/lib/env/env_schema.py +++ b/lib/env/env_schema.py @@ -169,7 +169,6 @@ SELECT_OPTIONS: dict[str, tuple[tuple[str, str], ...]] = { ), "OKX_OPTIONS_MARGIN_MODE": ( ("coin", "币本位(USDT买币桥)"), - ("usdc", "USDC(USDⓈ权利金)"), ), "OKX_OPTIONS_CLOSE_GATE_MODE": ( ("premium", "权利金×倍数"), diff --git a/lib/env/env_ui_manifest.py b/lib/env/env_ui_manifest.py index 4d39752..7fb6e32 100644 --- a/lib/env/env_ui_manifest.py +++ b/lib/env/env_ui_manifest.py @@ -27,7 +27,7 @@ _EXCHANGE_LIVE_FIELDS: dict[str, list[tuple[str, str, str]]] = { ( "OKX_SHOW_PERP_FUNDS", "显示永续资金", - "默认开启;关闭后顶栏隐藏 USDT 资金账户与交易账户,总资金仅计期权 USDC 侧", + "默认开启;关闭后顶栏隐藏 USDT 资金账户与交易账户", ), ], "binance": [ @@ -176,63 +176,38 @@ _OPTIONS_SECTION: dict[str, Any] = { "title": "期权账户", "exchanges": frozenset({"okx"}), "fields": [ - ("OKX_OPTIONS_ENABLED", "启用期权模块", "与永续共用上方 OKX_API_*;不再单独配置期权密钥"), + ("OKX_OPTIONS_ENABLED", "启用期权模块", "与永续共用上方 OKX_API_*;CoinOX 固定币本位"), ("OKX_OPTIONS_ACCOUNT_LABEL", "期权账户备注", ""), - ( - "OKX_OPTIONS_MARGIN_MODE", - "单笔期权本位", - "usdc=USDⓈ权利金;coin=币本位+USDT买币桥(默认)。有持仓/半成品桥时勿切换;改后需重启", - ), - ( - "OKX_OPTIONS_TRADE_BUDGET_USDC", - "单笔预算(USDC)", - "仅 USDC 模式且全仓复利关闭时显示/生效;用于「按可用余额打满」及张数/币数上限", - ), - ("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95;USDC 打满/全仓复利与币本位复利共用"), + ("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95;币本位复利/固定预算共用"), ( "OKX_OPTIONS_COIN_COMPOUND", - "币本位按交易户USDT复利", + "按交易户USDT复利", "默认 true;预算=交易账户USDT×缓冲;关闭则用下方固定 USDT 预算×缓冲", ), ( "OKX_OPTIONS_COIN_BUDGET_USDT", - "币本位固定预算(USDT)", - "仅币本位且复利关闭时生效", + "固定预算(USDT)", + "仅复利关闭时生效", ), ( "OKX_OPTIONS_COIN_MAX_USDT_ENABLED", - "币本位单笔上限开关", + "单笔上限开关", "默认 false=靠人工转走控规模;true 时预算不超过下方 N U", ), ( "OKX_OPTIONS_COIN_MAX_USDT", - "币本位单笔上限(USDT)", + "单笔上限(USDT)", "仅上限开关开启时生效", ), ( "OKX_OPTIONS_COIN_SPOT_BUY_BUFFER", - "币本位现货买入缓冲", - "相对权利金倍数,默认 1.10(=多买10%);也可写 0.10 表示+10%。按最大可开张数×卖一权利金×本缓冲买币,不全额兑换", - ), - ( - "OKX_OPTIONS_COMPOUND_FULL_ENABLED", - "全仓复利开关", - "默认 true;仅 USDC 模式。开启时隐藏单笔预算且不可用打满预算,下单以全仓复利为主;关闭则恢复单笔预算并隐藏全仓复利", - ), - ( - "OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", - "全仓复利上限开关", - "仅全仓复利开启时有意义;默认 false=不设上限用期权户全部可用;true 时按下方上限封顶", - ), - ( - "OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", - "全仓复利上限(USDC)", - "仅「全仓复利」且「上限开关」都开启时生效;例如 300", + "现货买入缓冲", + "相对权利金倍数,默认 1.10(=多买10%);按最大可开张数×卖一权利金×本缓冲买币", ), ( "OKX_OPTIONS_MAX_ACTIVE_POSITIONS", "期权持仓上限(笔)", - "仅「单独期权」模式生效;默认 0=不限制;按交易所期权合约笔数计数,同合约加仓不占新笔数", + "仅「单独期权」模式生效;默认 0=不限制", ), ("OKX_OPTIONS_DEFAULT_UNDERLY", "默认标的", "如 ETH"), ( @@ -248,7 +223,7 @@ _OPTIONS_SECTION: dict[str, Any] = { ( "OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", "链上仅显示有卖一", - "默认 true;开启后隐藏无卖一深度或深度不足1张的合约(含标记价估算行)", + "默认 true;开启后隐藏无卖一深度或深度不足1张的合约", ), ( "OKX_OPTIONS_CLOSE_GATE_MODE", @@ -258,18 +233,13 @@ _OPTIONS_SECTION: dict[str, Any] = { ( "OKX_OPTIONS_CLOSE_RECYCLE_MULT", "门控权利金倍数(全局)", - "可选;填写则覆盖下方分本位默认值", + "可选;填写则覆盖下方币本位默认值", ), ( "OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN", - "门控权利金倍数(币本位)", + "门控权利金倍数", "默认 1.05;premium 模式下 recyclable(U)≥premium(U)×本值", ), - ( - "OKX_OPTIONS_CLOSE_RECYCLE_MULT_USDC", - "门控权利金倍数(USDC)", - "默认 2;premium 模式下 recyclable(U)≥premium(U)×本值", - ), ( "OKX_OPTIONS_CLOSE_NET_PNL_MIN_U", "门控净盈亏下限(U)", @@ -583,28 +553,19 @@ def build_env_ui_payload( def _mark_options_env_field_visibility(fields: list[dict[str, Any]]) -> list[dict[str, Any]]: - """按本位/全仓复利隐藏无关项(供 SSR/前端;切换开关仍可再显示).""" - compound_on = True - margin_mode = "coin" + """CoinOX 固定币本位;按复利开关隐藏固定预算等项.""" + coin_compound = True for f in fields: key = f.get("key") cur = str(f.get("current") or f.get("default") or "").strip() - if key == "OKX_OPTIONS_COMPOUND_FULL_ENABLED": - compound_on = _env_truthy(cur or "true") - elif key == "OKX_OPTIONS_MARGIN_MODE": - margin_mode = (cur or "coin").lower() - if margin_mode not in ("coin", "usdc"): - margin_mode = "coin" + if key == "OKX_OPTIONS_COIN_COMPOUND": + coin_compound = _env_truthy(cur or "true") out: list[dict[str, Any]] = [] for f in fields: item = dict(f) key = item.get("key") hide = False - if key == "OKX_OPTIONS_TRADE_BUDGET_USDC" and compound_on: - hide = True - if key == "OKX_OPTIONS_CLOSE_RECYCLE_MULT_USDC" and margin_mode == "coin": - hide = True - if key == "OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN" and margin_mode == "usdc": + if key == "OKX_OPTIONS_COIN_BUDGET_USDT" and coin_compound: hide = True if hide: item["hidden"] = True diff --git a/lib/exchange/okx_options_lib.py b/lib/exchange/okx_options_lib.py index 051198c..826b297 100644 --- a/lib/exchange/okx_options_lib.py +++ b/lib/exchange/okx_options_lib.py @@ -850,7 +850,7 @@ def build_option_chain( family = inst_family_for_underlying(u, margin_mode=margin_mode) except Exception: - family = f"{u}-USD_UM" + family = f"{u}-USD" uly = f"{u}-USD" idx = index_px if index_px is not None else fetch_index_price(ex, uly) chain_margin = "usdc" if "_UM" in family.upper() else "coin" diff --git a/lib/hedge_plan/hedge_plan_register.py b/lib/hedge_plan/hedge_plan_register.py index edef314..926b1a1 100644 --- a/lib/hedge_plan/hedge_plan_register.py +++ b/lib/hedge_plan/hedge_plan_register.py @@ -93,7 +93,11 @@ def _build_cfg(app_module: Any) -> dict[str, Any]: "chain_max_dte": float(os.getenv("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS") or os.getenv("OKX_OPTIONS_MAX_DTE_DAYS") or "14"), "perp_account_label": (os.getenv("OKX_ACCOUNT_LABEL") or "合约账户").strip(), "options_account_label": (os.getenv("OKX_OPTIONS_ACCOUNT_LABEL") or "期权账户").strip(), - "trade_budget_usdc": float(os.getenv("OKX_OPTIONS_TRADE_BUDGET_USDC") or "10"), + "trade_budget_usdc": float( + os.getenv("OKX_OPTIONS_COIN_BUDGET_USDT") + or os.getenv("OKX_OPTIONS_TRADE_BUDGET_USDC") + or "10" + ), # 对冲专用缓冲;与期权页 OKX_OPTIONS_BUDGET_BUFFER 独立 "budget_buffer": float(os.getenv("HEDGE_PLAN_BUDGET_BUFFER") or "0.95"), "oo_bias_split_by": _oo_bias_split_by(), @@ -769,7 +773,7 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None: "chain_max_dte_days": chain_max_dte, "account_kind": "options", "account_label": cfg.get("options_account_label") or "期权账户", - "account_note": "期权腿使用期权账户(交易 USDC)", + "account_note": "期权腿使用币本位(交易账户 USDT 买币开期权)", "options_account": opt_acct, "trade_budget_usdc": cfg.get("trade_budget_usdc"), "budget_buffer": cfg.get("budget_buffer"), diff --git a/lib/hedge_plan/templates/hedge_plan_panel.html b/lib/hedge_plan/templates/hedge_plan_panel.html index ed9d37d..458f350 100644 --- a/lib/hedge_plan/templates/hedge_plan_panel.html +++ b/lib/hedge_plan/templates/hedge_plan_panel.html @@ -53,7 +53,7 @@
规则说明
-

账户:永续腿走合约账户(USDT);期权腿走期权账户(USDC)。两账户分开下单、资金不互通。

+

账户:永续腿走合约账户(USDT);期权腿走币本位(交易账户 USDT 买币开期权)。

模式:在 env HEDGE_PLAN_OPTION_PRIMARY 切换(true=以期权为主 / false=保险模式);标题前标识当前模式。

保险模式:做多配 Put、做空配 Call;左填开仓/止盈止损;仅实值/平值;交易所 TP/SL 出场。

以期权为主:填参后点「策略启动」进入盯盘(非现场开仓);杠杆/间隔达标后自动先开期权再市价永续。右侧列表仅展示达标候选。

@@ -92,7 +92,7 @@

资金与杠杆配置

@@ -405,4 +405,4 @@
- + diff --git a/lib/instance/instance_display_prefs_lib.py b/lib/instance/instance_display_prefs_lib.py index 44af683..9b6f851 100644 --- a/lib/instance/instance_display_prefs_lib.py +++ b/lib/instance/instance_display_prefs_lib.py @@ -26,7 +26,7 @@ DEFAULT_INSTANCE_DISPLAY: dict[str, bool] = { "show_settings_transfer": True, "show_settings_export": True, "show_settings_password": True, - "show_settings_options_swap": True, + "show_settings_options_swap": False, # CoinOX 已移除 USDT↔USDC 兑换 "show_settings_options_transfer": True, } @@ -86,6 +86,7 @@ def normalize_display_prefs(raw: dict | None) -> dict[str, bool]: out["show_nav_strategy"] = False out["show_nav_strategy_records"] = False out["show_nav_system_guide"] = False + out["show_settings_options_swap"] = False return out @@ -140,7 +141,6 @@ def display_meta_for_ui() -> list[dict[str, Any]]: "show_settings_transfer", "show_settings_export", "show_settings_password", - "show_settings_options_swap", "show_settings_options_transfer", ] return [ diff --git a/lib/options/options_margin_mode_lib.py b/lib/options/options_margin_mode_lib.py index b120115..7f010e1 100644 --- a/lib/options/options_margin_mode_lib.py +++ b/lib/options/options_margin_mode_lib.py @@ -53,9 +53,8 @@ def margin_mode_from_inst_id(inst_id: str) -> str: def premium_ccy_for_mode(margin_mode: str, underlying: str = "ETH") -> str: - if normalize_options_margin_mode(margin_mode) == MODE_COIN: - return (underlying or "ETH").strip().upper() or "ETH" - return "USDC" + """CoinOX 仅币本位:权利金币种为标的币.""" + return (underlying or "ETH").strip().upper() or "ETH" def spot_quote_inst_id(underlying: str) -> str: diff --git a/lib/options/options_register.py b/lib/options/options_register.py index 3302362..9782ba2 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -1,2088 +1,1531 @@ -"""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.options.options_pricing_lib import ( - calc_order_size, - ct_mult_from_meta, - min_sz_from_meta, - premium_per_sheet, -) -from lib.exchange.okx_options_lib import ( - _safe_float, - cap_option_buy_sheets_to_ask_depth, - option_buy_liquidity_ok, - td_mode_for_option_buy, -) - - -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, str(default))) - except (TypeError, ValueError): - return default - - -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", True) - attach_options_templates(app, repo_root) - cfg = _build_cfg(app_module) - 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) - - hub_ctx = dict(app.config.get("HUB_CTX") or {}) - hub_ctx["options_snapshot_fn"] = snapshot_fn - 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_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", True), - "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", 0.95), - "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_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 _budget_full_usdc(cfg: dict[str, Any], ex: Any) -> tuple[float | None, str]: - """打满可用额度 = min(交易户可用 USDC, 单笔预算);calc_order_size 再乘 budget_buffer.""" - from lib.exchange.okx_options_lib import fetch_options_trading_usdc - from lib.options.options_pricing_lib import resolve_budget_full_usdc - - raw = fetch_options_trading_usdc(ex) - if raw is None or float(raw) <= 0: - return None, "交易账户 USDC 可用余额不足" - trading = float(raw) - cap = _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", float(cfg.get("trade_budget") or 10.0)) - if cap <= 0: - return None, "单笔预算无效(OKX_OPTIONS_TRADE_BUDGET_USDC)" - return resolve_budget_full_usdc(trading, float(cap)), "" - - -def _compound_full_enabled() -> bool: - return _env_bool("OKX_OPTIONS_COMPOUND_FULL_ENABLED", True) - - -def _budget_full_blocked_by_compound_msg() -> str | None: - if _compound_full_enabled(): - return "全仓复利已开启,不可使用单笔预算/打满;请关闭全仓复利或改用全仓复利模式" - return None - - -def _size_mode_budget_cap( - cfg: dict[str, Any], mode: str, budget_cap: float | None -) -> float | None: - """全仓复利开启时禁用单笔预算封顶(sheets/eth 也不再受 trade_budget 限制).""" - if mode in ("budget_full", "compound_full"): - return budget_cap - if mode in ("sheets", "eth_amount"): - if _compound_full_enabled(): - return None - return budget_cap - return None - - -def _normalize_size_mode(mode: str) -> tuple[str, str | None]: - """全仓复利关闭时强制离开 compound_full,避免前端残留选中导致无法开仓.""" - m = (mode or "sheets").strip() or "sheets" - if m == "compound_full" and not _compound_full_enabled(): - return "sheets", "全仓复利已关闭,已改用指定张数" - if m == "budget_full" and _compound_full_enabled(): - return "compound_full", None - return m, None - - -def _compound_full_usdc(cfg: dict[str, Any], ex: Any) -> tuple[float | None, str]: - """全仓复利 = 期权交易户可用(可选上限封顶);再由 calc_order_size × budget_buffer.""" - if not _compound_full_enabled(): - return None, "全仓复利未开启(OKX_OPTIONS_COMPOUND_FULL_ENABLED)" - from lib.exchange.okx_options_lib import fetch_options_trading_usdc - from lib.options.options_pricing_lib import resolve_compound_full_usdc - - raw = fetch_options_trading_usdc(ex) - if raw is None or float(raw) <= 0: - return None, "交易账户 USDC 可用余额不足" - trading = float(raw) - # 额度热更读 env(与模板启动值无关) - cap_on = _env_bool("OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", False) - cap_v = _env_float("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", 300.0) - if cap_on and cap_v <= 0: - return None, "全仓上限无效(OKX_OPTIONS_COMPOUND_FULL_CAP_USDC)" - return ( - resolve_compound_full_usdc( - trading, - cap_enabled=cap_on, - cap_usdc=cap_v, - ), - "", - ) - - -def _is_budget_mode(mode: str) -> bool: - return mode in ("budget_full", "compound_full") - - -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 "")} - - def _hist(inst_id: str): - return fetch_option_position_history(ex, inst_id) - - 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=_hist) - try: - hist_all = fetch_all_option_positions_history(ex, limit=200) - backfill_closed_options_realized_pnl_from_history(conn, hist_all) - except Exception: - pass - conn.commit() - finally: - conn.close() - - -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.hub.hub_strategy_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)} - conn = cfg["get_db"]() - try: - init_options_tables(conn) - from lib.options.options_spot_bridge_lib import list_open_bridges - - open_bridges = list_open_bridges(conn) - if open_bridges: - payload["bridge_status"] = str(open_bridges[0].get("status") or "") - payload["bridge_underlying"] = str(open_bridges[0].get("underlying") or "") - except Exception: - pass - finally: - conn.close() - 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}) - u = (request.args.get("underlying") or cfg["default_underly"]).upper() - # 热更新:链展示天数每次读 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", 0.95) - 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"}) - 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 "sheets").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.hedge_plan.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}"}) - - budget = cfg["trade_budget"] - budget_cap = cfg["trade_budget"] - available_usdc = None - if mode == "budget_full": - blocked = _budget_full_blocked_by_compound_msg() - if blocked: - return jsonify( - { - "ok": False, - "msg": blocked, - "compound_full_enabled": _compound_full_enabled(), - } - ) - budget, budget_err = _budget_full_usdc(cfg, ex) - if budget is None: - return jsonify({"ok": False, "msg": budget_err, "compound_full_enabled": _compound_full_enabled()}) - budget_cap = budget - from lib.exchange.okx_options_lib import fetch_options_trading_usdc - - available_usdc = fetch_options_trading_usdc(ex) - elif mode == "compound_full": - if not _compound_full_enabled(): - return jsonify( - { - "ok": False, - "msg": "全仓复利未开启(OKX_OPTIONS_COMPOUND_FULL_ENABLED)", - "compound_full_enabled": False, - } - ) - budget, budget_err = _compound_full_usdc(cfg, ex) - if budget is None: - return jsonify({"ok": False, "msg": budget_err, "compound_full_enabled": True}) - budget_cap = budget - from lib.exchange.okx_options_lib import fetch_options_trading_usdc - - available_usdc = fetch_options_trading_usdc(ex) - elif mode in ("sheets", "eth_amount") and _compound_full_enabled(): - budget_cap = None - eth_amount = None - try: - if request.args.get("eth_amount"): - eth_amount = float(request.args.get("eth_amount")) - except (TypeError, ValueError): - pass - ask = q.get("ask") - ask_sz = q.get("ask_sz") - try: - from lib.hedge_plan.okx_trade_mode_lib import block_standalone_open_by_mode_msg - - mode_block = block_standalone_open_by_mode_msg() - if mode_block: - return jsonify( - { - **q, - "ok": True, - "can_open": False, - "msg": mode_block, - "quote_per_unit": ask, - "premium_per_sheet": None, - "sizing": { - "ok": False, - "msg": mode_block, - "sheets": 0, - "eth_amount": 0.0, - "total_premium": 0.0, - }, - "available_usdc": available_usdc, - "budget_full_usdc": budget if mode == "budget_full" else None, - "compound_full_usdc": budget if mode == "compound_full" else None, - } - ) - except Exception as e: - return jsonify( - { - "ok": False, - "can_open": False, - "msg": f"交易模式校验失败: {e}", - } - ) - try: - from lib.hedge_plan.hedge_options_exclusive_lib import block_standalone_option_open_msg - - conn_q = cfg["get_db"]() - try: - excl = block_standalone_option_open_msg(conn_q) - finally: - conn_q.close() - if excl: - return jsonify( - { - **q, - "ok": True, - "can_open": False, - "msg": excl, - "quote_per_unit": ask, - "premium_per_sheet": None, - "sizing": { - "ok": False, - "msg": excl, - "sheets": 0, - "eth_amount": 0.0, - "total_premium": 0.0, - }, - "available_usdc": available_usdc, - "budget_full_usdc": budget if mode == "budget_full" else None, - "compound_full_usdc": budget if mode == "compound_full" else None, - } - ) - except Exception as e: - return jsonify({"ok": False, "can_open": False, "msg": f"互斥校验失败: {e}"}) - can_open, block_msg = option_buy_liquidity_ok(ask, ask_sz) - if not can_open: - # 合约可报价,但不可开仓:返回参考标记价供展示 - return jsonify( - { - **q, - "ok": True, - "can_open": False, - "msg": block_msg or q.get("open_block_msg") or "暂无卖一深度,无法买入", - "quote_per_unit": None, - "premium_per_sheet": None, - "sizing": { - "ok": False, - "msg": block_msg or "暂无卖一深度,无法买入", - "sheets": 0, - "eth_amount": 0.0, - "total_premium": 0.0, - }, - "available_usdc": available_usdc, - "budget_full_usdc": budget if mode == "budget_full" else None, - "compound_full_usdc": budget if mode == "compound_full" else None, - } - ) - from lib.options.options_position_limit_lib import ( - compound_full_single_position_block_msg, - option_position_limit_block_msg, - ) - - if mode == "compound_full": - compound_block = compound_full_single_position_block_msg( - ex, fetch_positions=cfg.get("fetch_option_positions") - ) - if compound_block: - return jsonify( - { - **q, - "ok": True, - "can_open": False, - "msg": compound_block, - "quote_per_unit": ask, - "premium_per_sheet": None, - "sizing": { - "ok": False, - "msg": compound_block, - "sheets": 0, - "eth_amount": 0.0, - "total_premium": 0.0, - }, - "available_usdc": available_usdc, - "budget_full_usdc": None, - "compound_full_usdc": budget, - } - ) - - pos_limit_msg = option_position_limit_block_msg( - ex, - opening_inst_id=inst_id, - fetch_positions=cfg.get("fetch_option_positions"), - ) - if pos_limit_msg: - return jsonify( - { - **q, - "ok": True, - "can_open": False, - "msg": pos_limit_msg, - "quote_per_unit": ask, - "premium_per_sheet": None, - "sizing": { - "ok": False, - "msg": pos_limit_msg, - "sheets": 0, - "eth_amount": 0.0, - "total_premium": 0.0, - }, - "available_usdc": available_usdc, - "budget_full_usdc": budget if mode == "budget_full" else None, - "compound_full_usdc": budget if mode == "compound_full" else None, - } - ) - sizing = calc_order_size( - quote_per_unit=float(ask), - ct_mult=float(ct_mult), - min_sz=int(min_sz), - budget_usdc=budget if _is_budget_mode(mode) else None, - budget_buffer=cfg["budget_buffer"], - eth_amount=eth_amount if mode == "eth_amount" else None, - sheets=sheet_count if mode == "sheets" else None, - budget_cap=_size_mode_budget_cap(cfg, mode, budget_cap) - if mode in ("budget_full", "compound_full", "sheets", "eth_amount") - else None, - ) - if sizing.get("ok"): - capped, cap_msg = cap_option_buy_sheets_to_ask_depth( - int(sizing.get("sheets") or 0), - ask_sz, - min_sz=int(min_sz), - ) - if capped is None: - sizing = { - "ok": False, - "msg": cap_msg, - "sheets": 0, - "eth_amount": 0.0, - "total_premium": 0.0, - } - elif capped < int(sizing.get("sheets") or 0): - sizing = calc_order_size( - quote_per_unit=float(ask), - ct_mult=float(ct_mult), - min_sz=int(min_sz), - sheets=capped, - budget_cap=_size_mode_budget_cap(cfg, mode, budget_cap) - if mode in ("budget_full", "compound_full", "sheets", "eth_amount") - else None, - ) - if sizing.get("ok"): - sizing["ask_depth_capped"] = True - sizing["ask_sz"] = ask_sz - sizing["msg"] = f"已按卖一深度限制为 {capped} 张" - q = _attach_close_preview( - cfg, - ex, - q, - sheets=int(sizing.get("sheets") or sheet_count or 0), - premium_paid=_open_premium_paid(cfg, inst_id), - ) - return jsonify( - { - **q, - "can_open": True, - "quote_per_unit": ask, - "premium_per_sheet": premium_per_sheet(float(ask), float(ct_mult)), - "sizing": sizing, - "available_usdc": available_usdc, - "budget_full_usdc": budget if mode == "budget_full" else None, - "compound_full_usdc": budget if mode == "compound_full" else None, - "mode": mode, - "mode_note": mode_note, - "compound_full_enabled": _compound_full_enabled(), - } - ) - - @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.hedge_plan.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}) - try: - from lib.hedge_plan.hedge_options_exclusive_lib import block_standalone_option_open_msg - - conn_gate = cfg["get_db"]() - try: - block_msg = block_standalone_option_open_msg(conn_gate) - finally: - conn_gate.close() - if block_msg: - return jsonify({"ok": False, "msg": block_msg}) - except Exception as e: - return jsonify({"ok": False, "msg": f"互斥校验失败: {e}"}) - data = request.get_json(silent=True) or {} - inst_id = (data.get("inst_id") or "").strip() - mode = (data.get("mode") or "sheets").strip() - mode, mode_note = _normalize_size_mode(mode) - signal_note = (data.get("signal_note") or "").strip() - 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"}) - 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}"}) - q = cfg["quote_option_contract"](ex, inst_id) - if not q.get("ok"): - return jsonify(q) - ask = q.get("ask") - ask_sz = q.get("ask_sz") - can_open, block_msg = option_buy_liquidity_ok(ask, ask_sz) - if not can_open: - return jsonify( - { - "ok": False, - "msg": block_msg or q.get("open_block_msg") or "暂无卖一深度,无法买入", - "can_open": False, - "mark": q.get("mark"), - "ref_ask": q.get("ref_ask"), - } - ) - from lib.options.options_position_limit_lib import ( - compound_full_single_position_block_msg, - option_position_limit_block_msg, - ) - - if mode == "compound_full": - compound_block = compound_full_single_position_block_msg( - ex, fetch_positions=cfg.get("fetch_option_positions") - ) - if compound_block: - return jsonify({"ok": False, "msg": compound_block, "can_open": False}) - - pos_limit_msg = option_position_limit_block_msg( - ex, - opening_inst_id=inst_id, - fetch_positions=cfg.get("fetch_option_positions"), - ) - if pos_limit_msg: - return jsonify({"ok": False, "msg": pos_limit_msg, "can_open": False}) - ct_mult = float(q.get("ct_mult") or 0.01) - min_sz = int(q.get("min_sz") or 1) - eth_amount = None - sheet_count = None - if mode == "eth_amount": - try: - eth_amount = float(data.get("eth_amount")) - except (TypeError, ValueError): - return jsonify({"ok": False, "msg": "ETH 数量无效"}) - elif mode == "sheets": - try: - sheet_count = int(data.get("sheets")) - except (TypeError, ValueError): - sheet_count = None - if sheet_count is None or int(sheet_count) < 1: - # 全仓复利关闭后前端可能仍带着旧 mode 过来,归一后缺张数则默认 1 - if (data.get("mode") or "").strip() == "compound_full": - sheet_count = 1 - else: - return jsonify({"ok": False, "msg": "张数无效"}) - budget = cfg["trade_budget"] - budget_cap = cfg["trade_budget"] - if mode == "budget_full": - blocked = _budget_full_blocked_by_compound_msg() - if blocked: - return jsonify({"ok": False, "msg": blocked, "compound_full_enabled": _compound_full_enabled()}) - budget, budget_err = _budget_full_usdc(cfg, ex) - if budget is None: - return jsonify({"ok": False, "msg": budget_err}) - budget_cap = budget - elif mode == "compound_full": - if not _compound_full_enabled(): - return jsonify( - { - "ok": False, - "msg": "全仓复利未开启,请改用指定张数或先开启全仓复利", - "compound_full_enabled": False, - } - ) - budget, budget_err = _compound_full_usdc(cfg, ex) - if budget is None: - return jsonify({"ok": False, "msg": budget_err}) - budget_cap = budget - elif mode in ("sheets", "eth_amount") and _compound_full_enabled(): - budget_cap = None - sizing = calc_order_size( - quote_per_unit=float(ask), - ct_mult=ct_mult, - min_sz=min_sz, - budget_usdc=budget if _is_budget_mode(mode) else None, - budget_buffer=cfg["budget_buffer"], - eth_amount=eth_amount, - sheets=sheet_count, - budget_cap=_size_mode_budget_cap(cfg, mode, budget_cap) - if mode in ("budget_full", "compound_full", "sheets", "eth_amount") - else None, - ) - if not sizing.get("ok"): - return jsonify({"ok": False, "msg": sizing.get("msg") or "张数计算失败", "sizing": sizing}) - sheets = int(sizing["sheets"]) - capped, cap_msg = cap_option_buy_sheets_to_ask_depth(sheets, ask_sz, min_sz=min_sz) - if capped is None: - return jsonify({"ok": False, "msg": cap_msg or "卖一深度不足,无法买入"}) - if capped < sheets: - return jsonify( - { - "ok": False, - "msg": f"卖一深度仅 {int(capped)} 张,不足请求 {int(sheets)} 张,拒绝缩量成交", - "requested_sheets": int(sheets), - "ask_sz": ask_sz, - } - ) - tick_sz = q.get("tick_sz") - order = cfg["place_option_limit_order"]( - ex, - inst_id=inst_id, - side="buy", - sheets=sheets, - price=float(ask), - td_mode=td_mode_for_option_buy(cfg["td_mode"]), - tick_sz=tick_sz, - ord_type="ioc", - ) - if not order.get("ok"): - return jsonify(order) - ord_id = str((order.get("data") or {}).get("ordId") or "").strip() - if not ord_id: - return jsonify({"ok": False, "msg": "下单成功但未返回订单号", "order": order}) - from lib.exchange.okx_options_lib import wait_option_order_full_fill - - try: - fill_timeout = max(2.0, float(os.getenv("OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC") or "12")) - except (TypeError, ValueError): - fill_timeout = 12.0 - fill = wait_option_order_full_fill( - ex, - inst_id=inst_id, - ord_id=ord_id, - need_sheets=int(sheets), - timeout_sec=fill_timeout, - cancel_on_timeout=True, - ) - if not fill.get("ok"): - filled_n = int(fill.get("filled_sheets") or 0) - orphan_close = None - if filled_n > 0: - try: - from lib.options.options_close_exec_lib import close_option_by_bid1 - - orphan_close = close_option_by_bid1( - cfg, ex, inst_id, sheets=filled_n, require_recycle_gate=False - ) - except Exception as e: - orphan_close = {"ok": False, "msg": str(e)} - return jsonify( - { - "ok": False, - "msg": fill.get("msg") or "未完全成交,开仓失败", - "filled_sheets": filled_n, - "orphan_close": orphan_close, - "fill": fill, - "order": order, - } - ) - fill_px = float(fill.get("avg_px") or ask) - filled_n = int(fill.get("filled_sheets") or sheets) - sheets = filled_n - sizing = dict(sizing) - sizing["sheets"] = sheets - sizing["eth_amount"] = round(sheets * ct_mult, 8) - from lib.options.options_pricing_lib import PREMIUM_DECIMALS_COIN, round_premium_amount - - sizing["total_premium"] = round_premium_amount( - fill_px * sheets * ct_mult, decimals=PREMIUM_DECIMALS_COIN - ) - conn = cfg["get_db"]() - trade_id = None - target_mon = None - open_underlying = "" - open_opt_type = None - try: - init_options_tables(conn) - from lib.options.options_profit_exit_lib import ensure_profit_exit_columns - from lib.options.options_time_lib import options_app_now_str - - ensure_profit_exit_columns(conn) - meta = q.get("meta") or {} - u = str(meta.get("uly") or inst_id).split("-")[0] - opt_type = meta.get("optType") - open_underlying = u - open_opt_type = opt_type - cur = conn.execute( - """ - INSERT INTO options_trades - (inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount, - open_quote, premium_paid, status, signal_note, exchange_ord_id, - profit_exit_enabled, profit_exit_mult, profit_exit_state, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?) - """, - ( - inst_id, - u, - opt_type, - q.get("strike"), - str(q.get("exp_time") or ""), - sheets, - sizing["eth_amount"], - fill_px, - sizing["total_premium"], - signal_note, - ord_id, - 1 if profit_exit_enabled else 0, - profit_exit_mult if profit_exit_enabled else 1.0, - "active" if profit_exit_enabled else "idle", - options_app_now_str(), - ), - ) - trade_id = int(cur.lastrowid) - if target_index is not None: - from lib.options.options_target_lib import upsert_target_monitor - - target_mon = upsert_target_monitor( - conn, - inst_id=inst_id, - target_index=target_index, - underlying=u, - opt_type=str(opt_type) if opt_type else None, - trade_id=trade_id, - sheets=sheets, - ) - if profit_exit_enabled: - pass # 列已由 init_options_tables / ensure 迁移 - conn.commit() - finally: - conn.close() - from lib.exchange.okx_options_lib import invalidate_option_positions_cache - from lib.options.options_notify_lib import notify_options_open - - invalidate_option_positions_cache() - _sync_options_trades(cfg, force=True) - try: - conn_n = cfg["get_db"]() - try: - notify_options_open( - cfg, - conn_n, - trade_id=trade_id, - inst_id=inst_id, - underlying=open_underlying, - opt_type=open_opt_type, - sheets=sheets, - premium_paid=sizing.get("total_premium"), - open_quote=fill_px, - target_index=target_index, - signal_note=signal_note, - ) - finally: - conn_n.close() - except Exception: - pass - return jsonify( - { - "ok": True, - "order": order, - "sizing": sizing, - "trade_id": trade_id, - "target_monitor": target_mon, - } - ) - - @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: - init_options_tables(conn) - from lib.options.options_time_lib import options_app_now_str - - 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 - from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst - - tgt_map = targets_by_inst(conn) - profit_exit_map = profit_exit_by_inst(conn) - hedge_target_map = active_options_targets_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") - hedge_target = hedge_target_map.get(inst) - if hedge_target: - row["hedge_plan_target"] = hedge_target - 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() - return jsonify({"ok": True, "positions": rows}) - - @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: - from lib.hedge_plan.hedge_plan_db import ( - active_hedge_option_inst_ids, - init_hedge_plan_tables, - ) - - conn_h = cfg["get_db"]() - try: - init_hedge_plan_tables(conn_h) - if inst_id in active_hedge_option_inst_ids(conn_h): - return jsonify( - { - "ok": False, - "msg": "该合约属于进行中的对冲计划,请在对冲计划中管理,禁止在期权页设置目标", - } - ) - finally: - conn_h.close() - except Exception as e: - return jsonify({"ok": False, "msg": f"对冲托管校验失败: {e}"}) - 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"}) - try: - from lib.hedge_plan.hedge_plan_db import ( - active_hedge_option_inst_ids, - init_hedge_plan_tables, - ) - - conn_h = cfg["get_db"]() - try: - init_hedge_plan_tables(conn_h) - if inst_id in active_hedge_option_inst_ids(conn_h): - return jsonify( - { - "ok": False, - "msg": "该合约属于进行中的对冲计划,请在对冲计划中管理,禁止在期权页设置翻倍出场", - } - ) - finally: - conn_h.close() - except Exception as e: - return jsonify({"ok": False, "msg": f"对冲托管校验失败: {e}"}) - 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(): - 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: - from lib.hedge_plan.hedge_plan_db import ( - active_hedge_option_inst_ids, - init_hedge_plan_tables, - ) - - conn_h = cfg["get_db"]() - try: - init_hedge_plan_tables(conn_h) - if inst_id in active_hedge_option_inst_ids(conn_h): - return jsonify( - { - "ok": False, - "msg": "该合约属于进行中的对冲计划,请在对冲计划中管理,禁止在期权页平仓", - } - ) - finally: - conn_h.close() - except Exception as e: - return jsonify({"ok": False, "msg": f"对冲托管校验失败: {e}"}) - 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": "张数无效"}) - from lib.options.options_close_exec_lib import close_option_by_bid1 - - # 手动买一平仓:只验有效流动性;2×门控仅用于自动/目标位平仓 - result = close_option_by_bid1( - cfg, - ex, - inst_id, - sheets=sheets_i, - require_recycle_gate=False, - ) - if result.get("ok"): - from lib.exchange.okx_options_lib import invalidate_option_positions_cache - - invalidate_option_positions_cache() - _sync_options_trades(cfg, force=True) - if result.get("fully_closed"): - try: - from lib.options.options_target_lib import cancel_target_monitor - from lib.options.options_notify_lib import notify_options_close - - conn2 = cfg["get_db"]() - try: - cancel_target_monitor(conn2, inst_id=inst_id) - conn2.commit() - notify_options_close( - cfg, - conn2, - inst_id=inst_id, - reason="手动平仓", - 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 - 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 jsonify(result) - - @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 - conn = cfg["get_db"]() - try: - init_options_tables(conn) - from lib.options.options_spot_bridge_lib import sell_residual_after_option_flat - - out = sell_residual_after_option_flat(conn, ex, underlying=underlying, inst_id=inst_id) - if out.get("ok"): - _mark_balances_stale(cfg) - return jsonify(out) - finally: - conn.close() - - @app.route("/api/options/convert/quote", methods=["POST"]) - @lr - def api_options_convert_quote(): - ex, err = _require_options_ex(cfg) - if ex is None: - return jsonify({"ok": False, "msg": err}) - data = request.get_json(silent=True) or {} - try: - amount = float(data.get("amount")) - except (TypeError, ValueError): - return jsonify({"ok": False, "msg": "数量无效"}) - return jsonify(cfg["estimate_usdt_to_usdc"](ex, amount)) - - @app.route("/api/options/convert/execute", methods=["POST"]) - @lr - def api_options_convert_execute(): - ex, err = _require_options_ex(cfg) - if ex is None: - return jsonify({"ok": False, "msg": err}) - data = request.get_json(silent=True) or {} - quote_id = (data.get("quote_id") or "").strip() - result = cfg["execute_convert"](ex, quote_id) - if result.get("ok"): - conn = cfg["get_db"]() - try: - init_options_tables(conn) - conn.execute( - """ - INSERT INTO options_convert_log (from_ccy, to_ccy, rfq_sz, received_sz, quote_id, status, message) - VALUES ('USDT', 'USDC', ?, ?, ?, 'ok', '') - """, - ( - data.get("rfq_sz"), - (result.get("data") or {}).get("baseSz"), - quote_id, - ), - ) - conn.commit() - finally: - conn.close() - return jsonify(result) - - @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}) - data = request.get_json(silent=True) or {} - ccy = (data.get("ccy") or "USDC").upper() - from_acct = (data.get("from") or "funding").strip() - to_acct = (data.get("to") or "trading").strip() - try: - amount = float(data.get("amount")) - except (TypeError, ValueError): - return jsonify({"ok": False, "msg": "数量无效"}) - result = cfg["transfer_ccy"](ex, ccy, amount, from_acct, to_acct) - if result.get("ok"): - conn = cfg["get_db"]() - try: - init_options_tables(conn) - conn.execute( - """ - INSERT INTO options_transfer_log (ccy, amount, from_account, to_account, status, message) - VALUES (?, ?, ?, ?, 'ok', '') - """, - (ccy, amount, from_acct, to_acct), - ) - conn.commit() - finally: - conn.close() - _mark_balances_stale(cfg) - return jsonify(result) - - @app.route("/api/options/spot/swap", methods=["POST"]) - @lr - def api_options_spot_swap(): - ex, err = _require_options_ex(cfg) - if ex is None: - return jsonify({"ok": False, "msg": err}) - data = request.get_json(silent=True) or {} - direction = (data.get("direction") or "usdt_to_usdc").strip() - try: - amount = float(data.get("amount")) - except (TypeError, ValueError): - return jsonify({"ok": False, "msg": "数量无效"}) - result = cfg["spot_market_swap_usdt_usdc"](ex, direction=direction, amount=amount) - if result.get("ok"): - _mark_balances_stale(cfg) - return jsonify(result) - - @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_history_lib import load_options_history - from lib.options.options_positions_lib import sum_options_net_pnl_usdc - from lib.options.options_stats_lib import compute_options_stats_from_history - - raw_live = cfg["fetch_option_positions"](ex) - if raw_live is None: - return jsonify({"ok": False, "msg": "获取期权持仓失败"}) - history = load_options_history(ex, cfg) - index_px = None - try: - from lib.exchange.okx_options_lib import fetch_index_price - from lib.options.options_margin_mode_lib import ( - is_coin_margin_mode, - normalize_options_margin_mode, - ) - - underly = (cfg.get("default_underly") or "ETH").strip().upper() or "ETH" - if is_coin_margin_mode(normalize_options_margin_mode(cfg.get("margin_mode"))): - index_px = fetch_index_price(ex, underly) - except Exception: - index_px = None - stats = compute_options_stats_from_history(history, index_px=index_px) - # sum_options_net_pnl_usdc 已按指数折 U;勿再 × 指数(否则顶栏会变成上万假亏) - 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) - from lib.options.options_margin_mode_lib import is_coin_margin_mode - - pnl_unit = "U" if is_coin_margin_mode() else str(stats.get("pnl_unit") or "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): - from lib.exchange.okx_options_lib import fetch_option_position_history - from lib.options.options_monitor_lib import reconcile_live_open_trades, sync_open_options_trades - - ex = cfg.get("exchange_options") - if ex is None: - return 0 - raw = cfg["fetch_option_positions"](ex) - if raw is None: - return 0 - live_ids = {str(p.get("instId") or "") for p in raw if str(p.get("instId") or "")} - reconcile_live_open_trades(conn, live_inst_ids=live_ids) - return sync_open_options_trades( - conn, - live_inst_ids=live_ids, - fetch_history_fn=lambda inst_id: fetch_option_position_history(ex, inst_id), - notify_cfg=cfg, - ) - - 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 - - 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, - }, - daemon=True, - name="options-monitor", - ) - t.start() +"""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.options.options_pricing_lib import ( + calc_order_size, + ct_mult_from_meta, + min_sz_from_meta, + premium_per_sheet, +) +from lib.exchange.okx_options_lib import ( + _safe_float, + cap_option_buy_sheets_to_ask_depth, + option_buy_liquidity_ok, + td_mode_for_option_buy, +) + + +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, str(default))) + except (TypeError, ValueError): + return default + + +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", True) + attach_options_templates(app, repo_root) + cfg = _build_cfg(app_module) + 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) + + hub_ctx = dict(app.config.get("HUB_CTX") or {}) + hub_ctx["options_snapshot_fn"] = snapshot_fn + 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_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", True), + "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", 0.95), + "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_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 _budget_full_usdc(cfg: dict[str, Any], ex: Any) -> tuple[float | None, str]: + """打满可用额度 = min(交易户可用 USDC, 单笔预算);calc_order_size 再乘 budget_buffer.""" + from lib.exchange.okx_options_lib import fetch_options_trading_usdc + from lib.options.options_pricing_lib import resolve_budget_full_usdc + + raw = fetch_options_trading_usdc(ex) + if raw is None or float(raw) <= 0: + return None, "交易账户 USDC 可用余额不足" + trading = float(raw) + cap = _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", float(cfg.get("trade_budget") or 10.0)) + if cap <= 0: + return None, "单笔预算无效(OKX_OPTIONS_TRADE_BUDGET_USDC)" + return resolve_budget_full_usdc(trading, float(cap)), "" + + +def _compound_full_enabled() -> bool: + return _env_bool("OKX_OPTIONS_COMPOUND_FULL_ENABLED", True) + + +def _budget_full_blocked_by_compound_msg() -> str | None: + if _compound_full_enabled(): + return "全仓复利已开启,不可使用单笔预算/打满;请关闭全仓复利或改用全仓复利模式" + return None + + +def _size_mode_budget_cap( + cfg: dict[str, Any], mode: str, budget_cap: float | None +) -> float | None: + """全仓复利开启时禁用单笔预算封顶(sheets/eth 也不再受 trade_budget 限制).""" + if mode in ("budget_full", "compound_full"): + return budget_cap + if mode in ("sheets", "eth_amount"): + if _compound_full_enabled(): + return None + return budget_cap + return None + + +def _normalize_size_mode(mode: str) -> tuple[str, str | None]: + """全仓复利关闭时强制离开 compound_full,避免前端残留选中导致无法开仓.""" + m = (mode or "sheets").strip() or "sheets" + if m == "compound_full" and not _compound_full_enabled(): + return "sheets", "全仓复利已关闭,已改用指定张数" + if m == "budget_full" and _compound_full_enabled(): + return "compound_full", None + return m, None + + +def _compound_full_usdc(cfg: dict[str, Any], ex: Any) -> tuple[float | None, str]: + """全仓复利 = 期权交易户可用(可选上限封顶);再由 calc_order_size × budget_buffer.""" + if not _compound_full_enabled(): + return None, "全仓复利未开启(OKX_OPTIONS_COMPOUND_FULL_ENABLED)" + from lib.exchange.okx_options_lib import fetch_options_trading_usdc + from lib.options.options_pricing_lib import resolve_compound_full_usdc + + raw = fetch_options_trading_usdc(ex) + if raw is None or float(raw) <= 0: + return None, "交易账户 USDC 可用余额不足" + trading = float(raw) + # 额度热更读 env(与模板启动值无关) + cap_on = _env_bool("OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", False) + cap_v = _env_float("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", 300.0) + if cap_on and cap_v <= 0: + return None, "全仓上限无效(OKX_OPTIONS_COMPOUND_FULL_CAP_USDC)" + return ( + resolve_compound_full_usdc( + trading, + cap_enabled=cap_on, + cap_usdc=cap_v, + ), + "", + ) + + +def _is_budget_mode(mode: str) -> bool: + return mode in ("budget_full", "compound_full") + + +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 "")} + + def _hist(inst_id: str): + return fetch_option_position_history(ex, inst_id) + + 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=_hist) + try: + hist_all = fetch_all_option_positions_history(ex, limit=200) + backfill_closed_options_realized_pnl_from_history(conn, hist_all) + except Exception: + pass + conn.commit() + finally: + conn.close() + + +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.hub.hub_strategy_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)} + conn = cfg["get_db"]() + try: + init_options_tables(conn) + from lib.options.options_spot_bridge_lib import list_open_bridges + + open_bridges = list_open_bridges(conn) + if open_bridges: + payload["bridge_status"] = str(open_bridges[0].get("status") or "") + payload["bridge_underlying"] = str(open_bridges[0].get("underlying") or "") + except Exception: + pass + finally: + conn.close() + 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}) + u = (request.args.get("underlying") or cfg["default_underly"]).upper() + # 热更新:链展示天数每次读 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", 0.95) + 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"}) + 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 "sheets").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.hedge_plan.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": "CoinOX 仅支持币本位期权"}) + + @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.hedge_plan.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}) + try: + from lib.hedge_plan.hedge_options_exclusive_lib import block_standalone_option_open_msg + + conn_gate = cfg["get_db"]() + try: + block_msg = block_standalone_option_open_msg(conn_gate) + finally: + conn_gate.close() + if block_msg: + return jsonify({"ok": False, "msg": block_msg}) + except Exception as e: + return jsonify({"ok": False, "msg": f"互斥校验失败: {e}"}) + data = request.get_json(silent=True) or {} + inst_id = (data.get("inst_id") or "").strip() + mode = (data.get("mode") or "sheets").strip() + mode, mode_note = _normalize_size_mode(mode) + signal_note = (data.get("signal_note") or "").strip() + 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"}) + 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": "CoinOX 仅支持币本位期权"}) + + @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: + init_options_tables(conn) + from lib.options.options_time_lib import options_app_now_str + + 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 + from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst + + tgt_map = targets_by_inst(conn) + profit_exit_map = profit_exit_by_inst(conn) + hedge_target_map = active_options_targets_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") + hedge_target = hedge_target_map.get(inst) + if hedge_target: + row["hedge_plan_target"] = hedge_target + 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() + return jsonify({"ok": True, "positions": rows}) + + @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: + from lib.hedge_plan.hedge_plan_db import ( + active_hedge_option_inst_ids, + init_hedge_plan_tables, + ) + + conn_h = cfg["get_db"]() + try: + init_hedge_plan_tables(conn_h) + if inst_id in active_hedge_option_inst_ids(conn_h): + return jsonify( + { + "ok": False, + "msg": "该合约属于进行中的对冲计划,请在对冲计划中管理,禁止在期权页设置目标", + } + ) + finally: + conn_h.close() + except Exception as e: + return jsonify({"ok": False, "msg": f"对冲托管校验失败: {e}"}) + 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"}) + try: + from lib.hedge_plan.hedge_plan_db import ( + active_hedge_option_inst_ids, + init_hedge_plan_tables, + ) + + conn_h = cfg["get_db"]() + try: + init_hedge_plan_tables(conn_h) + if inst_id in active_hedge_option_inst_ids(conn_h): + return jsonify( + { + "ok": False, + "msg": "该合约属于进行中的对冲计划,请在对冲计划中管理,禁止在期权页设置翻倍出场", + } + ) + finally: + conn_h.close() + except Exception as e: + return jsonify({"ok": False, "msg": f"对冲托管校验失败: {e}"}) + 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(): + 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: + from lib.hedge_plan.hedge_plan_db import ( + active_hedge_option_inst_ids, + init_hedge_plan_tables, + ) + + conn_h = cfg["get_db"]() + try: + init_hedge_plan_tables(conn_h) + if inst_id in active_hedge_option_inst_ids(conn_h): + return jsonify( + { + "ok": False, + "msg": "该合约属于进行中的对冲计划,请在对冲计划中管理,禁止在期权页平仓", + } + ) + finally: + conn_h.close() + except Exception as e: + return jsonify({"ok": False, "msg": f"对冲托管校验失败: {e}"}) + 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": "张数无效"}) + from lib.options.options_close_exec_lib import close_option_by_bid1 + + # 手动买一平仓:只验有效流动性;2×门控仅用于自动/目标位平仓 + result = close_option_by_bid1( + cfg, + ex, + inst_id, + sheets=sheets_i, + require_recycle_gate=False, + ) + if result.get("ok"): + from lib.exchange.okx_options_lib import invalidate_option_positions_cache + + invalidate_option_positions_cache() + _sync_options_trades(cfg, force=True) + if result.get("fully_closed"): + try: + from lib.options.options_target_lib import cancel_target_monitor + from lib.options.options_notify_lib import notify_options_close + + conn2 = cfg["get_db"]() + try: + cancel_target_monitor(conn2, inst_id=inst_id) + conn2.commit() + notify_options_close( + cfg, + conn2, + inst_id=inst_id, + reason="手动平仓", + 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 + 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 jsonify(result) + + @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 + conn = cfg["get_db"]() + try: + init_options_tables(conn) + from lib.options.options_spot_bridge_lib import sell_residual_after_option_flat + + out = sell_residual_after_option_flat(conn, ex, underlying=underlying, inst_id=inst_id) + if out.get("ok"): + _mark_balances_stale(cfg) + return jsonify(out) + finally: + conn.close() + + @app.route("/api/options/convert/quote", methods=["POST"]) + @lr + def api_options_convert_quote(): + return jsonify({"ok": False, "msg": "CoinOX 已移除 USDC 兑换"}), 410 + + @app.route("/api/options/convert/execute", methods=["POST"]) + @lr + def api_options_convert_execute(): + return jsonify({"ok": False, "msg": "CoinOX 已移除 USDC 兑换"}), 410 + + @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}) + data = request.get_json(silent=True) or {} + ccy = (data.get("ccy") or "USDT").upper() + from_acct = (data.get("from") or "funding").strip() + to_acct = (data.get("to") or "trading").strip() + try: + amount = float(data.get("amount")) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "数量无效"}) + result = cfg["transfer_ccy"](ex, ccy, amount, from_acct, to_acct) + if result.get("ok"): + conn = cfg["get_db"]() + try: + init_options_tables(conn) + conn.execute( + """ + INSERT INTO options_transfer_log (ccy, amount, from_account, to_account, status, message) + VALUES (?, ?, ?, ?, 'ok', '') + """, + (ccy, amount, from_acct, to_acct), + ) + conn.commit() + finally: + conn.close() + _mark_balances_stale(cfg) + return jsonify(result) + + @app.route("/api/options/spot/swap", methods=["POST"]) + @lr + def api_options_spot_swap(): + return jsonify({"ok": False, "msg": "CoinOX 已移除 USDC 兑换"}), 410 + + @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_history_lib import load_options_history + from lib.options.options_positions_lib import sum_options_net_pnl_usdc + from lib.options.options_stats_lib import compute_options_stats_from_history + + raw_live = cfg["fetch_option_positions"](ex) + if raw_live is None: + return jsonify({"ok": False, "msg": "获取期权持仓失败"}) + history = load_options_history(ex, cfg) + index_px = None + try: + from lib.exchange.okx_options_lib import fetch_index_price + from lib.options.options_margin_mode_lib import ( + is_coin_margin_mode, + normalize_options_margin_mode, + ) + + underly = (cfg.get("default_underly") or "ETH").strip().upper() or "ETH" + if is_coin_margin_mode(normalize_options_margin_mode(cfg.get("margin_mode"))): + index_px = fetch_index_price(ex, underly) + except Exception: + index_px = None + stats = compute_options_stats_from_history(history, index_px=index_px) + # sum_options_net_pnl_usdc 已按指数折 U;勿再 × 指数(否则顶栏会变成上万假亏) + 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) + from lib.options.options_margin_mode_lib import is_coin_margin_mode + + pnl_unit = "U" if is_coin_margin_mode() else str(stats.get("pnl_unit") or "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): + from lib.exchange.okx_options_lib import fetch_option_position_history + from lib.options.options_monitor_lib import reconcile_live_open_trades, sync_open_options_trades + + ex = cfg.get("exchange_options") + if ex is None: + return 0 + raw = cfg["fetch_option_positions"](ex) + if raw is None: + return 0 + live_ids = {str(p.get("instId") or "") for p in raw if str(p.get("instId") or "")} + reconcile_live_open_trades(conn, live_inst_ids=live_ids) + return sync_open_options_trades( + conn, + live_inst_ids=live_ids, + fetch_history_fn=lambda inst_id: fetch_option_position_history(ex, inst_id), + notify_cfg=cfg, + ) + + 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 + + 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, + }, + daemon=True, + name="options-monitor", + ) + t.start() diff --git a/lib/options/templates/options_settings_transfer.html b/lib/options/templates/options_settings_transfer.html index 48d6990..c72f061 100644 --- a/lib/options/templates/options_settings_transfer.html +++ b/lib/options/templates/options_settings_transfer.html @@ -4,8 +4,9 @@