From 1835a79698d94f3fee7316991cbb8896b4c479bf Mon Sep 17 00:00:00 2001 From: dekun Date: Tue, 13 Oct 2026 19:09:06 +0800 Subject: [PATCH] =?UTF-8?q?feat=E6=9C=9F=E6=9D=83=E6=A0=87=E7=9A=84?= =?UTF-8?q?=E7=99=BD=E5=90=8D=E5=8D=95=EF=BC=9A=E5=8F=AF=E5=9B=BA=E5=AE=9A?= =?UTF-8?q?=E5=8F=AA=E6=98=BE=E7=A4=BA=20ETH=20=E6=88=96=20BTC=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .env.example | 3 ++ app.py | 21 +++++++-- lib/common/static/options_panel.js | 24 +++++++++- lib/env/env_ui_manifest.py | 7 ++- lib/exchange/okx_options_lib.py | 7 ++- lib/options/options_cond_order_lib.py | 10 +++++ lib/options/options_hub_lib.py | 6 ++- lib/options/options_register.py | 31 ++++++++++++- lib/options/options_underly_lib.py | 56 ++++++++++++++++++++++++ lib/options/templates/options_panel.html | 12 +++-- tests/test_options_underly_lib.py | 51 +++++++++++++++++++++ 11 files changed, 215 insertions(+), 13 deletions(-) create mode 100644 lib/options/options_underly_lib.py create mode 100644 tests/test_options_underly_lib.py diff --git a/.env.example b/.env.example index b735a06..ec1c133 100644 --- a/.env.example +++ b/.env.example @@ -132,6 +132,9 @@ OKX_OPTIONS_BUDGET_BUFFER=0.95 OKX_TRADE_MODE=options # 期权同时持仓上限(笔);0=不限制;同合约加仓不占新笔数;热更 OKX_OPTIONS_MAX_ACTIVE_POSITIONS=0 +# 期权标的白名单(逗号分隔,仅 ETH/BTC)。只配一个则交易页只显示该标的;开仓/链/报价/条件单均校验。资金页不受影响。 +OKX_OPTIONS_UNDERLYINGS=ETH,BTC +# 默认选中标的;须落在白名单内,否则取白名单第一项 OKX_OPTIONS_DEFAULT_UNDERLY=ETH # 期权链仅显示卖一深度≥1张的合约(估算卖一/无深度不显示);false 则显示全部 OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED=true diff --git a/app.py b/app.py index 44fd9f0..cfc9d2b 100644 --- a/app.py +++ b/app.py @@ -3925,6 +3925,19 @@ def render_main_page(page="options", embed_mode=None): options_trading_btc = None options_margin_mode = "coin" options_underly = "ETH" + try: + from lib.options.options_underly_lib import ( + parse_options_underlyings, + resolve_default_underly, + ) + + _options_uly_allowed = parse_options_underlyings() + _options_uly_default = resolve_default_underly(_options_uly_allowed) + options_underly = _options_uly_default + except Exception: + _options_uly_allowed = ["ETH", "BTC"] + _options_uly_default = OKX_OPTIONS_DEFAULT_UNDERLY or "ETH" + options_underly = _options_uly_default _sim_mode_for_header = False _exchange_display_for_header = EXCHANGE_DISPLAY_NAME try: @@ -3955,7 +3968,8 @@ def render_main_page(page="options", embed_mode=None): options_trading_eth = _op.get("trading_eth") options_trading_btc = _op.get("trading_btc") options_margin_mode = _op.get("options_margin_mode") or "coin" - options_underly = _op.get("options_underly") or "ETH" + # 顶栏展示仍可用交易所包里的 underly;交易默认以白名单解析为准 + options_underly = _options_uly_default or _op.get("options_underly") or "ETH" except Exception: options_trading_usdc = None options_funding_usdc = None @@ -3965,7 +3979,7 @@ def render_main_page(page="options", embed_mode=None): options_trading_eth = None options_trading_btc = None options_margin_mode = "coin" - options_underly = "ETH" + options_underly = _options_uly_default recommended_capital = get_recommended_capital(capital_for_sizing) conn = get_db() key_list = ( @@ -4201,7 +4215,8 @@ def render_main_page(page="options", embed_mode=None): options_compound_full_cap_usdc=float( os.getenv("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC") or "300" ), - options_default_underly=OKX_OPTIONS_DEFAULT_UNDERLY, + options_default_underly=_options_uly_default, + options_allowed_underlys=_options_uly_allowed, options_chain_ask_liq_filter=os.getenv( "OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", "true" ).lower() diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index 36b9500..b696e9f 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -8,8 +8,30 @@ const panelCache = (window.__optionsPanelCache = window.__optionsPanelCache || {}); + const allowedUnderlys = (function () { + const raw = String(root.dataset.allowedUnderlys || "ETH,BTC"); + const list = raw + .split(",") + .map(function (s) { + return String(s || "") + .trim() + .toUpperCase(); + }) + .filter(function (u) { + return u === "ETH" || u === "BTC"; + }); + return list.length ? list : ["ETH", "BTC"]; + })(); + const bootUnderly = (function () { + const d = String(root.dataset.defaultUnderly || "ETH") + .trim() + .toUpperCase(); + if (allowedUnderlys.indexOf(d) >= 0) return d; + return allowedUnderlys[0] || "ETH"; + })(); + const state = { - underlying: root.dataset.defaultUnderly || "ETH", + underlying: bootUnderly, optType: "C", moneyFilter: "all", chainView: "list", diff --git a/lib/env/env_ui_manifest.py b/lib/env/env_ui_manifest.py index 6d198ed..d59c4ef 100644 --- a/lib/env/env_ui_manifest.py +++ b/lib/env/env_ui_manifest.py @@ -112,7 +112,12 @@ _OPTIONS_SECTION: dict[str, Any] = { "期权持仓上限(笔)", "默认 1=一次一仓;0=不限制;按交易所期权合约笔数计数,同合约加仓不占新笔数", ), - ("OKX_OPTIONS_DEFAULT_UNDERLY", "默认标的", "如 ETH"), + ( + "OKX_OPTIONS_UNDERLYINGS", + "标的白名单", + "逗号分隔,仅 ETH/BTC;只配一个则交易页只显示该标的(开仓/链/报价/条件单校验);资金页不受影响;默认 ETH,BTC", + ), + ("OKX_OPTIONS_DEFAULT_UNDERLY", "默认标的", "如 ETH;须在白名单内,否则取白名单第一项"), ( "OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", "期权链展示天数", diff --git a/lib/exchange/okx_options_lib.py b/lib/exchange/okx_options_lib.py index 53992fb..a4389bc 100644 --- a/lib/exchange/okx_options_lib.py +++ b/lib/exchange/okx_options_lib.py @@ -710,7 +710,12 @@ def options_header_balance_pack( margin_mode = normalize_options_margin_mode() except Exception: margin_mode = "usdc" - underly = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() or "ETH" + try: + from lib.options.options_underly_lib import resolve_default_underly + + underly = resolve_default_underly() + except Exception: + underly = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() or "ETH" coin_key = "btc" if underly == "BTC" else "eth" return { "trading_usdc": _round(bal.get("trading_usdc")), diff --git a/lib/options/options_cond_order_lib.py b/lib/options/options_cond_order_lib.py index 921c5e5..e9f0097 100644 --- a/lib/options/options_cond_order_lib.py +++ b/lib/options/options_cond_order_lib.py @@ -248,6 +248,16 @@ def create_cond_order( return {"ok": False, "msg": "本机已有活跃条件单"} underlying = (payload.get("underlying") or "ETH").strip().upper() or "ETH" + try: + from lib.options.options_underly_lib import resolve_default_underly, underly_not_allowed_msg + + if not (payload.get("underlying") or "").strip(): + underlying = resolve_default_underly() + uly_block = underly_not_allowed_msg(underlying) + if uly_block: + return {"ok": False, "msg": uly_block} + except Exception: + pass opt_type = (payload.get("opt_type") or "P").strip().upper() if opt_type not in ("P", "C"): return {"ok": False, "msg": "opt_type 须为 P 或 C"} diff --git a/lib/options/options_hub_lib.py b/lib/options/options_hub_lib.py index c049e2f..d620784 100644 --- a/lib/options/options_hub_lib.py +++ b/lib/options/options_hub_lib.py @@ -133,6 +133,9 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]: except Exception: open_bridges = [] + from lib.options.options_underly_lib import underly_cfg_snapshot + + _uly_snap = underly_cfg_snapshot() out = { "ok": True, "enabled": True, @@ -152,7 +155,8 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]: "max_active_positions": options_max_active_positions(), "options_margin_mode": margin_mode, "options_margin_mode_label": "币本位" if margin_mode == "coin" else "USDC", - "options_underly": (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() or "ETH", + "options_underly": _uly_snap["default_underly"], + "options_allowed_underlys": _uly_snap["allowed_underlys"], "coin_budget": coin_budget, "bridge_status": bridge_status, "open_bridges": open_bridges, diff --git a/lib/options/options_register.py b/lib/options/options_register.py index 42f5f74..246d8d0 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -28,6 +28,18 @@ def _env_float(key: str, default: float) -> float: return float(default) +def _underly_blocked(underlying: str | None = None, *, inst_id: str | None = None) -> str | None: + """热读白名单;返回拒因或 None.""" + from lib.options.options_underly_lib import ( + inst_underly_not_allowed_msg, + underly_not_allowed_msg, + ) + + if inst_id: + return inst_underly_not_allowed_msg(inst_id) + return underly_not_allowed_msg(underlying or "") + + def attach_options_templates(app: Flask, repo_root: str) -> None: tpl_dir = os.path.join(repo_root, "lib", "options", "templates") if not os.path.isdir(tpl_dir): @@ -527,7 +539,12 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: 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() + from lib.options.options_underly_lib import resolve_default_underly + + u = (request.args.get("underlying") or resolve_default_underly()).upper() + blocked = _underly_blocked(u) + if blocked: + return jsonify({"ok": False, "msg": blocked}) # 热更新:链展示天数每次读 env,保存后刷新链即可 chain_max_dte = _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", float(cfg.get("chain_max_dte_days") or 14)) try: @@ -594,6 +611,9 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: inst_id = (request.args.get("inst_id") or "").strip() if not inst_id: return jsonify({"ok": False, "msg": "缺少 inst_id"}) + blocked = _underly_blocked(inst_id=inst_id) + if blocked: + return jsonify({"ok": False, "msg": blocked, "can_open": False}) q = cfg["quote_option_contract"](ex, inst_id) if not q.get("ok"): return jsonify(q) @@ -817,6 +837,9 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: profit_exit_mult = normalize_profit_exit_mult(data.get("profit_exit_mult"), default=1.0) if not inst_id: return jsonify({"ok": False, "msg": "缺少 inst_id"}) + blocked = _underly_blocked(inst_id=inst_id) + if blocked: + return jsonify({"ok": False, "msg": blocked, "can_open": False}) try: from lib.options.options_margin_mode_lib import is_coin_margin_mode from lib.options.options_coin_open_lib import open_coin_option_buy_full @@ -1006,6 +1029,9 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: raw_eth = bal.get("trading_eth_avail") or bal.get("trading_eth") raw_btc = bal.get("trading_btc_avail") or bal.get("trading_btc") + from lib.options.options_underly_lib import underly_cfg_snapshot + + _uly_snap = underly_cfg_snapshot() spot_sell_hint = { "bridge_status": str(bridge.get("status") or "") if bridge else None, "bridge_underlying": str(bridge.get("underlying") or "").upper() if bridge else None, @@ -1014,7 +1040,8 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: "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(), + "default_underly": _uly_snap["default_underly"], + "allowed_underlys": _uly_snap["allowed_underlys"], } finally: conn_b.close() diff --git a/lib/options/options_underly_lib.py b/lib/options/options_underly_lib.py new file mode 100644 index 0000000..2ac0a66 --- /dev/null +++ b/lib/options/options_underly_lib.py @@ -0,0 +1,56 @@ +"""期权标的白名单:OKX_OPTIONS_UNDERLYINGS + DEFAULT_UNDERLY.""" +from __future__ import annotations + +import os +from typing import Any + +_ALLOWED = ("ETH", "BTC") + + +def parse_options_underlyings() -> list[str]: + """env 白名单;空或缺省为 ETH,BTC.仅认 ETH/BTC.""" + raw = (os.getenv("OKX_OPTIONS_UNDERLYINGS") or "").strip() + if not raw: + return ["ETH", "BTC"] + out: list[str] = [] + for part in raw.replace(";", ",").split(","): + u = part.strip().upper() + if u in _ALLOWED and u not in out: + out.append(u) + return out or ["ETH", "BTC"] + + +def resolve_default_underly(allowed: list[str] | None = None) -> str: + """DEFAULT 落在白名单内;否则取白名单第一项.""" + allow = list(allowed) if allowed is not None else parse_options_underlyings() + if not allow: + allow = ["ETH", "BTC"] + d = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() or "ETH" + if d in allow: + return d + return allow[0] + + +def underly_from_inst_id(inst_id: str) -> str: + return (str(inst_id or "").split("-")[0] or "").strip().upper() + + +def underly_not_allowed_msg(underlying: str, allowed: list[str] | None = None) -> str | None: + """白名单外返回拒因;允许则 None.""" + allow = list(allowed) if allowed is not None else parse_options_underlyings() + u = (underlying or "").strip().upper() + if u in allow: + return None + return f"标的 {u or '—'} 不在本机白名单({','.join(allow) or '—'})" + + +def inst_underly_not_allowed_msg(inst_id: str, allowed: list[str] | None = None) -> str | None: + return underly_not_allowed_msg(underly_from_inst_id(inst_id), allowed) + + +def underly_cfg_snapshot() -> dict[str, Any]: + allow = parse_options_underlyings() + return { + "allowed_underlys": allow, + "default_underly": resolve_default_underly(allow), + } diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html index 47ea9c3..9a8be86 100644 --- a/lib/options/templates/options_panel.html +++ b/lib/options/templates/options_panel.html @@ -1,5 +1,8 @@ +{% set _uly_allowed = options_allowed_underlys if options_allowed_underlys is defined and options_allowed_underlys else ['ETH', 'BTC'] %} +{% set _uly_default = options_default_underly | default(_uly_allowed[0] if _uly_allowed else 'ETH') %}
- - + {% for u in _uly_allowed %} + + {% endfor %} @@ -364,4 +368,4 @@
- + diff --git a/tests/test_options_underly_lib.py b/tests/test_options_underly_lib.py new file mode 100644 index 0000000..9dc9c27 --- /dev/null +++ b/tests/test_options_underly_lib.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +import os +import unittest + +from lib.options.options_underly_lib import ( + inst_underly_not_allowed_msg, + parse_options_underlyings, + resolve_default_underly, + underly_not_allowed_msg, +) + + +class OptionsUnderlyLibTests(unittest.TestCase): + def setUp(self): + self._old_u = os.environ.get("OKX_OPTIONS_UNDERLYINGS") + self._old_d = os.environ.get("OKX_OPTIONS_DEFAULT_UNDERLY") + + def tearDown(self): + self._restore("OKX_OPTIONS_UNDERLYINGS", self._old_u) + self._restore("OKX_OPTIONS_DEFAULT_UNDERLY", self._old_d) + + @staticmethod + def _restore(key: str, val: str | None) -> None: + if val is None: + os.environ.pop(key, None) + else: + os.environ[key] = val + + def test_default_both(self): + os.environ.pop("OKX_OPTIONS_UNDERLYINGS", None) + self.assertEqual(parse_options_underlyings(), ["ETH", "BTC"]) + + def test_eth_only(self): + os.environ["OKX_OPTIONS_UNDERLYINGS"] = "ETH" + self.assertEqual(parse_options_underlyings(), ["ETH"]) + self.assertIsNone(underly_not_allowed_msg("ETH")) + self.assertIsNotNone(underly_not_allowed_msg("BTC")) + self.assertIsNotNone(inst_underly_not_allowed_msg("BTC-USD-260328-100000-C")) + + def test_default_clamped_to_whitelist(self): + os.environ["OKX_OPTIONS_UNDERLYINGS"] = "BTC" + os.environ["OKX_OPTIONS_DEFAULT_UNDERLY"] = "ETH" + self.assertEqual(resolve_default_underly(), "BTC") + + def test_ignores_junk(self): + os.environ["OKX_OPTIONS_UNDERLYINGS"] = "ETH, DOGE ,btc" + self.assertEqual(parse_options_underlyings(), ["ETH", "BTC"]) + + +if __name__ == "__main__": + unittest.main()