diff --git a/crypto_monitor_okx/.env.example b/crypto_monitor_okx/.env.example index 36607f7..8d3ebba 100644 --- a/crypto_monitor_okx/.env.example +++ b/crypto_monitor_okx/.env.example @@ -112,6 +112,8 @@ OKX_OPTIONS_ACCOUNT_LABEL=主账户·期权 OKX_OPTIONS_TRADE_BUDGET_USDC=10 OKX_OPTIONS_BUDGET_BUFFER=0.95 OKX_OPTIONS_DEFAULT_UNDERLY=ETH +# 期权链仅显示卖一深度≥1张的合约(估算卖一/无深度不显示);false 则显示全部 +OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED=true OKX_OPTIONS_MAX_DTE_DAYS=2 OKX_OPTIONS_CHAIN_MAX_DTE_DAYS=14 OKX_SUB_ACCOUNT_NAME= diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index d0ef7d7..f3ec0f8 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -6793,6 +6793,10 @@ def render_main_page(page="trade", embed_mode=None): in ("1", "true", "yes", "on"), options_trade_budget=OKX_OPTIONS_TRADE_BUDGET_USDC, options_default_underly=OKX_OPTIONS_DEFAULT_UNDERLY, + options_chain_ask_liq_filter=os.getenv( + "OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", "true" + ).lower() + in ("1", "true", "yes", "on"), risk_status=risk_status, max_active_positions=MAX_ACTIVE_POSITIONS, manual_min_planned_rr=MANUAL_MIN_PLANNED_RR, diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index c17b165..ca7d080 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -14,6 +14,8 @@ moneyFilter: "all", chainView: "list", strikeExpandAll: false, + /** 环境 OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED;链接口可热更新 */ + askLiqFilter: root.dataset.askLiqFilter !== "0", chain: panelCache.chain || null, selectedInst: null, orderQuote: null, @@ -361,21 +363,41 @@ return ""; } + function askLiqFilterOn() { + return !!state.askLiqFilter; + } + + function hasAskLiquidity(c) { + if (!c) return false; + if (c.ask_estimated) return false; + const a = Number(c.ask); + const s = Number(c.ask_sz); + return Number.isFinite(a) && a > 0 && Number.isFinite(s) && s >= 1; + } + + function syncAskLiqFilterFromChain(d) { + if (!d || d.ask_liq_filter_enabled == null) return; + state.askLiqFilter = !!d.ask_liq_filter_enabled; + root.dataset.askLiqFilter = state.askLiqFilter ? "1" : "0"; + } + function countContractsForType(contracts) { if (state.chainView === "t") { return countStraddleStrikes(contracts); } return (contracts || []).filter(function (c) { - return c.opt_type === state.optType; + if (c.opt_type !== state.optType) return false; + if (askLiqFilterOn() && !hasAskLiquidity(c)) return false; + return true; }).length; } function countStraddleStrikes(contracts) { - const strikes = new Set(); - (contracts || []).forEach(function (c) { - if (c.strike != null) strikes.add(String(c.strike)); + const rows = buildStraddleRows(contracts).filter(function (row) { + if (!askLiqFilterOn()) return true; + return hasAskLiquidity(row.call) || hasAskLiquidity(row.put); }); - return strikes.size; + return rows.length; } function buildStraddleRows(contracts) { @@ -418,7 +440,11 @@ function filterStraddleRows(rows, indexPx) { const atmStrike = findAtmStrike(rows, indexPx); return rows.filter(function (row) { - return matchesStrikeRowFilter(row.strike, indexPx, atmStrike); + if (!matchesStrikeRowFilter(row.strike, indexPx, atmStrike)) return false; + if (askLiqFilterOn() && !hasAskLiquidity(row.call) && !hasAskLiquidity(row.put)) { + return false; + } + return true; }); } @@ -479,7 +505,10 @@ function filterChainContracts(contracts) { return (contracts || []).filter(function (c) { - return c.opt_type === state.optType && matchesMoneyFilter(c.moneyness); + if (c.opt_type !== state.optType) return false; + if (!matchesMoneyFilter(c.moneyness)) return false; + if (askLiqFilterOn() && !hasAskLiquidity(c)) return false; + return true; }); } @@ -536,11 +565,32 @@ } const line = document.getElementById("opt-index-line"); if (line) { + const liqHint = askLiqFilterOn() ? "仅显示卖一深度≥1张" : "显示全部卖一(含估算~)"; line.textContent = - "指数 " + state.underlying + " ≈ " + fmt(idx, 2) + " · 默认显示全部 · 实值含平值 · 虚值=价外"; + "指数 " + state.underlying + " ≈ " + fmt(idx, 2) + + " · 默认最近一期 · " + liqHint + " · 实值含平值 · 虚值=价外"; } } + function pickNearestExpiry(exps) { + if (!exps || !exps.length) return ""; + const now = Date.now(); + let best = null; + let bestDelta = Infinity; + exps.forEach(function (e) { + const t = Number(e.exp_time); + if (!Number.isFinite(t)) return; + const delta = t - now; + if (delta < -60000) return; + if (delta < bestDelta) { + bestDelta = delta; + best = e; + } + }); + if (best) return String(best.exp_time); + return String(exps[0].exp_time); + } + function renderExpiryOptions(preserveSelection) { const sel = document.getElementById("opt-exp-select"); if (!sel) return; @@ -555,6 +605,8 @@ }); if (prev && exps.some(function (e) { return String(e.exp_time) === String(prev); })) { sel.value = prev; + } else if (exps.length) { + sel.value = pickNearestExpiry(exps); } } @@ -852,7 +904,8 @@ if (!list.length) { const label = moneyFilterLabel(); const suffix = label ? label : optTypeLabel(state.optType); - tbody.innerHTML = '该到期日暂无' + suffix + "合约"; + const liqTip = askLiqFilterOn() ? "(卖一深度≥1 时才显示,可在环境配置关闭筛选)" : ""; + tbody.innerHTML = '该到期日暂无' + suffix + "合约" + liqTip + ""; state.selectedInst = null; return; } @@ -911,8 +964,10 @@ const atmStrike = findAtmStrike(rows, indexPx); let matchedSelected = false; rows.forEach(function (row) { - const call = row.call; - const put = row.put; + const callRaw = row.call; + const putRaw = row.put; + const call = callRaw && (!askLiqFilterOn() || hasAskLiquidity(callRaw)) ? callRaw : null; + const put = putRaw && (!askLiqFilterOn() || hasAskLiquidity(putRaw)) ? putRaw : null; const combined = straddleAskPerUnit(call && call.ask, put && put.ask); const tr = document.createElement("tr"); tr.className = "opt-strike-row opt-strike-row-t"; @@ -1090,6 +1145,7 @@ panelCache.chain = d; panelCache.underlying = uly; panelCache.optType = state.optType; + syncAskLiqFilterFromChain(d); if (!soft) { state.selectedInst = null; resetMoneyFilterToAll(); diff --git a/lib/env/env_schema.py b/lib/env/env_schema.py index 0f9cb9e..81086d8 100644 --- a/lib/env/env_schema.py +++ b/lib/env/env_schema.py @@ -85,6 +85,7 @@ HOT_RELOAD_EXACT = frozenset({ "HEDGE_PLAN_ENABLED", "HEDGE_PLAN_SHOW_PERP_OPTIONS", "HEDGE_PLAN_SHOW_OPTIONS_OPTIONS", + "OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", "HEDGE_PLAN_LIVE_ORDER", "HEDGE_PLAN_OPEN_ORDER", "HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", diff --git a/lib/env/env_ui_manifest.py b/lib/env/env_ui_manifest.py index dc599cd..c75473f 100644 --- a/lib/env/env_ui_manifest.py +++ b/lib/env/env_ui_manifest.py @@ -126,6 +126,11 @@ _OPTIONS_SECTION: dict[str, Any] = { ("OKX_OPTIONS_TRADE_BUDGET_USDC", "单笔预算(USDC)", ""), ("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95"), ("OKX_OPTIONS_DEFAULT_UNDERLY", "默认标的", "如 ETH"), + ( + "OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", + "链上仅显示有卖一", + "默认 true;开启后隐藏无卖一深度或深度不足1张的合约(含标记价估算行)", + ), ], } @@ -186,6 +191,7 @@ _RUNTIME_ENV_DEFAULTS: dict[str, str] = { "RISK_MOOD_ISSUES_DAILY_FREEZE": "true", "HEDGE_PLAN_SHOW_PERP_OPTIONS": "true", "HEDGE_PLAN_SHOW_OPTIONS_OPTIONS": "true", + "OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED": "true", "HEDGE_PLAN_OO_CLOSE_MODE_ENABLED": "true", "HEDGE_PLAN_OO_BIAS_SPLIT_BY": "budget", "HEDGE_PLAN_OO_BIAS_RATIO": "0.7", diff --git a/lib/options/options_register.py b/lib/options/options_register.py index 8d79382..5d196f7 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -108,6 +108,7 @@ def _build_cfg(app_module: Any) -> dict[str, Any]: "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),仅买一限价 @@ -370,6 +371,8 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: 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) if not expiries: return jsonify( { @@ -377,9 +380,17 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: "msg": chain_err or "暂无到期日,请稍后点「刷新链」", **chain, "chain_max_dte_days": cfg["chain_max_dte_days"], + "ask_liq_filter_enabled": ask_liq_filter, } ) - return jsonify({"ok": True, **chain, "chain_max_dte_days": cfg["chain_max_dte_days"]}) + return jsonify( + { + "ok": True, + **chain, + "chain_max_dte_days": cfg["chain_max_dte_days"], + "ask_liq_filter_enabled": ask_liq_filter, + } + ) @app.route("/api/options/quote") @lr diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html index 2b06a3d..9c4db6c 100644 --- a/lib/options/templates/options_panel.html +++ b/lib/options/templates/options_panel.html @@ -1,5 +1,6 @@
+ data-default-underly="{{ options_default_underly | default('ETH') }}" + data-ask-liq-filter="{% if options_chain_ask_liq_filter is defined %}{{ '1' if options_chain_ask_liq_filter else '0' }}{% else %}1{% endif %}"> {% if not options_enabled %}
期权 API 未启用:请在 crypto_monitor_okx/.env 设置 OKX_OPTIONS_ENABLED=true 及主账户 OKX_OPTIONS_API_*,然后 pm2 restart crypto_okx --update-env.
{% endif %} @@ -7,7 +8,7 @@

期权下单 开平仓与监控说明

-

报价单位为每 1 ETH/BTC;1 张 = 0.01.列表含卖一/买一;T 型仅卖一(买方开仓),中间为跨式双买测算.链上无卖一挂单时以标记价/内在价值估算并标 ~(仅参考).开仓只认真实卖一价且卖一深度>0;无深度时面板显示参考标记价并禁用买入.链展示近 14 日到期.T 型默认 ATM ±5 档,可展开全部.平仓仅买一限价,见说明.

+

报价单位为每 1 ETH/BTC;1 张 = 0.01.默认选中最近一期到期,可手动改.列表含卖一/买一;T 型仅卖一(买方开仓),中间为跨式双买测算.环境配置「链上仅显示有卖一」开启时,隐藏无真实卖一或深度不足 1 张的合约(估算价 ~ 亦不显示).开仓只认真实卖一价且卖一深度≥1.链展示近 14 日到期.T 型默认 ATM ±5 档,可展开全部.平仓仅买一限价,见说明.

@@ -299,4 +300,4 @@
- +