Turn position/margin/sizing/direction env fields into dropdown selects.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -215,6 +215,27 @@
|
|||||||
});
|
});
|
||||||
const cur = (field.current || field.default || "false").toLowerCase();
|
const cur = (field.current || field.default || "false").toLowerCase();
|
||||||
input.value = cur === "true" || cur === "1" ? "true" : "false";
|
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 {
|
} else {
|
||||||
input = document.createElement("input");
|
input = document.createElement("input");
|
||||||
input.id = "env-f-" + field.key;
|
input.id = "env-f-" + field.key;
|
||||||
|
|||||||
Vendored
+59
@@ -102,6 +102,28 @@ SENSITIVE_EXACT = frozenset({
|
|||||||
|
|
||||||
SENSITIVE_SUBSTR = ("_SECRET", "_PASSPHRASE", "_API_KEY", "_PASSWORD")
|
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:
|
def _is_sensitive(key: str) -> bool:
|
||||||
if key in SENSITIVE_EXACT:
|
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)
|
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:
|
def _restart_required(key: str) -> bool:
|
||||||
if key in HOT_RELOAD_EXACT:
|
if key in HOT_RELOAD_EXACT:
|
||||||
return False
|
return False
|
||||||
@@ -126,6 +169,8 @@ def _hot_reload(key: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _field_type(key: str, value: str) -> str:
|
def _field_type(key: str, value: str) -> str:
|
||||||
|
if key in SELECT_OPTIONS:
|
||||||
|
return "select"
|
||||||
low = (value or "").strip().lower()
|
low = (value or "").strip().lower()
|
||||||
if low in ("true", "false"):
|
if low in ("true", "false"):
|
||||||
return "bool"
|
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")
|
errors.append(f"{key} 须为 true/false")
|
||||||
continue
|
continue
|
||||||
val = "true" if low in ("true", "1", "yes", "on") else "false"
|
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
|
clean[key] = val
|
||||||
return clean, errors
|
return clean, errors
|
||||||
|
|
||||||
|
|||||||
Vendored
+30
-10
@@ -11,7 +11,9 @@ from lib.env.env_schema import (
|
|||||||
_is_sensitive,
|
_is_sensitive,
|
||||||
_mask_value,
|
_mask_value,
|
||||||
_restart_required,
|
_restart_required,
|
||||||
|
normalize_select_value,
|
||||||
parse_env_example_schema,
|
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_KEY", "API Key", "永续子账户"),
|
||||||
("OKX_API_SECRET", "API Secret", "永续子账户"),
|
("OKX_API_SECRET", "API Secret", "永续子账户"),
|
||||||
("OKX_API_PASSPHRASE", "API Passphrase", "OKX 必填"),
|
("OKX_API_PASSPHRASE", "API Passphrase", "OKX 必填"),
|
||||||
("OKX_TD_MODE", "保证金模式", "cross=全仓,isolated=逐仓"),
|
("OKX_TD_MODE", "保证金模式", ""),
|
||||||
("OKX_POS_MODE", "持仓模式", "hedge=双向,net=单向净持仓"),
|
("OKX_POS_MODE", "持仓模式", ""),
|
||||||
("OKX_POSITION_INST_TYPE", "仓位查询类型", "如 SWAP"),
|
("OKX_POSITION_INST_TYPE", "仓位查询类型", "如 SWAP"),
|
||||||
("OKX_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"),
|
("OKX_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"),
|
||||||
],
|
],
|
||||||
@@ -30,16 +32,16 @@ _EXCHANGE_LIVE_FIELDS: dict[str, list[tuple[str, str, str]]] = {
|
|||||||
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"),
|
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"),
|
||||||
("BINANCE_API_KEY", "API Key", "永续子账户"),
|
("BINANCE_API_KEY", "API Key", "永续子账户"),
|
||||||
("BINANCE_API_SECRET", "API Secret", "永续子账户"),
|
("BINANCE_API_SECRET", "API Secret", "永续子账户"),
|
||||||
("BINANCE_MARGIN_MODE", "保证金模式", "cross=全仓,isolated=逐仓"),
|
("BINANCE_MARGIN_MODE", "保证金模式", ""),
|
||||||
("BINANCE_POSITION_MODE", "持仓模式", "hedge=双向,one_way=单向"),
|
("BINANCE_POSITION_MODE", "持仓模式", ""),
|
||||||
("BINANCE_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"),
|
("BINANCE_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"),
|
||||||
],
|
],
|
||||||
"gate": [
|
"gate": [
|
||||||
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"),
|
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"),
|
||||||
("GATE_API_KEY", "API Key", "永续子账户"),
|
("GATE_API_KEY", "API Key", "永续子账户"),
|
||||||
("GATE_API_SECRET", "API Secret", "永续子账户"),
|
("GATE_API_SECRET", "API Secret", "永续子账户"),
|
||||||
("GATE_TD_MODE", "保证金模式", "cross=全仓,isolated=逐仓"),
|
("GATE_TD_MODE", "保证金模式", ""),
|
||||||
("GATE_POS_MODE", "持仓模式", "hedge=双向,single=单向"),
|
("GATE_POS_MODE", "持仓模式", ""),
|
||||||
("GATE_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"),
|
("GATE_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -55,13 +57,13 @@ _SHARED_SECTIONS: list[dict[str, Any]] = [
|
|||||||
{
|
{
|
||||||
"title": "交易执行",
|
"title": "交易执行",
|
||||||
"fields": [
|
"fields": [
|
||||||
("POSITION_SIZING_MODE", "计仓模式", "risk=以损定仓,full_margin=全仓杠杆"),
|
("POSITION_SIZING_MODE", "计仓模式", "切换须无仓后重启"),
|
||||||
("RISK_PERCENT", "以损定仓风险%", "单笔风险占资金比例"),
|
("RISK_PERCENT", "以损定仓风险%", "单笔风险占资金比例"),
|
||||||
("FULL_MARGIN_BUFFER_RATIO", "全仓资金缓冲比例", "如 0.98"),
|
("FULL_MARGIN_BUFFER_RATIO", "全仓资金缓冲比例", "如 0.98"),
|
||||||
("BTC_LEVERAGE", "BTC 默认杠杆", ""),
|
("BTC_LEVERAGE", "BTC 默认杠杆", ""),
|
||||||
("ALT_LEVERAGE", "山寨默认杠杆", ""),
|
("ALT_LEVERAGE", "山寨默认杠杆", ""),
|
||||||
("TRADE_DIRECTION_RESTRICT_ENABLED", "方向限制开关", ""),
|
("TRADE_DIRECTION_RESTRICT_ENABLED", "方向限制开关", ""),
|
||||||
("TRADE_DIRECTION", "允许方向", "long_only / short_only / both"),
|
("TRADE_DIRECTION", "允许方向", "需同时开启「方向限制开关」才生效"),
|
||||||
("TRADE_SYMBOL_RESTRICT_ENABLED", "币种白名单开关", ""),
|
("TRADE_SYMBOL_RESTRICT_ENABLED", "币种白名单开关", ""),
|
||||||
("TRADE_SYMBOL_WHITELIST", "白名单币种", "逗号分隔,如 BTC,ETH"),
|
("TRADE_SYMBOL_WHITELIST", "白名单币种", "逗号分隔,如 BTC,ETH"),
|
||||||
("TRADING_DAY_RESET_HOUR", "交易日切点(北京时间)", "整点,默认 8"),
|
("TRADING_DAY_RESET_HOUR", "交易日切点(北京时间)", "整点,默认 8"),
|
||||||
@@ -183,7 +185,12 @@ def _build_field(
|
|||||||
val = _effective_env_value(key, values, schema_default)
|
val = _effective_env_value(key, values, schema_default)
|
||||||
masked = _mask_value(key, val)
|
masked = _mask_value(key, val)
|
||||||
ftype = meta.get("type") or _field_type(key, val or schema_default)
|
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,
|
"key": key,
|
||||||
"label": label,
|
"label": label,
|
||||||
"note": note or meta.get("note") or "",
|
"note": note or meta.get("note") or "",
|
||||||
@@ -197,6 +204,13 @@ def _build_field(
|
|||||||
"tail": masked.get("tail") or "",
|
"tail": masked.get("tail") or "",
|
||||||
"has_value": masked["has_value"],
|
"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]]:
|
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]] = []
|
fields: list[dict[str, Any]] = []
|
||||||
for key, _label, _note in sec["fields"]:
|
for key, _label, _note in sec["fields"]:
|
||||||
if key in schema:
|
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:
|
else:
|
||||||
default = ""
|
default = ""
|
||||||
fields.append(
|
fields.append(
|
||||||
@@ -270,6 +289,7 @@ def validate_env_ui_updates(
|
|||||||
"sensitive": _is_sensitive(key),
|
"sensitive": _is_sensitive(key),
|
||||||
"restart_required": _restart_required(key),
|
"restart_required": _restart_required(key),
|
||||||
"hot_reload": _hot_reload(key),
|
"hot_reload": _hot_reload(key),
|
||||||
|
"options": select_options_for(key),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
groups.append({"title": sec["title"], "fields": fields})
|
groups.append({"title": sec["title"], "fields": fields})
|
||||||
|
|||||||
@@ -47,6 +47,13 @@
|
|||||||
<option value="true"{% if cur in ('true', '1', 'yes', 'on') %} selected{% endif %}>开启</option>
|
<option value="true"{% if cur in ('true', '1', 'yes', 'on') %} selected{% endif %}>开启</option>
|
||||||
<option value="false"{% if cur not in ('true', '1', 'yes', 'on') %} selected{% endif %}>关闭</option>
|
<option value="false"{% if cur not in ('true', '1', 'yes', 'on') %} selected{% endif %}>关闭</option>
|
||||||
</select>
|
</select>
|
||||||
|
{% elif field.type == 'select' and field.options %}
|
||||||
|
{% set cur = (field.current or field.default or '') %}
|
||||||
|
<select class="env-field-input" id="env-f-{{ field.key }}" data-env-key="{{ field.key }}">
|
||||||
|
{% for opt in field.options %}
|
||||||
|
<option value="{{ opt.value }}"{% if cur == opt.value %} selected{% endif %}>{{ opt.label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
{% elif field.sensitive %}
|
{% elif field.sensitive %}
|
||||||
{% if field.has_value %}
|
{% if field.has_value %}
|
||||||
<div class="env-sensitive-current muted">已配置 <span class="env-masked-value">{{ field.masked }}</span></div>
|
<div class="env-sensitive-current muted">已配置 <span class="env-masked-value">{{ field.masked }}</span></div>
|
||||||
|
|||||||
@@ -59,6 +59,39 @@ class TestEnvSchema(unittest.TestCase):
|
|||||||
clean, errors = validate_env_updates(groups, {"B": "1"})
|
clean, errors = validate_env_updates(groups, {"B": "1"})
|
||||||
self.assertTrue(errors)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user