diff --git a/docs/env配置说明.md b/docs/env配置说明.md new file mode 100644 index 0000000..3c3c018 --- /dev/null +++ b/docs/env配置说明.md @@ -0,0 +1,228 @@ +# env 配置页说明 + +本文档描述各交易实例 Web 端 **「env 配置」** 页展示项、含义、生效方式,以及与系统设置、中控密钥的分工。 + +> **不在本页展示的配置**(服务端口、数据库路径、关键位门控、轮询间隔等)仍保存在实例目录 `.env` 中,需 SSH 编辑或部署脚本维护,见文末「隐藏项」。 + +--- + +## 1. 设计原则 + +| 原则 | 说明 | +|------|------| +| **只展示运营相关项** | 不暴露全量 `.env`,避免误改基础设施 | +| **前端仅中文** | 页面只显示中文标签与说明,不显示 `APP_XXX` 等变量名 | +| **账户密码不进本页** | 登录用户名/密码在 **系统设置 → 账户密码修改** 中维护 | +| **密钥自动托管** | 中控通信密钥、登录会话密钥由 **首次部署脚本自动生成并写入**,本页不提供编辑 | +| **保存标注** | 每项标注「保存即生效」或「需重启」;含需重启项时可用「保存并重启」 | + +--- + +## 2. 密钥分工(自动生成,本页不可见) + +部署时由脚本统一生成并写入对应 `.env`(已有值则跳过,避免覆盖生产环境)。 + +| 类型 | 环境变量 | 写入位置 | 用途 | +|------|----------|----------|------| +| **中控通信密钥** | `HUB_BRIDGE_TOKEN` | 中控 `manual_trading_hub/.env` + 三实例 `.env`(**相同值**) | 中控调用实例 API(`X-Hub-Token`)、iframe SSO 签发与校验 | +| **登录会话密钥** | `FLASK_SECRET_KEY` | 三实例 `.env`(**三所相同**) | Flask Session 签名;与网页登录态相关,与中控密钥 **分离** | +| **登录账号** | `APP_USERNAME` / `APP_PASSWORD` | 三实例 `.env`(建议三所统一) | 直链实例 `/login` 使用;**仅在系统设置中修改** | + +说明: + +- **中控密钥 ≠ 登录密钥**:前者给中控与实例之间通信用;后者给浏览器 Session 用。 +- 经中控 iframe / SSO 打开实例时,可免输实例密码;直链 IP/域名仍走 `/login`。 +- 首次部署可生成随机强密码;用户日后在 **系统设置** 改密,不经过本页。 + +--- + +## 3. 页面布局(三列卡片) + +| 列 1 | 列 2 | 列 3 | +|------|------|------| +| 交易所与实盘 | 企业微信 | AI 复盘 | +| 交易执行 | 交易风控 | 账户冷静期 | +| 自动划转 | 当日资金 | 期权账户(仅 OKX) | + +Binance / Gate 无期权模块时,第三列最后一格不显示或显示「本所无期权」。 + +--- + +## 4. 各卡片字段(中文展示名) + +### 4.1 交易所与实盘 + +| 中文名 | 说明 | 重启 | +|--------|------|------| +| 开启实盘下单 | 关闭时仅走本地流程,不向交易所发单 | 需重启 | +| API Key | 永续子账户 API Key | 需重启 | +| API Secret | 永续子账户 Secret | 需重启 | +| API Passphrase | 仅 OKX 显示 | 需重启 | +| 保证金模式 | 全仓 / 逐仓 | 需重启 | +| 持仓模式 | 双向 / 单向净持仓等(按所) | 需重启 | +| 仓位查询类型 | 仅 OKX:如 SWAP | 需重启 | +| 账户备注 | 企业微信推送中显示的交易所备注 | 保存即生效 | + +**本卡片不包含**:网页登录账号密码、是否关闭登录校验、中控通信密钥。 + +--- + +### 4.2 企业微信 + +| 中文名 | 说明 | +|--------|------| +| 机器人 Webhook | 行情、风控、提醒推送地址 | +| 推送超时(秒) | 可选,默认 10 | + +--- + +### 4.3 AI 复盘 + +| 中文名 | 说明 | +|--------|------| +| AI 提供方 | 云端 OpenAI 兼容 / 本地 Ollama | +| API 地址 | OpenAI 兼容 base URL | +| API 密钥 | 敏感,留空表示不修改 | +| 模型名称 | 如 gemma、deepseek 等 | +| Ollama 地址 | 本地服务 URL | +| Ollama 模型 | 本地模型名 | +| 请求超时(秒) | 默认 120 | + +--- + +### 4.4 交易执行 + +| 中文名 | 说明 | +|--------|------| +| 计仓模式 | 以损定仓 / 全仓杠杆 | +| 以损定仓风险% | 单笔风险占资金比例 | +| 全仓资金缓冲比例 | 全仓模式下可用资金折扣 | +| BTC 默认杠杆 | | +| 山寨默认杠杆 | | +| 方向限制开关 | | +| 允许方向 | 多 / 空 / 双向 | +| 币种白名单开关 | | +| 白名单币种 | 逗号分隔 | +| 交易日切点(北京时间) | 默认 8 点 | +| 切点前禁止新开仓 | | +| 最大同时持仓 | | +| 人工最低盈亏比 | | +| 强制清仓开关 | | +| 强制清仓整点(北京) | | + +--- + +### 4.5 交易风控(日内开仓) + +| 中文名 | 说明 | +|--------|------| +| 单日开仓提醒阈值 | 达到次数后 AI 克制提醒(不拦单) | +| 单日开仓硬上限 | 0 表示不启用;达到后禁止新开仓 | + +详见 [daily-open-limit.md](./daily-open-limit.md)。 + +--- + +### 4.6 账户冷静期 + +| 中文名 | 说明 | +|--------|------| +| 冷静期总开关 | | +| 手动平仓冷静(小时) | | +| 复盘情绪冷静(小时) | | +| 日手动平仓次数上限 | | +| 情绪标签日冻结 | | + +详见 [account-risk-cooldown.md](./account-risk-cooldown.md)。 + +--- + +### 4.7 自动划转 + +| 中文名 | 说明 | +|--------|------| +| 启用自动划转 | | +| 目标余额(U) | 交易账户目标 USDT | +| 划出账户 | funding / swap | +| 划入账户 | swap / funding | +| 执行整点(北京时间) | | +| 划转币种 | 默认 USDT | + +详见 [auto-transfer-daily.md](./auto-transfer-daily.md)。 + +--- + +### 4.8 当日资金 + +| 中文名 | 说明 | +|--------|------| +| 日起始基数(U) | | +| 回撤后基数(U) | | +| 盈利后基数(U) | | + +与自动划转目标余额相互独立;若需一致请手动对齐。 + +--- + +### 4.9 期权账户(仅 OKX) + +| 中文名 | 说明 | +|--------|------| +| 启用期权模块 | | +| 期权 API Key / Secret / Passphrase | 主账户,与永续子账户分离 | +| 期权账户备注 | | +| 单笔预算(USDC) | | +| 预算缓冲比例 | | +| 默认标的 | 如 ETH | +| 最大到期天数 | 等常用策略参数 | + +高级参数与完整说明见 [期权方案.md](./期权方案.md)、[期权用法.md](./期权用法.md)。 + +--- + +## 5. 操作说明 + +1. 修改后点 **保存**:即时生效项立即应用;需重启项写入 `.env` 但未重启进程。 +2. 含需重启项时点 **保存并重启**:写 `.env` 后 PM2 重启当前实例。 +3. **重新加载**:从磁盘重新读取 `.env` 刷新表单(放弃未保存修改)。 +4. 敏感项(API、密钥)显示为掩码;**留空提交表示不修改原值**。 + +--- + +## 6. 隐藏项(本页不展示) + +以下仍存在于 `.env`,仅供运维或 SSH 修改: + +- 服务:`APP_HOST`、`APP_PORT`、`APP_DEBUG` +- 数据:`DB_PATH`、`UPLOAD_DIR` +- 关键位门控:全部 `KEY_*`、`KLINE_*` +- 轮询与同步:`BALANCE_REFRESH_SECONDS`、`PRICE_REFRESH_SECONDS`、`MONITOR_POLL_SECONDS`、`BREAKEVEN_*`、`RECONCILE_*` +- 代理:`OKX_SOCKS_PROXY`、`BINANCE_HTTP_PROXY` 等 +- 备份:`BACKUP_*` +- 中控嵌入细节:`APP_ALLOW_HUB_EMBED`、`HUB_EMBED_*`、`APP_COOKIE_SECURE` +- 登录相关:`APP_AUTH_DISABLED`、`APP_USERNAME`、`APP_PASSWORD`、`FLASK_SECRET_KEY`、`HUB_BRIDGE_TOKEN` + +后续若需要可增加「高级模式」折叠区,默认关闭。 + +--- + +## 7. 与系统设置的关系 + +| 能力 | env 配置 | 系统设置 | +|------|----------|----------| +| 登录用户名/密码 | ❌ | ✅ 账户密码修改 | +| 交易所 API | ✅ | ❌ | +| 导航/区块显示 | ❌ | ✅ 导航显示 | +| 手动资金划转 | ❌ | ✅ 永续资金划转 | +| 数据导出 | ❌ | ✅ 数据导出 | +| 期权兑换/划转 UI | ❌ | ✅(OKX,可开关) | + +系统设置说明见 [系统设置说明.md](./系统设置说明.md)。 + +--- + +## 8. 实现备注(开发用) + +- 白名单分组:`lib/env/env_ui_manifest.py`(按 `exchange_key` 过滤) +- 中文标签:`ENV_UI_LABELS` 映射,模板只渲染 `label` / `note` +- 全量校验仍基于 `.env.example`;POST 仅接受 manifest 内 key diff --git a/docs/系统设置说明.md b/docs/系统设置说明.md new file mode 100644 index 0000000..68ab9db --- /dev/null +++ b/docs/系统设置说明.md @@ -0,0 +1,166 @@ +# 系统设置页说明 + +本文档描述各交易实例 Web 端 **「系统设置」** 页各区块功能、与 env 配置页的分工,以及导航显示开关规则。 + +--- + +## 1. 页面结构 + +系统设置为 **两列卡片** 布局,各区块可在「导航显示」中单独开关(见第 2 节)。 + +| 区块 | 默认显示 | 说明 | +|------|----------|------| +| 导航显示 | 固定 | 控制顶栏与其它设置区块是否出现 | +| 账户密码修改 | 可关 | 修改网页登录用户名/密码 | +| 永续资金划转 | 可关 | 手动在资金账户与交易账户间划转 USDT | +| 数据导出 | 可关 | 下载 CSV | +| 币种兑换 | 可关 | 仅 OKX 期权相关 | +| 期权资金划转 | 可关 | 仅 OKX | +| 期权设置面板 | OKX 有模块时 | 较大块,占整行 | + +**固定不可隐藏**(顶栏):关键位监控、实盘下单、系统设置。 + +--- + +## 2. 导航显示 + +### 2.1 顶栏导航开关 + +| 开关 | 对应 Tab | +|------|----------| +| 策略交易 | 策略交易 | +| 策略交易记录 | 策略交易记录 | +| 交易记录与复盘 | 交易记录与复盘 | +| 统计分析 | 统计分析 | +| 风控说明 | 风控说明 | +| env 配置 | env 配置 | +| 期权 | 期权(仅 OKX 等有期权模块时有效) | + +保存后 **立即生效**,无需重启。中控 iframe 内嵌导航同步生效。 + +### 2.2 系统设置内区块开关 + +| 开关 | 隐藏内容 | +|------|----------| +| 资金划转 | 永续资金划转卡片 | +| 数据导出 | 数据导出卡片 | +| 账户密码修改 | 改密卡片 | +| 期权币种兑换 | OKX 兑换卡片 | +| 期权资金划转 | OKX 期权划转卡片 | + +--- + +## 3. 账户密码修改 + +### 用途 + +- 修改 **直链打开实例** 时 `/login` 使用的用户名与密码。 +- 写入本实例目录 `.env` 的 `APP_USERNAME`、`APP_PASSWORD`。 +- **三所建议使用相同账号**,便于记忆;本页仅改 **当前实例** 的 `.env`,若需三所一致请分别保存或后续做批量同步。 + +### 与中控 / 密钥的关系 + +| 项目 | 是否在系统设置改 | 说明 | +|------|------------------|------| +| 网页登录密码 | ✅ | 本区块 | +| 中控通信密钥 `HUB_BRIDGE_TOKEN` | ❌ | 部署时自动生成,中控与实例一致 | +| 登录会话密钥 `FLASK_SECRET_KEY` | ❌ | 部署时自动生成,三所相同 | +| 交易所 API | ❌ | 在 **env 配置** 页 | + +### 操作流程 + +1. 输入 **当前密码**(与 `.env` 中 `APP_PASSWORD` 一致)。 +2. 可选填 **新用户名**;不填则保持原用户名。 +3. 输入 **新密码** 与 **确认密码**(至少 6 位)。 +4. 保存后 **自动重启当前实例**(PM2),请用新密码登录。 + +经中控 SSO 打开实例时,通常无需输入实例密码;改密主要影响 **直链访问**。 + +--- + +## 4. 永续资金划转 + +### 用途 + +在 **子账户永续** 场景下,于 **资金账户(funding)** 与 **交易账户(swap)** 之间手动划转 USDT。 + +### 与 env 配置的关系 + +| 能力 | 系统设置 | env 配置 | +|------|----------|----------| +| **手动**划转一笔 | ✅ 本区块 | ❌ | +| **自动**每日划转规则 | ❌ | ✅「自动划转」卡片 | + +自动划转规则(开关、目标余额、整点等)在 env 配置中维护,见 [env配置说明.md](./env配置说明.md)。 + +--- + +## 5. 数据导出 + +提供 CSV 下载(版本号见页内标注): + +| 链接 | 内容 | +|------|------| +| 交易记录 | 成交/订单相关导出 | +| 复盘记录 | 复盘日记 | +| 关键位(当前) | 当前关键位列表 | +| 关键位历史 | 历史关键位 | + +导出为只读操作,不修改配置。 + +--- + +## 6. 期权相关(仅 OKX) + +当实例启用期权模块时,系统设置可能包含: + +- **币种兑换**:期权账户内币种兑换操作 +- **期权资金划转**:期权与永续/资金账户间划转 +- **期权设置面板**:页内期权参数与状态(大块区域) + +是否在顶栏显示「期权」Tab,由 **导航显示 → 期权** 控制;是否在设置页显示兑换/划转卡片,由对应子开关控制。 + +期权 env 参数(API、预算、策略默认值)在 **env 配置 → 期权账户** 维护,见 [期权用法.md](./期权用法.md)。 + +--- + +## 7. 顶栏与设置页差异 + +| 页面 | 顶栏资金信息 | 说明 | +|------|--------------|------| +| 关键位、实盘、策略等 | 显示 | 含资金、盈亏等 | +| 系统设置、风控说明、env 配置 | 隐藏资金条 | 与实盘顶栏共用组件,设置类页面简化展示 | + +主题切换(明/暗)在系统设置页可用(若已接入主题切换 UI)。 + +--- + +## 8. 权限与安全 + +- 所有设置 API 需 **已登录**(或部署时 `APP_AUTH_DISABLED=true` 的联调环境)。 +- 改密、env 保存、PM2 重启等写操作 **不接受** 仅带 `X-Hub-Token` 的中控请求修改(防止中控误改实例配置)。 +- 生产环境建议 `APP_AUTH_DISABLED=false`,公网务必开启登录校验。 + +--- + +## 9. 首次部署时的账号与密钥(规划) + +以下由 **部署脚本** 自动完成,**不在** 系统设置或 env 配置页手工填写: + +1. **生成 `HUB_BRIDGE_TOKEN`** → 写入中控 + 三实例 `.env`(相同)。 +2. **生成 `FLASK_SECRET_KEY`** → 写入三实例 `.env`(三所相同)。 +3. **生成初始 `APP_USERNAME=admin`、`APP_PASSWORD=admin123`** → 写入三实例(仅当尚未配置时);用户日后在 **系统设置** 改密。 + +脚本应对 **已有非空值** 跳过写入,避免覆盖生产环境。 + +--- + +## 10. 相关文档 + +| 文档 | 内容 | +|------|------| +| [env配置说明.md](./env配置说明.md) | env 配置页字段与密钥分工 | +| [account-risk-cooldown.md](./account-risk-cooldown.md) | 账户冷静期规则 | +| [auto-transfer-daily.md](./auto-transfer-daily.md) | 自动划转 | +| [daily-open-limit.md](./daily-open-limit.md) | 单日开仓限制 | +| [manual_trading_hub/使用说明.md](../manual_trading_hub/使用说明.md) | 中控与 SSO | diff --git a/lib/common/static/instance_settings_prefs.js b/lib/common/static/instance_settings_prefs.js index 96d593c..358afa1 100644 --- a/lib/common/static/instance_settings_prefs.js +++ b/lib/common/static/instance_settings_prefs.js @@ -145,7 +145,7 @@ card.className = "env-field-card"; const label = document.createElement("label"); label.className = "env-field-label"; - label.textContent = field.key; + label.textContent = field.label || field.key; card.appendChild(label); if (field.note) { const note = document.createElement("div"); @@ -156,10 +156,13 @@ let input; if (field.type === "bool") { input = document.createElement("select"); - ["true", "false"].forEach((v) => { + [ + ["true", "开启"], + ["false", "关闭"], + ].forEach(([v, text]) => { const o = document.createElement("option"); o.value = v; - o.textContent = v; + o.textContent = text; input.appendChild(o); }); const cur = (field.current || field.default || "false").toLowerCase(); diff --git a/lib/env/env_ui_manifest.py b/lib/env/env_ui_manifest.py new file mode 100644 index 0000000..a30e6eb --- /dev/null +++ b/lib/env/env_ui_manifest.py @@ -0,0 +1,245 @@ +"""env 配置页 UI 白名单:中文标签、按交易所过滤。""" +from __future__ import annotations + +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": "AI 复盘", + "fields": [ + ("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"), + ], + }, + { + "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"), + ], +} + + +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 {} + default_val = meta.get("default") or values.get(key) or "" + val = values.get(key) + if val is None: + val = default_val + masked = _mask_value(key, val) + ftype = meta.get("type") or _field_type(key, default_val) + return { + "key": key, + "label": label, + "note": note or meta.get("note") or "", + "default": 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"], + "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) + 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}) + 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) diff --git a/lib/instance/instance_settings_lib.py b/lib/instance/instance_settings_lib.py index 9024171..5e15e48 100644 --- a/lib/instance/instance_settings_lib.py +++ b/lib/instance/instance_settings_lib.py @@ -189,9 +189,10 @@ def settings_page_context(page: str, *, instance_base_dir: str | None = None, ** return {} ctx: dict[str, Any] = {"instance_settings": build_instance_settings_view(**kwargs)} if p == "env_config" and instance_base_dir: - from lib.env.env_schema import build_env_payload + from lib.env.env_ui_manifest import build_env_ui_payload + exchange_key = str(kwargs.get("exchange_key") or "") env_path = os.path.join(instance_base_dir, ".env") example_path = os.path.join(instance_base_dir, ".env.example") - ctx["env_config_groups"] = build_env_payload(example_path, env_path).get("groups") or [] + ctx["env_config_groups"] = build_env_ui_payload(exchange_key, example_path, env_path) return ctx diff --git a/lib/instance/instance_settings_register.py b/lib/instance/instance_settings_register.py index 042c7ba..0e423a2 100644 --- a/lib/instance/instance_settings_register.py +++ b/lib/instance/instance_settings_register.py @@ -8,12 +8,12 @@ from typing import Any, Callable from flask import jsonify, request, session from lib.env.env_file_lib import apply_env_updates, env_get, read_env_lines -from lib.env.env_schema import ( - build_env_payload, - parse_env_example_schema, - updates_need_restart, - validate_env_updates, +from lib.env.env_ui_manifest import ( + build_env_ui_payload, + filter_updates_for_ui, + validate_env_ui_updates, ) +from lib.env.env_schema import parse_env_example_schema from lib.instance.instance_display_prefs_lib import ( display_meta_for_ui, get_display_prefs, @@ -85,26 +85,27 @@ def register_instance_settings_routes( @app.route("/api/settings/env/meta", methods=["GET"]) @api_auth def api_env_meta(): - payload = build_env_payload(example_path, env_path) - return jsonify({"ok": True, **payload}) + groups = build_env_ui_payload(exchange_key, example_path, env_path) + return jsonify({"ok": True, "groups": groups}) @app.route("/api/settings/env", methods=["GET", "POST"]) @api_auth def api_settings_env(): if request.method == "GET": - payload = build_env_payload(example_path, env_path) - return jsonify({"ok": True, **payload}) + groups = build_env_ui_payload(exchange_key, example_path, env_path) + return jsonify({"ok": True, "groups": groups}) body = request.get_json(silent=True) or {} updates = body.get("values") if isinstance(body.get("values"), dict) else body if not isinstance(updates, dict): return jsonify({"ok": False, "msg": "无效请求体"}), 400 - groups = parse_env_example_schema(example_path) - clean, errors = validate_env_updates(groups, updates) + updates = filter_updates_for_ui(exchange_key, updates) + clean, errors = validate_env_ui_updates(exchange_key, example_path, updates) if errors: return jsonify({"ok": False, "msg": "; ".join(errors)}), 400 if not clean: return jsonify({"ok": True, "changed_keys": [], "restart_required": False}) 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) return jsonify( { diff --git a/lib/instance/templates/embed_shell.html b/lib/instance/templates/embed_shell.html index 73c982f..2122892 100644 --- a/lib/instance/templates/embed_shell.html +++ b/lib/instance/templates/embed_shell.html @@ -103,7 +103,7 @@ const ORDER_ENTRY_MODEL_CODE_TO_CATEGORY = {{ entry_model_code_to_category | toj - + diff --git a/lib/instance/templates/env_config_panel.html b/lib/instance/templates/env_config_panel.html index 0b7c017..96db571 100644 --- a/lib/instance/templates/env_config_panel.html +++ b/lib/instance/templates/env_config_panel.html @@ -1,8 +1,8 @@ -{# env配置:按功能分组,三列卡片布局(SSR 预渲染) #} +{# env配置:白名单分组,仅中文标签 #}

env 配置

-

读取并编辑本实例 .env。标注「保存即生效」的项会立即应用;标注「需重启」的项保存后请点「保存并重启」。

+

仅展示运营相关配置。标注「保存即生效」的项会立即应用;标注「需重启」的项保存后请点「保存并重启」。完整说明见项目文档 docs/env配置说明.md

@@ -18,15 +18,15 @@
{% for field in group.fields %}
- + {% if field.note %}
{{ field.note }}
{% endif %} {% if field.type == 'bool' %} {% else %} window.__INSTANCE_DISPLAY__ = {{ display | tojson }}; - + \ No newline at end of file diff --git a/lib/instance/templates/settings_panel.html b/lib/instance/templates/settings_panel.html index ac68f20..49f222d 100644 --- a/lib/instance/templates/settings_panel.html +++ b/lib/instance/templates/settings_panel.html @@ -1,5 +1,6 @@ {# 系统设置:2 列独立卡片 #}
+

各区块说明见 docs/系统设置说明.md

{% include 'display_prefs_panel.html' %} diff --git a/scripts/bootstrap_deploy_secrets.py b/scripts/bootstrap_deploy_secrets.py new file mode 100644 index 0000000..83593c3 --- /dev/null +++ b/scripts/bootstrap_deploy_secrets.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""首次部署:自动生成中控通信密钥、登录会话密钥,并写入初始登录账号。 + +- HUB_BRIDGE_TOKEN:中控 + 三实例(相同) +- FLASK_SECRET_KEY:三实例(相同) +- APP_USERNAME=admin、APP_PASSWORD=admin123:仅当 .env 中尚未设置密码时写入 + +已有非空且非占位符的值不会被覆盖。 +""" +from __future__ import annotations + +import argparse +import os +import secrets +import sys + +_REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _REPO not in sys.path: + sys.path.insert(0, _REPO) + +from lib.env.env_file_lib import apply_env_updates, env_get, read_env_lines + +INSTANCE_DIRS = ( + ("okx", os.path.join(_REPO, "crypto_monitor_okx")), + ("binance", os.path.join(_REPO, "crypto_monitor_binance")), + ("gate", os.path.join(_REPO, "crypto_monitor_gate")), +) +HUB_DIR = os.path.join(_REPO, "manual_trading_hub") + +FLASK_PLACEHOLDERS = frozenset( + {"", "CHANGE_TO_LONG_RANDOM_SECRET", "crypto_monitor_2026_secret_key"} +) +HUB_PLACEHOLDERS = frozenset({"", "your-long-random-token"}) + + +def _env_path(base: str) -> str: + return os.path.join(base, ".env") + + +def _should_set(current: str | None, placeholders: frozenset[str]) -> bool: + val = (current or "").strip() + return val in placeholders + + +def main() -> int: + parser = argparse.ArgumentParser(description="Bootstrap deploy secrets") + parser.add_argument("--dry-run", action="store_true", help="只打印将写入的项,不改文件") + args = parser.parse_args() + + hub_token = secrets.token_urlsafe(32) + flask_secret = secrets.token_urlsafe(48) + planned: list[tuple[str, dict[str, str]]] = [] + + # 中控 HUB_BRIDGE_TOKEN + hub_env = _env_path(HUB_DIR) + if os.path.isfile(hub_env): + hub_lines = read_env_lines(hub_env) + if _should_set(env_get(hub_lines, "HUB_BRIDGE_TOKEN"), HUB_PLACEHOLDERS): + planned.append((hub_env, {"HUB_BRIDGE_TOKEN": hub_token})) + + # 三实例 + for _name, inst_dir in INSTANCE_DIRS: + path = _env_path(inst_dir) + if not os.path.isfile(path): + continue + lines = read_env_lines(path) + updates: dict[str, str] = {} + if _should_set(env_get(lines, "HUB_BRIDGE_TOKEN"), HUB_PLACEHOLDERS): + updates["HUB_BRIDGE_TOKEN"] = hub_token + if _should_set(env_get(lines, "FLASK_SECRET_KEY"), FLASK_PLACEHOLDERS): + updates["FLASK_SECRET_KEY"] = flask_secret + if not (env_get(lines, "APP_USERNAME") or "").strip(): + updates["APP_USERNAME"] = "admin" + if _should_set(env_get(lines, "APP_PASSWORD"), frozenset({""})): + updates["APP_PASSWORD"] = "admin123" + if updates: + planned.append((path, updates)) + + if not planned: + print("无需写入:密钥与登录项均已配置。") + return 0 + + for path, updates in planned: + rel = os.path.relpath(path, _REPO) + keys = ", ".join(sorted(updates.keys())) + if args.dry_run: + print(f"[dry-run] {rel}: {keys}") + continue + apply_env_updates(path, updates) + print(f"已写入 {rel}: {keys}") + + if not args.dry_run: + print("完成。初始登录:admin / admin123(若本次写入了 APP_PASSWORD)。") + print("请 pm2 restart 中控与三实例使 FLASK_SECRET_KEY / HUB_BRIDGE_TOKEN 生效。") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())