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
+2 -2
View File
@@ -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)
+12
View File
@@ -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}")
+2 -2
View File
@@ -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)
+12
View File
@@ -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}")
+2 -2
View File
@@ -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)
+12
View File
@@ -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}")
+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>