From aabbbef0a9ca514e21156b6bbc03b2f035e09b60 Mon Sep 17 00:00:00 2001 From: dekun Date: Fri, 17 Jul 2026 19:41:47 +0800 Subject: [PATCH] Turn position/margin/sizing/direction env fields into dropdown selects. Co-authored-by: Cursor --- lib/common/static/instance_settings_prefs.js | 21 +++++++ lib/env/env_schema.py | 59 ++++++++++++++++++++ lib/env/env_ui_manifest.py | 40 +++++++++---- lib/instance/templates/env_config_panel.html | 7 +++ tests/test_instance_display_env_settings.py | 33 +++++++++++ 5 files changed, 150 insertions(+), 10 deletions(-) diff --git a/lib/common/static/instance_settings_prefs.js b/lib/common/static/instance_settings_prefs.js index e43676d..5506999 100644 --- a/lib/common/static/instance_settings_prefs.js +++ b/lib/common/static/instance_settings_prefs.js @@ -215,6 +215,27 @@ }); const cur = (field.current || field.default || "false").toLowerCase(); input.value = cur === "true" || cur === "1" ? "true" : "false"; + } else if (field.type === "select" && Array.isArray(field.options) && field.options.length) { + input = document.createElement("select"); + input.id = "env-f-" + field.key; + const cur = String(field.current || field.default || ""); + const seen = new Set(); + field.options.forEach((opt) => { + const v = String(opt.value != null ? opt.value : ""); + if (seen.has(v)) return; + seen.add(v); + const o = document.createElement("option"); + o.value = v; + o.textContent = opt.label || v; + input.appendChild(o); + }); + if (cur && !seen.has(cur)) { + const o = document.createElement("option"); + o.value = cur; + o.textContent = cur; + input.insertBefore(o, input.firstChild); + } + input.value = cur || (field.options[0] && field.options[0].value) || ""; } else { input = document.createElement("input"); input.id = "env-f-" + field.key; diff --git a/lib/env/env_schema.py b/lib/env/env_schema.py index 45d2625..4c659e2 100644 --- a/lib/env/env_schema.py +++ b/lib/env/env_schema.py @@ -102,6 +102,28 @@ SENSITIVE_EXACT = frozenset({ SENSITIVE_SUBSTR = ("_SECRET", "_PASSPHRASE", "_API_KEY", "_PASSWORD") +# env 配置页下拉:value → 中文标签 +SELECT_OPTIONS: dict[str, tuple[tuple[str, str], ...]] = { + "OKX_TD_MODE": (("cross", "全仓"), ("isolated", "逐仓")), + "OKX_POS_MODE": (("hedge", "双向"), ("net", "单向净持仓")), + "BINANCE_MARGIN_MODE": (("cross", "全仓"), ("isolated", "逐仓")), + "BINANCE_POSITION_MODE": (("hedge", "双向"), ("one_way", "单向")), + "GATE_TD_MODE": (("cross", "全仓"), ("isolated", "逐仓")), + "GATE_POS_MODE": (("hedge", "双向"), ("single", "单向")), + "POSITION_SIZING_MODE": (("risk", "以损定仓"), ("full_margin", "全仓杠杆")), + "TRADE_DIRECTION": ( + ("both", "双向均可"), + ("long_only", "仅做多"), + ("short_only", "仅做空"), + ), +} + +_SELECT_ALIASES: dict[str, dict[str, str]] = { + "OKX_TD_MODE": {"cross_margin": "cross", "isolated_margin": "isolated"}, + "BINANCE_MARGIN_MODE": {"cross_margin": "cross", "isolated_margin": "isolated"}, + "GATE_TD_MODE": {"cross_margin": "cross", "isolated_margin": "isolated"}, +} + def _is_sensitive(key: str) -> bool: if key in SENSITIVE_EXACT: @@ -109,6 +131,27 @@ def _is_sensitive(key: str) -> bool: return any(s in key for s in SENSITIVE_SUBSTR) +def select_options_for(key: str) -> list[dict[str, str]]: + opts = SELECT_OPTIONS.get(key) or () + return [{"value": v, "label": lab} for v, lab in opts] + + +def normalize_select_value(key: str, value: Optional[str]) -> str: + raw = (value or "").strip() + if not raw: + return "" + low = raw.lower() + aliases = _SELECT_ALIASES.get(key) or {} + if low in aliases: + return aliases[low] + allowed = {v for v, _ in (SELECT_OPTIONS.get(key) or ())} + if low in allowed: + return low + if raw in allowed: + return raw + return raw + + def _restart_required(key: str) -> bool: if key in HOT_RELOAD_EXACT: return False @@ -126,6 +169,8 @@ def _hot_reload(key: str) -> bool: def _field_type(key: str, value: str) -> str: + if key in SELECT_OPTIONS: + return "select" low = (value or "").strip().lower() if low in ("true", "false"): return "bool" @@ -286,6 +331,20 @@ def validate_env_updates(groups: list[dict], updates: dict[str, str]) -> tuple[d errors.append(f"{key} 须为 true/false") continue val = "true" if low in ("true", "1", "yes", "on") else "false" + elif ftype == "select" or key in SELECT_OPTIONS: + allowed_vals = { + str(o.get("value") if isinstance(o, dict) else o[0]).lower() + for o in (allowed[key].get("options") or select_options_for(key)) + } + norm = normalize_select_value(key, val) + if allowed_vals and norm.lower() not in allowed_vals: + labels = " / ".join( + f"{o['value']}({o['label']})" if isinstance(o, dict) else f"{o[0]}({o[1]})" + for o in (allowed[key].get("options") or select_options_for(key)) + ) + errors.append(f"{key} 须为: {labels}") + continue + val = norm clean[key] = val return clean, errors diff --git a/lib/env/env_ui_manifest.py b/lib/env/env_ui_manifest.py index 182dfd0..076840d 100644 --- a/lib/env/env_ui_manifest.py +++ b/lib/env/env_ui_manifest.py @@ -11,7 +11,9 @@ from lib.env.env_schema import ( _is_sensitive, _mask_value, _restart_required, + normalize_select_value, parse_env_example_schema, + select_options_for, ) # 各所「交易所与实盘」字段(顺序即页面顺序) @@ -21,8 +23,8 @@ _EXCHANGE_LIVE_FIELDS: dict[str, list[tuple[str, str, str]]] = { ("OKX_API_KEY", "API Key", "永续子账户"), ("OKX_API_SECRET", "API Secret", "永续子账户"), ("OKX_API_PASSPHRASE", "API Passphrase", "OKX 必填"), - ("OKX_TD_MODE", "保证金模式", "cross=全仓,isolated=逐仓"), - ("OKX_POS_MODE", "持仓模式", "hedge=双向,net=单向净持仓"), + ("OKX_TD_MODE", "保证金模式", ""), + ("OKX_POS_MODE", "持仓模式", ""), ("OKX_POSITION_INST_TYPE", "仓位查询类型", "如 SWAP"), ("OKX_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"), ], @@ -30,16 +32,16 @@ _EXCHANGE_LIVE_FIELDS: dict[str, list[tuple[str, str, str]]] = { ("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"), ("BINANCE_API_KEY", "API Key", "永续子账户"), ("BINANCE_API_SECRET", "API Secret", "永续子账户"), - ("BINANCE_MARGIN_MODE", "保证金模式", "cross=全仓,isolated=逐仓"), - ("BINANCE_POSITION_MODE", "持仓模式", "hedge=双向,one_way=单向"), + ("BINANCE_MARGIN_MODE", "保证金模式", ""), + ("BINANCE_POSITION_MODE", "持仓模式", ""), ("BINANCE_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"), ], "gate": [ ("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"), ("GATE_API_KEY", "API Key", "永续子账户"), ("GATE_API_SECRET", "API Secret", "永续子账户"), - ("GATE_TD_MODE", "保证金模式", "cross=全仓,isolated=逐仓"), - ("GATE_POS_MODE", "持仓模式", "hedge=双向,single=单向"), + ("GATE_TD_MODE", "保证金模式", ""), + ("GATE_POS_MODE", "持仓模式", ""), ("GATE_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"), ], } @@ -55,13 +57,13 @@ _SHARED_SECTIONS: list[dict[str, Any]] = [ { "title": "交易执行", "fields": [ - ("POSITION_SIZING_MODE", "计仓模式", "risk=以损定仓,full_margin=全仓杠杆"), + ("POSITION_SIZING_MODE", "计仓模式", "切换须无仓后重启"), ("RISK_PERCENT", "以损定仓风险%", "单笔风险占资金比例"), ("FULL_MARGIN_BUFFER_RATIO", "全仓资金缓冲比例", "如 0.98"), ("BTC_LEVERAGE", "BTC 默认杠杆", ""), ("ALT_LEVERAGE", "山寨默认杠杆", ""), ("TRADE_DIRECTION_RESTRICT_ENABLED", "方向限制开关", ""), - ("TRADE_DIRECTION", "允许方向", "long_only / short_only / both"), + ("TRADE_DIRECTION", "允许方向", "需同时开启「方向限制开关」才生效"), ("TRADE_SYMBOL_RESTRICT_ENABLED", "币种白名单开关", ""), ("TRADE_SYMBOL_WHITELIST", "白名单币种", "逗号分隔,如 BTC,ETH"), ("TRADING_DAY_RESET_HOUR", "交易日切点(北京时间)", "整点,默认 8"), @@ -183,7 +185,12 @@ def _build_field( val = _effective_env_value(key, values, schema_default) masked = _mask_value(key, val) ftype = meta.get("type") or _field_type(key, val or schema_default) - return { + options = select_options_for(key) + if options: + ftype = "select" + val = normalize_select_value(key, val) or val + masked = _mask_value(key, val) + out: dict[str, Any] = { "key": key, "label": label, "note": note or meta.get("note") or "", @@ -197,6 +204,13 @@ def _build_field( "tail": masked.get("tail") or "", "has_value": masked["has_value"], } + if options: + cur = (out["current"] or out["default"] or "").strip() + opt_vals = {o["value"] for o in options} + if cur and cur not in opt_vals: + options = [{"value": cur, "label": cur}] + options + out["options"] = options + return out def ui_sections_for_exchange(exchange_key: str) -> list[dict[str, Any]]: @@ -260,7 +274,12 @@ def validate_env_ui_updates( fields: list[dict[str, Any]] = [] for key, _label, _note in sec["fields"]: if key in schema: - fields.append(schema[key]) + field = dict(schema[key]) + opts = select_options_for(key) + if opts: + field["type"] = "select" + field["options"] = opts + fields.append(field) else: default = "" fields.append( @@ -270,6 +289,7 @@ def validate_env_ui_updates( "sensitive": _is_sensitive(key), "restart_required": _restart_required(key), "hot_reload": _hot_reload(key), + "options": select_options_for(key), } ) groups.append({"title": sec["title"], "fields": fields}) diff --git a/lib/instance/templates/env_config_panel.html b/lib/instance/templates/env_config_panel.html index 40e6088..1df2d97 100644 --- a/lib/instance/templates/env_config_panel.html +++ b/lib/instance/templates/env_config_panel.html @@ -47,6 +47,13 @@ + {% elif field.type == 'select' and field.options %} + {% set cur = (field.current or field.default or '') %} + {% elif field.sensitive %} {% if field.has_value %}
已配置 {{ field.masked }}
diff --git a/tests/test_instance_display_env_settings.py b/tests/test_instance_display_env_settings.py index d185e40..22404e0 100644 --- a/tests/test_instance_display_env_settings.py +++ b/tests/test_instance_display_env_settings.py @@ -59,6 +59,39 @@ class TestEnvSchema(unittest.TestCase): clean, errors = validate_env_updates(groups, {"B": "1"}) self.assertTrue(errors) + def test_select_fields_and_validate(self): + from lib.env.env_schema import SELECT_OPTIONS, normalize_select_value, select_options_for + from lib.env.env_ui_manifest import build_env_ui_payload + + self.assertEqual(normalize_select_value("BINANCE_MARGIN_MODE", "cross_margin"), "cross") + opts = select_options_for("TRADE_DIRECTION") + self.assertEqual({o["value"] for o in opts}, {"both", "long_only", "short_only"}) + for key in ( + "OKX_TD_MODE", + "OKX_POS_MODE", + "POSITION_SIZING_MODE", + "TRADE_DIRECTION", + ): + self.assertIn(key, SELECT_OPTIONS) + + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + example = os.path.join(root, "crypto_monitor_okx", ".env.example") + env_path = os.path.join(root, "crypto_monitor_okx", ".env") + if not os.path.isfile(example): + self.skipTest("missing okx .env.example") + groups = build_env_ui_payload("okx", example, env_path if os.path.isfile(env_path) else example) + by_key = {f["key"]: f for g in groups for f in g["fields"]} + for key in ("OKX_TD_MODE", "OKX_POS_MODE", "POSITION_SIZING_MODE", "TRADE_DIRECTION"): + self.assertEqual(by_key[key]["type"], "select") + self.assertTrue(by_key[key]["options"]) + + groups_v = [{"title": "t", "fields": [by_key["TRADE_DIRECTION"]]}] + clean, errors = validate_env_updates(groups_v, {"TRADE_DIRECTION": "long_only"}) + self.assertEqual(errors, []) + self.assertEqual(clean["TRADE_DIRECTION"], "long_only") + _, bad = validate_env_updates(groups_v, {"TRADE_DIRECTION": "sideways"}) + self.assertTrue(bad) + if __name__ == "__main__": unittest.main()