diff --git a/crypto_monitor_binance/.env.example b/crypto_monitor_binance/.env.example
index 30d727e..7e9fb68 100644
--- a/crypto_monitor_binance/.env.example
+++ b/crypto_monitor_binance/.env.example
@@ -52,11 +52,11 @@ UPLOAD_DIR=static/images
# BINANCE_FUNDING_INCLUDE_SPOT=false
# 计仓:risk=以损定仓(默认);full_margin=合约可用×FULL_MARGIN_BUFFER_RATIO 全仓杠杆(须无仓后重启)
POSITION_SIZING_MODE=risk
-# 方向限制(默认 false=双向均可;true 时按 TRADE_DIRECTION 限制,修改后须重启)
+# 方向限制(默认 false=双向均可;true 时按 TRADE_DIRECTION 限制;保存后热生效)
# TRADE_DIRECTION=long_only | short_only | both(或 多/空/双向)
TRADE_DIRECTION_RESTRICT_ENABLED=false
TRADE_DIRECTION=both
-# 币种白名单(默认 false=全币种可手输;true 时关键位/下单/策略仅下拉选择)
+# 币种白名单(默认 false=全币种可手输;true 时关键位/下单/策略仅下拉;保存后热生效)
TRADE_SYMBOL_RESTRICT_ENABLED=false
TRADE_SYMBOL_WHITELIST=BTC,ETH
# 每天起始基数(U)
diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py
index 56a9f46..1091e45 100644
--- a/crypto_monitor_binance/app.py
+++ b/crypto_monitor_binance/app.py
@@ -408,6 +408,17 @@ AUTO_TRANSFER_BJ_HOUR = int(os.getenv("AUTO_TRANSFER_BJ_HOUR", "8"))
POSITION_SIZING_MODE = load_position_sizing_mode()
KEY_AUTO_ORDER_ENABLED = load_key_auto_order_enabled()
TRADE_POLICY = load_trade_policy()
+
+
+def _reload_trade_policy_from_env(changed_keys=None):
+ """env 保存后热更新方向/白名单策略(无需重启)."""
+ global TRADE_POLICY
+ keys = set(changed_keys or [])
+ from lib.env.env_schema import TRADE_POLICY_ENV_KEYS
+
+ if keys and not keys.intersection(TRADE_POLICY_ENV_KEYS):
+ return
+ TRADE_POLICY = load_trade_policy()
WECHAT_TIMEOUT_SECONDS = int(os.getenv("WECHAT_TIMEOUT_SECONDS", "10"))
AI_TIMEOUT_SECONDS = int(os.getenv("AI_TIMEOUT_SECONDS", "120"))
MONITOR_POLL_SECONDS = int(os.getenv("MONITOR_POLL_SECONDS", "3"))
@@ -10052,6 +10063,7 @@ try:
exchange_key="binance",
username=USERNAME,
password=PASSWORD,
+ on_env_changed=lambda keys: _reload_trade_policy_from_env(keys),
)
except Exception as _settings_err:
print(f"[instance_settings] binance: {_settings_err}")
diff --git a/crypto_monitor_gate/.env.example b/crypto_monitor_gate/.env.example
index 3950dc0..485cefc 100644
--- a/crypto_monitor_gate/.env.example
+++ b/crypto_monitor_gate/.env.example
@@ -50,11 +50,11 @@ UPLOAD_DIR=static/images
# TOTAL_CAPITAL=100
# 计仓:risk=以损定仓(默认);full_margin=合约可用×FULL_MARGIN_BUFFER_RATIO 全仓杠杆(须无仓后重启)
POSITION_SIZING_MODE=risk
-# 方向限制(默认 false=双向均可;true 时按 TRADE_DIRECTION 限制,修改后须重启)
+# 方向限制(默认 false=双向均可;true 时按 TRADE_DIRECTION 限制;保存后热生效)
# TRADE_DIRECTION=long_only | short_only | both(或 多/空/双向)
TRADE_DIRECTION_RESTRICT_ENABLED=false
TRADE_DIRECTION=both
-# 币种白名单(默认 false=全币种可手输;true 时关键位/下单/策略仅下拉选择)
+# 币种白名单(默认 false=全币种可手输;true 时关键位/下单/策略仅下拉;保存后热生效)
TRADE_SYMBOL_RESTRICT_ENABLED=false
TRADE_SYMBOL_WHITELIST=BTC,ETH
# 每天起始基数(U)
diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py
index 00c1250..c392a8e 100644
--- a/crypto_monitor_gate/app.py
+++ b/crypto_monitor_gate/app.py
@@ -401,6 +401,17 @@ AUTO_TRANSFER_BJ_HOUR = int(os.getenv("AUTO_TRANSFER_BJ_HOUR", "8"))
POSITION_SIZING_MODE = load_position_sizing_mode()
KEY_AUTO_ORDER_ENABLED = load_key_auto_order_enabled()
TRADE_POLICY = load_trade_policy()
+
+
+def _reload_trade_policy_from_env(changed_keys=None):
+ """env 保存后热更新方向/白名单策略(无需重启)."""
+ global TRADE_POLICY
+ keys = set(changed_keys or [])
+ from lib.env.env_schema import TRADE_POLICY_ENV_KEYS
+
+ if keys and not keys.intersection(TRADE_POLICY_ENV_KEYS):
+ return
+ TRADE_POLICY = load_trade_policy()
WECHAT_TIMEOUT_SECONDS = int(os.getenv("WECHAT_TIMEOUT_SECONDS", "10"))
AI_TIMEOUT_SECONDS = int(os.getenv("AI_TIMEOUT_SECONDS", "120"))
MONITOR_POLL_SECONDS = int(os.getenv("MONITOR_POLL_SECONDS", "3"))
@@ -9895,6 +9906,7 @@ try:
exchange_key="gate",
username=USERNAME,
password=PASSWORD,
+ on_env_changed=lambda keys: _reload_trade_policy_from_env(keys),
)
except Exception as _settings_err:
print(f"[instance_settings] gate: {_settings_err}")
diff --git a/crypto_monitor_okx/.env.example b/crypto_monitor_okx/.env.example
index cadfc13..3679d59 100644
--- a/crypto_monitor_okx/.env.example
+++ b/crypto_monitor_okx/.env.example
@@ -55,11 +55,11 @@ UPLOAD_DIR=static/images
# TOTAL_CAPITAL=100 # 已弃用,资金展示读交易所
# 计仓:risk=以损定仓(默认);full_margin=合约可用×FULL_MARGIN_BUFFER_RATIO 全仓杠杆(须无仓后重启)
POSITION_SIZING_MODE=risk
-# 方向限制(默认 false=双向均可;true 时按 TRADE_DIRECTION 限制,修改后须重启)
+# 方向限制(默认 false=双向均可;true 时按 TRADE_DIRECTION 限制;保存后热生效)
# TRADE_DIRECTION=long_only | short_only | both(或 多/空/双向)
TRADE_DIRECTION_RESTRICT_ENABLED=false
TRADE_DIRECTION=both
-# 币种白名单(默认 false=全币种可手输;true 时关键位/下单/策略仅下拉选择)
+# 币种白名单(默认 false=全币种可手输;true 时关键位/下单/策略仅下拉;保存后热生效)
TRADE_SYMBOL_RESTRICT_ENABLED=false
TRADE_SYMBOL_WHITELIST=BTC,ETH
# 每天起始基数(U)
diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py
index 73eaa2d..775ef4a 100644
--- a/crypto_monitor_okx/app.py
+++ b/crypto_monitor_okx/app.py
@@ -377,6 +377,17 @@ AUTO_TRANSFER_BJ_HOUR = int(os.getenv("AUTO_TRANSFER_BJ_HOUR", "8"))
POSITION_SIZING_MODE = load_position_sizing_mode()
KEY_AUTO_ORDER_ENABLED = load_key_auto_order_enabled()
TRADE_POLICY = load_trade_policy()
+
+
+def _reload_trade_policy_from_env(changed_keys=None):
+ """env 保存后热更新方向/白名单策略(无需重启)."""
+ global TRADE_POLICY
+ keys = set(changed_keys or [])
+ from lib.env.env_schema import TRADE_POLICY_ENV_KEYS
+
+ if keys and not keys.intersection(TRADE_POLICY_ENV_KEYS):
+ return
+ TRADE_POLICY = load_trade_policy()
WECHAT_TIMEOUT_SECONDS = int(os.getenv("WECHAT_TIMEOUT_SECONDS", "10"))
AI_TIMEOUT_SECONDS = int(os.getenv("AI_TIMEOUT_SECONDS", "120"))
MONITOR_POLL_SECONDS = int(os.getenv("MONITOR_POLL_SECONDS", "3"))
@@ -9614,6 +9625,7 @@ try:
exchange_key="okx",
username=USERNAME,
password=PASSWORD,
+ on_env_changed=lambda keys: _reload_trade_policy_from_env(keys),
)
except Exception as _settings_err:
print(f"[instance_settings] okx: {_settings_err}")
diff --git a/lib/common/static/instance_settings_prefs.js b/lib/common/static/instance_settings_prefs.js
index 18b9aa4..e4209ff 100644
--- a/lib/common/static/instance_settings_prefs.js
+++ b/lib/common/static/instance_settings_prefs.js
@@ -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();
diff --git a/lib/env/env_schema.py b/lib/env/env_schema.py
index 1ea8b36..af75920 100644
--- a/lib/env/env_schema.py
+++ b/lib/env/env_schema.py
@@ -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
diff --git a/lib/env/env_ui_manifest.py b/lib/env/env_ui_manifest.py
index 4d04d2a..b99c5be 100644
--- a/lib/env/env_ui_manifest.py
+++ b/lib/env/env_ui_manifest.py
@@ -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
diff --git a/lib/instance/instance_settings_register.py b/lib/instance/instance_settings_register.py
index f55666a..f179d16 100644
--- a/lib/instance/instance_settings_register.py
+++ b/lib/instance/instance_settings_register.py
@@ -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,
diff --git a/lib/instance/templates/embed_shell.html b/lib/instance/templates/embed_shell.html
index ea53055..affe2e3 100644
--- a/lib/instance/templates/embed_shell.html
+++ b/lib/instance/templates/embed_shell.html
@@ -115,7 +115,7 @@ const ORDER_ENTRY_MODEL_CODE_TO_CATEGORY = {{ entry_model_code_to_category | toj
-
+