diff --git a/.env.example b/.env.example index e397116..3ceee9b 100644 --- a/.env.example +++ b/.env.example @@ -116,7 +116,7 @@ OKX_OPTIONS_ENABLED=true # OKX_OPTIONS_API_PASSPHRASE= OKX_OPTIONS_ACCOUNT_LABEL=账户·期权 -# 固定币本位(USDT买币桥);历史 OKX_OPTIONS_MARGIN_MODE=usdc 会被忽略并视为 coin +# 仅币本位期权(USDT买币桥);USDC 期权已下线,此项恒视为 coin OKX_OPTIONS_MARGIN_MODE=coin OKX_OPTIONS_COIN_COMPOUND=true OKX_OPTIONS_COIN_BUDGET_USDT=10 diff --git a/app.py b/app.py index eca3f0c..67d2f5e 100644 --- a/app.py +++ b/app.py @@ -5117,23 +5117,9 @@ def force_close_before_reset(): conn.commit() conn.close() -# 后台线程 +# 后台线程(永续已下线:不再跑划转/强平/关键位/委托监控;期权监控见 options_monitor_loop) def background_task(): while True: - try: - auto_transfer_once_per_day() - # force_close 自带连接,勿套在另一未关闭连接上 - force_close_before_reset() - conn = get_db() - try: - reconcile_external_closes(conn) - conn.commit() - finally: - conn.close() - check_key_monitors() - check_order_monitors() - except Exception as e: - print(f"[monitor_loop] {e}", flush=True) time.sleep(MONITOR_POLL_SECONDS) @@ -5637,16 +5623,14 @@ def index(): @app.route("/key_monitor") @login_required def key_monitor_page(): - redir = redirect_to_embed_shell_if_enabled("key_monitor") - if redir is not None: - return redir - return render_main_page("key_monitor") + # 永续关键位监控已下线 + return redirect("/options") @app.route("/trade") @login_required def trade_page(): - # 实盘下单界面已移除;永续下单仍由对冲计划走 place_exchange_order + # 永续实盘下单已下线 return redirect("/options") @@ -6303,7 +6287,7 @@ def order_focus(): @login_required def add_order(): # 实盘下单界面已移除;永续开平仓请走对冲计划(place_exchange_order / close_exchange_order) - flash("实盘下单界面已移除,请使用对冲计划或期权页") + flash("永续实盘下单已下线,请使用期权页") return redirect("/options") @@ -7679,5 +7663,5 @@ if __name__ == "__main__": from lib.common.flask_access_log_lib import silence_werkzeug_access_log silence_werkzeug_access_log() - threading.Thread(target=background_task, daemon=True).start() + # 永续 background_task 已下线;期权监控由 install_options_trading 启动 app.run(host=HOST, port=PORT, debug=DEBUG, threaded=True) diff --git a/lib/common/static/instance_settings_prefs.js b/lib/common/static/instance_settings_prefs.js index 8d61b39..f7e340f 100644 --- a/lib/common/static/instance_settings_prefs.js +++ b/lib/common/static/instance_settings_prefs.js @@ -415,41 +415,25 @@ function syncOptionsSectionVisibility(body) { if (!body) return; - var margin = (envFieldValue(body, "OKX_OPTIONS_MARGIN_MODE", "coin") || "coin").toLowerCase(); - var isCoin = margin !== "usdc"; + // 仅币本位期权:USDC/永续相关 env 始终隐藏 + var isCoin = true; var gateMode = (envFieldValue(body, "OKX_OPTIONS_CLOSE_GATE_MODE", "premium") || "premium").toLowerCase(); - var compoundOn = envTruthy(envFieldValue(body, "OKX_OPTIONS_COMPOUND_FULL_ENABLED", "true")); var coinCompoundOn = envTruthy(envFieldValue(body, "OKX_OPTIONS_COIN_COMPOUND", "true")); var coinMaxOn = envTruthy(envFieldValue(body, "OKX_OPTIONS_COIN_MAX_USDT_ENABLED", "false")); - var compoundCapOn = envTruthy(envFieldValue(body, "OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", "false")); COIN_ONLY_ENV_KEYS.forEach(function (key) { setEnvRowHidden(envFieldRowByKey(body, key), !isCoin); }); USDC_ONLY_ENV_KEYS.forEach(function (key) { - setEnvRowHidden(envFieldRowByKey(body, key), isCoin); + setEnvRowHidden(envFieldRowByKey(body, key), true); }); + setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_MARGIN_MODE"), true); + setEnvRowHidden(envFieldRowByKey(body, "OKX_SHOW_PERP_FUNDS"), true); - // 门控子项:按模式显隐(仍受本位过滤) - if (isCoin) { - setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN"), gateMode !== "premium"); - } else { - setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_CLOSE_RECYCLE_MULT"), gateMode !== "premium"); - } + setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN"), gateMode !== "premium"); setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT"), gateMode !== "net_pnl"); - - if (!isCoin) { - setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_TRADE_BUDGET_USDC"), compoundOn); - setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED"), !compoundOn); - setEnvRowHidden( - envFieldRowByKey(body, "OKX_OPTIONS_COMPOUND_FULL_CAP_USDC"), - !(compoundOn && compoundCapOn) - ); - } - if (isCoin) { - setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_COIN_BUDGET_USDT"), coinCompoundOn); - setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_COIN_MAX_USDT"), !coinMaxOn); - } + setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_COIN_BUDGET_USDT"), coinCompoundOn); + setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_COIN_MAX_USDT"), !coinMaxOn); } function bindOptionsSectionVisibility(body) { diff --git a/lib/env/env_ui_manifest.py b/lib/env/env_ui_manifest.py index d0c6c4f..933d06f 100644 --- a/lib/env/env_ui_manifest.py +++ b/lib/env/env_ui_manifest.py @@ -313,16 +313,27 @@ def _field_current(fields: list[dict[str, Any]], key: str, default: str = "") -> def _mark_options_section_hidden(fields: list[dict[str, Any]]) -> list[dict[str, Any]]: - """按门控模式/复利/单笔上限标记 hidden.""" + """按门控模式/复利/单笔上限标记 hidden;USDC/永续相关项始终隐藏.""" gate_mode = (_field_current(fields, "OKX_OPTIONS_CLOSE_GATE_MODE", "premium") or "premium").lower() coin_compound_on = _env_truthy(_field_current(fields, "OKX_OPTIONS_COIN_COMPOUND", "true")) coin_max_on = _env_truthy(_field_current(fields, "OKX_OPTIONS_COIN_MAX_USDT_ENABLED", "false")) + retired = { + "OKX_OPTIONS_MARGIN_MODE", + "OKX_OPTIONS_TRADE_BUDGET_USDC", + "OKX_OPTIONS_COMPOUND_FULL_ENABLED", + "OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", + "OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", + "OKX_OPTIONS_CLOSE_RECYCLE_MULT", + "OKX_SHOW_PERP_FUNDS", + } out: list[dict[str, Any]] = [] for f in fields: key = str(f.get("key") or "") hide = False - if key == "OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN" and gate_mode != "premium": + if key in retired: + hide = True + elif key == "OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN" and gate_mode != "premium": hide = True elif key == "OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT" and gate_mode != "net_pnl": hide = True diff --git a/lib/exchange/okx_options_lib.py b/lib/exchange/okx_options_lib.py index 9e12863..4aa0001 100644 --- a/lib/exchange/okx_options_lib.py +++ b/lib/exchange/okx_options_lib.py @@ -814,7 +814,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) now_ms = time.time() * 1000 diff --git a/lib/instance/instance_display_prefs_lib.py b/lib/instance/instance_display_prefs_lib.py index f310ad8..ea55ffc 100644 --- a/lib/instance/instance_display_prefs_lib.py +++ b/lib/instance/instance_display_prefs_lib.py @@ -10,8 +10,8 @@ DISPLAY_RUNTIME_PREFIX = "display." DEFAULT_INSTANCE_DISPLAY: dict[str, bool] = { "show_nav_dashboard": False, "show_nav_account_ledger": False, - "show_nav_key_monitor": True, - "show_nav_trade": False, # 实盘下单界面已移除 + "show_nav_key_monitor": False, # 永续关键位已下线 + "show_nav_trade": False, # 永续实盘下单已下线 "show_nav_strategy": False, "show_nav_strategy_records": False, "show_nav_records": False, # 永续交易记录与复盘已移除 @@ -22,10 +22,10 @@ DEFAULT_INSTANCE_DISPLAY: dict[str, bool] = { "show_nav_env_config": True, "show_nav_options": True, "show_nav_options_review": True, - "show_settings_transfer": False, # 永续资金划转默认隐藏 + "show_settings_transfer": False, # 永续资金划转已下线 "show_settings_export": True, "show_settings_password": True, - "show_settings_options_swap": True, + "show_settings_options_swap": False, # USDC 币种兑换已下线 "show_settings_options_transfer": True, } @@ -111,8 +111,8 @@ def display_prefs_template_context(get_db: Callable) -> dict[str, Any]: def tab_allowed(tab: str, display: Optional[dict[str, bool]] = None) -> bool: prefs = normalize_display_prefs(display or {}) t = (tab or "").strip() - if t in ("trade", "records", "stats", "hedge_plan"): - return False # 实盘下单 / 交易记录复盘 / 统计分析 / 对冲计划 已移除 + if t in ("trade", "records", "stats", "hedge_plan", "key_monitor"): + return False # 永续相关页面已下线 key = NAV_TAB_ALLOWED.get(t) if not key: return True @@ -123,7 +123,6 @@ def display_meta_for_ui() -> list[dict[str, Any]]: nav_keys = [ "show_nav_dashboard", "show_nav_account_ledger", - "show_nav_key_monitor", "show_nav_risk_policy", "show_nav_live_guide", "show_nav_system_guide", @@ -132,10 +131,8 @@ def display_meta_for_ui() -> list[dict[str, Any]]: "show_nav_options_review", ] settings_keys = [ - "show_settings_transfer", "show_settings_export", "show_settings_password", - "show_settings_options_swap", "show_settings_options_transfer", ] return [ diff --git a/lib/instance/instance_settings_lib.py b/lib/instance/instance_settings_lib.py index 8216004..799444b 100644 --- a/lib/instance/instance_settings_lib.py +++ b/lib/instance/instance_settings_lib.py @@ -127,21 +127,10 @@ def build_settings_tabs(display: dict[str, Any] | None, instance_settings: dict[ tabs.append({"key": "sim_funds", "title": "模拟资金"}) if disp.get("show_settings_password", True): tabs.append({"key": "password", "title": "账户密码"}) - if inst.get("show_transfer") and disp.get("show_settings_transfer", False): - tabs.append({"key": "transfer", "title": "永续划转"}) + # 永续划转 / USDC 币种兑换已下线 if disp.get("show_settings_export", True): tabs.append({"key": "export", "title": "数据导出"}) tabs.append({"key": "backup", "title": "备份恢复"}) - if inst.get("options_settings_enabled") and disp.get("show_settings_options_swap", True): - _coin = False - try: - from lib.options.options_margin_mode_lib import is_coin_margin_mode - - _coin = bool(is_coin_margin_mode()) - except Exception: - _coin = False - if not _coin: - tabs.append({"key": "options_swap", "title": "币种兑换"}) if inst.get("options_settings_enabled") and disp.get("show_settings_options_transfer", True): tabs.append({"key": "options_transfer", "title": "期权划转"}) return tabs diff --git a/lib/options/options_cond_order_lib.py b/lib/options/options_cond_order_lib.py index e64fc43..921c5e5 100644 --- a/lib/options/options_cond_order_lib.py +++ b/lib/options/options_cond_order_lib.py @@ -642,29 +642,23 @@ def _try_open_full( inst_id: str, order: dict[str, Any], ) -> dict[str, Any]: - 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 note = f"中控条件单:{order.get('hub_order_id')}" target_index = _safe_float(order.get("target_index")) pe_en = bool(order.get("profit_exit_enabled")) pe_mult = float(order.get("profit_exit_mult") or 1.0) - if is_coin_margin_mode(): - return open_coin_option_buy_full( - cfg, - ex, - inst_id=inst_id, - signal_note=note, - target_index=target_index, - profit_exit_enabled=pe_en, - profit_exit_mult=pe_mult, - target_sheets=None, - ) - return { - "ok": False, - "msg": "条件单开仓当前仅支持币本位客户端规则(买币→开仓)", - "need_depth": False, - } + # USDC 期权开仓已下线,条件单仅走币本位 + return open_coin_option_buy_full( + cfg, + ex, + inst_id=inst_id, + signal_note=note, + target_index=target_index, + profit_exit_enabled=pe_en, + profit_exit_mult=pe_mult, + target_sheets=None, + ) def run_cond_orders_tick( diff --git a/lib/options/options_margin_mode_lib.py b/lib/options/options_margin_mode_lib.py index 6c6705d..553e421 100644 --- a/lib/options/options_margin_mode_lib.py +++ b/lib/options/options_margin_mode_lib.py @@ -35,11 +35,10 @@ def is_coin_margin_mode(raw: Any = None) -> bool: def inst_family_for_underlying(underlying: str, *, margin_mode: str | None = None) -> str: + """仅币本位期权族 {U}-USD;USD_UM 已下线.""" + del margin_mode u = (underlying or "ETH").strip().upper() or "ETH" - mode = normalize_options_margin_mode(margin_mode) - if mode == MODE_COIN: - return f"{u}-USD" - return f"{u}-USD_UM" + return f"{u}-USD" def margin_mode_from_inst_id(inst_id: str) -> str: @@ -55,9 +54,10 @@ 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" + """币本位权利金为标的币;历史 USDC 行(margin_mode=usdc)仍标 USDC.""" + if str(margin_mode or "").strip().lower() == MODE_USDC: + return "USDC" + 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 3537942..92fc3f9 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -1,2133 +1,1527 @@ -"""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) or default) - except (TypeError, ValueError): - return float(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", False) - attach_options_templates(app, repo_root) - cfg = _build_cfg(app_module) - try: - from lib.sim.hooks import patch_options_cfg - - patch_options_cfg(cfg) - except Exception: - pass - try: - from lib.options.options_notify_lib import bind_options_notify_cfg - - bind_options_notify_cfg(cfg) - except Exception: - pass - app.extensions["options_cfg"] = cfg - register_options_routes(app, cfg) - _register_options_hub_bridge(app, cfg) - if enabled: - _start_monitor_thread(app, cfg) - - -def _register_options_hub_bridge(app: Flask, cfg: dict[str, Any]) -> None: - from lib.options.options_hub_lib import build_options_hub_snapshot - - def snapshot_fn(): - return build_options_hub_snapshot(cfg) - - def close_fn(inst_id: str) -> dict[str, Any]: - # 中控总览只全平(不改张数);无效买一在执行层拒绝 - return _run_manual_bid_close(cfg, inst_id, sheets=None, reason="中控平仓") - - hub_ctx = dict(app.config.get("HUB_CTX") or {}) - hub_ctx["options_snapshot_fn"] = snapshot_fn - hub_ctx["options_close_fn"] = close_fn - hub_ctx["options_cfg"] = cfg - app.config["HUB_CTX"] = hub_ctx - - -def _build_cfg(app_module: Any) -> dict[str, Any]: - from lib.exchange.okx_options_lib import ( - build_option_chain, - estimate_usdt_to_usdc, - execute_convert, - fetch_option_book_depth, - fetch_option_positions, - fetch_options_balances, - format_position_row, - options_api_ready, - cancel_option_order, - fetch_option_pending_orders, - place_option_limit_order, - place_option_market_order, - quote_option_contract, - spot_market_swap_usdt_usdc, - transfer_ccy, - ) - - return { - "enabled": _env_bool("OKX_OPTIONS_ENABLED", False), - "get_db": app_module.get_db, - "login_required": app_module.login_required, - "exchange_options": getattr(app_module, "exchange_options", None), - "send_wechat": app_module.send_wechat_msg, - "render_main_page": app_module.render_main_page, - "trade_budget": _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", 10.0), - "budget_buffer": _env_float("OKX_OPTIONS_BUDGET_BUFFER", 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 _run_manual_bid_close( - cfg: dict[str, Any], - inst_id: str, - *, - sheets: int | None = None, - reason: str = "手动平仓", -) -> dict[str, Any]: - """手动买一限价平仓(全平或指定张数);残档/无效买一由 close_option_by_bid1 拒绝.""" - ex, err = _require_options_ex(cfg) - if ex is None: - return {"ok": False, "msg": err} - inst_id = (inst_id or "").strip() - if not inst_id: - return {"ok": False, "msg": "缺少 inst_id"} - from lib.options.options_close_exec_lib import close_option_by_bid1 - - result = close_option_by_bid1( - cfg, - ex, - inst_id, - sheets=sheets, - require_recycle_gate=False, - ) - if not result.get("ok"): - return result - from lib.exchange.okx_options_lib import invalidate_option_positions_cache - - invalidate_option_positions_cache() - _sync_options_trades(cfg, force=True) - try: - from lib.options.options_notify_lib import notify_options_close - from lib.options.options_target_lib import cancel_target_monitor - - conn2 = cfg["get_db"]() - try: - if result.get("fully_closed"): - cancel_target_monitor(conn2, inst_id=inst_id) - conn2.commit() - note = reason if result.get("fully_closed") else f"{reason}(部分)" - notify_options_close( - cfg, - conn2, - inst_id=inst_id, - reason=note, - sheets=result.get("submitted_sheets"), - premium_received=result.get("premium_received"), - close_quote=result.get("locked_bid_px") or result.get("bid"), - ) - finally: - conn2.close() - except Exception: - pass - if result.get("fully_closed"): - try: - from lib.options.options_coin_open_lib import maybe_sell_spot_after_close - - spot_sell = maybe_sell_spot_after_close(cfg, ex, inst_id=inst_id, close_result=result) - if spot_sell is not None: - result = dict(result) - result["spot_sell"] = spot_sell - if spot_sell.get("bridge_status") == "pending_sell_spot": - result["msg"] = ( - str(result.get("msg") or "平仓成功") - + ";但卖回 USDT 失败,请点「重试卖回」" - ) - except Exception as e: - result = dict(result) - result["spot_sell"] = {"ok": False, "msg": str(e)} - _mark_balances_stale(cfg) - return result - - -def _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 "")} - - # 网络拉取历史必须在 DB 事务外,否则握锁期间卖币/中控快照会 database is locked - pending_insts: list[str] = [] - conn = cfg["get_db"]() - try: - init_options_tables(conn) - rows = conn.execute( - "SELECT inst_id FROM options_trades WHERE status = 'open'" - ).fetchall() - for r in rows: - iid = str(r["inst_id"] or "").strip() - if iid and iid not in live_ids: - pending_insts.append(iid) - try: - conn.commit() - except Exception: - pass - finally: - conn.close() - - hist_by_inst: dict[str, list] = {} - for iid in pending_insts: - try: - hist_by_inst[iid] = fetch_option_position_history(ex, iid) or [] - except Exception: - hist_by_inst[iid] = [] - hist_all = None - try: - hist_all = fetch_all_option_positions_history(ex, limit=200) - except Exception: - hist_all = None - - conn = cfg["get_db"]() - try: - init_options_tables(conn) - reconcile_live_open_trades(conn, live_inst_ids=live_ids) - sync_open_options_trades( - conn, - live_inst_ids=live_ids, - fetch_history_fn=lambda inst_id: hist_by_inst.get(str(inst_id) or [], []), - notify_cfg=cfg, - ) - if hist_all is not None: - try: - backfill_closed_options_realized_pnl_from_history(conn, hist_all) - except Exception: - pass - conn.commit() - except Exception: - try: - conn.rollback() - except Exception: - pass - finally: - try: - conn.close() - except Exception: - pass - try: - from lib.options.options_monitor_lib import sell_spot_after_options_sync - - sell_spot_after_options_sync(cfg) - _mark_balances_stale(cfg) - except Exception: - pass - - -def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: - lr = cfg["login_required"] - - @app.route("/options/guide") - @lr - def options_trade_guide(): - """期权开平仓与监控说明(独立页).""" - from pathlib import Path - - from flask import render_template_string - - from lib.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)} - 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.options.okx_trade_mode_lib import block_standalone_open_by_mode_msg - - mode_block = block_standalone_open_by_mode_msg() - except Exception as e: - return jsonify({"ok": False, "can_open": False, "msg": f"交易模式校验失败: {e}"}) - if mode_block: - return jsonify( - { - **q, - "ok": True, - "can_open": False, - "msg": mode_block, - "options_margin_mode": "coin", - "sizing": {"ok": False, "msg": mode_block, "sheets": 0, "eth_amount": 0.0, "total_premium": 0.0}, - } - ) - if margin_mode_from_inst_id(inst_id) != "coin": - return jsonify( - { - **q, - "ok": True, - "can_open": False, - "msg": "当前为币本位模式,请选择 ETH-USD / BTC-USD 合约(非 USD_UM)", - "options_margin_mode": "coin", - "sizing": { - "ok": False, - "msg": "合约非币本位", - "sheets": 0, - "eth_amount": 0.0, - "total_premium": 0.0, - }, - } - ) - budget_info = coin_budget_preview(cfg, ex) - if not can_open: - return jsonify( - { - **q, - "ok": True, - "can_open": False, - "msg": block_msg or q.get("open_block_msg") or "暂无卖一深度,无法买入", - "options_margin_mode": "coin", - "coin_budget": budget_info, - "sizing": { - "ok": False, - "msg": block_msg or "暂无卖一深度,无法买入", - "sheets": 0, - "eth_amount": 0.0, - "total_premium": 0.0, - }, - } - ) - if not budget_info.get("ok"): - return jsonify( - { - **q, - "ok": True, - "can_open": False, - "msg": budget_info.get("msg") or "交易账户 USDT 不足", - "options_margin_mode": "coin", - "coin_budget": budget_info, - "sizing": { - "ok": False, - "msg": budget_info.get("msg") or "交易账户 USDT 不足", - "sheets": 0, - "eth_amount": 0.0, - "total_premium": 0.0, - }, - } - ) - idx = _safe_float(q.get("index_px")) or _safe_float(q.get("idxPx")) - budget_usdt = float(budget_info["budget_usdt"]) - target_sheets = sheet_count if mode == "sheets" and sheet_count is not None else None - if mode == "eth" and request.args.get("eth"): - # 指定币量:按币量反推张数后再走统一规划 - try: - eth_want = float(request.args.get("eth")) - except (TypeError, ValueError): - eth_want = 0.0 - if eth_want > 0 and float(ct_mult) > 0: - import math - - target_sheets = max(int(min_sz), int(math.floor(eth_want / float(ct_mult) + 1e-12))) - from lib.options.options_margin_mode_lib import plan_coin_open_by_budget - - sizing = plan_coin_open_by_budget( - quote_per_unit=float(ask), - ct_mult=float(ct_mult), - min_sz=int(min_sz), - budget_usdt=budget_usdt, - index_px=float(idx or 0), - ask_sz=ask_sz, - target_sheets=target_sheets, - ) - if sizing.get("ok"): - sizing["premium_ccy"] = (inst_id.split("-")[0] if inst_id else "ETH").upper() - sizing["est_coin"] = sizing.get("buy_coin") - q = _attach_close_preview( - cfg, - ex, - q, - sheets=int(sizing.get("sheets") or 0), - premium_paid=_open_premium_paid(cfg, inst_id), - ) - return jsonify( - { - **q, - "can_open": bool(sizing.get("ok")), - "quote_per_unit": ask, - "premium_per_sheet": round(float(ask) * float(ct_mult), 8), - "sizing": sizing, - "mode": mode, - "mode_note": mode_note, - "options_margin_mode": "coin", - "coin_budget": budget_info, - "compound_full_enabled": _compound_full_enabled(), - } - ) - except Exception as e: - return jsonify({"ok": False, "msg": f"币本位报价失败: {e}"}) - - 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.options.okx_trade_mode_lib import block_standalone_open_by_mode_msg - - mode_block = block_standalone_open_by_mode_msg() - if mode_block: - return jsonify( - { - **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}", - } - ) - 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.options.okx_trade_mode_lib import block_standalone_open_by_mode_msg - - mode_block = block_standalone_open_by_mode_msg() - if mode_block: - return jsonify({"ok": False, "msg": mode_block, "can_open": False}) - except Exception as e: - return jsonify({"ok": False, "msg": f"交易模式校验失败: {e}", "can_open": False}) - data = request.get_json(silent=True) or {} - inst_id = (data.get("inst_id") or "").strip() - mode = (data.get("mode") or "sheets").strip() - mode, mode_note = _normalize_size_mode(mode) - signal_note = (data.get("signal_note") or "").strip() - # 中控条件单占用时禁止手动开仓 - try: - from lib.options.options_cond_order_lib import cond_order_blocks_manual_open_msg - from lib.options.options_db import init_options_tables - - conn_blk = cfg["get_db"]() - try: - init_options_tables(conn_blk) - block = cond_order_blocks_manual_open_msg(conn_blk) - if block: - return jsonify({"ok": False, "msg": block, "cond_order_locked": True}) - finally: - conn_blk.close() - except Exception: - pass - if mode_note and mode == "sheets" and (data.get("mode") or "").strip() == "compound_full": - # 前端残留全仓复利选中时,已自动改指定张数;继续开仓 - pass - target_index = None - raw_target = data.get("target_index") - if raw_target is not None and str(raw_target).strip() != "": - try: - target_index = float(raw_target) - except (TypeError, ValueError): - return jsonify({"ok": False, "msg": "目标位无效"}) - if target_index <= 0: - return jsonify({"ok": False, "msg": "目标位无效"}) - profit_exit_enabled = bool(data.get("profit_exit_enabled")) - profit_exit_mult = 1.0 - if profit_exit_enabled: - from lib.options.options_profit_exit_lib import normalize_profit_exit_mult - - profit_exit_mult = normalize_profit_exit_mult(data.get("profit_exit_mult"), default=1.0) - if not inst_id: - return jsonify({"ok": False, "msg": "缺少 inst_id"}) - 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 - - 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 - from lib.options.options_time_lib import options_app_now_str - - 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, - premium_ccy=sizing.get("premium_ccy") or "USDC", - ) - 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: - from lib.options.options_time_lib import options_app_now_str - - init_options_tables(conn) - conn.execute( - """ - UPDATE options_trades - SET status = 'cancelled', - signal_note = CASE - WHEN signal_note IS NULL OR TRIM(signal_note) = '' THEN '委托撤销' - ELSE signal_note - END, - closed_at = ? - WHERE inst_id = ? AND exchange_ord_id = ? AND status = 'open' - """, - (options_app_now_str(), inst_id, ord_id), - ) - conn.commit() - finally: - conn.close() - except Exception: - pass - _sync_options_trades(cfg, force=True) - return jsonify(out), (200 if out.get("ok") else 400) - - @app.route("/api/options/positions") - @lr - def api_options_positions(): - ex, err = _require_options_ex(cfg) - if ex is None: - return jsonify({"ok": False, "msg": err}) - raw = cfg["fetch_option_positions"](ex) - if raw is None: - return jsonify({"ok": False, "msg": "获取期权持仓失败"}) - _sync_options_trades(cfg, raw_positions=raw) - meta_cache: dict[str, dict[str, Any] | None] = {} - conn = cfg["get_db"]() - try: - from lib.options.options_target_lib import targets_by_inst - from lib.options.options_profit_exit_lib import profit_exit_by_inst - - tgt_map = targets_by_inst(conn) - profit_exit_map = profit_exit_by_inst(conn) - rows = [] - for p in raw: - inst = str(p.get("instId") or "").strip() - premium_override = sum_open_premium_paid(conn, inst) if inst else None - row = _enrich_position_row_display( - cfg, - ex, - p, - meta_cache=meta_cache, - premium_override=premium_override, - ) - _attach_close_preview(cfg, ex, row, premium_paid=_safe_float(row.get("premium_paid"))) - mon = tgt_map.get(inst) - if mon: - row["target_index"] = mon.get("target_index") - row["target_monitor_id"] = mon.get("id") - row["target_monitor"] = mon - pe = profit_exit_map.get(inst) - if pe: - row["profit_exit_enabled"] = pe.get("profit_exit_enabled") - row["profit_exit_mult"] = pe.get("profit_exit_mult") - row["profit_exit_state"] = pe.get("profit_exit_state") - row["profit_exit_required_recycle"] = pe.get("required_recycle") - try: - from lib.instance.instance_dashboard_lib import _resolve_options_source - - source_key, source_label, source_plan_id = _resolve_options_source(conn, inst) - row["source"] = source_key - row["source_label"] = source_label - row["source_plan_id"] = source_plan_id - except Exception: - row.setdefault("source", "option") - row.setdefault("source_label", "纯期权") - row.setdefault("source_plan_id", None) - rows.append(row) - finally: - conn.close() - spot_sell_hint = None - try: - from lib.options.options_margin_mode_lib import is_coin_margin_mode - - if is_coin_margin_mode(): - from lib.options.options_spot_bridge_lib import list_open_bridges - - conn_b = cfg["get_db"]() - try: - from lib.options.options_db import init_options_tables - - init_options_tables(conn_b) - open_bridges = list_open_bridges(conn_b) - bal = cfg["fetch_options_balances"](ex, scope="main") - bridge = open_bridges[0] if open_bridges else None - from lib.options.options_spot_bridge_lib import ( - display_sellable_coin, - spot_min_sell_sz, - ) - - raw_eth = bal.get("trading_eth_avail") or bal.get("trading_eth") - raw_btc = bal.get("trading_btc_avail") or bal.get("trading_btc") - spot_sell_hint = { - "bridge_status": str(bridge.get("status") or "") if bridge else None, - "bridge_underlying": str(bridge.get("underlying") or "").upper() if bridge else None, - # 粉尘归零:不够现货最小可卖则前端不展示、不提示重试 - "trading_eth": display_sellable_coin(ex, "ETH", raw_eth), - "trading_btc": display_sellable_coin(ex, "BTC", raw_btc), - "min_sz_eth": spot_min_sell_sz(ex, "ETH"), - "min_sz_btc": spot_min_sell_sz(ex, "BTC"), - "default_underly": (cfg.get("default_underly") or "ETH").strip().upper(), - } - finally: - conn_b.close() - except Exception: - spot_sell_hint = None - cond_locked = False - cond_msg = "" - cond_order = None - try: - from lib.options.options_cond_order_lib import has_active_cond_order, list_cond_orders - - conn_c = cfg["get_db"]() - try: - init_options_tables(conn_c) - cond_locked = has_active_cond_order(conn_c) - if cond_locked: - act = list_cond_orders(conn_c, active_only=True) - if act: - cond_order = act[0] - cond_msg = str(act[0].get("message") or act[0].get("status") or "") - finally: - conn_c.close() - except Exception: - pass - return jsonify( - { - "ok": True, - "positions": rows, - "spot_sell": spot_sell_hint, - "cond_order_locked": cond_locked, - "cond_order_msg": cond_msg, - "cond_order": cond_order, - } - ) - - @app.route("/api/options/targets") - @lr - def api_options_targets(): - conn = cfg["get_db"]() - try: - from lib.options.options_target_lib import list_active_targets, list_closing_targets - - return jsonify({"ok": True, "targets": list_active_targets(conn) + list_closing_targets(conn)}) - finally: - conn.close() - - @app.route("/api/options/target", methods=["POST"]) - @lr - def api_options_target_set(): - ex, err = _require_options_ex(cfg) - if ex is None: - return jsonify({"ok": False, "msg": err}) - data = request.get_json(silent=True) or {} - inst_id = (data.get("inst_id") or "").strip() - if not inst_id: - return jsonify({"ok": False, "msg": "缺少 inst_id"}) - try: - target_index = float(data.get("target_index")) - except (TypeError, ValueError): - return jsonify({"ok": False, "msg": "目标位无效"}) - if target_index <= 0: - return jsonify({"ok": False, "msg": "目标位无效"}) - raw = cfg["fetch_option_positions"](ex) - if raw is None: - return jsonify({"ok": False, "msg": "获取期权持仓失败"}) - pos = _find_position(raw, inst_id) - if not pos: - return jsonify({"ok": False, "msg": "未找到持仓"}) - from lib.options.options_target_lib import upsert_target_monitor - - fmt = cfg["format_position_row"](pos) - conn = cfg["get_db"]() - try: - trade = conn.execute( - """ - SELECT id, opt_type, underlying FROM options_trades - WHERE inst_id = ? AND status = 'open' - ORDER BY id DESC LIMIT 1 - """, - (inst_id,), - ).fetchone() - trade_id = int(trade["id"]) if trade else None - sheets_sum = sum_open_sheets(conn, inst_id) - sheets = sheets_sum if sheets_sum is not None else int(fmt.get("pos") or 0) - opt_type = (trade["opt_type"] if trade else None) or fmt.get("opt_type") - underlying = (trade["underlying"] if trade else None) or fmt.get("underlying") - out = upsert_target_monitor( - conn, - inst_id=inst_id, - target_index=target_index, - underlying=str(underlying) if underlying else None, - opt_type=str(opt_type) if opt_type else None, - trade_id=trade_id, - sheets=sheets, - ) - conn.commit() - return jsonify(out) - finally: - conn.close() - - @app.route("/api/options/target/cancel", methods=["POST"]) - @lr - def api_options_target_cancel(): - data = request.get_json(silent=True) or {} - inst_id = (data.get("inst_id") or "").strip() or None - monitor_id = data.get("id") - try: - mid = int(monitor_id) if monitor_id is not None and str(monitor_id).strip() != "" else None - except (TypeError, ValueError): - return jsonify({"ok": False, "msg": "监控 id 无效"}) - if not inst_id and mid is None: - return jsonify({"ok": False, "msg": "缺少 inst_id 或 id"}) - from lib.options.options_target_lib import cancel_target_monitor - - conn = cfg["get_db"]() - try: - n = cancel_target_monitor(conn, inst_id=inst_id, monitor_id=mid) - conn.commit() - return jsonify({"ok": True, "cancelled": n}) - finally: - conn.close() - - @app.route("/api/options/profit-exit", methods=["POST"]) - @lr - def api_options_profit_exit_set(): - ex, err = _require_options_ex(cfg) - if ex is None: - return jsonify({"ok": False, "msg": err}) - data = request.get_json(silent=True) or {} - inst_id = (data.get("inst_id") or "").strip() - if not inst_id: - return jsonify({"ok": False, "msg": "缺少 inst_id"}) - enabled_raw = data.get("enabled") - if enabled_raw is None: - enabled_raw = data.get("profit_exit_enabled") - enabled = bool(enabled_raw) and str(enabled_raw).strip().lower() not in ( - "0", - "false", - "off", - "no", - ) - from lib.options.options_profit_exit_lib import normalize_profit_exit_mult, set_profit_exit - - mult = normalize_profit_exit_mult(data.get("mult", data.get("profit_exit_mult")), default=1.0) - raw = cfg["fetch_option_positions"](ex) - if raw is None: - return jsonify({"ok": False, "msg": "获取期权持仓失败"}) - if not _find_position(raw, inst_id): - return jsonify({"ok": False, "msg": "未找到持仓"}) - conn = cfg["get_db"]() - try: - out = set_profit_exit(conn, inst_id=inst_id, enabled=enabled, mult=mult) - if out.get("ok"): - conn.commit() - return jsonify(out) - finally: - conn.close() - - @app.route("/api/options/close", methods=["POST"]) - @lr - def api_options_close(): - data = request.get_json(silent=True) or {} - inst_id = (data.get("inst_id") or "").strip() - if not inst_id: - return jsonify({"ok": False, "msg": "缺少 inst_id"}) - if data.get("market"): - return jsonify({"ok": False, "msg": "已禁用市价平仓,仅支持买一限价"}) - sheets = data.get("sheets") - try: - sheets_i = int(sheets) if sheets is not None and str(sheets).strip() != "" else None - except (TypeError, ValueError): - return jsonify({"ok": False, "msg": "张数无效"}) - # 手动买一平仓:只验有效流动性;2×门控仅用于自动/目标位平仓 - return jsonify(_run_manual_bid_close(cfg, inst_id, sheets=sheets_i, reason="手动平仓")) - - @app.route("/api/options/spot-bridge/retry-sell", methods=["POST"]) - @lr - def api_options_spot_bridge_retry_sell(): - """币本位:重试把残留标的币市价卖回 USDT.""" - ex, err = _require_options_ex(cfg) - if ex is None: - return jsonify({"ok": False, "msg": err}) - data = request.get_json(silent=True) or {} - underlying = (data.get("underlying") or cfg.get("default_underly") or "ETH").strip().upper() - inst_id = (data.get("inst_id") or "").strip() or None - from lib.options.options_spot_bridge_lib import sell_residual_after_option_flat - - out = sell_residual_after_option_flat( - cfg["get_db"], - ex, - underlying=underlying, - inst_id=inst_id, - ) - if out.get("ok"): - _mark_balances_stale(cfg) - return jsonify(out) - - @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": "数量无效"}) - try: - import lib.exchange.okx_options_lib as _opt_lib - - xfer = getattr(_opt_lib, "transfer_ccy", None) - except Exception: - xfer = None - if not callable(xfer): - xfer = cfg.get("transfer_ccy") - result = xfer(ex, ccy, amount, from_acct, to_acct) if callable(xfer) else { - "ok": False, - "msg": "划转不可用", - } - 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_margin_mode_lib import is_coin_margin_mode - from lib.options.options_positions_lib import sum_options_net_pnl_usdc - from lib.options.options_stats_lib import ( - compute_options_stats_from_history, - history_pnl_to_usdt, - ) - - raw_live = cfg["fetch_option_positions"](ex) - if raw_live is None: - return jsonify({"ok": False, "msg": "获取期权持仓失败"}) - history = load_options_history(ex, cfg) - # 币本位已平盈亏按指数折 U,与持仓浮盈/合计口径一致 - stats = compute_options_stats_from_history(history_pnl_to_usdt(history, ex)) - open_float = sum_options_net_pnl_usdc(cfg, ex, raw_live) - net_realized = _safe_float(stats.get("net_realized_pnl")) or 0.0 - total_pnl = None - if open_float is not None: - total_pnl = round(net_realized + float(open_float), 4) - elif stats.get("total_closed"): - total_pnl = round(net_realized, 4) - # 币本位统计统一标 U;USDC 模式仍标 USDC - pnl_unit = "U" if is_coin_margin_mode() else "USDC" - return jsonify( - { - "ok": True, - **stats, - "open_float_pnl": open_float, - "total_pnl": total_pnl, - "pnl_unit": pnl_unit, - } - ) - - @app.route("/api/options/history/", methods=["DELETE"]) - @lr - def api_options_history_delete(history_key: str): - ex, err = _require_options_ex(cfg) - if ex is None: - return jsonify({"ok": False, "msg": err}) - key = (history_key or "").strip() - if not key: - return jsonify({"ok": False, "msg": "缺少 history_key"}) - data = request.get_json(silent=True) or {} - inst_id = str(data.get("inst_id") or request.args.get("inst_id") or "").strip() or None - closed_at = str(data.get("closed_at") or request.args.get("closed_at") or "").strip() or None - conn = cfg["get_db"]() - try: - init_options_tables(conn) - conn.execute( - "INSERT OR IGNORE INTO options_history_hidden (history_key) VALUES (?)", - (key,), - ) - # 同步隐藏期权复盘,避免本地已平记录刷新后又出现 - try: - from lib.options.options_review_lib import hide_review_keys - - hide_review_keys( - conn, - history_key=key, - inst_id=inst_id, - closed_at=closed_at, - ) - if inst_id: - # 去掉已导入的复盘快照(按合约+平仓时间) - if closed_at: - rows = conn.execute( - """ - SELECT id, history_key FROM options_review_trades - WHERE inst_id = ? - AND substr(COALESCE(closed_at,''),1,16) = substr(?,1,16) - """, - (inst_id, closed_at), - ).fetchall() - else: - rows = conn.execute( - """ - SELECT id, history_key FROM options_review_trades - WHERE inst_id = ? - """, - (inst_id,), - ).fetchall() - for r in rows: - conn.execute( - "DELETE FROM options_review_entries WHERE trade_id=?", - (int(r["id"]),), - ) - conn.execute( - "DELETE FROM options_review_trades WHERE id=?", - (int(r["id"]),), - ) - conn.execute( - "INSERT OR IGNORE INTO options_review_hidden(history_key, inst_id, closed_at) VALUES (?,?,?)", - (str(r["history_key"]), inst_id, (closed_at or "")[:19] or None), - ) - fps = [] - if closed_at: - fps.append(f"inst_close:{inst_id}:{closed_at[:16]}") - fps.append(f"inst:{inst_id}") - for fp in fps: - conn.execute( - "INSERT OR IGNORE INTO options_review_hidden(history_key, inst_id, closed_at) VALUES (?,?,?)", - (fp, inst_id, (closed_at or "")[:19] or None), - ) - except Exception: - pass - conn.commit() - finally: - conn.close() - return jsonify({"ok": True}) - - -def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None: - if app.extensions.get("options_monitor_started"): - return - app.extensions["options_monitor_started"] = True - - def _bid(inst_id: str) -> float | None: - ex = cfg.get("exchange_options") - if ex is None: - return None - try: - q = cfg["quote_option_contract"](ex, inst_id) - return q.get("bid") - except Exception: - return None - - def _positions(): - ex = cfg.get("exchange_options") - if ex is None: - return [] - raw = cfg["fetch_option_positions"](ex) - if raw is None: - return [] - return [cfg["format_position_row"](p) for p in raw] - - def _sync(_conn=None): - # 不用监控线程传入的连接;force=False 走 15s 节流,卖币由监控循环在关闭连接后另扫 - try: - _sync_options_trades(cfg, force=False) - return 1 - except Exception: - return 0 - - def _target_close(inst_id: str) -> dict[str, Any]: - from lib.options.options_target_lib import close_option_by_bid_depth - - ex = cfg.get("exchange_options") - if ex is None: - return {"ok": False, "msg": "期权 exchange 未就绪"} - result = close_option_by_bid_depth(cfg, ex, inst_id) - if result.get("ok"): - try: - _sync_options_trades(cfg, force=True) - except Exception: - pass - try: - _mark_balances_stale(cfg) - except Exception: - pass - return result - - def _profit_exit_close(inst_id: str) -> dict[str, Any]: - from lib.options.options_profit_exit_lib import close_option_by_bid_profit_exit - - ex = cfg.get("exchange_options") - if ex is None: - return {"ok": False, "msg": "期权 exchange 未就绪"} - result = close_option_by_bid_profit_exit(cfg, ex, inst_id) - if result.get("ok"): - try: - _sync_options_trades(cfg, force=True) - except Exception: - pass - try: - _mark_balances_stale(cfg) - except Exception: - pass - return result - - def _stale_pending() -> dict[str, Any]: - from lib.exchange.okx_options_lib import invalidate_option_positions_cache - from lib.options.options_pending_lib import cancel_stale_close_pending_orders - - ex = cfg.get("exchange_options") - if ex is None: - return {"ok": False, "msg": "期权 exchange 未就绪"} - ttl = float(cfg.get("pending_ttl_seconds") or 600.0) - out = cancel_stale_close_pending_orders( - fetch_pending=lambda _ex: cfg["fetch_option_pending_orders"](_ex), - cancel_order=lambda _ex, inst_id, ord_id: cfg["cancel_option_order"]( - _ex, inst_id=inst_id, ord_id=ord_id - ), - ttl_seconds=ttl, - ex=ex, - ) - if out.get("cancelled"): - try: - invalidate_option_positions_cache() - except Exception: - pass - try: - send = cfg.get("send_wechat") - if callable(send): - parts = [ - "【OKX期权·挂单超时撤销】", - f"账户:{cfg.get('account_label') or 'OKX期权'}", - f"超时:{ttl:g}s", - f"撤销:{out.get('cancelled')} 笔", - ] - for o in out.get("orders") or []: - parts.append(f"- {o.get('inst_id')} #{o.get('ord_id')}") - send("\n".join(parts)) - except Exception: - pass - return out - - def _cond_order_tick(_conn=None) -> None: - from lib.options.options_cond_order_lib import run_cond_orders_tick - - # 传 get_db:条件单开仓前会释放连接,避免嵌套锁库 - run_cond_orders_tick( - cfg["get_db"], - cfg, - send_wechat=cfg.get("send_wechat"), - account_label=str(cfg.get("account_label") or ""), - ) - - t = threading.Thread( - target=options_monitor_loop, - kwargs={ - "enabled": True, - "poll_seconds": cfg["poll_seconds"], - "get_db": cfg["get_db"], - "fetch_positions": _positions, - "ticker_bid_fn": _bid, - "send_wechat": cfg["send_wechat"], - "account_label": cfg["account_label"], - "profit_ratio": cfg["profit_ratio"], - "sync_trades_fn": _sync, - "target_close_fn": _target_close, - "profit_exit_close_fn": _profit_exit_close, - "profit_exit_cfg": cfg, - "stale_pending_fn": _stale_pending, - "cond_order_tick_fn": _cond_order_tick, - }, - daemon=True, - name="options-monitor", - ) - t.start() +"""OKX 期权模块:Flask 路由注册.""" +from __future__ import annotations + +import os +import threading +import time +from typing import Any + +from flask import Flask, jsonify, redirect, request, url_for +from jinja2 import ChoiceLoader, FileSystemLoader + +from lib.options.options_db import init_options_tables, sum_open_premium_paid, sum_open_sheets +from lib.options.options_monitor_lib import options_monitor_loop +from lib.exchange.okx_options_lib import _safe_float + + +def _env_bool(key: str, default: bool = False) -> bool: + raw = (os.getenv(key) or "").strip().lower() + if not raw: + return default + return raw in ("1", "true", "yes", "on") + + +def _env_float(key: str, default: float) -> float: + try: + return float(os.getenv(key) or default) + except (TypeError, ValueError): + return float(default) + + +def attach_options_templates(app: Flask, repo_root: str) -> None: + tpl_dir = os.path.join(repo_root, "lib", "options", "templates") + if not os.path.isdir(tpl_dir): + return + existing = app.jinja_loader + loaders = [FileSystemLoader(tpl_dir)] + if existing is not None: + if isinstance(existing, ChoiceLoader): + loaders = list(existing.loaders) + loaders + else: + loaders.insert(0, existing) + app.jinja_loader = ChoiceLoader(loaders) + + +def install_options_trading(app: Flask, repo_root: str, app_module: Any) -> None: + enabled = _env_bool("OKX_OPTIONS_ENABLED", False) + attach_options_templates(app, repo_root) + cfg = _build_cfg(app_module) + try: + from lib.sim.hooks import patch_options_cfg + + patch_options_cfg(cfg) + except Exception: + pass + try: + from lib.options.options_notify_lib import bind_options_notify_cfg + + bind_options_notify_cfg(cfg) + except Exception: + pass + app.extensions["options_cfg"] = cfg + register_options_routes(app, cfg) + _register_options_hub_bridge(app, cfg) + if enabled: + _start_monitor_thread(app, cfg) + + +def _register_options_hub_bridge(app: Flask, cfg: dict[str, Any]) -> None: + from lib.options.options_hub_lib import build_options_hub_snapshot + + def snapshot_fn(): + return build_options_hub_snapshot(cfg) + + def close_fn(inst_id: str) -> dict[str, Any]: + # 中控总览只全平(不改张数);无效买一在执行层拒绝 + return _run_manual_bid_close(cfg, inst_id, sheets=None, reason="中控平仓") + + hub_ctx = dict(app.config.get("HUB_CTX") or {}) + hub_ctx["options_snapshot_fn"] = snapshot_fn + hub_ctx["options_close_fn"] = close_fn + hub_ctx["options_cfg"] = cfg + app.config["HUB_CTX"] = hub_ctx + + +def _build_cfg(app_module: Any) -> dict[str, Any]: + from lib.exchange.okx_options_lib import ( + build_option_chain, + estimate_usdt_to_usdc, + execute_convert, + fetch_option_book_depth, + fetch_option_positions, + fetch_options_balances, + format_position_row, + options_api_ready, + cancel_option_order, + fetch_option_pending_orders, + place_option_limit_order, + place_option_market_order, + quote_option_contract, + spot_market_swap_usdt_usdc, + transfer_ccy, + ) + + return { + "enabled": _env_bool("OKX_OPTIONS_ENABLED", False), + "get_db": app_module.get_db, + "login_required": app_module.login_required, + "exchange_options": getattr(app_module, "exchange_options", None), + "send_wechat": app_module.send_wechat_msg, + "render_main_page": app_module.render_main_page, + "trade_budget": _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", 10.0), + "budget_buffer": _env_float("OKX_OPTIONS_BUDGET_BUFFER", 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 _run_manual_bid_close( + cfg: dict[str, Any], + inst_id: str, + *, + sheets: int | None = None, + reason: str = "手动平仓", +) -> dict[str, Any]: + """手动买一限价平仓(全平或指定张数);残档/无效买一由 close_option_by_bid1 拒绝.""" + ex, err = _require_options_ex(cfg) + if ex is None: + return {"ok": False, "msg": err} + inst_id = (inst_id or "").strip() + if not inst_id: + return {"ok": False, "msg": "缺少 inst_id"} + from lib.options.options_close_exec_lib import close_option_by_bid1 + + result = close_option_by_bid1( + cfg, + ex, + inst_id, + sheets=sheets, + require_recycle_gate=False, + ) + if not result.get("ok"): + return result + from lib.exchange.okx_options_lib import invalidate_option_positions_cache + + invalidate_option_positions_cache() + _sync_options_trades(cfg, force=True) + try: + from lib.options.options_notify_lib import notify_options_close + from lib.options.options_target_lib import cancel_target_monitor + + conn2 = cfg["get_db"]() + try: + if result.get("fully_closed"): + cancel_target_monitor(conn2, inst_id=inst_id) + conn2.commit() + note = reason if result.get("fully_closed") else f"{reason}(部分)" + notify_options_close( + cfg, + conn2, + inst_id=inst_id, + reason=note, + sheets=result.get("submitted_sheets"), + premium_received=result.get("premium_received"), + close_quote=result.get("locked_bid_px") or result.get("bid"), + ) + finally: + conn2.close() + except Exception: + pass + if result.get("fully_closed"): + try: + from lib.options.options_coin_open_lib import maybe_sell_spot_after_close + + spot_sell = maybe_sell_spot_after_close(cfg, ex, inst_id=inst_id, close_result=result) + if spot_sell is not None: + result = dict(result) + result["spot_sell"] = spot_sell + if spot_sell.get("bridge_status") == "pending_sell_spot": + result["msg"] = ( + str(result.get("msg") or "平仓成功") + + ";但卖回 USDT 失败,请点「重试卖回」" + ) + except Exception as e: + result = dict(result) + result["spot_sell"] = {"ok": False, "msg": str(e)} + _mark_balances_stale(cfg) + return result + + +def _compound_full_enabled() -> bool: + """USDC 全仓复利已下线;恒为 False,避免前端残留 compound_full 模式.""" + return False + + +def _normalize_size_mode(mode: str) -> tuple[str, str | None]: + """USDC 预算/全仓模式已下线;统一落到指定张数.""" + m = (mode or "sheets").strip() or "sheets" + if m in ("compound_full", "budget_full"): + return "sheets", "USDC 期权模式已下线,已改用指定张数" + return m, None + + +def _open_premium_paid(cfg: dict[str, Any], inst_id: str) -> float | None: + conn = cfg["get_db"]() + try: + init_options_tables(conn) + return sum_open_premium_paid(conn, inst_id) + finally: + conn.close() + + +def _position_avail_sheets(pos: dict[str, Any]) -> int: + avail = _safe_float(pos.get("availPos")) + if avail is None or avail <= 0: + avail = abs(_safe_float(pos.get("pos")) or 0) + return max(0, int(avail or 0)) + + +def _find_position(rows: list[dict[str, Any]] | None, inst_id: str) -> dict[str, Any] | None: + return next((p for p in rows or [] if str(p.get("instId")) == inst_id), None) + + +def _refresh_position_avail(cfg: dict[str, Any], ex: Any, inst_id: str) -> int | None: + from lib.exchange.okx_options_lib import invalidate_option_positions_cache + + invalidate_option_positions_cache() + raw = cfg["fetch_option_positions"](ex) + if raw is None: + return None + pos = _find_position(raw, inst_id) + if not pos: + return 0 + return _position_avail_sheets(pos) + + +def _enrich_position_row_display( + cfg: dict[str, Any], + ex: Any, + raw_pos: dict[str, Any], + *, + meta_cache: dict[str, dict[str, Any] | None] | None = None, + premium_override: float | None = None, +) -> dict[str, Any]: + from lib.options.options_history_lib import enrich_position_row_display + + return enrich_position_row_display( + cfg, + ex, + raw_pos, + meta_cache=meta_cache, + premium_override=premium_override, + ) + + +def _attach_close_preview( + cfg: dict[str, Any], + ex: Any, + row: dict[str, Any], + *, + sheets: int | None = None, + premium_paid: float | None = None, +) -> dict[str, Any]: + from lib.options.options_positions_lib import attach_close_preview + + return attach_close_preview( + cfg, + ex, + row, + sheets=sheets, + premium_paid=premium_paid, + ) + + +_OPTIONS_SYNC_LOCK = threading.Lock() +_OPTIONS_SYNC_LAST_AT = 0.0 +_OPTIONS_SYNC_INTERVAL_SEC = 15.0 + + +def _sync_options_trades( + cfg: dict[str, Any], + *, + raw_positions: list[dict[str, Any]] | None = None, + force: bool = False, +) -> None: + global _OPTIONS_SYNC_LAST_AT + ex = cfg.get("exchange_options") + if ex is None: + return + now = time.time() + with _OPTIONS_SYNC_LOCK: + if not force and now - _OPTIONS_SYNC_LAST_AT < _OPTIONS_SYNC_INTERVAL_SEC: + return + _OPTIONS_SYNC_LAST_AT = now + from lib.exchange.okx_options_lib import fetch_all_option_positions_history, fetch_option_position_history + from lib.options.options_monitor_lib import ( + backfill_closed_options_realized_pnl_from_history, + reconcile_live_open_trades, + sync_open_options_trades, + ) + + if raw_positions is None: + raw = cfg["fetch_option_positions"](ex) + if raw is None: + return + else: + raw = raw_positions + live_ids = {str(p.get("instId") or "") for p in raw if str(p.get("instId") or "")} + + # 网络拉取历史必须在 DB 事务外,否则握锁期间卖币/中控快照会 database is locked + pending_insts: list[str] = [] + conn = cfg["get_db"]() + try: + init_options_tables(conn) + rows = conn.execute( + "SELECT inst_id FROM options_trades WHERE status = 'open'" + ).fetchall() + for r in rows: + iid = str(r["inst_id"] or "").strip() + if iid and iid not in live_ids: + pending_insts.append(iid) + try: + conn.commit() + except Exception: + pass + finally: + conn.close() + + hist_by_inst: dict[str, list] = {} + for iid in pending_insts: + try: + hist_by_inst[iid] = fetch_option_position_history(ex, iid) or [] + except Exception: + hist_by_inst[iid] = [] + hist_all = None + try: + hist_all = fetch_all_option_positions_history(ex, limit=200) + except Exception: + hist_all = None + + conn = cfg["get_db"]() + try: + init_options_tables(conn) + reconcile_live_open_trades(conn, live_inst_ids=live_ids) + sync_open_options_trades( + conn, + live_inst_ids=live_ids, + fetch_history_fn=lambda inst_id: hist_by_inst.get(str(inst_id) or [], []), + notify_cfg=cfg, + ) + if hist_all is not None: + try: + backfill_closed_options_realized_pnl_from_history(conn, hist_all) + except Exception: + pass + conn.commit() + except Exception: + try: + conn.rollback() + except Exception: + pass + finally: + try: + conn.close() + except Exception: + pass + try: + from lib.options.options_monitor_lib import sell_spot_after_options_sync + + sell_spot_after_options_sync(cfg) + _mark_balances_stale(cfg) + except Exception: + pass + + +def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: + lr = cfg["login_required"] + + @app.route("/options/guide") + @lr + def options_trade_guide(): + """期权开平仓与监控说明(独立页).""" + from pathlib import Path + + from flask import render_template_string + + from lib.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)} + 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.options.okx_trade_mode_lib import block_standalone_open_by_mode_msg + + mode_block = block_standalone_open_by_mode_msg() + except Exception as e: + return jsonify({"ok": False, "can_open": False, "msg": f"交易模式校验失败: {e}"}) + if mode_block: + return jsonify( + { + **q, + "ok": True, + "can_open": False, + "msg": mode_block, + "options_margin_mode": "coin", + "sizing": {"ok": False, "msg": mode_block, "sheets": 0, "eth_amount": 0.0, "total_premium": 0.0}, + } + ) + if margin_mode_from_inst_id(inst_id) != "coin": + return jsonify( + { + **q, + "ok": True, + "can_open": False, + "msg": "当前为币本位模式,请选择 ETH-USD / BTC-USD 合约(非 USD_UM)", + "options_margin_mode": "coin", + "sizing": { + "ok": False, + "msg": "合约非币本位", + "sheets": 0, + "eth_amount": 0.0, + "total_premium": 0.0, + }, + } + ) + budget_info = coin_budget_preview(cfg, ex) + if not can_open: + return jsonify( + { + **q, + "ok": True, + "can_open": False, + "msg": block_msg or q.get("open_block_msg") or "暂无卖一深度,无法买入", + "options_margin_mode": "coin", + "coin_budget": budget_info, + "sizing": { + "ok": False, + "msg": block_msg or "暂无卖一深度,无法买入", + "sheets": 0, + "eth_amount": 0.0, + "total_premium": 0.0, + }, + } + ) + if not budget_info.get("ok"): + return jsonify( + { + **q, + "ok": True, + "can_open": False, + "msg": budget_info.get("msg") or "交易账户 USDT 不足", + "options_margin_mode": "coin", + "coin_budget": budget_info, + "sizing": { + "ok": False, + "msg": budget_info.get("msg") or "交易账户 USDT 不足", + "sheets": 0, + "eth_amount": 0.0, + "total_premium": 0.0, + }, + } + ) + idx = _safe_float(q.get("index_px")) or _safe_float(q.get("idxPx")) + budget_usdt = float(budget_info["budget_usdt"]) + target_sheets = sheet_count if mode == "sheets" and sheet_count is not None else None + if mode == "eth" and request.args.get("eth"): + # 指定币量:按币量反推张数后再走统一规划 + try: + eth_want = float(request.args.get("eth")) + except (TypeError, ValueError): + eth_want = 0.0 + if eth_want > 0 and float(ct_mult) > 0: + import math + + target_sheets = max(int(min_sz), int(math.floor(eth_want / float(ct_mult) + 1e-12))) + from lib.options.options_margin_mode_lib import plan_coin_open_by_budget + + sizing = plan_coin_open_by_budget( + quote_per_unit=float(ask), + ct_mult=float(ct_mult), + min_sz=int(min_sz), + budget_usdt=budget_usdt, + index_px=float(idx or 0), + ask_sz=ask_sz, + target_sheets=target_sheets, + ) + if sizing.get("ok"): + sizing["premium_ccy"] = (inst_id.split("-")[0] if inst_id else "ETH").upper() + sizing["est_coin"] = sizing.get("buy_coin") + q = _attach_close_preview( + cfg, + ex, + q, + sheets=int(sizing.get("sheets") or 0), + premium_paid=_open_premium_paid(cfg, inst_id), + ) + return jsonify( + { + **q, + "can_open": bool(sizing.get("ok")), + "quote_per_unit": ask, + "premium_per_sheet": round(float(ask) * float(ct_mult), 8), + "sizing": sizing, + "mode": mode, + "mode_note": mode_note, + "options_margin_mode": "coin", + "coin_budget": budget_info, + "compound_full_enabled": _compound_full_enabled(), + } + ) + except Exception as e: + return jsonify({"ok": False, "msg": f"币本位报价失败: {e}"}) + return jsonify({"ok": False, "msg": "仅支持币本位期权报价", "can_open": False}) + + @app.route("/api/options/open", methods=["POST"]) + @lr + def api_options_open(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + try: + from lib.options.okx_trade_mode_lib import block_standalone_open_by_mode_msg + + mode_block = block_standalone_open_by_mode_msg() + if mode_block: + return jsonify({"ok": False, "msg": mode_block, "can_open": False}) + except Exception as e: + return jsonify({"ok": False, "msg": f"交易模式校验失败: {e}", "can_open": False}) + data = request.get_json(silent=True) or {} + inst_id = (data.get("inst_id") or "").strip() + mode = (data.get("mode") or "sheets").strip() + mode, mode_note = _normalize_size_mode(mode) + signal_note = (data.get("signal_note") or "").strip() + # 中控条件单占用时禁止手动开仓 + try: + from lib.options.options_cond_order_lib import cond_order_blocks_manual_open_msg + from lib.options.options_db import init_options_tables + + conn_blk = cfg["get_db"]() + try: + init_options_tables(conn_blk) + block = cond_order_blocks_manual_open_msg(conn_blk) + if block: + return jsonify({"ok": False, "msg": block, "cond_order_locked": True}) + finally: + conn_blk.close() + except Exception: + pass + if mode_note and mode == "sheets" and (data.get("mode") or "").strip() == "compound_full": + # 前端残留全仓复利选中时,已自动改指定张数;继续开仓 + pass + target_index = None + raw_target = data.get("target_index") + if raw_target is not None and str(raw_target).strip() != "": + try: + target_index = float(raw_target) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "目标位无效"}) + if target_index <= 0: + return jsonify({"ok": False, "msg": "目标位无效"}) + profit_exit_enabled = bool(data.get("profit_exit_enabled")) + profit_exit_mult = 1.0 + if profit_exit_enabled: + from lib.options.options_profit_exit_lib import normalize_profit_exit_mult + + profit_exit_mult = normalize_profit_exit_mult(data.get("profit_exit_mult"), default=1.0) + if not inst_id: + return jsonify({"ok": False, "msg": "缺少 inst_id"}) + try: + from lib.options.options_margin_mode_lib import is_coin_margin_mode + from lib.options.options_coin_open_lib import open_coin_option_buy_full + + if is_coin_margin_mode(): + want_sheets = None + if mode == "sheets": + try: + want_sheets = int(data.get("sheets") or 0) or None + except (TypeError, ValueError): + want_sheets = None + elif mode == "eth": + try: + eth_want = float(data.get("eth") or 0) + except (TypeError, ValueError): + eth_want = 0.0 + if eth_want > 0: + q0 = cfg["quote_option_contract"](ex, inst_id) + ct0 = float((q0 or {}).get("ct_mult") or 0.01) + min0 = int((q0 or {}).get("min_sz") or 1) + if ct0 > 0: + import math + + want_sheets = max(min0, int(math.floor(eth_want / ct0 + 1e-12))) + result = open_coin_option_buy_full( + cfg, + ex, + inst_id=inst_id, + signal_note=signal_note, + target_index=target_index, + profit_exit_enabled=profit_exit_enabled, + profit_exit_mult=profit_exit_mult, + target_sheets=want_sheets, + ) + if result.get("ok"): + from lib.exchange.okx_options_lib import invalidate_option_positions_cache + + invalidate_option_positions_cache() + _mark_balances_stale(cfg) + return jsonify(result) + except Exception as e: + return jsonify({"ok": False, "msg": f"币本位开仓失败: {e}"}) + return jsonify({"ok": False, "msg": "仅支持币本位期权开仓"}) + + @app.route("/api/options/orders/pending") + @lr + def api_options_orders_pending(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + inst_id = (request.args.get("inst_id") or "").strip() or None + try: + orders = cfg["fetch_option_pending_orders"](ex, inst_id) + except Exception as e: + return jsonify({"ok": False, "msg": f"获取委托失败: {e}"}) + from lib.options.options_pending_lib import enrich_pending_orders + + ttl = float(cfg.get("pending_ttl_seconds") or 600.0) + enriched = enrich_pending_orders(orders, ttl_seconds=ttl) + return jsonify( + { + "ok": True, + "orders": enriched, + "count": len(enriched), + "pending_ttl_seconds": ttl, + } + ) + + @app.route("/api/options/orders/cancel", methods=["POST"]) + @lr + def api_options_orders_cancel(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + data = request.get_json(silent=True) or {} + inst_id = (data.get("inst_id") or "").strip() + ord_id = (data.get("ord_id") or "").strip() + if not inst_id or not ord_id: + return jsonify({"ok": False, "msg": "缺少 inst_id 或 ord_id"}) + out = cfg["cancel_option_order"](ex, inst_id=inst_id, ord_id=ord_id) + if out.get("ok"): + from lib.exchange.okx_options_lib import invalidate_option_positions_cache + + invalidate_option_positions_cache() + # 本地未成交开仓记录标记取消,避免假 open + try: + conn = cfg["get_db"]() + try: + from lib.options.options_time_lib import options_app_now_str + + init_options_tables(conn) + conn.execute( + """ + UPDATE options_trades + SET status = 'cancelled', + signal_note = CASE + WHEN signal_note IS NULL OR TRIM(signal_note) = '' THEN '委托撤销' + ELSE signal_note + END, + closed_at = ? + WHERE inst_id = ? AND exchange_ord_id = ? AND status = 'open' + """, + (options_app_now_str(), inst_id, ord_id), + ) + conn.commit() + finally: + conn.close() + except Exception: + pass + _sync_options_trades(cfg, force=True) + return jsonify(out), (200 if out.get("ok") else 400) + + @app.route("/api/options/positions") + @lr + def api_options_positions(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + raw = cfg["fetch_option_positions"](ex) + if raw is None: + return jsonify({"ok": False, "msg": "获取期权持仓失败"}) + _sync_options_trades(cfg, raw_positions=raw) + meta_cache: dict[str, dict[str, Any] | None] = {} + conn = cfg["get_db"]() + try: + from lib.options.options_target_lib import targets_by_inst + from lib.options.options_profit_exit_lib import profit_exit_by_inst + + tgt_map = targets_by_inst(conn) + profit_exit_map = profit_exit_by_inst(conn) + rows = [] + for p in raw: + inst = str(p.get("instId") or "").strip() + premium_override = sum_open_premium_paid(conn, inst) if inst else None + row = _enrich_position_row_display( + cfg, + ex, + p, + meta_cache=meta_cache, + premium_override=premium_override, + ) + _attach_close_preview(cfg, ex, row, premium_paid=_safe_float(row.get("premium_paid"))) + mon = tgt_map.get(inst) + if mon: + row["target_index"] = mon.get("target_index") + row["target_monitor_id"] = mon.get("id") + row["target_monitor"] = mon + pe = profit_exit_map.get(inst) + if pe: + row["profit_exit_enabled"] = pe.get("profit_exit_enabled") + row["profit_exit_mult"] = pe.get("profit_exit_mult") + row["profit_exit_state"] = pe.get("profit_exit_state") + row["profit_exit_required_recycle"] = pe.get("required_recycle") + try: + from lib.instance.instance_dashboard_lib import _resolve_options_source + + source_key, source_label, source_plan_id = _resolve_options_source(conn, inst) + row["source"] = source_key + row["source_label"] = source_label + row["source_plan_id"] = source_plan_id + except Exception: + row.setdefault("source", "option") + row.setdefault("source_label", "纯期权") + row.setdefault("source_plan_id", None) + rows.append(row) + finally: + conn.close() + spot_sell_hint = None + try: + from lib.options.options_margin_mode_lib import is_coin_margin_mode + + if is_coin_margin_mode(): + from lib.options.options_spot_bridge_lib import list_open_bridges + + conn_b = cfg["get_db"]() + try: + from lib.options.options_db import init_options_tables + + init_options_tables(conn_b) + open_bridges = list_open_bridges(conn_b) + bal = cfg["fetch_options_balances"](ex, scope="main") + bridge = open_bridges[0] if open_bridges else None + from lib.options.options_spot_bridge_lib import ( + display_sellable_coin, + spot_min_sell_sz, + ) + + raw_eth = bal.get("trading_eth_avail") or bal.get("trading_eth") + raw_btc = bal.get("trading_btc_avail") or bal.get("trading_btc") + spot_sell_hint = { + "bridge_status": str(bridge.get("status") or "") if bridge else None, + "bridge_underlying": str(bridge.get("underlying") or "").upper() if bridge else None, + # 粉尘归零:不够现货最小可卖则前端不展示、不提示重试 + "trading_eth": display_sellable_coin(ex, "ETH", raw_eth), + "trading_btc": display_sellable_coin(ex, "BTC", raw_btc), + "min_sz_eth": spot_min_sell_sz(ex, "ETH"), + "min_sz_btc": spot_min_sell_sz(ex, "BTC"), + "default_underly": (cfg.get("default_underly") or "ETH").strip().upper(), + } + finally: + conn_b.close() + except Exception: + spot_sell_hint = None + cond_locked = False + cond_msg = "" + cond_order = None + try: + from lib.options.options_cond_order_lib import has_active_cond_order, list_cond_orders + + conn_c = cfg["get_db"]() + try: + init_options_tables(conn_c) + cond_locked = has_active_cond_order(conn_c) + if cond_locked: + act = list_cond_orders(conn_c, active_only=True) + if act: + cond_order = act[0] + cond_msg = str(act[0].get("message") or act[0].get("status") or "") + finally: + conn_c.close() + except Exception: + pass + return jsonify( + { + "ok": True, + "positions": rows, + "spot_sell": spot_sell_hint, + "cond_order_locked": cond_locked, + "cond_order_msg": cond_msg, + "cond_order": cond_order, + } + ) + + @app.route("/api/options/targets") + @lr + def api_options_targets(): + conn = cfg["get_db"]() + try: + from lib.options.options_target_lib import list_active_targets, list_closing_targets + + return jsonify({"ok": True, "targets": list_active_targets(conn) + list_closing_targets(conn)}) + finally: + conn.close() + + @app.route("/api/options/target", methods=["POST"]) + @lr + def api_options_target_set(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + data = request.get_json(silent=True) or {} + inst_id = (data.get("inst_id") or "").strip() + if not inst_id: + return jsonify({"ok": False, "msg": "缺少 inst_id"}) + try: + target_index = float(data.get("target_index")) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "目标位无效"}) + if target_index <= 0: + return jsonify({"ok": False, "msg": "目标位无效"}) + raw = cfg["fetch_option_positions"](ex) + if raw is None: + return jsonify({"ok": False, "msg": "获取期权持仓失败"}) + pos = _find_position(raw, inst_id) + if not pos: + return jsonify({"ok": False, "msg": "未找到持仓"}) + from lib.options.options_target_lib import upsert_target_monitor + + fmt = cfg["format_position_row"](pos) + conn = cfg["get_db"]() + try: + trade = conn.execute( + """ + SELECT id, opt_type, underlying FROM options_trades + WHERE inst_id = ? AND status = 'open' + ORDER BY id DESC LIMIT 1 + """, + (inst_id,), + ).fetchone() + trade_id = int(trade["id"]) if trade else None + sheets_sum = sum_open_sheets(conn, inst_id) + sheets = sheets_sum if sheets_sum is not None else int(fmt.get("pos") or 0) + opt_type = (trade["opt_type"] if trade else None) or fmt.get("opt_type") + underlying = (trade["underlying"] if trade else None) or fmt.get("underlying") + out = upsert_target_monitor( + conn, + inst_id=inst_id, + target_index=target_index, + underlying=str(underlying) if underlying else None, + opt_type=str(opt_type) if opt_type else None, + trade_id=trade_id, + sheets=sheets, + ) + conn.commit() + return jsonify(out) + finally: + conn.close() + + @app.route("/api/options/target/cancel", methods=["POST"]) + @lr + def api_options_target_cancel(): + data = request.get_json(silent=True) or {} + inst_id = (data.get("inst_id") or "").strip() or None + monitor_id = data.get("id") + try: + mid = int(monitor_id) if monitor_id is not None and str(monitor_id).strip() != "" else None + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "监控 id 无效"}) + if not inst_id and mid is None: + return jsonify({"ok": False, "msg": "缺少 inst_id 或 id"}) + from lib.options.options_target_lib import cancel_target_monitor + + conn = cfg["get_db"]() + try: + n = cancel_target_monitor(conn, inst_id=inst_id, monitor_id=mid) + conn.commit() + return jsonify({"ok": True, "cancelled": n}) + finally: + conn.close() + + @app.route("/api/options/profit-exit", methods=["POST"]) + @lr + def api_options_profit_exit_set(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + data = request.get_json(silent=True) or {} + inst_id = (data.get("inst_id") or "").strip() + if not inst_id: + return jsonify({"ok": False, "msg": "缺少 inst_id"}) + enabled_raw = data.get("enabled") + if enabled_raw is None: + enabled_raw = data.get("profit_exit_enabled") + enabled = bool(enabled_raw) and str(enabled_raw).strip().lower() not in ( + "0", + "false", + "off", + "no", + ) + from lib.options.options_profit_exit_lib import normalize_profit_exit_mult, set_profit_exit + + mult = normalize_profit_exit_mult(data.get("mult", data.get("profit_exit_mult")), default=1.0) + raw = cfg["fetch_option_positions"](ex) + if raw is None: + return jsonify({"ok": False, "msg": "获取期权持仓失败"}) + if not _find_position(raw, inst_id): + return jsonify({"ok": False, "msg": "未找到持仓"}) + conn = cfg["get_db"]() + try: + out = set_profit_exit(conn, inst_id=inst_id, enabled=enabled, mult=mult) + if out.get("ok"): + conn.commit() + return jsonify(out) + finally: + conn.close() + + @app.route("/api/options/close", methods=["POST"]) + @lr + def api_options_close(): + data = request.get_json(silent=True) or {} + inst_id = (data.get("inst_id") or "").strip() + if not inst_id: + return jsonify({"ok": False, "msg": "缺少 inst_id"}) + if data.get("market"): + return jsonify({"ok": False, "msg": "已禁用市价平仓,仅支持买一限价"}) + sheets = data.get("sheets") + try: + sheets_i = int(sheets) if sheets is not None and str(sheets).strip() != "" else None + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "张数无效"}) + # 手动买一平仓:只验有效流动性;2×门控仅用于自动/目标位平仓 + return jsonify(_run_manual_bid_close(cfg, inst_id, sheets=sheets_i, reason="手动平仓")) + + @app.route("/api/options/spot-bridge/retry-sell", methods=["POST"]) + @lr + def api_options_spot_bridge_retry_sell(): + """币本位:重试把残留标的币市价卖回 USDT.""" + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + data = request.get_json(silent=True) or {} + underlying = (data.get("underlying") or cfg.get("default_underly") or "ETH").strip().upper() + inst_id = (data.get("inst_id") or "").strip() or None + from lib.options.options_spot_bridge_lib import sell_residual_after_option_flat + + out = sell_residual_after_option_flat( + cfg["get_db"], + ex, + underlying=underlying, + inst_id=inst_id, + ) + if out.get("ok"): + _mark_balances_stale(cfg) + return jsonify(out) + + @app.route("/api/options/convert/quote", methods=["POST"]) + @lr + def api_options_convert_quote(): + return jsonify({"ok": False, "msg": "USDC 兑换已下线,仅支持币本位期权"}) + + @app.route("/api/options/convert/execute", methods=["POST"]) + @lr + def api_options_convert_execute(): + return jsonify({"ok": False, "msg": "USDC 兑换已下线,仅支持币本位期权"}) + + @app.route("/api/options/transfer", methods=["POST"]) + @lr + def api_options_transfer(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + 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": "数量无效"}) + try: + import lib.exchange.okx_options_lib as _opt_lib + + xfer = getattr(_opt_lib, "transfer_ccy", None) + except Exception: + xfer = None + if not callable(xfer): + xfer = cfg.get("transfer_ccy") + result = xfer(ex, ccy, amount, from_acct, to_acct) if callable(xfer) else { + "ok": False, + "msg": "划转不可用", + } + 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": "USDT↔USDC 兑换已下线,仅支持币本位期权"}) + + @app.route("/api/options/history") + @lr + def api_options_history(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + from lib.options.options_history_lib import load_options_history + + raw_live = cfg["fetch_option_positions"](ex) + if raw_live is None: + return jsonify({"ok": False, "msg": "获取期权持仓失败"}) + history = load_options_history(ex, cfg) + live_ids = {str(x.get("inst_id") or "") for x in history if x.get("status") == "open"} + return jsonify({"ok": True, "history": history, "live_inst_ids": sorted(live_ids)}) + + @app.route("/api/options/stats") + @lr + def api_options_stats(): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + from lib.options.options_history_lib import load_options_history + from lib.options.options_margin_mode_lib import is_coin_margin_mode + from lib.options.options_positions_lib import sum_options_net_pnl_usdc + from lib.options.options_stats_lib import ( + compute_options_stats_from_history, + history_pnl_to_usdt, + ) + + raw_live = cfg["fetch_option_positions"](ex) + if raw_live is None: + return jsonify({"ok": False, "msg": "获取期权持仓失败"}) + history = load_options_history(ex, cfg) + # 币本位已平盈亏按指数折 U,与持仓浮盈/合计口径一致 + stats = compute_options_stats_from_history(history_pnl_to_usdt(history, ex)) + open_float = sum_options_net_pnl_usdc(cfg, ex, raw_live) + net_realized = _safe_float(stats.get("net_realized_pnl")) or 0.0 + total_pnl = None + if open_float is not None: + total_pnl = round(net_realized + float(open_float), 4) + elif stats.get("total_closed"): + total_pnl = round(net_realized, 4) + # 币本位统计统一标 U;USDC 模式仍标 USDC + pnl_unit = "U" if is_coin_margin_mode() else "USDC" + return jsonify( + { + "ok": True, + **stats, + "open_float_pnl": open_float, + "total_pnl": total_pnl, + "pnl_unit": pnl_unit, + } + ) + + @app.route("/api/options/history/", methods=["DELETE"]) + @lr + def api_options_history_delete(history_key: str): + ex, err = _require_options_ex(cfg) + if ex is None: + return jsonify({"ok": False, "msg": err}) + key = (history_key or "").strip() + if not key: + return jsonify({"ok": False, "msg": "缺少 history_key"}) + data = request.get_json(silent=True) or {} + inst_id = str(data.get("inst_id") or request.args.get("inst_id") or "").strip() or None + closed_at = str(data.get("closed_at") or request.args.get("closed_at") or "").strip() or None + conn = cfg["get_db"]() + try: + init_options_tables(conn) + conn.execute( + "INSERT OR IGNORE INTO options_history_hidden (history_key) VALUES (?)", + (key,), + ) + # 同步隐藏期权复盘,避免本地已平记录刷新后又出现 + try: + from lib.options.options_review_lib import hide_review_keys + + hide_review_keys( + conn, + history_key=key, + inst_id=inst_id, + closed_at=closed_at, + ) + if inst_id: + # 去掉已导入的复盘快照(按合约+平仓时间) + if closed_at: + rows = conn.execute( + """ + SELECT id, history_key FROM options_review_trades + WHERE inst_id = ? + AND substr(COALESCE(closed_at,''),1,16) = substr(?,1,16) + """, + (inst_id, closed_at), + ).fetchall() + else: + rows = conn.execute( + """ + SELECT id, history_key FROM options_review_trades + WHERE inst_id = ? + """, + (inst_id,), + ).fetchall() + for r in rows: + conn.execute( + "DELETE FROM options_review_entries WHERE trade_id=?", + (int(r["id"]),), + ) + conn.execute( + "DELETE FROM options_review_trades WHERE id=?", + (int(r["id"]),), + ) + conn.execute( + "INSERT OR IGNORE INTO options_review_hidden(history_key, inst_id, closed_at) VALUES (?,?,?)", + (str(r["history_key"]), inst_id, (closed_at or "")[:19] or None), + ) + fps = [] + if closed_at: + fps.append(f"inst_close:{inst_id}:{closed_at[:16]}") + fps.append(f"inst:{inst_id}") + for fp in fps: + conn.execute( + "INSERT OR IGNORE INTO options_review_hidden(history_key, inst_id, closed_at) VALUES (?,?,?)", + (fp, inst_id, (closed_at or "")[:19] or None), + ) + except Exception: + pass + conn.commit() + finally: + conn.close() + return jsonify({"ok": True}) + + +def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None: + if app.extensions.get("options_monitor_started"): + return + app.extensions["options_monitor_started"] = True + + def _bid(inst_id: str) -> float | None: + ex = cfg.get("exchange_options") + if ex is None: + return None + try: + q = cfg["quote_option_contract"](ex, inst_id) + return q.get("bid") + except Exception: + return None + + def _positions(): + ex = cfg.get("exchange_options") + if ex is None: + return [] + raw = cfg["fetch_option_positions"](ex) + if raw is None: + return [] + return [cfg["format_position_row"](p) for p in raw] + + def _sync(_conn=None): + # 不用监控线程传入的连接;force=False 走 15s 节流,卖币由监控循环在关闭连接后另扫 + try: + _sync_options_trades(cfg, force=False) + return 1 + except Exception: + return 0 + + def _target_close(inst_id: str) -> dict[str, Any]: + from lib.options.options_target_lib import close_option_by_bid_depth + + ex = cfg.get("exchange_options") + if ex is None: + return {"ok": False, "msg": "期权 exchange 未就绪"} + result = close_option_by_bid_depth(cfg, ex, inst_id) + if result.get("ok"): + try: + _sync_options_trades(cfg, force=True) + except Exception: + pass + try: + _mark_balances_stale(cfg) + except Exception: + pass + return result + + def _profit_exit_close(inst_id: str) -> dict[str, Any]: + from lib.options.options_profit_exit_lib import close_option_by_bid_profit_exit + + ex = cfg.get("exchange_options") + if ex is None: + return {"ok": False, "msg": "期权 exchange 未就绪"} + result = close_option_by_bid_profit_exit(cfg, ex, inst_id) + if result.get("ok"): + try: + _sync_options_trades(cfg, force=True) + except Exception: + pass + try: + _mark_balances_stale(cfg) + except Exception: + pass + return result + + def _stale_pending() -> dict[str, Any]: + from lib.exchange.okx_options_lib import invalidate_option_positions_cache + from lib.options.options_pending_lib import cancel_stale_close_pending_orders + + ex = cfg.get("exchange_options") + if ex is None: + return {"ok": False, "msg": "期权 exchange 未就绪"} + ttl = float(cfg.get("pending_ttl_seconds") or 600.0) + out = cancel_stale_close_pending_orders( + fetch_pending=lambda _ex: cfg["fetch_option_pending_orders"](_ex), + cancel_order=lambda _ex, inst_id, ord_id: cfg["cancel_option_order"]( + _ex, inst_id=inst_id, ord_id=ord_id + ), + ttl_seconds=ttl, + ex=ex, + ) + if out.get("cancelled"): + try: + invalidate_option_positions_cache() + except Exception: + pass + try: + send = cfg.get("send_wechat") + if callable(send): + parts = [ + "【OKX期权·挂单超时撤销】", + f"账户:{cfg.get('account_label') or 'OKX期权'}", + f"超时:{ttl:g}s", + f"撤销:{out.get('cancelled')} 笔", + ] + for o in out.get("orders") or []: + parts.append(f"- {o.get('inst_id')} #{o.get('ord_id')}") + send("\n".join(parts)) + except Exception: + pass + return out + + def _cond_order_tick(_conn=None) -> None: + from lib.options.options_cond_order_lib import run_cond_orders_tick + + # 传 get_db:条件单开仓前会释放连接,避免嵌套锁库 + run_cond_orders_tick( + cfg["get_db"], + cfg, + send_wechat=cfg.get("send_wechat"), + account_label=str(cfg.get("account_label") or ""), + ) + + t = threading.Thread( + target=options_monitor_loop, + kwargs={ + "enabled": True, + "poll_seconds": cfg["poll_seconds"], + "get_db": cfg["get_db"], + "fetch_positions": _positions, + "ticker_bid_fn": _bid, + "send_wechat": cfg["send_wechat"], + "account_label": cfg["account_label"], + "profit_ratio": cfg["profit_ratio"], + "sync_trades_fn": _sync, + "target_close_fn": _target_close, + "profit_exit_close_fn": _profit_exit_close, + "profit_exit_cfg": cfg, + "stale_pending_fn": _stale_pending, + "cond_order_tick_fn": _cond_order_tick, + }, + daemon=True, + name="options-monitor", + ) + t.start()