fix: hot-reload trade direction/whitelist on env save without forced restart.

EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-17 19:24:10 +08:00
parent b7966fd935
commit 4d381e9104
12 changed files with 111 additions and 15 deletions
+9 -3
View File
@@ -376,9 +376,15 @@
const needRestart = restartAfter || data.restart_required;
if (needRestart) {
setStatus(status, "已保存,正在重启实例…");
await restartInstance();
setStatus(status, "保存并重启完成");
await loadEnvConfig();
try {
await restartInstance();
setStatus(status, "保存并重启完成,若页面异常请手动刷新");
} catch (re) {
setStatus(status, (re && re.message) || "重启超时,请稍后刷新页面查看", true);
}
try {
await loadEnvConfig();
} catch (_) {}
} else {
setStatus(status, "已保存(即时生效项已应用)");
await loadEnvConfig();
+33
View File
@@ -93,6 +93,24 @@ HOT_RELOAD_EXACT = frozenset({
"HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION",
})
TRADE_POLICY_ENV_KEYS = frozenset({
"TRADE_DIRECTION_RESTRICT_ENABLED",
"TRADE_DIRECTION",
"TRADE_SYMBOL_RESTRICT_ENABLED",
"TRADE_SYMBOL_WHITELIST",
})
def normalize_trade_direction(raw: str) -> str:
s = (raw or "").strip().lower().replace("-", "_").replace(" ", "")
if s in ("long_only", "long", "longonly", "", "只多", "仅多", "做多"):
return "long_only"
if s in ("short_only", "short", "shortonly", "", "只空", "仅空", "做空"):
return "short_only"
if s in ("both", "双向", "多空", "all"):
return "both"
return (raw or "").strip()
SENSITIVE_EXACT = frozenset({
"APP_PASSWORD",
"FLASK_SECRET_KEY",
@@ -315,6 +333,14 @@ def validate_env_updates(groups: list[dict], updates: dict[str, str]) -> tuple[d
if val not in ("risk", "full_margin"):
errors.append("计仓模式须为 risk(以损定仓)或 full_margin(全仓杠杆)")
continue
if key == "TRADE_DIRECTION":
val = normalize_trade_direction(val)
if val not in ("long_only", "short_only", "both"):
errors.append("允许方向须为 long_only / short_only / both")
continue
if key == "TRADE_SYMBOL_RESTRICT_ENABLED":
# 开启白名单但名单为空时,后面统一补默认
pass
if ftype == "bool":
low = val.lower()
if low not in ("true", "false", "1", "0", "yes", "no", "on", "off"):
@@ -334,6 +360,13 @@ def validate_env_updates(groups: list[dict], updates: dict[str, str]) -> tuple[d
errors.append(f"{key} 须为整数")
continue
clean[key] = val
# 开启币种白名单且名单为空时,补默认 BTC,ETH,避免「开了等于没开」
if clean.get("TRADE_SYMBOL_RESTRICT_ENABLED") == "true":
wl = (clean.get("TRADE_SYMBOL_WHITELIST") or "").strip()
if not wl:
# 若本次没提交白名单,仍允许用已有 env;这里只在明确提交空串时补默认
if "TRADE_SYMBOL_WHITELIST" in (updates or {}):
clean["TRADE_SYMBOL_WHITELIST"] = "BTC,ETH"
return clean, errors
+19 -4
View File
@@ -12,6 +12,7 @@ from lib.env.env_schema import (
_mask_value,
_restart_required,
normalize_position_sizing_mode,
normalize_trade_direction,
parse_env_example_schema,
)
@@ -61,10 +62,10 @@ _SHARED_SECTIONS: list[dict[str, Any]] = [
("FULL_MARGIN_BUFFER_RATIO", "全仓资金缓冲比例", "如 0.98"),
("BTC_LEVERAGE", "BTC 默认杠杆", ""),
("ALT_LEVERAGE", "山寨默认杠杆", ""),
("TRADE_DIRECTION_RESTRICT_ENABLED", "方向限制开关", ""),
("TRADE_DIRECTION", "允许方向", "long_only / short_only / both"),
("TRADE_SYMBOL_RESTRICT_ENABLED", "币种白名单开关", ""),
("TRADE_SYMBOL_WHITELIST", "白名单币种", "逗号分隔,如 BTC,ETH"),
("TRADE_DIRECTION_RESTRICT_ENABLED", "方向限制开关", "关闭=双向均可;开启后按下方「允许方向」限制。保存后立即生效,一般无需重启。"),
("TRADE_DIRECTION", "允许方向", "仅在方向限制开启时生效"),
("TRADE_SYMBOL_RESTRICT_ENABLED", "币种白名单开关", "关闭=可手输任意币种;开启后仅白名单可选。保存后立即生效,一般无需重启。"),
("TRADE_SYMBOL_WHITELIST", "白名单币种", "逗号分隔如 BTC,ETH;开启白名单时勿留空"),
("TRADING_DAY_RESET_HOUR", "交易日切点(北京时间)", "整点,默认 8"),
("TRADING_DAY_RESET_OPEN_GUARD_ENABLED", "切点前禁止新开仓", ""),
("MAX_ACTIVE_POSITIONS", "最大同时持仓", ""),
@@ -163,6 +164,12 @@ _POSITION_SIZING_OPTIONS: list[dict[str, str]] = [
{"value": "full_margin", "label": "全仓杠杆 (full_margin)"},
]
_TRADE_DIRECTION_OPTIONS: list[dict[str, str]] = [
{"value": "both", "label": "双向 (both)"},
{"value": "long_only", "label": "仅做多 (long_only)"},
{"value": "short_only", "label": "仅做空 (short_only)"},
]
def _effective_env_value(key: str, file_values: dict[str, str], schema_default: str = "") -> str:
if key in file_values:
@@ -219,6 +226,14 @@ def _build_field(
cur = "risk"
out["current"] = cur
out["default"] = cur
if key == "TRADE_DIRECTION":
out["type"] = "enum"
out["options"] = list(_TRADE_DIRECTION_OPTIONS)
cur = normalize_trade_direction(out["current"] or out["default"] or "both")
if cur not in ("long_only", "short_only", "both"):
cur = "both"
out["current"] = cur
out["default"] = cur
return out
@@ -60,6 +60,7 @@ def register_instance_settings_routes(
exchange_key: str,
username: str,
password: str,
on_env_changed: Callable[[list[str]], None] | None = None,
) -> None:
env_path = os.path.join(base_dir, ".env")
example_path = os.path.join(base_dir, ".env.example")
@@ -107,6 +108,11 @@ def register_instance_settings_routes(
changed = apply_env_updates(env_path, clean)
groups = parse_env_example_schema(example_path)
reload_info = apply_env_reload(env_path, get_db, changed, groups)
if changed and callable(on_env_changed):
try:
on_env_changed(changed)
except Exception as e:
print(f"[instance_settings] on_env_changed: {e}", flush=True)
return jsonify(
{
"ok": True,
+1 -1
View File
@@ -115,7 +115,7 @@ const ORDER_ENTRY_MODEL_CODE_TO_CATEGORY = {{ entry_model_code_to_category | toj
<script>
window.__INSTANCE_DISPLAY__ = {{ display | tojson }};
</script>
<script src="/static/instance_settings_prefs.js?v=16"></script>
<script src="/static/instance_settings_prefs.js?v=17"></script>
<script src="/static/instance_live.js?v=6"></script>
<script src="/static/instance_embed.js?v=25"></script>
</body>
+1 -1
View File
@@ -2020,6 +2020,6 @@ document.addEventListener("DOMContentLoaded", function () {
});
{% endif %}
</script>
<script src="/static/instance_settings_prefs.js?v=16"></script>
<script src="/static/instance_settings_prefs.js?v=17"></script>
</body>
</html>