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 - + diff --git a/lib/instance/templates/index.html b/lib/instance/templates/index.html index 84b15b6..5439bff 100644 --- a/lib/instance/templates/index.html +++ b/lib/instance/templates/index.html @@ -2020,6 +2020,6 @@ document.addEventListener("DOMContentLoaded", function () { }); {% endif %} - + \ No newline at end of file