feat期权标的白名单:可固定只显示 ETH 或 BTC。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-13 19:09:06 +08:00
parent 8d846794a2
commit 1835a79698
11 changed files with 215 additions and 13 deletions
+3
View File
@@ -132,6 +132,9 @@ OKX_OPTIONS_BUDGET_BUFFER=0.95
OKX_TRADE_MODE=options OKX_TRADE_MODE=options
# 期权同时持仓上限(笔);0=不限制;同合约加仓不占新笔数;热更 # 期权同时持仓上限(笔);0=不限制;同合约加仓不占新笔数;热更
OKX_OPTIONS_MAX_ACTIVE_POSITIONS=0 OKX_OPTIONS_MAX_ACTIVE_POSITIONS=0
# 期权标的白名单(逗号分隔,仅 ETH/BTC)。只配一个则交易页只显示该标的;开仓/链/报价/条件单均校验。资金页不受影响。
OKX_OPTIONS_UNDERLYINGS=ETH,BTC
# 默认选中标的;须落在白名单内,否则取白名单第一项
OKX_OPTIONS_DEFAULT_UNDERLY=ETH OKX_OPTIONS_DEFAULT_UNDERLY=ETH
# 期权链仅显示卖一深度≥1张的合约(估算卖一/无深度不显示);false 则显示全部 # 期权链仅显示卖一深度≥1张的合约(估算卖一/无深度不显示);false 则显示全部
OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED=true OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED=true
+18 -3
View File
@@ -3925,6 +3925,19 @@ def render_main_page(page="options", embed_mode=None):
options_trading_btc = None options_trading_btc = None
options_margin_mode = "coin" options_margin_mode = "coin"
options_underly = "ETH" 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 _sim_mode_for_header = False
_exchange_display_for_header = EXCHANGE_DISPLAY_NAME _exchange_display_for_header = EXCHANGE_DISPLAY_NAME
try: try:
@@ -3955,7 +3968,8 @@ def render_main_page(page="options", embed_mode=None):
options_trading_eth = _op.get("trading_eth") options_trading_eth = _op.get("trading_eth")
options_trading_btc = _op.get("trading_btc") options_trading_btc = _op.get("trading_btc")
options_margin_mode = _op.get("options_margin_mode") or "coin" 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: except Exception:
options_trading_usdc = None options_trading_usdc = None
options_funding_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_eth = None
options_trading_btc = None options_trading_btc = None
options_margin_mode = "coin" options_margin_mode = "coin"
options_underly = "ETH" options_underly = _options_uly_default
recommended_capital = get_recommended_capital(capital_for_sizing) recommended_capital = get_recommended_capital(capital_for_sizing)
conn = get_db() conn = get_db()
key_list = ( key_list = (
@@ -4201,7 +4215,8 @@ def render_main_page(page="options", embed_mode=None):
options_compound_full_cap_usdc=float( options_compound_full_cap_usdc=float(
os.getenv("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC") or "300" 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( options_chain_ask_liq_filter=os.getenv(
"OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", "true" "OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", "true"
).lower() ).lower()
+23 -1
View File
@@ -8,8 +8,30 @@
const panelCache = (window.__optionsPanelCache = window.__optionsPanelCache || {}); 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 = { const state = {
underlying: root.dataset.defaultUnderly || "ETH", underlying: bootUnderly,
optType: "C", optType: "C",
moneyFilter: "all", moneyFilter: "all",
chainView: "list", chainView: "list",
+6 -1
View File
@@ -112,7 +112,12 @@ _OPTIONS_SECTION: dict[str, Any] = {
"期权持仓上限(笔)", "期权持仓上限(笔)",
"默认 1=一次一仓;0=不限制;按交易所期权合约笔数计数,同合约加仓不占新笔数", "默认 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", "OKX_OPTIONS_CHAIN_MAX_DTE_DAYS",
"期权链展示天数", "期权链展示天数",
+5
View File
@@ -710,6 +710,11 @@ def options_header_balance_pack(
margin_mode = normalize_options_margin_mode() margin_mode = normalize_options_margin_mode()
except Exception: except Exception:
margin_mode = "usdc" margin_mode = "usdc"
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" underly = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() or "ETH"
coin_key = "btc" if underly == "BTC" else "eth" coin_key = "btc" if underly == "BTC" else "eth"
return { return {
+10
View File
@@ -248,6 +248,16 @@ def create_cond_order(
return {"ok": False, "msg": "本机已有活跃条件单"} return {"ok": False, "msg": "本机已有活跃条件单"}
underlying = (payload.get("underlying") or "ETH").strip().upper() or "ETH" 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() opt_type = (payload.get("opt_type") or "P").strip().upper()
if opt_type not in ("P", "C"): if opt_type not in ("P", "C"):
return {"ok": False, "msg": "opt_type 须为 P 或 C"} return {"ok": False, "msg": "opt_type 须为 P 或 C"}
+5 -1
View File
@@ -133,6 +133,9 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
except Exception: except Exception:
open_bridges = [] open_bridges = []
from lib.options.options_underly_lib import underly_cfg_snapshot
_uly_snap = underly_cfg_snapshot()
out = { out = {
"ok": True, "ok": True,
"enabled": 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(), "max_active_positions": options_max_active_positions(),
"options_margin_mode": margin_mode, "options_margin_mode": margin_mode,
"options_margin_mode_label": "币本位" if margin_mode == "coin" else "USDC", "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, "coin_budget": coin_budget,
"bridge_status": bridge_status, "bridge_status": bridge_status,
"open_bridges": open_bridges, "open_bridges": open_bridges,
+29 -2
View File
@@ -28,6 +28,18 @@ def _env_float(key: str, default: float) -> float:
return float(default) 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: def attach_options_templates(app: Flask, repo_root: str) -> None:
tpl_dir = os.path.join(repo_root, "lib", "options", "templates") tpl_dir = os.path.join(repo_root, "lib", "options", "templates")
if not os.path.isdir(tpl_dir): 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) ex, err = _require_options_ex(cfg)
if ex is None: if ex is None:
return jsonify({"ok": False, "msg": err}) 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,保存后刷新链即可 # 热更新:链展示天数每次读 env,保存后刷新链即可
chain_max_dte = _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", float(cfg.get("chain_max_dte_days") or 14)) chain_max_dte = _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", float(cfg.get("chain_max_dte_days") or 14))
try: 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() inst_id = (request.args.get("inst_id") or "").strip()
if not inst_id: if not inst_id:
return jsonify({"ok": False, "msg": "缺少 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) q = cfg["quote_option_contract"](ex, inst_id)
if not q.get("ok"): if not q.get("ok"):
return jsonify(q) 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) profit_exit_mult = normalize_profit_exit_mult(data.get("profit_exit_mult"), default=1.0)
if not inst_id: if not inst_id:
return jsonify({"ok": False, "msg": "缺少 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: try:
from lib.options.options_margin_mode_lib import is_coin_margin_mode 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 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_eth = bal.get("trading_eth_avail") or bal.get("trading_eth")
raw_btc = bal.get("trading_btc_avail") or bal.get("trading_btc") 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 = { spot_sell_hint = {
"bridge_status": str(bridge.get("status") or "") if bridge else None, "bridge_status": str(bridge.get("status") or "") if bridge else None,
"bridge_underlying": str(bridge.get("underlying") or "").upper() 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), "trading_btc": display_sellable_coin(ex, "BTC", raw_btc),
"min_sz_eth": spot_min_sell_sz(ex, "ETH"), "min_sz_eth": spot_min_sell_sz(ex, "ETH"),
"min_sz_btc": spot_min_sell_sz(ex, "BTC"), "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: finally:
conn_b.close() conn_b.close()
+56
View File
@@ -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),
}
+8 -4
View File
@@ -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') %}
<div class="options-page-wrap" style="grid-column:1/-1" id="options-root" <div class="options-page-wrap" style="grid-column:1/-1" id="options-root"
data-default-underly="{{ options_default_underly | default('ETH') }}" data-default-underly="{{ _uly_default }}"
data-allowed-underlys="{{ _uly_allowed | join(',') }}"
data-budget-buffer="{{ options_budget_buffer | default(1) }}" data-budget-buffer="{{ options_budget_buffer | default(1) }}"
data-trade-budget="{{ options_trade_budget | default(10) }}" data-trade-budget="{{ options_trade_budget | default(10) }}"
data-compound-full-enabled="{% if options_compound_full_enabled %}1{% else %}0{% endif %}" data-compound-full-enabled="{% if options_compound_full_enabled %}1{% else %}0{% endif %}"
@@ -54,8 +57,9 @@
</div> </div>
</details> </details>
<div class="form-row options-chain-toolbar"> <div class="form-row options-chain-toolbar">
<button type="button" class="btn-secondary opt-uly-btn active" data-uly="ETH">ETH</button> {% for u in _uly_allowed %}
<button type="button" class="btn-secondary opt-uly-btn" data-uly="BTC">BTC</button> <button type="button" class="btn-secondary opt-uly-btn{% if u == _uly_default %} active{% endif %}" data-uly="{{ u }}">{{ u }}</button>
{% endfor %}
<select id="opt-exp-select"><option value="">选择到期日</option></select> <select id="opt-exp-select"><option value="">选择到期日</option></select>
<span class="opt-chain-view-group"> <span class="opt-chain-view-group">
<button type="button" class="btn-secondary opt-view-btn active" data-view="list">列表</button> <button type="button" class="btn-secondary opt-view-btn active" data-view="list">列表</button>
@@ -364,4 +368,4 @@
</div> </div>
</div> </div>
<script src="/static/options_expiry_countdown.js?v=1"></script> <script src="/static/options_expiry_countdown.js?v=1"></script>
<script src="/static/options_panel.js?v=71"></script> <script src="/static/options_panel.js?v=72"></script>
+51
View File
@@ -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()