4d381e9104
EOF Co-authored-by: Cursor <cursoragent@cursor.com>
385 lines
12 KiB
Python
385 lines
12 KiB
Python
"""从 .env.example 构建 env 配置 schema(分组,敏感,重启标注)."""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
from typing import Any, Optional
|
|
|
|
from lib.env.env_file_lib import env_get, env_get_all, read_env_lines
|
|
|
|
_GROUP_RE = re.compile(r"^#\s*=+\s*(.+?)\s*=+\s*$")
|
|
_SEPARATOR_RE = re.compile(r"^#\s*=+\s*$")
|
|
_SECTION_DASH_RE = re.compile(r"^#\s*---\s*(.+?)\s*---\s*$")
|
|
_KEY_LINE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\s*=")
|
|
|
|
RESTART_REQUIRED_EXACT = frozenset({
|
|
"APP_HOST",
|
|
"APP_PORT",
|
|
"APP_DEBUG",
|
|
"DB_PATH",
|
|
"UPLOAD_DIR",
|
|
"FLASK_SECRET_KEY",
|
|
"POSITION_SIZING_MODE",
|
|
"LIVE_TRADING_ENABLED",
|
|
"OKX_TD_MODE",
|
|
"OKX_POS_MODE",
|
|
"OKX_POSITION_INST_TYPE",
|
|
"BINANCE_MARGIN_MODE",
|
|
"BINANCE_POSITION_MODE",
|
|
"GATE_TD_MODE",
|
|
"GATE_POS_MODE",
|
|
"PM2_APP_NAME",
|
|
})
|
|
|
|
RESTART_REQUIRED_PREFIXES = (
|
|
"OKX_API_",
|
|
"OKX_OPTIONS_API_",
|
|
"BINANCE_API_",
|
|
"GATE_API_",
|
|
"OKX_SOCKS_",
|
|
"OKX_HTTP_",
|
|
"OKX_HTTPS_",
|
|
"BINANCE_HTTP_",
|
|
"BINANCE_HTTPS_",
|
|
"GATE_HTTP_",
|
|
"GATE_HTTPS_",
|
|
)
|
|
|
|
HOT_RELOAD_EXACT = frozenset({
|
|
"RISK_PERCENT",
|
|
"MAX_ACTIVE_POSITIONS",
|
|
"MANUAL_MIN_PLANNED_RR",
|
|
"KEY_AUTO_MIN_PLANNED_RR",
|
|
"DAILY_OPEN_ALERT_THRESHOLD",
|
|
"DAILY_OPEN_HARD_LIMIT",
|
|
"TRADING_DAY_RESET_HOUR",
|
|
"TRADING_DAY_RESET_OPEN_GUARD_ENABLED",
|
|
"RISK_CONTROL_ENABLED",
|
|
"RISK_COOLING_HOURS_MANUAL",
|
|
"RISK_COOLING_HOURS_MANUAL_JOURNAL",
|
|
"RISK_MANUAL_CLOSE_DAILY_LIMIT",
|
|
"RISK_MOOD_ISSUES_DAILY_FREEZE",
|
|
"KEY_AUTO_ORDER_ENABLED",
|
|
"TRADE_DIRECTION_RESTRICT_ENABLED",
|
|
"TRADE_DIRECTION",
|
|
"TRADE_SYMBOL_RESTRICT_ENABLED",
|
|
"TRADE_SYMBOL_WHITELIST",
|
|
"BALANCE_REFRESH_SECONDS",
|
|
"PRICE_REFRESH_SECONDS",
|
|
"MONITOR_POLL_SECONDS",
|
|
"AUTO_TRANSFER_ENABLED",
|
|
"AUTO_TRANSFER_AMOUNT",
|
|
"AUTO_TRANSFER_BJ_HOUR",
|
|
"FORCE_CLOSE_ENABLED",
|
|
"FORCE_CLOSE_BJ_HOUR",
|
|
"BTC_LEVERAGE",
|
|
"ALT_LEVERAGE",
|
|
"DAILY_START_CAPITAL",
|
|
"DAILY_LOSS_CAPITAL",
|
|
"DAILY_PROFIT_CAPITAL",
|
|
"FULL_MARGIN_BUFFER_RATIO",
|
|
"APP_USERNAME",
|
|
"APP_PASSWORD",
|
|
"APP_AUTH_DISABLED",
|
|
"WECHAT_WEBHOOK",
|
|
"HEDGE_PLAN_ENABLED",
|
|
"HEDGE_PLAN_LIVE_ORDER",
|
|
"HEDGE_PLAN_OPEN_ORDER",
|
|
"HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS",
|
|
"HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS",
|
|
"HEDGE_PLAN_OO_CLOSE_WINNER_ONLY",
|
|
"MAX_ACTIVE_HEDGE_PLANS",
|
|
"HEDGE_PLAN_MONITOR_POLL_SECONDS",
|
|
"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",
|
|
"HUB_BRIDGE_TOKEN",
|
|
"OPENAI_API_KEY",
|
|
})
|
|
|
|
SENSITIVE_SUBSTR = ("_SECRET", "_PASSPHRASE", "_API_KEY", "_PASSWORD")
|
|
|
|
|
|
def normalize_position_sizing_mode(raw: str) -> str:
|
|
"""把中文/别名规范成 risk | full_margin。"""
|
|
s = (raw or "").strip().lower().replace("-", "_").replace(" ", "")
|
|
if s in ("risk", "以损", "以损定仓", "riskpercent", "risk_percent"):
|
|
return "risk"
|
|
if s in (
|
|
"full_margin",
|
|
"fullmargin",
|
|
"full",
|
|
"全仓",
|
|
"全仓杠杆",
|
|
"margin",
|
|
):
|
|
return "full_margin"
|
|
return (raw or "").strip()
|
|
|
|
|
|
def _is_sensitive(key: str) -> bool:
|
|
if key in SENSITIVE_EXACT:
|
|
return True
|
|
return any(s in key for s in SENSITIVE_SUBSTR)
|
|
|
|
|
|
def _restart_required(key: str) -> bool:
|
|
if key in HOT_RELOAD_EXACT:
|
|
return False
|
|
if key in RESTART_REQUIRED_EXACT:
|
|
return True
|
|
return any(key.startswith(p) for p in RESTART_REQUIRED_PREFIXES)
|
|
|
|
|
|
def _hot_reload(key: str) -> bool:
|
|
if key in HOT_RELOAD_EXACT:
|
|
return True
|
|
if _restart_required(key):
|
|
return False
|
|
return key.startswith(("KEY_", "KLINE_", "BREAKEVEN_", "RECONCILE_", "ORDER_CHART_"))
|
|
|
|
|
|
def _field_type(key: str, value: str) -> str:
|
|
low = (value or "").strip().lower()
|
|
if low in ("true", "false"):
|
|
return "bool"
|
|
if key.endswith("_ENABLED") or key.startswith("RISK_MOOD_"):
|
|
return "bool"
|
|
try:
|
|
if "." in low:
|
|
float(low)
|
|
return "float"
|
|
int(low)
|
|
return "int"
|
|
except ValueError:
|
|
pass
|
|
return "text"
|
|
|
|
|
|
def _mask_value(key: str, value: Optional[str]) -> dict[str, Any]:
|
|
if value is None or value == "":
|
|
return {"value": "", "masked": "", "tail": "", "has_value": False}
|
|
if not _is_sensitive(key):
|
|
return {"value": value, "masked": value, "tail": "", "has_value": True}
|
|
tail = value[-4:] if len(value) >= 4 else value
|
|
return {"value": "", "masked": f"****{tail}", "tail": tail, "has_value": True}
|
|
|
|
|
|
def parse_env_example_schema(example_path: str) -> list[dict[str, Any]]:
|
|
if not os.path.isfile(example_path):
|
|
return []
|
|
lines = read_env_lines(example_path)
|
|
groups: list[dict[str, Any]] = []
|
|
group_map: dict[str, dict[str, Any]] = {}
|
|
current_group = "基础配置"
|
|
pending_note: list[str] = []
|
|
in_section_block = False
|
|
section_title_set = False
|
|
allow_section_blocks = False
|
|
|
|
def _ensure_group(title: str) -> dict[str, Any]:
|
|
title = (title or "").strip() or "其他"
|
|
if title not in group_map:
|
|
group_map[title] = {"title": title, "fields": []}
|
|
groups.append(group_map[title])
|
|
return group_map[title]
|
|
|
|
for raw in lines:
|
|
line = raw.rstrip()
|
|
stripped = line.strip()
|
|
if not stripped:
|
|
pending_note = []
|
|
continue
|
|
if _SEPARATOR_RE.match(stripped):
|
|
if not allow_section_blocks:
|
|
continue
|
|
if not in_section_block:
|
|
in_section_block = True
|
|
section_title_set = False
|
|
else:
|
|
in_section_block = False
|
|
continue
|
|
if in_section_block and stripped.startswith("#"):
|
|
note = stripped.lstrip("#").strip()
|
|
if note and not section_title_set:
|
|
current_group = note
|
|
_ensure_group(current_group)
|
|
section_title_set = True
|
|
elif note:
|
|
pending_note.append(note)
|
|
continue
|
|
gm = _GROUP_RE.match(stripped)
|
|
if gm:
|
|
title = gm.group(1).strip()
|
|
if title and title != "=":
|
|
current_group = title
|
|
_ensure_group(current_group)
|
|
in_section_block = False
|
|
section_title_set = False
|
|
pending_note = []
|
|
continue
|
|
dash = _SECTION_DASH_RE.match(stripped)
|
|
if dash:
|
|
allow_section_blocks = True
|
|
current_group = dash.group(1).strip()
|
|
_ensure_group(current_group)
|
|
in_section_block = False
|
|
section_title_set = False
|
|
pending_note = []
|
|
continue
|
|
if stripped.startswith("#"):
|
|
note = stripped.lstrip("#").strip()
|
|
if note and not note.startswith("="):
|
|
pending_note.append(note)
|
|
continue
|
|
km = _KEY_LINE.match(stripped)
|
|
if not km:
|
|
continue
|
|
key = km.group(1)
|
|
allow_section_blocks = True
|
|
default_val = env_get(lines, key) or ""
|
|
grp = _ensure_group(current_group)
|
|
note = " ".join(pending_note).strip()
|
|
grp["fields"].append(
|
|
{
|
|
"key": key,
|
|
"label": key,
|
|
"note": note,
|
|
"default": default_val,
|
|
"type": _field_type(key, default_val),
|
|
"sensitive": _is_sensitive(key),
|
|
"restart_required": _restart_required(key),
|
|
"hot_reload": _hot_reload(key),
|
|
}
|
|
)
|
|
pending_note = []
|
|
return [g for g in groups if g.get("fields")]
|
|
|
|
|
|
def build_env_payload(example_path: str, env_path: str) -> dict[str, Any]:
|
|
groups = parse_env_example_schema(example_path)
|
|
env_lines = read_env_lines(env_path)
|
|
values = env_get_all(env_lines)
|
|
for group in groups:
|
|
for field in group.get("fields") or []:
|
|
key = field["key"]
|
|
val = values.get(key)
|
|
if val is None:
|
|
val = field.get("default") or ""
|
|
masked = _mask_value(key, val)
|
|
field["current"] = masked["value"] if not field["sensitive"] else ""
|
|
field["masked"] = masked["masked"]
|
|
field["has_value"] = masked["has_value"]
|
|
return {"groups": groups}
|
|
|
|
|
|
def validate_env_updates(groups: list[dict], updates: dict[str, str]) -> tuple[dict[str, str], list[str]]:
|
|
allowed = {}
|
|
for group in groups:
|
|
for field in group.get("fields") or []:
|
|
allowed[field["key"]] = field
|
|
clean: dict[str, str] = {}
|
|
errors: list[str] = []
|
|
for key, value in (updates or {}).items():
|
|
if key not in allowed:
|
|
errors.append(f"未知配置项: {key}")
|
|
continue
|
|
if value is None:
|
|
continue
|
|
val = str(value).strip()
|
|
if allowed[key].get("sensitive") and (val == "" or (val.startswith("****") and len(val) <= 8)):
|
|
continue
|
|
# 数字项留空不覆盖已有 .env,避免 RISK_PERCENT= 导致重启崩掉
|
|
ftype = allowed[key].get("type")
|
|
if val == "" and ftype in ("float", "int"):
|
|
continue
|
|
if val == "" and key in (
|
|
"POSITION_SIZING_MODE",
|
|
"TRADE_DIRECTION",
|
|
"RISK_PERCENT",
|
|
"FULL_MARGIN_BUFFER_RATIO",
|
|
"BTC_LEVERAGE",
|
|
"ALT_LEVERAGE",
|
|
"MAX_ACTIVE_POSITIONS",
|
|
):
|
|
continue
|
|
# API Key 被密码管理器/自动填充成登录密码时通常很短;OKX Key 一般为 36 位
|
|
if key.endswith("_API_KEY") and 0 < len(val) < 16:
|
|
errors.append(f"{key} 长度异常,疑似自动填充;留空则不修改已有密钥")
|
|
continue
|
|
if key == "POSITION_SIZING_MODE":
|
|
val = normalize_position_sizing_mode(val)
|
|
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"):
|
|
errors.append(f"{key} 须为 true/false")
|
|
continue
|
|
val = "true" if low in ("true", "1", "yes", "on") else "false"
|
|
elif ftype == "float" and val != "":
|
|
try:
|
|
float(val)
|
|
except ValueError:
|
|
errors.append(f"{key} 须为数字")
|
|
continue
|
|
elif ftype == "int" and val != "":
|
|
try:
|
|
int(val)
|
|
except ValueError:
|
|
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
|
|
|
|
|
|
def updates_need_restart(groups: list[dict], changed_keys: list[str]) -> bool:
|
|
field_map = {}
|
|
for group in groups:
|
|
for field in group.get("fields") or []:
|
|
field_map[field["key"]] = field
|
|
for key in changed_keys:
|
|
meta = field_map.get(key) or {}
|
|
if meta.get("restart_required"):
|
|
return True
|
|
if not meta.get("hot_reload"):
|
|
return True
|
|
return False
|