From fda5d121ea5f19ee795affc75d25a46032fc8a3a Mon Sep 17 00:00:00 2001 From: dekun Date: Fri, 17 Jul 2026 19:07:38 +0800 Subject: [PATCH] fix: make full_margin env save/restart reliable (no empty numeric wipe). EOF Co-authored-by: Cursor --- lib/common/static/instance_settings_prefs.js | 15 ++++++ lib/env/env_schema.py | 49 +++++++++++++++++++- lib/env/env_ui_manifest.py | 29 ++++++++++-- lib/license/flask_gate.py | 12 ++++- 4 files changed, 99 insertions(+), 6 deletions(-) diff --git a/lib/common/static/instance_settings_prefs.js b/lib/common/static/instance_settings_prefs.js index 804985b..18b9aa4 100644 --- a/lib/common/static/instance_settings_prefs.js +++ b/lib/common/static/instance_settings_prefs.js @@ -215,6 +215,21 @@ }); const cur = (field.current || field.default || "false").toLowerCase(); input.value = cur === "true" || cur === "1" ? "true" : "false"; + } else if (field.type === "enum" && Array.isArray(field.options) && field.options.length) { + input = document.createElement("select"); + input.id = "env-f-" + field.key; + field.options.forEach((opt) => { + const o = document.createElement("option"); + const v = typeof opt === "string" ? opt : String(opt.value || ""); + o.value = v; + o.textContent = typeof opt === "string" ? opt : String(opt.label || opt.value || ""); + input.appendChild(o); + }); + const cur = String(field.current || field.default || ""); + input.value = cur; + if (![...input.options].some((o) => o.value === cur) && input.options.length) { + input.selectedIndex = 0; + } } 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..1ea8b36 100644 --- a/lib/env/env_schema.py +++ b/lib/env/env_schema.py @@ -103,6 +103,23 @@ SENSITIVE_EXACT = frozenset({ SENSITIVE_SUBSTR = ("_SECRET", "_PASSPHRASE", "_API_KEY", "_PASSWORD") +def normalize_position_sizing_mode(raw: str) -> str: + """把中文/别名规范成 risk | full_margin。""" + s = (raw or "").strip().lower().replace("-", "_").replace(" ", "") + if s in ("risk", "以损", "以损定仓", "riskpercent", "risk_percent"): + return "risk" + if s in ( + "full_margin", + "fullmargin", + "full", + "全仓", + "全仓杠杆", + "margin", + ): + return "full_margin" + return (raw or "").strip() + + def _is_sensitive(key: str) -> bool: if key in SENSITIVE_EXACT: return True @@ -275,17 +292,47 @@ def validate_env_updates(groups: list[dict], updates: dict[str, str]) -> tuple[d val = str(value).strip() if allowed[key].get("sensitive") and (val == "" or (val.startswith("****") and len(val) <= 8)): continue + # 数字项留空不覆盖已有 .env,避免 RISK_PERCENT= 导致重启崩掉 + ftype = allowed[key].get("type") + if val == "" and ftype in ("float", "int"): + continue + if val == "" and key in ( + "POSITION_SIZING_MODE", + "TRADE_DIRECTION", + "RISK_PERCENT", + "FULL_MARGIN_BUFFER_RATIO", + "BTC_LEVERAGE", + "ALT_LEVERAGE", + "MAX_ACTIVE_POSITIONS", + ): + continue # API Key 被密码管理器/自动填充成登录密码时通常很短;OKX Key 一般为 36 位 if key.endswith("_API_KEY") and 0 < len(val) < 16: errors.append(f"{key} 长度异常,疑似自动填充;留空则不修改已有密钥") continue - ftype = allowed[key].get("type") + if key == "POSITION_SIZING_MODE": + val = normalize_position_sizing_mode(val) + if val not in ("risk", "full_margin"): + errors.append("计仓模式须为 risk(以损定仓)或 full_margin(全仓杠杆)") + continue if ftype == "bool": low = val.lower() if low not in ("true", "false", "1", "0", "yes", "no", "on", "off"): errors.append(f"{key} 须为 true/false") continue val = "true" if low in ("true", "1", "yes", "on") else "false" + elif ftype == "float" and val != "": + try: + float(val) + except ValueError: + errors.append(f"{key} 须为数字") + continue + elif ftype == "int" and val != "": + try: + int(val) + except ValueError: + errors.append(f"{key} 须为整数") + continue clean[key] = val return clean, errors diff --git a/lib/env/env_ui_manifest.py b/lib/env/env_ui_manifest.py index f5bf5b3..4d04d2a 100644 --- a/lib/env/env_ui_manifest.py +++ b/lib/env/env_ui_manifest.py @@ -11,6 +11,7 @@ from lib.env.env_schema import ( _is_sensitive, _mask_value, _restart_required, + normalize_position_sizing_mode, parse_env_example_schema, ) @@ -55,7 +56,7 @@ _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 默认杠杆", ""), @@ -149,12 +150,25 @@ _RUNTIME_ENV_DEFAULTS: dict[str, str] = { "RISK_COOLING_HOURS_MANUAL_JOURNAL": "1", "RISK_MANUAL_CLOSE_DAILY_LIMIT": "2", "RISK_MOOD_ISSUES_DAILY_FREEZE": "true", + "RISK_PERCENT": "2", + "FULL_MARGIN_BUFFER_RATIO": "0.98", + "POSITION_SIZING_MODE": "risk", + "BTC_LEVERAGE": "10", + "ALT_LEVERAGE": "5", + "MAX_ACTIVE_POSITIONS": "1", } +_POSITION_SIZING_OPTIONS: list[dict[str, str]] = [ + {"value": "risk", "label": "以损定仓 (risk)"}, + {"value": "full_margin", "label": "全仓杠杆 (full_margin)"}, +] + def _effective_env_value(key: str, file_values: dict[str, str], schema_default: str = "") -> str: if key in file_values: - return file_values[key] + file_val = str(file_values.get(key) or "").strip() + if file_val != "": + return file_val runtime = os.getenv(key) if runtime is not None and str(runtime).strip() != "": return str(runtime).strip() @@ -183,7 +197,7 @@ 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 { + out: dict[str, Any] = { "key": key, "label": label, "note": note or meta.get("note") or "", @@ -197,6 +211,15 @@ def _build_field( "tail": masked.get("tail") or "", "has_value": masked["has_value"], } + if key == "POSITION_SIZING_MODE": + out["type"] = "enum" + out["options"] = list(_POSITION_SIZING_OPTIONS) + cur = normalize_position_sizing_mode(out["current"] or out["default"] or "risk") + if cur not in ("risk", "full_margin"): + cur = "risk" + out["current"] = cur + out["default"] = cur + return out def ui_sections_for_exchange(exchange_key: str) -> list[dict[str, Any]]: diff --git a/lib/license/flask_gate.py b/lib/license/flask_gate.py index da1e131..9648b7b 100644 --- a/lib/license/flask_gate.py +++ b/lib/license/flask_gate.py @@ -23,8 +23,15 @@ _POST_LICENSE_TARGET = "/login" def _license_public_path(path: str) -> bool: - """未授权时仍可访问的路径(授权页 / 接口 / 静态资源)。""" - if path in ("/license", "/api/license/status", "/api/license/redeem", "/api/license/validate", "/health"): + """未授权时仍可访问的路径(授权页 / 接口 / 静态资源 / 重启探活)。""" + if path in ( + "/license", + "/api/license/status", + "/api/license/redeem", + "/api/license/validate", + "/health", + "/api/admin/health", + ): return True if path.startswith("/static/"): return True @@ -33,6 +40,7 @@ def _license_public_path(path: str) -> bool: return False + def _license_manage_requested() -> bool: """已授权时默认禁止进入 /license;续费/换机用 ?renew=1。""" return (request.args.get("renew") or request.args.get("manage") or "").strip().lower() in (