Initialize crypto_monitor_user (user edition) from monitor codebase.
Retarget git remote, install path, and deploy docs from crypto_monitor to crypto_monitor_user. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Vendored
+121
@@ -0,0 +1,121 @@
|
||||
"""读写实例目录 .env(行级 upsert,原子落盘)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from typing import Optional
|
||||
|
||||
_KEY_LINE = re.compile(r"^(\s*)([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$")
|
||||
|
||||
|
||||
def parse_env_lines(text: str) -> list[str]:
|
||||
return text.replace("\r\n", "\n").replace("\r", "\n").splitlines()
|
||||
|
||||
|
||||
def read_env_lines(path: str) -> list[str]:
|
||||
if not os.path.isfile(path):
|
||||
return []
|
||||
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
||||
return parse_env_lines(f.read())
|
||||
|
||||
|
||||
def env_get(lines: list[str], key: str) -> Optional[str]:
|
||||
for line in lines:
|
||||
m = _KEY_LINE.match(line)
|
||||
if m and m.group(2) == key:
|
||||
raw = m.group(3).strip()
|
||||
if (raw.startswith('"') and raw.endswith('"')) or (raw.startswith("'") and raw.endswith("'")):
|
||||
return raw[1:-1]
|
||||
return raw
|
||||
return None
|
||||
|
||||
|
||||
def env_get_all(lines: list[str]) -> dict[str, str]:
|
||||
out: dict[str, str] = {}
|
||||
for line in lines:
|
||||
m = _KEY_LINE.match(line)
|
||||
if m:
|
||||
key = m.group(2)
|
||||
raw = m.group(3).strip()
|
||||
if (raw.startswith('"') and raw.endswith('"')) or (raw.startswith("'") and raw.endswith("'")):
|
||||
out[key] = raw[1:-1]
|
||||
else:
|
||||
out[key] = raw
|
||||
return out
|
||||
|
||||
|
||||
def upsert_env_line(lines: list[str], key: str, value: str) -> list[str]:
|
||||
pat = re.compile(r"^\s*" + re.escape(key) + r"\s*=")
|
||||
out: list[str] = []
|
||||
replaced = False
|
||||
safe = value if value is not None else ""
|
||||
if any(c in safe for c in (' ', '#', '"', "'")):
|
||||
safe = '"' + safe.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||||
new_line = f"{key}={safe}"
|
||||
for line in lines:
|
||||
if pat.match(line):
|
||||
if not replaced:
|
||||
out.append(new_line)
|
||||
replaced = True
|
||||
continue
|
||||
out.append(line)
|
||||
if not replaced:
|
||||
if out and out[-1].strip():
|
||||
out.append("")
|
||||
out.append(new_line)
|
||||
return out
|
||||
|
||||
|
||||
def write_env_lines_atomic(path: str, lines: list[str]) -> None:
|
||||
directory = os.path.dirname(os.path.abspath(path)) or "."
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(prefix=".env.", dir=directory, text=True)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f:
|
||||
f.write("\n".join(lines))
|
||||
if lines:
|
||||
f.write("\n")
|
||||
os.replace(tmp, path)
|
||||
finally:
|
||||
if os.path.exists(tmp):
|
||||
try:
|
||||
os.remove(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def apply_env_updates(path: str, updates: dict[str, str]) -> list[str]:
|
||||
lines = read_env_lines(path)
|
||||
changed: list[str] = []
|
||||
for key, value in updates.items():
|
||||
if value is None:
|
||||
continue
|
||||
old = env_get(lines, key)
|
||||
if old == value:
|
||||
continue
|
||||
lines = upsert_env_line(lines, key, value)
|
||||
changed.append(key)
|
||||
if changed:
|
||||
write_env_lines_atomic(path, lines)
|
||||
return changed
|
||||
|
||||
|
||||
def load_env_file_into_environ(path: str) -> None:
|
||||
if not os.path.exists(path):
|
||||
return
|
||||
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
||||
text = f.read()
|
||||
if text.startswith("\ufeff"):
|
||||
text = text[1:]
|
||||
for line in parse_env_lines(text):
|
||||
s = line.strip()
|
||||
if not s or s.startswith("#"):
|
||||
continue
|
||||
if "=" not in s:
|
||||
continue
|
||||
k, _, v = s.partition("=")
|
||||
clean_key = k.strip()
|
||||
clean_val = v.strip().strip('"').strip("'")
|
||||
if clean_key:
|
||||
os.environ[clean_key] = clean_val
|
||||
Vendored
+304
@@ -0,0 +1,304 @@
|
||||
"""从 .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",
|
||||
})
|
||||
|
||||
SENSITIVE_EXACT = frozenset({
|
||||
"APP_PASSWORD",
|
||||
"FLASK_SECRET_KEY",
|
||||
"HUB_BRIDGE_TOKEN",
|
||||
"OPENAI_API_KEY",
|
||||
})
|
||||
|
||||
SENSITIVE_SUBSTR = ("_SECRET", "_PASSPHRASE", "_API_KEY", "_PASSWORD")
|
||||
|
||||
|
||||
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
|
||||
# API Key 被密码管理器/自动填充成登录密码时通常很短;OKX Key 一般为 36 位
|
||||
if key.endswith("_API_KEY") and 0 < len(val) < 16:
|
||||
errors.append(f"{key} 长度异常,疑似自动填充;留空则不修改已有密钥")
|
||||
continue
|
||||
ftype = allowed[key].get("type")
|
||||
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"
|
||||
clean[key] = val
|
||||
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
|
||||
Vendored
+276
@@ -0,0 +1,276 @@
|
||||
"""env 配置页 UI 白名单:中文标签,按交易所过滤."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Optional
|
||||
|
||||
from lib.env.env_file_lib import env_get_all, read_env_lines
|
||||
from lib.env.env_schema import (
|
||||
_field_type,
|
||||
_hot_reload,
|
||||
_is_sensitive,
|
||||
_mask_value,
|
||||
_restart_required,
|
||||
parse_env_example_schema,
|
||||
)
|
||||
|
||||
# 各所「交易所与实盘」字段(顺序即页面顺序)
|
||||
_EXCHANGE_LIVE_FIELDS: dict[str, list[tuple[str, str, str]]] = {
|
||||
"okx": [
|
||||
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"),
|
||||
("OKX_API_KEY", "API Key", "永续子账户"),
|
||||
("OKX_API_SECRET", "API Secret", "永续子账户"),
|
||||
("OKX_API_PASSPHRASE", "API Passphrase", "OKX 必填"),
|
||||
("OKX_TD_MODE", "保证金模式", "cross=全仓,isolated=逐仓"),
|
||||
("OKX_POS_MODE", "持仓模式", "hedge=双向,net=单向净持仓"),
|
||||
("OKX_POSITION_INST_TYPE", "仓位查询类型", "如 SWAP"),
|
||||
("OKX_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"),
|
||||
],
|
||||
"binance": [
|
||||
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"),
|
||||
("BINANCE_API_KEY", "API Key", "永续子账户"),
|
||||
("BINANCE_API_SECRET", "API Secret", "永续子账户"),
|
||||
("BINANCE_MARGIN_MODE", "保证金模式", "cross=全仓,isolated=逐仓"),
|
||||
("BINANCE_POSITION_MODE", "持仓模式", "hedge=双向,one_way=单向"),
|
||||
("BINANCE_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"),
|
||||
],
|
||||
"gate": [
|
||||
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"),
|
||||
("GATE_API_KEY", "API Key", "永续子账户"),
|
||||
("GATE_API_SECRET", "API Secret", "永续子账户"),
|
||||
("GATE_TD_MODE", "保证金模式", "cross=全仓,isolated=逐仓"),
|
||||
("GATE_POS_MODE", "持仓模式", "hedge=双向,single=单向"),
|
||||
("GATE_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"),
|
||||
],
|
||||
}
|
||||
|
||||
_SHARED_SECTIONS: list[dict[str, Any]] = [
|
||||
{
|
||||
"title": "企业微信",
|
||||
"fields": [
|
||||
("WECHAT_WEBHOOK", "机器人 Webhook", "行情与风控推送地址"),
|
||||
("WECHAT_TIMEOUT_SECONDS", "推送超时(秒)", "默认 10"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "交易执行",
|
||||
"fields": [
|
||||
("POSITION_SIZING_MODE", "计仓模式", "risk=以损定仓,full_margin=全仓杠杆"),
|
||||
("RISK_PERCENT", "以损定仓风险%", "单笔风险占资金比例"),
|
||||
("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"),
|
||||
("TRADING_DAY_RESET_HOUR", "交易日切点(北京时间)", "整点,默认 8"),
|
||||
("TRADING_DAY_RESET_OPEN_GUARD_ENABLED", "切点前禁止新开仓", ""),
|
||||
("MAX_ACTIVE_POSITIONS", "最大同时持仓", ""),
|
||||
("MANUAL_MIN_PLANNED_RR", "人工最低盈亏比", "如 1.4"),
|
||||
("FORCE_CLOSE_ENABLED", "强制清仓开关", ""),
|
||||
("FORCE_CLOSE_BJ_HOUR", "强制清仓整点(北京)", ""),
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "交易风控",
|
||||
"fields": [
|
||||
("DAILY_OPEN_ALERT_THRESHOLD", "单日开仓提醒阈值", "达次数后 AI 提醒,不拦单"),
|
||||
("DAILY_OPEN_HARD_LIMIT", "单日开仓硬上限", "0=不启用"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "账户冷静期",
|
||||
"fields": [
|
||||
("RISK_CONTROL_ENABLED", "冷静期总开关", ""),
|
||||
("RISK_COOLING_HOURS_MANUAL", "手动平仓冷静(小时)", ""),
|
||||
("RISK_COOLING_HOURS_MANUAL_JOURNAL", "复盘情绪冷静(小时)", ""),
|
||||
("RISK_MANUAL_CLOSE_DAILY_LIMIT", "日手动平仓次数上限", ""),
|
||||
("RISK_MOOD_ISSUES_DAILY_FREEZE", "情绪标签日冻结", ""),
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "自动划转",
|
||||
"fields": [
|
||||
("AUTO_TRANSFER_ENABLED", "启用自动划转", ""),
|
||||
("AUTO_TRANSFER_AMOUNT", "目标余额(U)", "交易账户目标 USDT"),
|
||||
("AUTO_TRANSFER_FROM", "划出账户", "funding 或 swap"),
|
||||
("AUTO_TRANSFER_TO", "划入账户", "swap 或 funding"),
|
||||
("AUTO_TRANSFER_BJ_HOUR", "执行整点(北京时间)", ""),
|
||||
("TRANSFER_CCY", "划转币种", "默认 USDT"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "当日资金",
|
||||
"fields": [
|
||||
("DAILY_START_CAPITAL", "日起始基数(U)", ""),
|
||||
("DAILY_LOSS_CAPITAL", "回撤后基数(U)", ""),
|
||||
("DAILY_PROFIT_CAPITAL", "盈利后基数(U)", ""),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
_OPTIONS_SECTION: dict[str, Any] = {
|
||||
"title": "期权账户",
|
||||
"exchanges": frozenset({"okx"}),
|
||||
"fields": [
|
||||
("OKX_OPTIONS_ENABLED", "启用期权模块", ""),
|
||||
("OKX_OPTIONS_API_KEY", "期权 API Key", "主账户,与永续子账户分离"),
|
||||
("OKX_OPTIONS_API_SECRET", "期权 API Secret", ""),
|
||||
("OKX_OPTIONS_API_PASSPHRASE", "期权 API Passphrase", ""),
|
||||
("OKX_OPTIONS_ACCOUNT_LABEL", "期权账户备注", ""),
|
||||
("OKX_OPTIONS_TRADE_BUDGET_USDC", "单笔预算(USDC)", ""),
|
||||
("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95"),
|
||||
("OKX_OPTIONS_DEFAULT_UNDERLY", "默认标的", "如 ETH"),
|
||||
],
|
||||
}
|
||||
|
||||
_HEDGE_PLAN_SECTION: dict[str, Any] = {
|
||||
"title": "对冲计划",
|
||||
"exchanges": frozenset({"okx"}),
|
||||
"fields": [
|
||||
("HEDGE_PLAN_ENABLED", "启用对冲计划", "关闭则隐藏导航且不可开仓"),
|
||||
("HEDGE_PLAN_LIVE_ORDER", "允许对冲真实下单", "再与实盘 LIVE_TRADING_ENABLED 同开才可启动永期"),
|
||||
("HEDGE_PLAN_OPEN_ORDER", "永期开仓顺序", "options_first 或 perp_first"),
|
||||
("HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", "永期止损后强制平期权", "保护机制,建议保持 true"),
|
||||
("HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS", "永期止盈后强制平期权", "默认 false,保险腿不平"),
|
||||
("HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", "期期只平盈利腿", "达目标价只平盈利方"),
|
||||
("MAX_ACTIVE_HEDGE_PLANS", "最大同时活跃计划数", "建议 1"),
|
||||
("HEDGE_PLAN_MONITOR_POLL_SECONDS", "对冲监控轮询(秒)", "默认 15"),
|
||||
("HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION", "半腿失败时自动平期权", ""),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# 与运行时 os.getenv 默认一致;.env 未写明时展示实际生效值(同风控说明页)
|
||||
_RUNTIME_ENV_DEFAULTS: dict[str, str] = {
|
||||
"RISK_CONTROL_ENABLED": "true",
|
||||
"RISK_COOLING_HOURS_MANUAL": "4",
|
||||
"RISK_COOLING_HOURS_MANUAL_JOURNAL": "1",
|
||||
"RISK_MANUAL_CLOSE_DAILY_LIMIT": "2",
|
||||
"RISK_MOOD_ISSUES_DAILY_FREEZE": "true",
|
||||
}
|
||||
|
||||
|
||||
def _effective_env_value(key: str, file_values: dict[str, str], schema_default: str = "") -> str:
|
||||
if key in file_values:
|
||||
return file_values[key]
|
||||
runtime = os.getenv(key)
|
||||
if runtime is not None and str(runtime).strip() != "":
|
||||
return str(runtime).strip()
|
||||
if schema_default:
|
||||
return schema_default
|
||||
return _RUNTIME_ENV_DEFAULTS.get(key, "")
|
||||
|
||||
|
||||
def _schema_field_map(example_path: str) -> dict[str, dict[str, Any]]:
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
for group in parse_env_example_schema(example_path):
|
||||
for field in group.get("fields") or []:
|
||||
out[field["key"]] = dict(field)
|
||||
return out
|
||||
|
||||
|
||||
def _build_field(
|
||||
key: str,
|
||||
label: str,
|
||||
note: str,
|
||||
schema: dict[str, dict[str, Any]],
|
||||
values: dict[str, str],
|
||||
) -> dict[str, Any]:
|
||||
meta = schema.get(key) or {}
|
||||
schema_default = meta.get("default") or ""
|
||||
val = _effective_env_value(key, values, schema_default)
|
||||
masked = _mask_value(key, val)
|
||||
ftype = meta.get("type") or _field_type(key, val or schema_default)
|
||||
return {
|
||||
"key": key,
|
||||
"label": label,
|
||||
"note": note or meta.get("note") or "",
|
||||
"default": val,
|
||||
"type": ftype,
|
||||
"sensitive": meta.get("sensitive", _is_sensitive(key)),
|
||||
"restart_required": meta.get("restart_required", _restart_required(key)),
|
||||
"hot_reload": meta.get("hot_reload", _hot_reload(key)),
|
||||
"current": masked["value"] if not _is_sensitive(key) else "",
|
||||
"masked": masked["masked"],
|
||||
"tail": masked.get("tail") or "",
|
||||
"has_value": masked["has_value"],
|
||||
}
|
||||
|
||||
|
||||
def ui_sections_for_exchange(exchange_key: str) -> list[dict[str, Any]]:
|
||||
ex = (exchange_key or "").strip().lower()
|
||||
sections: list[dict[str, Any]] = []
|
||||
live_fields = _EXCHANGE_LIVE_FIELDS.get(ex, _EXCHANGE_LIVE_FIELDS["okx"])
|
||||
sections.append({"title": "交易所与实盘", "fields": live_fields})
|
||||
sections.extend(_SHARED_SECTIONS)
|
||||
if ex in _OPTIONS_SECTION.get("exchanges", frozenset()):
|
||||
sections.append(_OPTIONS_SECTION)
|
||||
if ex in _HEDGE_PLAN_SECTION.get("exchanges", frozenset()):
|
||||
sections.append(_HEDGE_PLAN_SECTION)
|
||||
return sections
|
||||
|
||||
|
||||
def ui_allowed_keys(exchange_key: str) -> frozenset[str]:
|
||||
keys: set[str] = set()
|
||||
for sec in ui_sections_for_exchange(exchange_key):
|
||||
for item in sec["fields"]:
|
||||
keys.add(item[0])
|
||||
return frozenset(keys)
|
||||
|
||||
|
||||
def build_env_ui_payload(
|
||||
exchange_key: str,
|
||||
example_path: str,
|
||||
env_path: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
schema = _schema_field_map(example_path)
|
||||
env_lines = read_env_lines(env_path)
|
||||
values = env_get_all(env_lines)
|
||||
groups: list[dict[str, Any]] = []
|
||||
for sec in ui_sections_for_exchange(exchange_key):
|
||||
fields = [
|
||||
_build_field(key, label, note, schema, values)
|
||||
for key, label, note in sec["fields"]
|
||||
]
|
||||
groups.append({
|
||||
"title": sec["title"],
|
||||
"fields": fields,
|
||||
"has_restart": any(f.get("restart_required") for f in fields),
|
||||
})
|
||||
return groups
|
||||
|
||||
|
||||
def filter_updates_for_ui(exchange_key: str, updates: dict[str, str]) -> dict[str, str]:
|
||||
allowed = ui_allowed_keys(exchange_key)
|
||||
return {k: v for k, v in (updates or {}).items() if k in allowed}
|
||||
|
||||
|
||||
def validate_env_ui_updates(
|
||||
exchange_key: str,
|
||||
example_path: str,
|
||||
updates: dict[str, str],
|
||||
) -> tuple[dict[str, str], list[str]]:
|
||||
from lib.env.env_schema import validate_env_updates
|
||||
|
||||
schema = _schema_field_map(example_path)
|
||||
groups: list[dict[str, Any]] = []
|
||||
for sec in ui_sections_for_exchange(exchange_key):
|
||||
fields: list[dict[str, Any]] = []
|
||||
for key, _label, _note in sec["fields"]:
|
||||
if key in schema:
|
||||
fields.append(schema[key])
|
||||
else:
|
||||
default = ""
|
||||
fields.append(
|
||||
{
|
||||
"key": key,
|
||||
"type": _field_type(key, default),
|
||||
"sensitive": _is_sensitive(key),
|
||||
"restart_required": _restart_required(key),
|
||||
"hot_reload": _hot_reload(key),
|
||||
}
|
||||
)
|
||||
groups.append({"title": sec["title"], "fields": fields})
|
||||
return validate_env_updates(groups, updates)
|
||||
Vendored
+237
@@ -0,0 +1,237 @@
|
||||
"""中控统一 AI 环境变量:字段定义,读写,同步三实例."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from lib.env.env_file_lib import apply_env_updates, env_get_all, load_env_file_into_environ, read_env_lines
|
||||
from lib.env.env_schema import (
|
||||
_field_type,
|
||||
_hot_reload,
|
||||
_is_sensitive,
|
||||
_mask_value,
|
||||
_restart_required,
|
||||
parse_env_example_schema,
|
||||
validate_env_updates,
|
||||
)
|
||||
from lib.paths import REPO_ROOT
|
||||
|
||||
AI_ENV_FIELDS: list[tuple[str, str, str]] = [
|
||||
("AI_PROVIDER", "AI 提供方", "openai 或 ollama"),
|
||||
("OPENAI_API_BASE", "API 地址", "OpenAI 兼容接口"),
|
||||
("OPENAI_API_KEY", "API 密钥", "留空表示不修改"),
|
||||
("OPENAI_MODEL", "云端模型", ""),
|
||||
("OLLAMA_API", "Ollama 地址", "本地服务 URL"),
|
||||
("AI_MODEL", "Ollama 模型", ""),
|
||||
("AI_TIMEOUT_SECONDS", "请求超时(秒)", "默认 120"),
|
||||
]
|
||||
|
||||
AI_ENV_KEYS = frozenset(k for k, _l, _n in AI_ENV_FIELDS)
|
||||
|
||||
INSTANCE_ENV_DIRS: dict[str, Path] = {
|
||||
"okx": REPO_ROOT / "crypto_monitor_okx",
|
||||
"binance": REPO_ROOT / "crypto_monitor_binance",
|
||||
"gate": REPO_ROOT / "crypto_monitor_gate",
|
||||
}
|
||||
|
||||
|
||||
def hub_env_path() -> str:
|
||||
return str(REPO_ROOT / "manual_trading_hub" / ".env")
|
||||
|
||||
|
||||
def hub_example_path() -> str:
|
||||
return str(REPO_ROOT / "manual_trading_hub" / ".env.example")
|
||||
|
||||
|
||||
def instance_example_path(exchange_key: str = "okx") -> str:
|
||||
ex = (exchange_key or "okx").strip().lower()
|
||||
base = INSTANCE_ENV_DIRS.get(ex, INSTANCE_ENV_DIRS["okx"])
|
||||
return str(base / ".env.example")
|
||||
|
||||
|
||||
def _schema_field_map(example_path: str) -> dict[str, dict[str, Any]]:
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
for group in parse_env_example_schema(example_path):
|
||||
for field in group.get("fields") or []:
|
||||
out[field["key"]] = dict(field)
|
||||
return out
|
||||
|
||||
|
||||
def _build_field(
|
||||
key: str,
|
||||
label: str,
|
||||
note: str,
|
||||
schema: dict[str, dict[str, Any]],
|
||||
values: dict[str, str],
|
||||
) -> dict[str, Any]:
|
||||
meta = schema.get(key) or {}
|
||||
schema_default = meta.get("default") or ""
|
||||
val = values.get(key, "")
|
||||
if val == "" and schema_default:
|
||||
val = schema_default
|
||||
masked = _mask_value(key, val)
|
||||
ftype = meta.get("type") or _field_type(key, val or schema_default)
|
||||
return {
|
||||
"key": key,
|
||||
"label": label,
|
||||
"note": note or meta.get("note") or "",
|
||||
"default": val,
|
||||
"type": ftype,
|
||||
"sensitive": meta.get("sensitive", _is_sensitive(key)),
|
||||
"restart_required": meta.get("restart_required", _restart_required(key)),
|
||||
"hot_reload": meta.get("hot_reload", _hot_reload(key)),
|
||||
"current": masked["value"] if not _is_sensitive(key) else "",
|
||||
"masked": masked["masked"],
|
||||
"tail": masked.get("tail") or "",
|
||||
"has_value": masked["has_value"],
|
||||
}
|
||||
|
||||
|
||||
def build_ai_env_payload(env_path: str | None = None, example_path: str | None = None) -> dict[str, Any]:
|
||||
env_path = env_path or hub_env_path()
|
||||
example_path = example_path or hub_example_path()
|
||||
schema = _schema_field_map(example_path)
|
||||
values = env_get_all(read_env_lines(env_path))
|
||||
fields = [
|
||||
_build_field(key, label, note, schema, values)
|
||||
for key, label, note in AI_ENV_FIELDS
|
||||
]
|
||||
sync_status = ai_sync_status()
|
||||
return {
|
||||
"title": "AI 复盘",
|
||||
"fields": fields,
|
||||
"sync_status": sync_status,
|
||||
}
|
||||
|
||||
|
||||
def ai_sync_status() -> dict[str, Any]:
|
||||
"""比较 hub 与三实例 AI 键是否一致(用于 UI 提示)."""
|
||||
hub_vals = env_get_all(read_env_lines(hub_env_path()))
|
||||
per_instance: dict[str, dict[str, Any]] = {}
|
||||
all_ok = True
|
||||
for ex, inst_dir in INSTANCE_ENV_DIRS.items():
|
||||
path = str(inst_dir / ".env")
|
||||
if not os.path.isfile(path):
|
||||
per_instance[ex] = {"ok": False, "msg": "缺少 .env"}
|
||||
all_ok = False
|
||||
continue
|
||||
inst_vals = env_get_all(read_env_lines(path))
|
||||
mismatched = [
|
||||
k
|
||||
for k in AI_ENV_KEYS
|
||||
if (hub_vals.get(k) or "") != (inst_vals.get(k) or "")
|
||||
]
|
||||
ok = not mismatched
|
||||
if not ok:
|
||||
all_ok = False
|
||||
per_instance[ex] = {
|
||||
"ok": ok,
|
||||
"mismatched_keys": mismatched,
|
||||
}
|
||||
return {"all_synced": all_ok, "instances": per_instance}
|
||||
|
||||
|
||||
def _ai_validate_groups(example_path: str) -> list[dict[str, Any]]:
|
||||
schema = _schema_field_map(example_path)
|
||||
fields: list[dict[str, Any]] = []
|
||||
for key, _label, _note in AI_ENV_FIELDS:
|
||||
if key in schema:
|
||||
fields.append(schema[key])
|
||||
else:
|
||||
fields.append(
|
||||
{
|
||||
"key": key,
|
||||
"type": _field_type(key, ""),
|
||||
"sensitive": _is_sensitive(key),
|
||||
"restart_required": _restart_required(key),
|
||||
"hot_reload": _hot_reload(key),
|
||||
}
|
||||
)
|
||||
return [{"title": "AI 复盘", "fields": fields}]
|
||||
|
||||
|
||||
def validate_ai_env_updates(updates: dict[str, str], example_path: str | None = None) -> tuple[dict[str, str], list[str]]:
|
||||
example_path = example_path or hub_example_path()
|
||||
groups = _ai_validate_groups(example_path)
|
||||
filtered = {k: v for k, v in (updates or {}).items() if k in AI_ENV_KEYS}
|
||||
unknown = [k for k in (updates or {}) if k not in AI_ENV_KEYS]
|
||||
errors = [f"未知配置项: {k}" for k in unknown]
|
||||
clean, val_errors = validate_env_updates(groups, filtered)
|
||||
errors.extend(val_errors)
|
||||
return clean, errors
|
||||
|
||||
|
||||
def apply_ai_env_to_all(updates: dict[str, str]) -> dict[str, Any]:
|
||||
"""写入 hub .env 并强制同步三实例相同键."""
|
||||
clean, errors = validate_ai_env_updates(updates)
|
||||
if errors:
|
||||
return {"ok": False, "errors": errors, "changed": {}}
|
||||
if not clean:
|
||||
return {"ok": True, "changed": {}, "restart_required": False}
|
||||
|
||||
changed: dict[str, list[str]] = {}
|
||||
targets = [("hub", hub_env_path())]
|
||||
for ex, inst_dir in INSTANCE_ENV_DIRS.items():
|
||||
targets.append((ex, str(inst_dir / ".env")))
|
||||
|
||||
for name, path in targets:
|
||||
if not os.path.isfile(path):
|
||||
if name == "hub":
|
||||
return {"ok": False, "errors": [f"缺少 {path}"], "changed": {}}
|
||||
continue
|
||||
keys = apply_env_updates(path, clean)
|
||||
if keys:
|
||||
changed[name] = keys
|
||||
load_env_file_into_environ(path)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"changed": changed,
|
||||
"restart_required": True,
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
|
||||
def restart_instances_then_hub_pm2() -> dict[str, Any]:
|
||||
"""先重启三实例,最后重启中控(避免当前请求被中断)."""
|
||||
if not sys.platform.startswith("linux"):
|
||||
return {"ok": False, "msg": "仅 Linux 服务器支持 PM2 重启", "results": []}
|
||||
from lib.instance.instance_pm2_lib import restart_instance_pm2
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
for ex in ("okx", "binance", "gate"):
|
||||
r = restart_instance_pm2(ex)
|
||||
results.append({"exchange": ex, **r})
|
||||
hub_result = _restart_pm2_app("manual-trading-hub")
|
||||
results.append({"app": "manual-trading-hub", **hub_result})
|
||||
ok = all(r.get("ok") for r in results)
|
||||
return {"ok": ok, "results": results}
|
||||
|
||||
|
||||
def restart_hub_and_instances_pm2() -> dict[str, Any]:
|
||||
"""兼容旧调用:与 restart_instances_then_hub_pm2 相同顺序."""
|
||||
return restart_instances_then_hub_pm2()
|
||||
|
||||
|
||||
def _restart_pm2_app(app_name: str) -> dict[str, Any]:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["pm2", "restart", app_name, "--update-env"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
return {
|
||||
"ok": proc.returncode == 0,
|
||||
"msg": (proc.stdout or proc.stderr or "").strip()[:500],
|
||||
"returncode": proc.returncode,
|
||||
}
|
||||
except FileNotFoundError:
|
||||
return {"ok": False, "msg": "未找到 pm2 命令"}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"ok": False, "msg": "pm2 restart 超时"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "msg": str(e)}
|
||||
Reference in New Issue
Block a user