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()