Normalize fullwidth punctuation to ASCII across codebase.

Add scripts/normalize_ambiguous_unicode.py; fix corrupted patch_instance_theme_templates.py. Preserves curly quotes in string literals; removes Git homoglyph warnings on .env.example.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-08 23:42:26 +08:00
parent aaa72c7961
commit b733e551a0
392 changed files with 71522 additions and 71369 deletions
+40 -40
View File
@@ -1,91 +1,91 @@
# =============================================================================
# 中控 hub.py / 子代理 agent.py 环境变量模板可提交 Git
# 使用cp .env.example .env 后填入真实值启动前由 shell export 或 dotenv 加载
# 云服务器完整说明见 云服务器部署说明.md
# 中控 hub.py / 子代理 agent.py 环境变量模板(可提交 Git)
# 使用:cp .env.example .env 后填入真实值;启动前由 shell export 或 dotenv 加载
# 云服务器完整说明:见 云服务器部署说明.md
# =============================================================================
# hub.py 监听
HUB_HOST=0.0.0.0
HUB_PORT=5100
# 仅本机访问可改为 127.0.0.1并设 HUB_TRUST_LAN=false
# 仅本机访问可改为 127.0.0.1,并设 HUB_TRUST_LAN=false
# 与三实例 .env 中 HUB_BRIDGE_TOKEN 相同的长随机串
# 中控 → 各 Flask请求头 X-Hub-Token
# 中控 → 各子代理请求头 X-Control-Token与 HUB_BRIDGE_TOKEN 同值agent 优先读 HUB_BRIDGE_TOKEN
# 中控「打开实例」SSO 链接也复用此令牌签名默认 2 小时内有效单次使用
# 中控 → 各 Flask:请求头 X-Hub-Token
# 中控 → 各子代理:请求头 X-Control-Token(与 HUB_BRIDGE_TOKEN 同值;agent 优先读 HUB_BRIDGE_TOKEN)
# 中控「打开实例」SSO 链接也复用此令牌签名(默认 2 小时内有效,单次使用)
# HUB_BRIDGE_TOKEN=your-long-random-token
# HUB_SSO_TTL_SEC=7200
# 逗号分隔的账户 id强制关闭不参与监控/全局全平设置页对应行勾选框灰掉
# 留空 = 不强制关闭仅不想用 OKX 时可设 HUB_DISABLED_IDS=1
# 逗号分隔的账户 id,强制关闭(不参与监控/全局全平;设置页对应行勾选框灰掉)
# 留空 = 不强制关闭;仅不想用 OKX 时可设 HUB_DISABLED_IDS=1
HUB_DISABLED_IDS=
# true=允许 RFC1918 私网访问中控页面false=仅 127.0.0.1反代须指向 127.0.0.1:5100
# true=允许 RFC1918 私网访问中控页面;false=仅 127.0.0.1(反代须指向 127.0.0.1:5100)
HUB_TRUST_LAN=true
# 云服务器用域名/HTTPS 反代访问中控时设为 true否则公网可能看到 {"detail":"forbidden"}
# 云服务器用域名/HTTPS 反代访问中控时设为 true(否则公网可能看到 {"detail":"forbidden"})
# HUB_ALLOW_PUBLIC=true
# 中控 Web 登录默认 admin / admin123生产环境请在 .env 中修改
# 中控 Web 登录(默认 admin / admin123;生产环境请在 .env 中修改)
HUB_USERNAME=admin
HUB_PASSWORD=admin123
# 会话签名密钥建议单独随机串未设则用用户名+密码拼接
# 会话签名密钥(建议单独随机串;未设则用用户名+密码拼接)
# HUB_SESSION_SECRET=another-long-random-string
# HTTPS 反代时建议 true仅 HTTPS 访问会带 Secure Cookiehttp://内网IP:5100 仍可登录
# HTTPS 反代时建议 true:仅 HTTPS 访问会带 Secure Cookie;http://内网IP:5100 仍可登录
# HUB_COOKIE_SECURE=true
# 登录保持天数默认 7
# 登录保持天数(默认 7)
# HUB_SESSION_DAYS=7
# 本地导航 / 门户 iframe 嵌入中控默认 true
# 本地导航 / 门户 iframe 嵌入中控(默认 true)
# HUB_ALLOW_EMBED=true
# 限制可嵌入的父页来源逗号分隔);默认 * 不限制
# 限制可嵌入的父页来源(逗号分隔);默认 * 不限制
# HUB_EMBED_ORIGINS=http://192.168.8.6:5070,https://hub.example.com
# 三实例允许被中控 iframe 内嵌各 crypto_monitor_*/.env与 hub 同步部署
# 三实例允许被中控 iframe 内嵌(各 crypto_monitor_*/.env,与 hub 同步部署)
# APP_ALLOW_HUB_EMBED=true
# HUB_EMBED_PARENT_ORIGINS=https://hub.example.com
# HTTPS 跨子域 iframe 时三实例还须 APP_COOKIE_SECURE=true见 crypto_monitor_*/.env.example
# HTTPS 跨子域 iframe 时三实例还须 APP_COOKIE_SECURE=true(见 crypto_monitor_*/.env.example)
# 浏览器打开的实例/复盘链接hub_settings 里 flask_url 为 127.0.0.1 时替换为对外地址
# 局域网填内网 IP见《局域网与反代部署说明.md》
# 浏览器打开的实例/复盘链接(hub_settings 里 flask_url 为 127.0.0.1 时替换为对外地址)
# 局域网:填内网 IP,见《局域网与反代部署说明.md》
# HUB_PUBLIC_ORIGIN=http://192.168.1.100
# 反代各实例 flask_url 建议直接写 https 域名可不设此项
# 反代:各实例 flask_url 建议直接写 https 域名,可不设此项
# HUB_PUBLIC_HOST=192.168.1.100
# HUB_PUBLIC_SCHEME=http
# 三实例网页登录直链反代/IP:端口 访问时输入中控点「打开实例」免输
# 各 crypto_monitor_*/.env 统一APP_USERNAME=... APP_PASSWORD=...
# 三实例网页登录(直链反代/IP:端口 访问时输入;中控点「打开实例」免输)
# 各 crypto_monitor_*/.env 统一:APP_USERNAME=... APP_PASSWORD=...
# 监控区hub 后台每 N 秒聚合一次浏览器经 SSE 收版本号再拉快照默认 5 秒
# 监控区:hub 后台每 N 秒聚合一次,浏览器经 SSE 收版本号再拉快照(默认 5 秒)
# HUB_BOARD_POLL_INTERVAL=5
# 单次聚合超时(秒,默认 agent 8 / flask 10 / board 45
# 单次聚合超时(秒,默认 agent 8 / flask 10 / board 45)
# HUB_AGENT_TIMEOUT=8
# HUB_FLASK_TIMEOUT=10
# HUB_BOARD_TIMEOUT=45
# 为 false 时不拉各实例 /api/price_snapshot关键位门控简化为「-」首屏明显更快
# 为 false 时不拉各实例 /api/price_snapshot(关键位门控简化为「-」,首屏明显更快)
# HUB_BOARD_KEY_PRICES=true
# ---------- 行情区 K 线库data/hub_kline.db默认保留 15 天----------
# ---------- 行情区 K 线库(data/hub_kline.db,默认保留 15 天)----------
# HUB_KLINE_RETENTION_DAYS=15
# HUB_KLINE_DB_PATH=/opt/crypto_monitor/manual_trading_hub/data/hub_kline.db
# 行情区后台轮询 + SSE对齐监控区 board
# 行情区后台轮询 + SSE(对齐监控区 board)
# HUB_CHART_POLL_INTERVAL=5
# HUB_CHART_POSITION_TIMEFRAME=5m
# HUB_CHART_WATCH_TTL_SEC=45
# HUB_CHART_MAX_SERIES_PER_TICK=24
# --- 子代理 agent.py在 crypto_monitor_* 目录启动时另设 EXCHANGE / PORT---
# 与 HUB_BRIDGE_TOKEN 一致时可只设其一agent 校验请求头 X-Control-Token
# --- 子代理 agent.py(在 crypto_monitor_* 目录启动时另设 EXCHANGE / PORT)---
# 与 HUB_BRIDGE_TOKEN 一致时可只设其一;agent 校验请求头 X-Control-Token
# CONTROL_TOKEN=your-long-random-token
# EXCHANGE=binance
# PORT=15200
# HOST=127.0.0.1
# ---------- 中控 AI 教练/ai模块 hub_ai/存 hub_ai_*.json----------
# 与三实例相同变量名默认 OpenAI 兼容网关改 AI_PROVIDER=ollama 可走本机 Ollama
# ---------- 中控 AI 教练(/ai,模块 hub_ai/,存 hub_ai_*.json)----------
# 与三实例相同变量名;默认 OpenAI 兼容网关(改 AI_PROVIDER=ollama 可走本机 Ollama)
# 详见 manual_trading_hub/AI教练说明.md 与仓库根 AI复盘与模型配置说明.md
AI_TIMEOUT_SECONDS=120
# AI 教练聊天默认输出 8192 token续写 4 次快照约 2 万字符历史单条 1500 字
# AI 教练聊天(默认:输出 8192 token,续写 4 次,快照约 2 万字符,历史单条 1500 字)
# CHAT_MAX_OUTPUT_TOKENS=8192
# CHAT_MAX_CONTINUATIONS=4
# CHAT_CONTEXT_MAX_CHARS=20000
@@ -93,20 +93,20 @@ AI_TIMEOUT_SECONDS=120
# CHAT_HISTORY_MAX_CHARS_PER_MSG=1500
# CHAT_AI_TIMEOUT_SECONDS=300
# AI 提供方openai默认OpenAI 兼容网关| ollama本机 Ollama
# AI 提供方:openai(默认,OpenAI 兼容网关)| ollama(本机 Ollama)
AI_PROVIDER=openai
OPENAI_API_BASE=https://op.bz121.com/v1
OPENAI_API_KEY=你的密钥
OPENAI_MODEL=gemma4:e4b
# 本机 OllamaAI_PROVIDER=ollama 时使用
# 本机 Ollama(AI_PROVIDER=ollama 时使用)
OLLAMA_API=http://127.0.0.1:11434/api/generate
AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest
# 交易日切分与三实例 TRADING_DAY_RESET_HOUR 一致定义「今日总结」的日期
# 交易日切分(与三实例 TRADING_DAY_RESET_HOUR 一致,定义「今日总结」的日期)
TRADING_DAY_RESET_HOUR=8
# 资金概况 / AI 上下文分户资金快照保留交易日数默认 180
# 资金概况 / AI 上下文:分户资金快照保留交易日数(默认 180)
# HUB_FUND_HISTORY_DAYS=180
# 自动备份系统设置 → 备份与恢复也可设 HUB_BACKUP_ROOT
# 自动备份(系统设置 → 备份与恢复;也可设 HUB_BACKUP_ROOT)
# HUB_BACKUP_ROOT=/root/backups/crypto_monitor_portal
# 资金概况曲线与回撤统计起始交易日
# 资金概况:曲线与回撤统计起始交易日
HUB_FUND_HISTORY_START_DAY=2026-06-09
+66 -66
View File
@@ -1,66 +1,66 @@
# 中控 AI 教练说明
中控 **AI 教练**`/ai`与三实例 `/records` 里的 **AI 复盘** 分离模块在 `manual_trading_hub/hub_ai/`数据存同目录 JSON
## 能力
| 功能 | 说明 |
|------|------|
| **交易教练** | 口语化陪聊注入三户监控快照与今日总结摘要后台自动生成不在页面展示 |
| **普通聊天** | 不绑交易数据适合闲聊答疑 |
| **交易监管** | 今日长会话手动/中控开平仓与新开仓自动推送 + 企业微信 + 可回聊见 [交易监管说明.md](./交易监管说明.md) |
| **会话历史** | 右侧列表切换删除消息一键复制 |
页面保留 **交易教练 / 普通聊天 / 交易监管** 与聊天区**今日总结** 已移至 **数据看板**`/dashboard`纯数据展示不再在 AI 页生成
## 存储
`hub_settings.json` 同目录`manual_trading_hub/`):
- `hub_ai_summaries.json` — 历史总结供交易教练上下文可选 API 仍保留
- `hub_ai_chat.json` — 聊天会话`active_session_id`多会话`bot_mode`
升级 / 迁移时请一并备份见 [本地数据迁移到云端.md](./本地数据迁移到云端.md))。
## 模型配置
**`manual_trading_hub/.env`** 配置**变量名与三实例完全相同**中控 `hub_ai/client.py` 共用仓库根 `ai_client.py`**默认也是 OpenAI 兼容网关**`AI_PROVIDER=openai`),与你在三所 `.env` 里配的那套一致即可
**推荐与三实例默认一致):**
```env
AI_PROVIDER=openai
OPENAI_API_BASE=https://op.bz121.com/v1
OPENAI_API_KEY=你的密钥
OPENAI_MODEL=gemma4:e4b
# 本机 Ollama 备用仅当 AI_PROVIDER=ollama 时生效
OLLAMA_API=http://127.0.0.1:11434/api/generate
AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest
```
改走本机无限制模型时`AI_PROVIDER=ollama`并填好 `OLLAMA_API` / `AI_MODEL``OPENAI_*` 可保留不动
总结与聊天使用**同一模型**同一套 `OPENAI_MODEL``AI_MODEL`);总结 temperature≈0.15聊天≈0.5
可选`TRADING_DAY_RESET_HOUR=8`与实例一致定义「今日」交易日)。
## 依赖接口
中控通过 HTTP 拉取各实例
- `GET /api/hub/monitor`已有
- `GET /api/hub/trades/today?trading_day=YYYY-MM-DD``hub_bridge` 注册需三实例更新代码并重启
子代理 `GET /status` 提供持仓与余额
## 与实例 AI 复盘的分工
| | 中控 AI 教练 | 实例 AI 复盘 |
|--|-------------|-------------|
| 入口 | `/ai` | 各所 `/records` |
| 数据 | 三户聚合 | 单户 `journal_entries` |
| 语气 | 聊天搭档 | 结构化教练报告 |
| 代码 | `hub_ai/*` | `ai_review_lib` + 各 `app.py` |
详见仓库根 [AI复盘与模型配置说明.md](../AI复盘与模型配置说明.md)实例侧)。
# 中控 AI 教练说明
中控 **AI 教练**(`/ai`)与三实例 `/records` 里的 **AI 复盘** 分离:模块在 `manual_trading_hub/hub_ai/`,数据存同目录 JSON.
## 能力
| 功能 | 说明 |
|------|------|
| **交易教练** | 口语化陪聊;注入三户监控快照与今日总结摘要(后台自动生成,不在页面展示) |
| **普通聊天** | 不绑交易数据,适合闲聊,答疑 |
| **交易监管** | 今日长会话;手动/中控开平仓与新开仓自动推送 + 企业微信 + 可回聊(见 [交易监管说明.md](./交易监管说明.md)) |
| **会话历史** | 右侧列表:切换,删除;消息一键复制 |
页面保留 **交易教练 / 普通聊天 / 交易监管** 与聊天区;**今日总结** 已移至 **数据看板**(`/dashboard`)纯数据展示,不再在 AI 页生成.
## 存储
`hub_settings.json` 同目录(`manual_trading_hub/`):
- `hub_ai_summaries.json` — 历史总结(供交易教练上下文,可选 API 仍保留)
- `hub_ai_chat.json` — 聊天会话(`active_session_id`,多会话,`bot_mode`)
升级 / 迁移时请一并备份(见 [本地数据迁移到云端.md](./本地数据迁移到云端.md)).
## 模型配置
**`manual_trading_hub/.env`** 配置,**变量名与三实例完全相同**;中控 `hub_ai/client.py` 共用仓库根 `ai_client.py`,**默认也是 OpenAI 兼容网关**(`AI_PROVIDER=openai`),与你在三所 `.env` 里配的那套一致即可.
**推荐(与三实例默认一致):**
```env
AI_PROVIDER=openai
OPENAI_API_BASE=https://op.bz121.com/v1
OPENAI_API_KEY=你的密钥
OPENAI_MODEL=gemma4:e4b
# 本机 Ollama 备用(仅当 AI_PROVIDER=ollama 时生效)
OLLAMA_API=http://127.0.0.1:11434/api/generate
AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest
```
改走本机无限制模型时,`AI_PROVIDER=ollama`,并填好 `OLLAMA_API` / `AI_MODEL`;`OPENAI_*` 可保留不动.
总结与聊天使用**同一模型**(同一套 `OPENAI_MODEL``AI_MODEL`);总结 temperature≈0.15,聊天≈0.5.
可选:`TRADING_DAY_RESET_HOUR=8`(与实例一致,定义「今日」交易日).
## 依赖接口
中控通过 HTTP 拉取各实例:
- `GET /api/hub/monitor`(已有)
- `GET /api/hub/trades/today?trading_day=YYYY-MM-DD`(`hub_bridge` 注册,需三实例更新代码并重启)
子代理 `GET /status` 提供持仓与余额.
## 与实例 AI 复盘的分工
| | 中控 AI 教练 | 实例 AI 复盘 |
|--|-------------|-------------|
| 入口 | `/ai` | 各所 `/records` |
| 数据 | 三户聚合 | 单户 `journal_entries` |
| 语气 | 聊天搭档 | 结构化教练报告 |
| 代码 | `hub_ai/*` | `ai_review_lib` + 各 `app.py` |
详见仓库根 [AI复盘与模型配置说明.md](../AI复盘与模型配置说明.md)(实例侧).
+105 -105
View File
@@ -1,105 +1,105 @@
# 复盘系统中控manual_trading_hub
> **完整说明**[使用说明.md](./使用说明.md) · **资金概况**[资金概况说明.md](./资金概况说明.md) · **数据看板**[数据看板说明.md](./数据看板说明.md) · **AI 教练**[AI教练说明.md](./AI教练说明.md) · **行情区**[行情区说明.md](./行情区说明.md) · **部署**[部署文档.md](./部署文档.md) · **云服务器**[云服务器部署说明.md](./云服务器部署说明.md) · **本地→云端迁移**[本地数据迁移到云端.md](./本地数据迁移到云端.md) · **局域网/反代**[局域网与反代部署说明.md](./局域网与反代部署说明.md) · **故障**[常见问题.md](./常见问题.md)
多账户 **监控聚合 + 紧急全平****不在中控网页下单**人工下单关键位**策略交易**`/strategy`)、复盘请在各 `crypto_monitor_*` 实例网页操作监控卡片 **「实例」** / **「复盘」**)。**增加子账户**见 [使用说明 §4.3](./使用说明.md#43-增加账户例如再挂一个-gate)
---
## 当前能力
| 能力 | 说明 |
|------|------|
| 监控区 | 持仓余额关键位摘要趋势计划机器人单只读 |
| 资金概况 | 总/分户资金资金户+交易户)、180 日曲线最大回撤 |
| **数据看板** | 三户当日总览/分户/平仓明细SSE 推送`/dashboard`见 [数据看板说明.md](./数据看板说明.md) |
| 行情区 | K 线多周期本地缓存技术指标从监控跳转持仓线 |
| **AI 教练** | 交易教练 + 普通聊天会话历史`/ai`见 [AI教练说明.md](./AI教练说明.md) |
| 紧急全平 | 单户 / 全局市价减仓 |
| 系统设置 | `hub_settings.json` 管理 URL启用**监控关键位 / 监控趋势计划**不控制策略交易页 |
| Web 登录 | `.env``HUB_PASSWORD` 后用户名+密码保护反代公网**务必**配置 |
| ~~下单区~~ | **已移除**避免与实例重复减少故障面 |
---
## 架构
```
浏览器 → hub.py (:5100) 监控 / 资金概况 / **数据看板** / 行情 / **AI 教练** / 设置 / 登录
├→ agent.py × N (:1520015202) 持仓全平
└→ 各 Flask (:5000/5001/5004) /api/hub/monitor 只读聚合
```
- 账户列表**系统设置** 或默认 `settings_store.py`不再使用环境变量 `HUB_AGENTS`)。
- 三实例须注册 **hub_bridge**仓库根 `hub_bridge.py`);PM2 建议 `PYTHONPATH=..`
---
## 快速启动Linux / PM2
```bash
cd /opt/crypto_monitor/manual_trading_hub
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
# 编辑 .envHUB_PASSWORDHUB_BRIDGE_TOKENHUB_PUBLIC_ORIGIN 等
pm2 start ecosystem.config.cjs # 3 agent + hub
pm2 save
bash scripts/verify_hub_deploy.sh
curl -s http://127.0.0.1:5100/api/ping
```
浏览器`http://<本机IP>:5100/monitor`行情 `/market`已设密码则先 `/login`)。
---
## 中控 `.env` 要点
| 变量 | 说明 |
|------|------|
| `HUB_PASSWORD` / `HUB_USERNAME` | 非空密码即启用登录 |
| `HUB_BRIDGE_TOKEN` | 与三实例一致 |
| `HUB_DISABLED_IDS` | 默认 `1` 关闭 OKX |
| `HUB_PUBLIC_ORIGIN` | 其它设备打开复盘/实例外链替换 127.0.0.1 |
| `HUB_COOKIE_SECURE` | HTTPS 反代建议 `true` |
详见 [.env.example](./.env.example)
---
## 子代理agent
每所策略目录单独进程`EXCHANGE` + `PORT`1520015202),密钥来自**该目录 `.env`**PM2 经 `scripts/run_agent.sh` 启动自动 `source .env`去 CRLF)。
| PORT | 目录 |
|------|------|
| 15200 | crypto_monitor_binance |
| 15201 | crypto_monitor_okx |
| 15202 | crypto_monitor_gate |
---
## 运维脚本
| 脚本 | 作用 |
|------|------|
| [scripts/fix_hub_deps.sh](./scripts/fix_hub_deps.sh) | 安装/更新 venv 依赖 |
| [scripts/verify_hub_deploy.sh](./scripts/verify_hub_deploy.sh) | 验收代码版本与 ping |
| [scripts/fix_env_crlf.sh](./scripts/fix_env_crlf.sh) | 修复 .env 的 Windows 换行 |
| [scripts/pm2_hub.sh](./scripts/pm2_hub.sh) | PM2 启停 hub+agent |
| [scripts/后台运行-Ubuntu.md](./scripts/后台运行-Ubuntu.md) | PM2 常驻 |
| [docs/ubuntu-server.md](../docs/ubuntu-server.md) | Ubuntu / Python / Node / PM2 |
---
## 文档索引
| 文档 | 内容 |
|------|------|
| [使用说明.md](./使用说明.md) | 页面API环境变量日常流程 |
| [行情区说明.md](./行情区说明.md) | K 线周期缓存快捷键拉取逻辑 |
| [部署文档.md](./部署文档.md) | UbuntuPM2反代升级 |
| [常见问题.md](./常见问题.md) | 已遇到问题与处理 |
| [.env.example](./.env.example) | 环境变量模板 |
# 复盘系统中控(manual_trading_hub)
> **完整说明**:[使用说明.md](./使用说明.md) · **资金概况**:[资金概况说明.md](./资金概况说明.md) · **数据看板**:[数据看板说明.md](./数据看板说明.md) · **AI 教练**:[AI教练说明.md](./AI教练说明.md) · **行情区**:[行情区说明.md](./行情区说明.md) · **部署**:[部署文档.md](./部署文档.md) · **云服务器**:[云服务器部署说明.md](./云服务器部署说明.md) · **本地→云端迁移**:[本地数据迁移到云端.md](./本地数据迁移到云端.md) · **局域网/反代**:[局域网与反代部署说明.md](./局域网与反代部署说明.md) · **故障**:[常见问题.md](./常见问题.md)
多账户 **监控聚合 + 紧急全平**;**不在中控网页下单**.人工下单,关键位,**策略交易**(`/strategy`),复盘请在各 `crypto_monitor_*` 实例网页操作(监控卡片 **「实例」** / **「复盘」**).**增加子账户**见 [使用说明 §4.3](./使用说明.md#43-增加账户例如再挂一个-gate).
---
## 当前能力
| 能力 | 说明 |
|------|------|
| 监控区 | 持仓,余额,关键位摘要,趋势计划,机器人单(只读) |
| 资金概况 | 总/分户资金(资金户+交易户),180 日曲线,最大回撤 |
| **数据看板** | 三户当日总览/分户/平仓明细,SSE 推送(`/dashboard`;见 [数据看板说明.md](./数据看板说明.md)) |
| 行情区 | K 线(多周期,本地缓存,技术指标,从监控跳转持仓线) |
| **AI 教练** | 交易教练 + 普通聊天,会话历史(`/ai`;见 [AI教练说明.md](./AI教练说明.md)) |
| 紧急全平 | 单户 / 全局市价减仓 |
| 系统设置 | `hub_settings.json` 管理 URL,启用,**监控关键位 / 监控趋势计划**(不控制策略交易页) |
| Web 登录 | `.env``HUB_PASSWORD` 后用户名+密码保护(反代公网**务必**配置) |
| ~~下单区~~ | **已移除**(避免与实例重复,减少故障面) |
---
## 架构
```
浏览器 → hub.py (:5100) 监控 / 资金概况 / **数据看板** / 行情 / **AI 教练** / 设置 / 登录
├→ agent.py × N (:1520015202) 持仓,全平
└→ 各 Flask (:5000/5001/5004) /api/hub/monitor 只读聚合
```
- 账户列表:**系统设置** 或默认 `settings_store.py`(不再使用环境变量 `HUB_AGENTS`).
- 三实例须注册 **hub_bridge**(仓库根 `hub_bridge.py`);PM2 建议 `PYTHONPATH=..`.
---
## 快速启动(Linux / PM2)
```bash
cd /opt/crypto_monitor/manual_trading_hub
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
# 编辑 .env:HUB_PASSWORD,HUB_BRIDGE_TOKEN,HUB_PUBLIC_ORIGIN 等
pm2 start ecosystem.config.cjs # 3 agent + hub
pm2 save
bash scripts/verify_hub_deploy.sh
curl -s http://127.0.0.1:5100/api/ping
```
浏览器:`http://<本机IP>:5100/monitor`(行情 `/market`;已设密码则先 `/login`).
---
## 中控 `.env` 要点
| 变量 | 说明 |
|------|------|
| `HUB_PASSWORD` / `HUB_USERNAME` | 非空密码即启用登录 |
| `HUB_BRIDGE_TOKEN` | 与三实例一致 |
| `HUB_DISABLED_IDS` | 默认 `1` 关闭 OKX |
| `HUB_PUBLIC_ORIGIN` | 其它设备打开复盘/实例外链(替换 127.0.0.1) |
| `HUB_COOKIE_SECURE` | HTTPS 反代建议 `true` |
详见 [.env.example](./.env.example).
---
## 子代理(agent)
每所策略目录单独进程,`EXCHANGE` + `PORT`(1520015202),密钥来自**该目录 `.env`**.PM2 经 `scripts/run_agent.sh` 启动(自动 `source .env`,去 CRLF).
| PORT | 目录 |
|------|------|
| 15200 | crypto_monitor_binance |
| 15201 | crypto_monitor_okx |
| 15202 | crypto_monitor_gate |
---
## 运维脚本
| 脚本 | 作用 |
|------|------|
| [scripts/fix_hub_deps.sh](./scripts/fix_hub_deps.sh) | 安装/更新 venv 依赖 |
| [scripts/verify_hub_deploy.sh](./scripts/verify_hub_deploy.sh) | 验收代码版本与 ping |
| [scripts/fix_env_crlf.sh](./scripts/fix_env_crlf.sh) | 修复 .env 的 Windows 换行 |
| [scripts/pm2_hub.sh](./scripts/pm2_hub.sh) | PM2 启停 hub+agent |
| [scripts/后台运行-Ubuntu.md](./scripts/后台运行-Ubuntu.md) | PM2 常驻 |
| [docs/ubuntu-server.md](../docs/ubuntu-server.md) | Ubuntu / Python / Node / PM2 |
---
## 文档索引
| 文档 | 内容 |
|------|------|
| [使用说明.md](./使用说明.md) | 页面,API,环境变量,日常流程 |
| [行情区说明.md](./行情区说明.md) | K 线周期,缓存,快捷键,拉取逻辑 |
| [部署文档.md](./部署文档.md) | Ubuntu,PM2,反代,升级 |
| [常见问题.md](./常见问题.md) | 已遇到问题与处理 |
| [.env.example](./.env.example) | 环境变量模板 |
+22 -22
View File
@@ -1,22 +1,22 @@
# 更新前快照行情区 + K 线库
> 行情区使用说明见 [行情区说明.md](./行情区说明.md)
更新前已打 Git 标签回滚方式
```bash
cd /opt/crypto_monitor # 或你的仓库路径
git fetch --tags
git checkout snapshot/pre-hub-market-20260528
# 恢复后重启
pm2 restart manual-trading-hub crypto_okx crypto_binance crypto_gate
```
回到最新主线
```bash
git checkout main
git pull
```
K 线数据库不纳入 Git):`manual_trading_hub/data/hub_kline.db`回滚代码不会自动删除该文件
# 更新前快照(行情区 + K 线库)
> 行情区使用说明见 [行情区说明.md](./行情区说明.md).
更新前已打 Git 标签,回滚方式:
```bash
cd /opt/crypto_monitor # 或你的仓库路径
git fetch --tags
git checkout snapshot/pre-hub-market-20260528
# 恢复后重启:
pm2 restart manual-trading-hub crypto_okx crypto_binance crypto_gate
```
回到最新主线:
```bash
git checkout main
git pull
```
K 线数据库(不纳入 Git):`manual_trading_hub/data/hub_kline.db`,回滚代码不会自动删除该文件.
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,7 @@
/**
* 仅子代理一般不单独用默认请 pm2 start ecosystem.config.cjs 一次起 hub+agent
* 仅子代理(一般不单独用;默认请 pm2 start ecosystem.config.cjs 一次起 hub+agent)
*
* 若只想重启子代理不动中控
* 若只想重启子代理,不动中控:
* pm2 restart manual-agent-binance manual-agent-gate ...
*/
const main = require("./ecosystem.config.cjs");
+6 -6
View File
@@ -1,19 +1,19 @@
/**
* PM2中控 hub + 三路子代理 agent一次启动全部
* PM2:中控 hub + 三路子代理 agent(一次启动全部)
*
* 前置
* 前置:
* cd manual_trading_hub
* source .venv/bin/activate && pip install -r requirements.txt
* cp .env.example .env
*
* 启动hub + 全部 agent):
* 启动(hub + 全部 agent):
* pm2 start ecosystem.config.cjs
* pm2 save && pm2 startup
*
* 仅中控pm2 start ecosystem.config.cjs --only manual-trading-hub
* 仅某 agentpm2 start ecosystem.config.cjs --only manual-agent-binance
* 仅中控:pm2 start ecosystem.config.cjs --only manual-trading-hub
* 仅某 agent:pm2 start ecosystem.config.cjs --only manual-agent-binance
*
* 快捷bash scripts/pm2_hub.sh start
* 快捷:bash scripts/pm2_hub.sh start
*/
const path = require("path");
+1 -1
View File
@@ -1,4 +1,4 @@
"""加载 manual_trading_hub/.envWindows 直接 python hub.py 时也需要)。"""
"""加载 manual_trading_hub/.env(Windows 直接 python hub.py 时也需要)."""
from __future__ import annotations
import os
File diff suppressed because it is too large Load Diff
+3475 -3475
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1 +1 @@
"""中控 AI 模块今日总结 + 交易员聊天与实例 ai_review 分离)。"""
"""中控 AI 模块:今日总结 + 交易员聊天(与实例 ai_review 分离)."""
+161 -161
View File
@@ -1,161 +1,161 @@
"""内照明心复盘语录 → 交易教练点评"""
from __future__ import annotations
from typing import Any
from hub_ai.client import generate_text, model_label
from hub_ai.rolling_summary import refresh_session_rolling_summary
from hub_ai.text_util import clip_text, is_ai_error_reply
from hub_ai.config import (
CHAT_MAX_CONTINUATIONS,
CHAT_MAX_OUTPUT_TOKENS,
CHAT_TEMPERATURE,
CHAT_USER_MESSAGE_MAX_CHARS,
)
from hub_ai.prompts import CHAT_SYSTEM, build_archive_quote_review_prompt
from hub_ai.store import (
CHAT_BOT_TRADING,
append_chat_message,
create_new_session,
delete_chat_session,
get_active_session,
list_chat_sessions,
)
from lib.hub.hub_symbol_archive_lib import list_daily_trades
def _tag_label(tag: str) -> str:
t = (tag or "").strip().lower()
if t == "sick":
return "犯病"
if t == "emotion":
return "情绪化"
return t or ""
def _fmt_pnl(v: Any) -> str:
try:
n = float(v or 0)
except (TypeError, ValueError):
return ""
sign = "+" if n > 0 else ""
return f"{sign}{n:.2f}U"
def _fmt_pct(v: Any) -> str:
try:
n = float(v)
except (TypeError, ValueError):
return ""
return f"{n:.1f}%"
def _fmt_rr(v: Any) -> str:
try:
n = float(v)
except (TypeError, ValueError):
return ""
return f"{n:.2f}:1"
def format_archive_trades_for_ai(payload: dict[str, Any]) -> str:
trades = payload.get("trades") or []
stats = payload.get("stats") or {}
lines = [
(
f"统计开仓 {int(stats.get('open_count') or 0)}"
f"盈利 {int(stats.get('win_count') or 0)} / 亏损 {int(stats.get('loss_count') or 0)}"
f"平均盈利 {_fmt_pnl(stats.get('avg_win'))}平均亏损 {_fmt_pnl(stats.get('avg_loss'))}"
f"胜率 {_fmt_pct(stats.get('win_rate'))}盈亏比 {_fmt_rr(stats.get('profit_loss_ratio'))}"
f"最大盈利 {_fmt_pnl(stats.get('max_win'))}最大亏损 {_fmt_pnl(stats.get('max_loss'))}"
f"犯病 {int(stats.get('sick_count') or 0)}"
f"盈亏合计 {_fmt_pnl(stats.get('pnl_total'))}"
f"剔除犯病盈亏 {_fmt_pnl(stats.get('pnl_ex_sick'))}"
)
]
if not trades:
lines.append("该日无交易记录")
return "\n".join(lines)
max_rows = 50
if len(trades) > max_rows:
lines.append(f"{len(trades)}以下展示最近 {max_rows}")
for i, t in enumerate(trades[:max_rows], 1):
ex = str(t.get("exchange_key") or t.get("account_exchange_key") or "")
sym = str(t.get("symbol") or "")
direction = str(t.get("direction") or "")
opened = str(t.get("opened_at") or "")
closed = str(t.get("closed_at") or "")
hold = str(t.get("hold_minutes_text") or t.get("hold_minutes") or "")
result = str(t.get("result") or "")
pnl = _fmt_pnl(t.get("pnl_amount"))
entry = str(t.get("entry_type") or t.get("entry_reason") or t.get("monitor_type") or "")
tag = _tag_label(str(t.get("behavior_tag") or ""))
note = clip_text(str(t.get("note") or "").strip(), 80)
line = (
f"{i}. {ex} | {sym} | {direction} | 开仓类型 {entry} | "
f"{opened} | 平 {closed} | 持仓 {hold} | 结果 {result} | "
f"盈亏 {pnl} | 标签 {tag}"
)
if note:
line += f" | 备注 {note}"
lines.append(line)
return "\n".join(lines)
def send_archive_quote_review(
*,
quote_date: str,
content: str,
) -> dict[str, Any]:
text = (content or "").strip()
if not text:
return {"ok": False, "msg": "语录内容不能为空"}
day = (quote_date or "").strip()[:10]
if not day:
return {"ok": False, "msg": "语录日期无效"}
session = create_new_session(
trading_day=day,
title=f"复盘 {day}",
bot_mode=CHAT_BOT_TRADING,
)
sid = session["id"]
archive_payload = list_daily_trades(trading_day=day, period="today")
archive_trades_text = format_archive_trades_for_ai(archive_payload)
user_for_prompt = clip_text(text, CHAT_USER_MESSAGE_MAX_CHARS)
user_prompt = build_archive_quote_review_prompt(
quote_date=day,
archive_trades_text=archive_trades_text,
user_message=user_for_prompt,
)
reply = generate_text(
system=CHAT_SYSTEM,
user=user_prompt,
temperature=CHAT_TEMPERATURE,
max_tokens=CHAT_MAX_OUTPUT_TOKENS,
max_continuations=CHAT_MAX_CONTINUATIONS,
)
if is_ai_error_reply(reply):
delete_chat_session(sid)
return {"ok": False, "msg": reply}
append_chat_message(sid, "user", text)
session = append_chat_message(sid, "assistant", reply)
refresh_session_rolling_summary(
sid,
prior_summary="",
user_text=text,
assistant_text=reply,
bot_mode=CHAT_BOT_TRADING,
)
session = get_active_session() or session
return {
"ok": True,
"trading_day": day,
"session": session,
"sessions": list_chat_sessions(),
"reply": reply,
"model": model_label(),
}
"""内照明心复盘语录 → 交易教练点评."""
from __future__ import annotations
from typing import Any
from hub_ai.client import generate_text, model_label
from hub_ai.rolling_summary import refresh_session_rolling_summary
from hub_ai.text_util import clip_text, is_ai_error_reply
from hub_ai.config import (
CHAT_MAX_CONTINUATIONS,
CHAT_MAX_OUTPUT_TOKENS,
CHAT_TEMPERATURE,
CHAT_USER_MESSAGE_MAX_CHARS,
)
from hub_ai.prompts import CHAT_SYSTEM, build_archive_quote_review_prompt
from hub_ai.store import (
CHAT_BOT_TRADING,
append_chat_message,
create_new_session,
delete_chat_session,
get_active_session,
list_chat_sessions,
)
from lib.hub.hub_symbol_archive_lib import list_daily_trades
def _tag_label(tag: str) -> str:
t = (tag or "").strip().lower()
if t == "sick":
return "犯病"
if t == "emotion":
return "情绪化"
return t or ""
def _fmt_pnl(v: Any) -> str:
try:
n = float(v or 0)
except (TypeError, ValueError):
return ""
sign = "+" if n > 0 else ""
return f"{sign}{n:.2f}U"
def _fmt_pct(v: Any) -> str:
try:
n = float(v)
except (TypeError, ValueError):
return ""
return f"{n:.1f}%"
def _fmt_rr(v: Any) -> str:
try:
n = float(v)
except (TypeError, ValueError):
return ""
return f"{n:.2f}:1"
def format_archive_trades_for_ai(payload: dict[str, Any]) -> str:
trades = payload.get("trades") or []
stats = payload.get("stats") or {}
lines = [
(
f"统计:开仓 {int(stats.get('open_count') or 0)},"
f"盈利 {int(stats.get('win_count') or 0)} / 亏损 {int(stats.get('loss_count') or 0)},"
f"平均盈利 {_fmt_pnl(stats.get('avg_win'))},平均亏损 {_fmt_pnl(stats.get('avg_loss'))},"
f"胜率 {_fmt_pct(stats.get('win_rate'))},盈亏比 {_fmt_rr(stats.get('profit_loss_ratio'))},"
f"最大盈利 {_fmt_pnl(stats.get('max_win'))},最大亏损 {_fmt_pnl(stats.get('max_loss'))},"
f"犯病 {int(stats.get('sick_count') or 0)},"
f"盈亏合计 {_fmt_pnl(stats.get('pnl_total'))},"
f"剔除犯病盈亏 {_fmt_pnl(stats.get('pnl_ex_sick'))}"
)
]
if not trades:
lines.append("(该日无交易记录)")
return "\n".join(lines)
max_rows = 50
if len(trades) > max_rows:
lines.append(f"({len(trades)},以下展示最近 {max_rows})")
for i, t in enumerate(trades[:max_rows], 1):
ex = str(t.get("exchange_key") or t.get("account_exchange_key") or "")
sym = str(t.get("symbol") or "")
direction = str(t.get("direction") or "")
opened = str(t.get("opened_at") or "")
closed = str(t.get("closed_at") or "")
hold = str(t.get("hold_minutes_text") or t.get("hold_minutes") or "")
result = str(t.get("result") or "")
pnl = _fmt_pnl(t.get("pnl_amount"))
entry = str(t.get("entry_type") or t.get("entry_reason") or t.get("monitor_type") or "")
tag = _tag_label(str(t.get("behavior_tag") or ""))
note = clip_text(str(t.get("note") or "").strip(), 80)
line = (
f"{i}. {ex} | {sym} | {direction} | 开仓类型 {entry} | "
f"{opened} | 平 {closed} | 持仓 {hold} | 结果 {result} | "
f"盈亏 {pnl} | 标签 {tag}"
)
if note:
line += f" | 备注 {note}"
lines.append(line)
return "\n".join(lines)
def send_archive_quote_review(
*,
quote_date: str,
content: str,
) -> dict[str, Any]:
text = (content or "").strip()
if not text:
return {"ok": False, "msg": "语录内容不能为空"}
day = (quote_date or "").strip()[:10]
if not day:
return {"ok": False, "msg": "语录日期无效"}
session = create_new_session(
trading_day=day,
title=f"复盘 {day}",
bot_mode=CHAT_BOT_TRADING,
)
sid = session["id"]
archive_payload = list_daily_trades(trading_day=day, period="today")
archive_trades_text = format_archive_trades_for_ai(archive_payload)
user_for_prompt = clip_text(text, CHAT_USER_MESSAGE_MAX_CHARS)
user_prompt = build_archive_quote_review_prompt(
quote_date=day,
archive_trades_text=archive_trades_text,
user_message=user_for_prompt,
)
reply = generate_text(
system=CHAT_SYSTEM,
user=user_prompt,
temperature=CHAT_TEMPERATURE,
max_tokens=CHAT_MAX_OUTPUT_TOKENS,
max_continuations=CHAT_MAX_CONTINUATIONS,
)
if is_ai_error_reply(reply):
delete_chat_session(sid)
return {"ok": False, "msg": reply}
append_chat_message(sid, "user", text)
session = append_chat_message(sid, "assistant", reply)
refresh_session_rolling_summary(
sid,
prior_summary="",
user_text=text,
assistant_text=reply,
bot_mode=CHAT_BOT_TRADING,
)
session = get_active_session() or session
return {
"ok": True,
"trading_day": day,
"session": session,
"sessions": list_chat_sessions(),
"reply": reply,
"model": model_label(),
}
+4 -4
View File
@@ -1,4 +1,4 @@
"""中控 AI 聊天附件解析"""
"""中控 AI 聊天附件解析."""
from __future__ import annotations
import base64
@@ -86,11 +86,11 @@ def parse_chat_attachments(raw_files: list[dict[str, Any]]) -> dict[str, Any]:
meta.append({"name": name, "kind": "text", "mime": mime, "size": size})
notes.append(f"文档 {name}")
continue
errors.append(f"{name}: 不支持的类型仅图片或 txt/md/json")
errors.append(f"{name}: 不支持的类型(仅图片或 txt/md/json)")
attachment_note = "".join(notes) if notes else ""
attachment_note = ";".join(notes) if notes else ""
if errors:
attachment_note = (attachment_note + "" if attachment_note else "") + "".join(errors)
attachment_note = (attachment_note + ";" if attachment_note else "") + ";".join(errors)
text_append = "\n\n".join(text_blocks)
return {
"images_b64": images_b64,
+275 -275
View File
@@ -1,275 +1,275 @@
"""中控 AI单会话聊天直到用户点击新开)。"""
from __future__ import annotations
import threading
from typing import Any, Optional
from hub_ai.attachments import parse_chat_attachments
from hub_ai.client import generate_text, model_label
from hub_ai.config import (
CHAT_CONTEXT_MAX_CHARS,
CHAT_FOLLOWUP_CONTEXT_MAX_CHARS,
CHAT_HISTORY_MAX_CHARS_PER_MSG,
CHAT_MAX_CONTINUATIONS,
CHAT_MAX_HISTORY_TURNS,
CHAT_MAX_OUTPUT_TOKENS,
CHAT_PROMPT_MAX_CHARS,
CHAT_SUMMARY_EXCERPT_MAX_CHARS,
CHAT_TEMPERATURE,
CHAT_USER_MESSAGE_MAX_CHARS,
trading_day_reset_hour,
)
from lib.hub.hub_trades_lib import current_trading_day
from hub_ai.context import (
build_chat_context,
format_chat_context_for_chat,
format_chat_position_overview,
)
from hub_ai.prompts import (
CHAT_GENERAL_SYSTEM,
CHAT_SYSTEM,
build_chat_user_prompt,
build_general_chat_user_prompt,
)
from hub_ai.rolling_summary import refresh_session_rolling_summary
from hub_ai.store import (
CHAT_BOT_GENERAL,
CHAT_BOT_TRADING,
append_chat_message,
create_new_session,
delete_chat_session,
ensure_active_session,
get_active_session,
list_chat_sessions,
load_chat_store,
set_active_session,
summary_excerpt_for_chat,
)
from hub_ai.text_util import clip_text, is_ai_error_reply
def _is_ai_error_reply(text: str) -> bool:
return is_ai_error_reply(text)
def _clip_text(text: str, max_chars: int) -> str:
return clip_text(text, max_chars)
def _history_lines(
messages: list[dict],
max_turns: int = CHAT_MAX_HISTORY_TURNS,
*,
max_chars_per_msg: int = CHAT_HISTORY_MAX_CHARS_PER_MSG,
total_max_chars: int | None = None,
) -> str:
rows = [m for m in (messages or []) if m.get("role") in ("user", "assistant")]
rows = rows[-max_turns * 2 :]
lines = []
for m in rows:
role = "用户" if m.get("role") == "user" else "搭档"
content = str(m.get("content") or "").strip()
if m.get("role") == "assistant" and _is_ai_error_reply(content):
continue
att = m.get("attachments") or []
if att:
names = "".join(str(a.get("name") or "附件") for a in att[:3])
content = f"{content} [附件: {names}]".strip()
content = _clip_text(content, max_chars_per_msg)
if content:
lines.append(f"{role}{content}")
if total_max_chars and total_max_chars > 0:
while lines and len("\n".join(lines)) > total_max_chars:
lines.pop(0)
return "\n".join(lines)
def _trading_context_bundle(ctx: dict[str, Any], *, prior_count: int) -> tuple[str, str]:
day = str(ctx.get("trading_day") or (ctx.get("totals") or {}).get("trading_day") or "")
if prior_count <= 0:
brief = format_chat_context_for_chat(ctx, max_chars=CHAT_CONTEXT_MAX_CHARS)
excerpt = summary_excerpt_for_chat(day, max_chars=CHAT_SUMMARY_EXCERPT_MAX_CHARS)
return brief, excerpt
totals = ctx.get("totals") or {}
overview = format_chat_position_overview(ctx)
slim = (
f"【续聊快照 {day}】平仓盈亏 {totals.get('total_pnl_u')}U | "
f"笔数 {totals.get('closed_count')} | "
f"持仓 {totals.get('open_position_count', 0)} 仓 | "
f"浮盈亏 {totals.get('float_pnl_u')}U"
)
brief = _clip_text(overview + "\n" + slim, CHAT_FOLLOWUP_CONTEXT_MAX_CHARS)
return brief, ""
def _history_budget(*sizes: int) -> int:
used = sum(int(s or 0) for s in sizes) + 2200
return max(1200, CHAT_PROMPT_MAX_CHARS - used)
def _prompt_memory(session: dict, prior_msgs: list[dict]) -> tuple[str, str]:
"""续聊优先用滚动摘要旧会话无摘要时仅带最近 1 轮兜底"""
rolling = str(session.get("rolling_summary") or "").strip()
if rolling:
return rolling, ""
prior_count = len([m for m in prior_msgs if m.get("role") in ("user", "assistant")])
if prior_count <= 0:
return "", ""
tail = _history_lines(
prior_msgs,
max_turns=1,
max_chars_per_msg=CHAT_HISTORY_MAX_CHARS_PER_MSG,
)
return "", tail
def get_chat_state() -> dict[str, Any]:
store = load_chat_store()
session = get_active_session()
if session:
session.setdefault("bot_mode", CHAT_BOT_TRADING)
session.setdefault("rolling_summary", "")
return {
"active_session_id": store.get("active_session_id"),
"session": session,
"sessions": list_chat_sessions(),
"model": model_label(),
}
def start_new_chat(*, trading_day: str, bot_mode: str = CHAT_BOT_TRADING) -> dict:
session = create_new_session(trading_day=trading_day, bot_mode=bot_mode)
return {
"ok": True,
"session": session,
"sessions": list_chat_sessions(),
"model": model_label(),
}
def switch_chat_session(session_id: str) -> dict[str, Any]:
session = set_active_session(session_id)
return {
"ok": True,
"session": session,
"sessions": list_chat_sessions(),
"model": model_label(),
}
def remove_chat_session(session_id: str) -> dict[str, Any]:
deleted, new_active = delete_chat_session(session_id)
if not deleted:
return {"ok": False, "msg": "session_not_found"}
session = get_active_session()
return {
"ok": True,
"active_session_id": new_active,
"session": session,
"sessions": list_chat_sessions(),
"model": model_label(),
}
def send_chat_message(
exchanges: list[dict],
message: str,
*,
trading_day: str | None = None,
raw_attachments: Optional[list[dict]] = None,
) -> dict[str, Any]:
text = (message or "").strip()
parsed = parse_chat_attachments(raw_attachments or [])
if parsed.get("errors") and not text and not parsed.get("images_b64"):
return {"ok": False, "msg": "".join(parsed["errors"])}
if not text and not parsed.get("images_b64") and not parsed.get("text_append"):
return {"ok": False, "msg": "消息不能为空"}
user_visible = text
if parsed.get("text_append"):
user_visible = (user_visible + "\n\n" + parsed["text_append"]).strip()
if not user_visible and parsed.get("attachment_note"):
user_visible = f"上传了 {parsed['attachment_note']}"
day = (trading_day or "").strip()[:10] or current_trading_day(
reset_hour=trading_day_reset_hour()
)
session = ensure_active_session(trading_day=day)
sid = session["id"]
prior_rolling = str(session.get("rolling_summary") or "")
prior_msgs = session.get("messages") or []
prior_count = len([m for m in prior_msgs if m.get("role") in ("user", "assistant")])
user_for_prompt = _clip_text(text or user_visible, CHAT_USER_MESSAGE_MAX_CHARS)
rolling_summary, history_tail = _prompt_memory(session, prior_msgs)
bot_mode = (session.get("bot_mode") or CHAT_BOT_TRADING).strip().lower()
if bot_mode == CHAT_BOT_GENERAL:
user_prompt = build_general_chat_user_prompt(
rolling_summary=rolling_summary,
history_lines=history_tail,
user_message=user_for_prompt,
attachment_note=str(parsed.get("attachment_note") or ""),
)
if parsed.get("text_append"):
user_prompt += "\n\n【附件正文】\n" + _clip_text(parsed["text_append"], 3000)
system_prompt = CHAT_GENERAL_SYSTEM
else:
ctx = build_chat_context(exchanges, trading_day=day)
day = ctx["trading_day"]
brief_ctx, excerpt = _trading_context_bundle(ctx, prior_count=prior_count)
user_prompt = build_chat_user_prompt(
context_text=brief_ctx,
trading_day=day,
summary_excerpt=excerpt,
rolling_summary=rolling_summary,
history_lines=history_tail,
user_message=user_for_prompt,
attachment_note=str(parsed.get("attachment_note") or ""),
)
if parsed.get("text_append"):
user_prompt += "\n\n【附件正文】\n" + _clip_text(parsed["text_append"], 3000)
system_prompt = CHAT_SYSTEM
reply = generate_text(
system=system_prompt,
user=user_prompt,
temperature=CHAT_TEMPERATURE,
images_b64=parsed.get("images_b64") or None,
max_tokens=CHAT_MAX_OUTPUT_TOKENS,
max_continuations=CHAT_MAX_CONTINUATIONS,
)
if _is_ai_error_reply(reply):
return {"ok": False, "msg": reply, "session_id": sid}
append_chat_message(
sid,
"user",
user_visible,
attachments=parsed.get("attachment_meta") or [],
)
session = append_chat_message(sid, "assistant", reply)
summary_kwargs = {
"session_id": sid,
"prior_summary": prior_rolling,
"user_text": user_visible,
"assistant_text": reply,
"bot_mode": bot_mode,
}
def _refresh_summary_bg() -> None:
try:
refresh_session_rolling_summary(**summary_kwargs)
except Exception:
pass
threading.Thread(target=_refresh_summary_bg, daemon=True).start()
session = get_active_session() or session
return {
"ok": True,
"trading_day": day,
"session": session,
"sessions": list_chat_sessions(),
"reply": reply,
"model": model_label(),
"attachment_warnings": parsed.get("errors") or [],
}
"""中控 AI:单会话聊天(直到用户点击新开)."""
from __future__ import annotations
import threading
from typing import Any, Optional
from hub_ai.attachments import parse_chat_attachments
from hub_ai.client import generate_text, model_label
from hub_ai.config import (
CHAT_CONTEXT_MAX_CHARS,
CHAT_FOLLOWUP_CONTEXT_MAX_CHARS,
CHAT_HISTORY_MAX_CHARS_PER_MSG,
CHAT_MAX_CONTINUATIONS,
CHAT_MAX_HISTORY_TURNS,
CHAT_MAX_OUTPUT_TOKENS,
CHAT_PROMPT_MAX_CHARS,
CHAT_SUMMARY_EXCERPT_MAX_CHARS,
CHAT_TEMPERATURE,
CHAT_USER_MESSAGE_MAX_CHARS,
trading_day_reset_hour,
)
from lib.hub.hub_trades_lib import current_trading_day
from hub_ai.context import (
build_chat_context,
format_chat_context_for_chat,
format_chat_position_overview,
)
from hub_ai.prompts import (
CHAT_GENERAL_SYSTEM,
CHAT_SYSTEM,
build_chat_user_prompt,
build_general_chat_user_prompt,
)
from hub_ai.rolling_summary import refresh_session_rolling_summary
from hub_ai.store import (
CHAT_BOT_GENERAL,
CHAT_BOT_TRADING,
append_chat_message,
create_new_session,
delete_chat_session,
ensure_active_session,
get_active_session,
list_chat_sessions,
load_chat_store,
set_active_session,
summary_excerpt_for_chat,
)
from hub_ai.text_util import clip_text, is_ai_error_reply
def _is_ai_error_reply(text: str) -> bool:
return is_ai_error_reply(text)
def _clip_text(text: str, max_chars: int) -> str:
return clip_text(text, max_chars)
def _history_lines(
messages: list[dict],
max_turns: int = CHAT_MAX_HISTORY_TURNS,
*,
max_chars_per_msg: int = CHAT_HISTORY_MAX_CHARS_PER_MSG,
total_max_chars: int | None = None,
) -> str:
rows = [m for m in (messages or []) if m.get("role") in ("user", "assistant")]
rows = rows[-max_turns * 2 :]
lines = []
for m in rows:
role = "用户" if m.get("role") == "user" else "搭档"
content = str(m.get("content") or "").strip()
if m.get("role") == "assistant" and _is_ai_error_reply(content):
continue
att = m.get("attachments") or []
if att:
names = ",".join(str(a.get("name") or "附件") for a in att[:3])
content = f"{content} [附件: {names}]".strip()
content = _clip_text(content, max_chars_per_msg)
if content:
lines.append(f"{role}:{content}")
if total_max_chars and total_max_chars > 0:
while lines and len("\n".join(lines)) > total_max_chars:
lines.pop(0)
return "\n".join(lines)
def _trading_context_bundle(ctx: dict[str, Any], *, prior_count: int) -> tuple[str, str]:
day = str(ctx.get("trading_day") or (ctx.get("totals") or {}).get("trading_day") or "")
if prior_count <= 0:
brief = format_chat_context_for_chat(ctx, max_chars=CHAT_CONTEXT_MAX_CHARS)
excerpt = summary_excerpt_for_chat(day, max_chars=CHAT_SUMMARY_EXCERPT_MAX_CHARS)
return brief, excerpt
totals = ctx.get("totals") or {}
overview = format_chat_position_overview(ctx)
slim = (
f"【续聊快照 {day}】平仓盈亏 {totals.get('total_pnl_u')}U | "
f"笔数 {totals.get('closed_count')} | "
f"持仓 {totals.get('open_position_count', 0)} 仓 | "
f"浮盈亏 {totals.get('float_pnl_u')}U"
)
brief = _clip_text(overview + "\n" + slim, CHAT_FOLLOWUP_CONTEXT_MAX_CHARS)
return brief, ""
def _history_budget(*sizes: int) -> int:
used = sum(int(s or 0) for s in sizes) + 2200
return max(1200, CHAT_PROMPT_MAX_CHARS - used)
def _prompt_memory(session: dict, prior_msgs: list[dict]) -> tuple[str, str]:
"""续聊优先用滚动摘要;旧会话无摘要时仅带最近 1 轮兜底."""
rolling = str(session.get("rolling_summary") or "").strip()
if rolling:
return rolling, ""
prior_count = len([m for m in prior_msgs if m.get("role") in ("user", "assistant")])
if prior_count <= 0:
return "", ""
tail = _history_lines(
prior_msgs,
max_turns=1,
max_chars_per_msg=CHAT_HISTORY_MAX_CHARS_PER_MSG,
)
return "", tail
def get_chat_state() -> dict[str, Any]:
store = load_chat_store()
session = get_active_session()
if session:
session.setdefault("bot_mode", CHAT_BOT_TRADING)
session.setdefault("rolling_summary", "")
return {
"active_session_id": store.get("active_session_id"),
"session": session,
"sessions": list_chat_sessions(),
"model": model_label(),
}
def start_new_chat(*, trading_day: str, bot_mode: str = CHAT_BOT_TRADING) -> dict:
session = create_new_session(trading_day=trading_day, bot_mode=bot_mode)
return {
"ok": True,
"session": session,
"sessions": list_chat_sessions(),
"model": model_label(),
}
def switch_chat_session(session_id: str) -> dict[str, Any]:
session = set_active_session(session_id)
return {
"ok": True,
"session": session,
"sessions": list_chat_sessions(),
"model": model_label(),
}
def remove_chat_session(session_id: str) -> dict[str, Any]:
deleted, new_active = delete_chat_session(session_id)
if not deleted:
return {"ok": False, "msg": "session_not_found"}
session = get_active_session()
return {
"ok": True,
"active_session_id": new_active,
"session": session,
"sessions": list_chat_sessions(),
"model": model_label(),
}
def send_chat_message(
exchanges: list[dict],
message: str,
*,
trading_day: str | None = None,
raw_attachments: Optional[list[dict]] = None,
) -> dict[str, Any]:
text = (message or "").strip()
parsed = parse_chat_attachments(raw_attachments or [])
if parsed.get("errors") and not text and not parsed.get("images_b64"):
return {"ok": False, "msg": ";".join(parsed["errors"])}
if not text and not parsed.get("images_b64") and not parsed.get("text_append"):
return {"ok": False, "msg": "消息不能为空"}
user_visible = text
if parsed.get("text_append"):
user_visible = (user_visible + "\n\n" + parsed["text_append"]).strip()
if not user_visible and parsed.get("attachment_note"):
user_visible = f"(上传了 {parsed['attachment_note']})"
day = (trading_day or "").strip()[:10] or current_trading_day(
reset_hour=trading_day_reset_hour()
)
session = ensure_active_session(trading_day=day)
sid = session["id"]
prior_rolling = str(session.get("rolling_summary") or "")
prior_msgs = session.get("messages") or []
prior_count = len([m for m in prior_msgs if m.get("role") in ("user", "assistant")])
user_for_prompt = _clip_text(text or user_visible, CHAT_USER_MESSAGE_MAX_CHARS)
rolling_summary, history_tail = _prompt_memory(session, prior_msgs)
bot_mode = (session.get("bot_mode") or CHAT_BOT_TRADING).strip().lower()
if bot_mode == CHAT_BOT_GENERAL:
user_prompt = build_general_chat_user_prompt(
rolling_summary=rolling_summary,
history_lines=history_tail,
user_message=user_for_prompt,
attachment_note=str(parsed.get("attachment_note") or ""),
)
if parsed.get("text_append"):
user_prompt += "\n\n【附件正文】\n" + _clip_text(parsed["text_append"], 3000)
system_prompt = CHAT_GENERAL_SYSTEM
else:
ctx = build_chat_context(exchanges, trading_day=day)
day = ctx["trading_day"]
brief_ctx, excerpt = _trading_context_bundle(ctx, prior_count=prior_count)
user_prompt = build_chat_user_prompt(
context_text=brief_ctx,
trading_day=day,
summary_excerpt=excerpt,
rolling_summary=rolling_summary,
history_lines=history_tail,
user_message=user_for_prompt,
attachment_note=str(parsed.get("attachment_note") or ""),
)
if parsed.get("text_append"):
user_prompt += "\n\n【附件正文】\n" + _clip_text(parsed["text_append"], 3000)
system_prompt = CHAT_SYSTEM
reply = generate_text(
system=system_prompt,
user=user_prompt,
temperature=CHAT_TEMPERATURE,
images_b64=parsed.get("images_b64") or None,
max_tokens=CHAT_MAX_OUTPUT_TOKENS,
max_continuations=CHAT_MAX_CONTINUATIONS,
)
if _is_ai_error_reply(reply):
return {"ok": False, "msg": reply, "session_id": sid}
append_chat_message(
sid,
"user",
user_visible,
attachments=parsed.get("attachment_meta") or [],
)
session = append_chat_message(sid, "assistant", reply)
summary_kwargs = {
"session_id": sid,
"prior_summary": prior_rolling,
"user_text": user_visible,
"assistant_text": reply,
"bot_mode": bot_mode,
}
def _refresh_summary_bg() -> None:
try:
refresh_session_rolling_summary(**summary_kwargs)
except Exception:
pass
threading.Thread(target=_refresh_summary_bg, daemon=True).start()
session = get_active_session() or session
return {
"ok": True,
"trading_day": day,
"session": session,
"sessions": list_chat_sessions(),
"reply": reply,
"model": model_label(),
"attachment_warnings": parsed.get("errors") or [],
}
+42 -42
View File
@@ -1,42 +1,42 @@
"""中控 AI 模型调用共用 ai_client 配置逻辑独立)。"""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Optional, Sequence
_REPO_ROOT = Path(__file__).resolve().parents[2]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
from lib.ai.ai_client import ai_generate, ai_generate_chat, ai_provider_label # noqa: E402
def model_label() -> str:
return ai_provider_label()
def generate_text(
*,
system: str,
user: str,
temperature: float,
images_b64: Optional[Sequence[str]] = None,
max_tokens: int | None = None,
max_continuations: int = 3,
) -> str:
if max_tokens is not None and max_tokens > 0:
return ai_generate_chat(
system=system,
user=user,
temperature=temperature,
images_b64=images_b64,
max_tokens=int(max_tokens),
max_continuations=max_continuations,
)
prompt = f"{system.strip()}\n\n---\n\n{user.strip()}"
return ai_generate(
prompt,
temperature=temperature,
images_b64=images_b64,
)
"""中控 AI 模型调用(共用 ai_client 配置,逻辑独立)."""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Optional, Sequence
_REPO_ROOT = Path(__file__).resolve().parents[2]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
from lib.ai.ai_client import ai_generate, ai_generate_chat, ai_provider_label # noqa: E402
def model_label() -> str:
return ai_provider_label()
def generate_text(
*,
system: str,
user: str,
temperature: float,
images_b64: Optional[Sequence[str]] = None,
max_tokens: int | None = None,
max_continuations: int = 3,
) -> str:
if max_tokens is not None and max_tokens > 0:
return ai_generate_chat(
system=system,
user=user,
temperature=temperature,
images_b64=images_b64,
max_tokens=int(max_tokens),
max_continuations=max_continuations,
)
prompt = f"{system.strip()}\n\n---\n\n{user.strip()}"
return ai_generate(
prompt,
temperature=temperature,
images_b64=images_b64,
)
+1 -1
View File
@@ -1,4 +1,4 @@
"""中控 AI 配置读 hub .env与实例同名 AI 变量)。"""
"""中控 AI 配置(读 hub .env,与实例同名 AI 变量)."""
from __future__ import annotations
import os
File diff suppressed because it is too large Load Diff
+18 -18
View File
@@ -1,18 +1,18 @@
"""中控 AI分户资金快照委托 hub_fund_history_lib保留 180 交易日)。"""
from __future__ import annotations
from typing import Any, Optional
from lib.hub.hub_fund_history_lib import (
FUND_HISTORY_DAYS,
format_fund_history_text,
get_fund_history,
record_fund_snapshot,
)
__all__ = [
"FUND_HISTORY_DAYS",
"format_fund_history_text",
"get_fund_history",
"record_fund_snapshot",
]
"""中控 AI:分户资金快照(委托 hub_fund_history_lib,保留 180 交易日)."""
from __future__ import annotations
from typing import Any, Optional
from lib.hub.hub_fund_history_lib import (
FUND_HISTORY_DAYS,
format_fund_history_text,
get_fund_history,
record_fund_snapshot,
)
__all__ = [
"FUND_HISTORY_DAYS",
"format_fund_history_text",
"get_fund_history",
"record_fund_snapshot",
]
+84 -84
View File
@@ -1,99 +1,99 @@
"""中控 AI 提示词与实例 ai_review 分离)。"""
"""中控 AI 提示词(与实例 ai_review 分离)."""
SUMMARY_SYSTEM = """
你是多账户加密货币合约交易的台账助手只根据用户提供的结构化数据输出中文 Markdown语气克制偏冷客观像值班记录
你是多账户加密货币合约交易的台账助手.只根据用户提供的结构化数据输出中文 Markdown,语气克制,偏冷,客观,像值班记录.
硬性规则
- 只能陈述数据中明确出现的数字与事实禁止编造成交止损扛单行情预测
- 上下文仅含今日一个交易日的平仓持仓与监控不得引用昨日历史走势或数据里未出现的账户名
- 未监控的账户必须标注未监控不得臆测其盈亏
- 连接失败或数据缺失的账户如实写明不要猜测
- 趋势回调计划顺势加仓关键位监控进行中的下单监控仅据数据列示无则写
- 第1~4节保持客观台账**第5节操作建议**可基于当日资金账户/交易账户余额仓位与监控单给出简短可执行的资金与仓位安排建议仍禁止预测涨跌保证收益
- 禁止输出 pipe 分隔的 Markdown 表格或详细数据支持附录禁止夸张词致命崩溃灾难等
硬性规则:
- 只能陈述数据中明确出现的数字与事实;禁止编造成交,止损,扛单,行情预测.
- 上下文仅含今日一个交易日的平仓,持仓与监控;不得引用昨日,历史走势或数据里未出现的账户名.
- 未监控的账户必须标注未监控,不得臆测其盈亏.
- 连接失败或数据缺失的账户如实写明,不要猜测.
- 趋势回调计划,顺势加仓,关键位监控,进行中的下单监控:仅据数据列示,无则写.
- 第1~4节保持客观台账;**第5节操作建议**可基于当日资金账户/交易账户余额,仓位与监控单,给出简短,可执行的资金与仓位安排建议(仍禁止预测涨跌,保证收益).
- 禁止输出 pipe 分隔的 Markdown 表格或详细数据支持附录;禁止夸张词(致命,崩溃,灾难等).
输出格式Markdown标题必须一致
**今日交易总结{trading_day}**
输出格式(Markdown,标题必须一致):
**今日交易总结({trading_day})**
**1. 总览**
- **合计盈亏(U)**今日平仓合计
- **平仓笔数**今日 / /
- **当前持仓浮盈亏(U)**
- **资金合计**资金账户 / 交易账户 仅已监控且有数据账户
- **合计盈亏(U)**:今日平仓合计
- **平仓笔数**:今日 ( / / )
- **当前持仓浮盈亏(U)**:
- **资金合计**:资金账户 / 交易账户 (仅已监控且有数据账户)
**2. 分户明细**
中控页面会自动渲染分户表格本节不要输出 pipe 分隔行或 Markdown 表格可写一句见下表或直接留空
中控页面会自动渲染分户表格,本节不要输出 pipe 分隔行或 Markdown 表格;可写一句见下表或直接留空.
**3. 需关注**
仅有依据时列出亏损浮亏监控/趋势/关键位异常资金缺口等若无则写
仅有依据时列出(亏损,浮亏,监控/趋势/关键位异常,资金缺口等);若无则写.
**4. 数据说明**
列出数据缺口某户未启用接口失败等
列出数据缺口(某户未启用,接口失败等).
**5. 操作建议**
基于各户当日资金账户与交易账户余额持仓与监控单给出 2~5 条简短建议是否需要从资金账户补充交易账户哪户风险敞口偏高等无依据则写暂无
基于各户当日资金账户与交易账户余额,持仓与监控单,给出 2~5 条简短建议(:是否需要从资金账户补充交易账户,哪户风险敞口偏高等).无依据则写暂无.
""".strip()
CHAT_SYSTEM = """
你是和用户一起盯盘的老搭档交易员熟悉他多个交易所账户的分工用中文口语化短句交流
你是和用户一起盯盘的老搭档交易员,熟悉他多个交易所账户的分工.用中文,口语化,短句交流.
语气要求
- 先理解对方的压力和情绪再轻轻帮他把事想清楚安慰体贴
- 可以指出执行或心态上的偏差点但用商量陪伴的口吻绝不用教育训诫上课列清单式说教
- 不要第1点第2点你应该不要作为你的教练我必须
- 不预测涨跌不保证收益不替用户做决定
- 只能依据提供的监控与交易数据说话看不到的就说我这边看不到你可以去 xx 实例页确认
- **持仓判定**只有快照里实盘持仓总览 / 持仓明细 / 交易所实盘才算已开仓空仓 / 0 就是没仓位浮盈亏 0U 且空仓时不要说还有仓卡着不动
- **监控单 持仓**趋势回调关键位顺势加仓下单监控是本地计划或挂单监控用户说已平仓时即使还有这些监控也不要当成手里还有仓
- 用户口述与快照冲突时以快照为准并口语说明我这边看到是空仓/有N仓
- 若附带今日总结摘要那是较早生成的缓存**实盘持仓以当前多账户快照里的实盘持仓总览为准**摘要里若提到持仓可能已过时
- 若用户上传图片可结合图中可见信息讨论看不清的明确说看不清
- **优先接住用户现在说对话核心摘要**用户聊心态悔单某笔操作时先顺着这个话题回应不要每句都复述账户资金数字
- **接续对话**对话核心摘要时须接着聊不要重复开场白整段回复必须写完以句号/问号/感叹号收尾不得停在半句话编号列表每条单独一行
- **止盈止损**持仓明细若出现止损xxx / 止盈xxx表示交易所条件单或监控计划里已有价位勿再暗示用户没挂止损/没设止盈仅当明细写止损=未检测到且无对应监控 SL 才可讨论补止损趋势持仓止盈=程序监控表示由程序盯止盈不是没止盈
- 快照里的盈亏/资金仅在需要核对事实时引用用户口述与快照冲突时以快照为准并口语说明
语气要求:
- 先理解对方的压力和情绪,再轻轻帮他把事想清楚(安慰,体贴).
- 可以指出执行或心态上的偏差点,但用商量,陪伴的口吻,绝不用教育,训诫,上课,列清单式说教.
- 不要第1点第2点你应该;不要作为你的教练我必须.
- 不预测涨跌,不保证收益,不替用户做决定.
- 只能依据提供的监控与交易数据说话;看不到的就说我这边看不到,你可以去 xx 实例页确认.
- **持仓判定**:只有快照里实盘持仓总览 / 持仓明细 / 交易所实盘才算已开仓;空仓 / 0 就是没仓位.浮盈亏 0U 且空仓时,不要说还有仓卡着不动.
- **监控单 持仓**:趋势回调,关键位,顺势加仓,下单监控是本地计划或挂单监控,用户说已平仓时,即使还有这些监控,也不要当成手里还有仓.
- 用户口述与快照冲突时,以快照为准并口语说明我这边看到是空仓/有N仓.
- 若附带今日总结摘要,那是较早生成的缓存,**实盘持仓以当前多账户快照里的实盘持仓总览为准**,摘要里若提到持仓可能已过时.
- 若用户上传图片,可结合图中可见信息讨论,看不清的明确说看不清.
- **优先接住用户现在说对话核心摘要**:用户聊心态,悔单,某笔操作时,先顺着这个话题回应,不要每句都复述账户资金数字.
- **接续对话**:对话核心摘要时须接着聊,不要重复开场白;整段回复必须写完,以句号/问号/感叹号收尾,不得停在半句话;编号列表每条单独一行.
- **止盈止损**:持仓明细若出现止损xxx / 止盈xxx,表示交易所条件单或监控计划里已有价位,勿再暗示用户没挂止损/没设止盈.仅当明细写止损=未检测到且无对应监控 SL ,才可讨论补止损.趋势持仓止盈=程序监控表示由程序盯止盈,不是没止盈.
- 快照里的盈亏/资金仅在需要核对事实时引用;用户口述与快照冲突时,以快照为准并口语说明.
""".strip()
def build_summary_user_prompt(context_text: str, trading_day: str) -> str:
return f"""
交易日今日{trading_day}
交易日(今日):{trading_day}
以下为中控聚合的多账户数据仅今日平仓持仓趋势回调/顺势加仓/关键位/监控单
以下为中控聚合的多账户数据(仅今日平仓,持仓,趋势回调/顺势加仓/关键位/监控单):
{context_text}
""".strip()
CHAT_GENERAL_SYSTEM = """
你是简洁友好的中文助手陪用户闲聊答疑整理思路
你是简洁,友好的中文助手,陪用户闲聊,答疑,整理思路.
规则
- 口语化自然不要列清单式说教不要作为 AI 我必须
- 用户未主动聊交易时不要主动扯合约仓位盈亏盯盘
- 你没有接入用户的交易账户数据不要编造持仓资金或监控状态若被问到交易事实说明这边看不到实盘建议去中控监控区或实例页查看
- 若用户上传图片或文档结合可见内容回应看不清的直说
- 接续对话核心摘要不要重复开场白回复须写完整以句号/问号/感叹号收尾
规则:
- 口语化,自然,不要列清单式说教,不要作为 AI 我必须.
- 用户未主动聊交易时,不要主动扯合约,仓位,盈亏,盯盘.
- 你没有接入用户的交易账户数据;不要编造持仓,资金或监控状态.若被问到交易事实,说明这边看不到实盘,建议去中控监控区或实例页查看.
- 若用户上传图片或文档,结合可见内容回应;看不清的直说.
- 接续对话核心摘要,不要重复开场白;回复须写完整,以句号/问号/感叹号收尾.
""".strip()
ROLLING_SUMMARY_TRADING_SYSTEM = """
你是交易教练的对话记录员此前摘要本轮用户+教练回复压成一条极短中文摘要
你是交易教练的对话记录员.此前摘要本轮用户+教练回复压成一条极短中文摘要.
要求
- 120~280 纯文本一段不要标题不要列表不要寒暄
- 只保留用户情绪/困扰涉及的交易事实教练核心建议已达成的共识待跟进事项
- 禁止编造未出现的信息数字与账户名须来自原文
要求:
- 120~280 ,纯文本一段,不要标题,不要列表,不要寒暄.
- 只保留:用户情绪/困扰,涉及的交易事实,教练核心建议,已达成的共识,待跟进事项.
- 禁止编造未出现的信息;数字与账户名须来自原文.
""".strip()
ROLLING_SUMMARY_GENERAL_SYSTEM = """
你是对话记录员此前摘要本轮用户+助手回复压成一条极短中文摘要
你是对话记录员.此前摘要本轮用户+助手回复压成一条极短中文摘要.
要求
- 100~240 纯文本一段不要标题不要列表
- 只保留话题用户诉求助手给出的关键信息待跟进事项
要求:
- 100~240 ,纯文本一段,不要标题,不要列表.
- 只保留:话题,用户诉求,助手给出的关键信息,待跟进事项.
""".strip()
@@ -108,10 +108,10 @@ def build_rolling_summary_user_prompt(
parts.extend(["【此前摘要】", prior_summary.strip()])
parts.extend([
"【本轮用户】",
user_text.strip() or "(空)",
user_text.strip() or "(空)",
"【本轮教练/助手】",
assistant_text.strip() or "(空)",
"请输出更新后的对话核心摘要",
assistant_text.strip() or "(空)",
"请输出更新后的对话核心摘要:",
])
return "\n\n".join(parts)
@@ -125,12 +125,12 @@ def build_general_chat_user_prompt(
) -> str:
parts: list[str] = []
if rolling_summary.strip():
parts.extend(["【对话核心摘要须接续勿重复开场", rolling_summary.strip()])
parts.extend(["【对话核心摘要(须接续,勿重复开场)", rolling_summary.strip()])
elif history_lines.strip():
parts.extend(["【最近对话】", history_lines.strip()])
if attachment_note.strip():
parts.extend(["【用户附件说明】", attachment_note.strip()])
parts.extend(["【用户现在说优先回应这一条", user_message.strip()])
parts.extend(["【用户现在说(优先回应这一条)", user_message.strip()])
return "\n\n".join(parts)
@@ -146,44 +146,44 @@ def build_chat_user_prompt(
) -> str:
parts = [f"【交易日】{trading_day}"]
if rolling_summary.strip():
parts.extend(["【对话核心摘要须接续勿重复开场", rolling_summary.strip()])
parts.extend(["【对话核心摘要(须接续,勿重复开场)", rolling_summary.strip()])
elif history_lines.strip():
parts.extend(["【最近对话】", history_lines.strip()])
parts.extend([
"【当前多账户快照事实参考持仓以「实盘持仓总览」为准",
context_text.strip() or "无监控数据",
"【当前多账户快照(事实参考;持仓以「实盘持仓总览」为准)",
context_text.strip() or "(无监控数据)",
])
if summary_excerpt.strip():
parts.extend([
"【今日总结摘要可能滞后持仓以快照为准",
"【今日总结摘要(可能滞后,持仓以快照为准)",
summary_excerpt.strip(),
])
if attachment_note.strip():
parts.extend(["【用户附件说明】", attachment_note.strip()])
parts.extend(["【用户现在说优先回应这一条", user_message.strip()])
parts.extend(["【用户现在说(优先回应这一条)", user_message.strip()])
return "\n\n".join(parts)
ARCHIVE_QUOTE_REVIEW_INSTRUCTION = """
任务用户从内照明心提交了一条复盘语录并附上该交易日的档案交易记录仅供你分析用户界面不展示明细
请结合语录与交易记录
1) 帮他核对自述与操作事实是否一致
2) 指出心态纪律执行上的偏差点若有
3) 给出可落地的改进建议
语气沿用交易教练体贴口语短句不用说教式清单不预测涨跌不保证收益
任务用户从内照明心提交了一条复盘语录,并附上该交易日的档案交易记录(仅供你分析,用户界面不展示明细).
请结合语录与交易记录:
1) 帮他核对自述与操作事实是否一致;
2) 指出心态,纪律,执行上的偏差点(若有);
3) 给出可落地的改进建议.
语气沿用交易教练:体贴,口语,短句,不用说教式清单;不预测涨跌,不保证收益.
""".strip()
SUPERVISOR_SYSTEM = """
你是交易监管值班员职责是防止过度交易与频繁手动操作用中文短句克制语气
你是交易监管值班员,职责是防止过度交易与频繁手动操作.用中文,短句,克制语气.
规则
- 只依据提供的结构化事件与账户快照说话禁止预测涨跌保证收益
- **手动平仓中控平仓新开仓**指出频率间隔是否偏急提醒休息不训斥
- **程序止盈/程序止损**肯定按计划执行鼓励保持纪律提醒别立刻反手再开
- 不替用户做决定不暗示绕过实例冷静期/日冻结
- 每次 1~3 必须写完整禁止长清单和第1点第2点
- 实例已进入冷静期/日冻结时明确说明状态建议暂停手动开平
规则:
- 只依据提供的结构化事件与账户快照说话;禁止预测涨跌,保证收益.
- **手动平仓,中控平仓,新开仓**:指出频率,间隔,是否偏急;提醒休息,不训斥.
- **程序止盈/程序止损**:肯定按计划执行,鼓励保持纪律,提醒别立刻反手再开.
- 不替用户做决定,不暗示绕过实例冷静期/日冻结.
- 每次 1~3 ,必须写完整;禁止长清单和第1点第2点.
- 实例已进入冷静期/日冻结时,明确说明状态,建议暂停手动开平.
""".strip()
@@ -200,11 +200,11 @@ def build_supervisor_ai_prompt(
"【监管事件】",
str(event or {}),
"【当前多账户快照】",
(context_text or "(无)").strip(),
(context_text or "(无)").strip(),
]
if warn_lines.strip():
parts.extend(["【已触发频率警告】", warn_lines.strip()])
parts.append("请给出 1~3 句监管评语")
parts.append("请给出 1~3 句监管评语:")
return "\n\n".join(parts)
@@ -220,7 +220,7 @@ def build_supervisor_chat_prompt(
parts.extend(["【今日监管对话】", history_lines.strip()])
parts.extend([
"【当前多账户快照】",
(context_text or "(无)").strip(),
(context_text or "(无)").strip(),
"【用户现在说】",
user_message.strip(),
])
@@ -236,9 +236,9 @@ def build_archive_quote_review_prompt(
parts = [
f"【复盘交易日】{quote_date}",
ARCHIVE_QUOTE_REVIEW_INSTRUCTION,
"【该日交易记录内照明心档案用户不可见此段",
(archive_trades_text or "该日无交易记录").strip(),
"【用户复盘语录对话框已展示请优先回应",
"【该日交易记录(内照明心档案,用户不可见此段)",
(archive_trades_text or "(该日无交易记录)").strip(),
"【用户复盘语录(对话框已展示,请优先回应)",
user_message.strip(),
]
return "\n\n".join(parts)
+4 -4
View File
@@ -1,4 +1,4 @@
"""聊天滚动摘要每轮后压缩历史续聊只带摘要 + 当前消息"""
"""聊天滚动摘要:每轮后压缩历史,续聊只带摘要 + 当前消息."""
from __future__ import annotations
from hub_ai.text_util import clip_text, is_ai_error_reply
@@ -24,7 +24,7 @@ def refresh_session_rolling_summary(
assistant_text: str,
bot_mode: str,
) -> str:
"""合并旧摘要与本轮对话生成新的短摘要并写入会话"""
"""合并旧摘要与本轮对话,生成新的短摘要并写入会话."""
user_clip = clip_text(user_text, 1200)
assistant_clip = clip_text(assistant_text, 1800)
if not user_clip and not assistant_clip:
@@ -63,7 +63,7 @@ def _fallback_summary(prior: str, user_text: str, assistant_text: str) -> str:
if prior.strip():
parts.append(prior.strip())
if user_text.strip():
parts.append(f"用户{clip_text(user_text, 200)}")
parts.append(f"用户:{clip_text(user_text, 200)}")
if assistant_text.strip():
parts.append(f"教练{clip_text(assistant_text, 280)}")
parts.append(f"教练:{clip_text(assistant_text, 280)}")
return clip_text("\n".join(parts), CHAT_ROLLING_SUMMARY_MAX_CHARS)
+200 -200
View File
@@ -1,200 +1,200 @@
"""中控 AI FastAPI 路由"""
from __future__ import annotations
import asyncio
from typing import Callable
from fastapi import APIRouter, Body, File, Form, HTTPException, UploadFile
from pydantic import BaseModel, Field
from hub_ai.archive_quote import send_archive_quote_review
from hub_ai.chat import (
get_chat_state,
remove_chat_session,
send_chat_message,
start_new_chat,
switch_chat_session,
)
from hub_ai.client import model_label
from hub_ai.config import trading_day_reset_hour
from hub_ai.context import build_daily_context
from hub_ai.store import get_latest_summary, list_summaries
from hub_ai.supervisor import send_supervisor_chat
from hub_ai.supervisor_store import get_supervisor_session_state
from hub_ai.summary import generate_daily_summary
from lib.hub.hub_trades_lib import current_trading_day
from settings_store import normalize_supervisor_settings
class ChatSendBody(BaseModel):
message: str = ""
trading_day: str = ""
class SummaryGenerateBody(BaseModel):
trading_day: str = ""
force: bool = False
class ChatNewBody(BaseModel):
trading_day: str = ""
bot_mode: str = "trading"
class ChatSwitchBody(BaseModel):
session_id: str = Field(..., min_length=1)
class ArchiveQuoteChatBody(BaseModel):
quote_date: str = ""
content: str = ""
class SupervisorChatBody(BaseModel):
message: str = ""
trading_day: str = ""
def create_hub_ai_router(*, load_all_exchanges: Callable[[], list]) -> APIRouter:
router = APIRouter(prefix="/api/ai", tags=["hub-ai"])
def _day(raw: str = "") -> str:
d = (raw or "").strip()[:10]
return d or current_trading_day(reset_hour=trading_day_reset_hour())
@router.get("/meta")
def api_ai_meta():
return {
"ok": True,
"model": model_label(),
"trading_day_reset_hour": trading_day_reset_hour(),
"trading_day": current_trading_day(reset_hour=trading_day_reset_hour()),
"storage": {
"summaries": "hub_ai_summaries.json",
"chat": "hub_ai_chat.json",
},
}
@router.get("/context")
def api_ai_context(trading_day: str = ""):
exchanges = load_all_exchanges()
ctx = build_daily_context(exchanges, trading_day=_day(trading_day))
return {"ok": True, **ctx}
@router.get("/summary")
def api_ai_summary_list(trading_day: str = ""):
day = _day(trading_day) if trading_day.strip() else ""
items = list_summaries(trading_day=day or None, limit=20)
latest = get_latest_summary(_day(trading_day)) if trading_day.strip() else (
items[0] if items else None
)
return {
"ok": True,
"trading_day": _day(trading_day) if trading_day.strip() else None,
"summaries": items,
"latest": latest,
"model": model_label(),
}
@router.post("/summary/generate")
def api_ai_summary_generate(body: SummaryGenerateBody = SummaryGenerateBody()):
exchanges = load_all_exchanges()
result = generate_daily_summary(
exchanges,
trading_day=_day(body.trading_day) if body.trading_day.strip() else None,
force=bool(body.force),
)
if not result.get("ok"):
raise HTTPException(status_code=502, detail=result.get("msg") or "生成失败")
result.pop("context", None)
return result
@router.get("/chat/session")
def api_ai_chat_session():
state = get_chat_state()
return {"ok": True, **state, "model": model_label()}
@router.post("/chat/new")
def api_ai_chat_new(body: ChatNewBody = ChatNewBody()):
day = _day(body.trading_day)
return start_new_chat(trading_day=day, bot_mode=body.bot_mode or "trading")
@router.post("/chat/switch")
def api_ai_chat_switch(body: ChatSwitchBody):
try:
return switch_chat_session(body.session_id.strip())
except KeyError:
raise HTTPException(status_code=404, detail="会话不存在")
@router.delete("/chat/session/{session_id}")
def api_ai_chat_delete(session_id: str):
result = remove_chat_session(session_id.strip())
if not result.get("ok"):
raise HTTPException(status_code=404, detail="会话不存在")
return result
@router.post("/chat/archive-quote")
def api_ai_chat_archive_quote(body: ArchiveQuoteChatBody = Body(...)):
result = send_archive_quote_review(
quote_date=body.quote_date,
content=body.content,
)
if not result.get("ok"):
raise HTTPException(status_code=502, detail=result.get("msg") or "发送失败")
return result
@router.post("/chat/send")
async def api_ai_chat_send(
message: str = Form(""),
trading_day: str = Form(""),
files: list[UploadFile] = File(default=[]),
):
exchanges = load_all_exchanges()
raw_attachments = []
for f in files or []:
if not f or not f.filename:
continue
data = await f.read()
raw_attachments.append(
{
"filename": f.filename,
"content_type": f.content_type or "",
"data": data,
}
)
result = await asyncio.to_thread(
send_chat_message,
exchanges,
message,
trading_day=_day(trading_day) if trading_day.strip() else None,
raw_attachments=raw_attachments,
)
if not result.get("ok"):
raise HTTPException(status_code=502, detail=result.get("msg") or "发送失败")
return result
@router.get("/supervisor/session")
def api_ai_supervisor_session(trading_day: str = ""):
day = _day(trading_day)
return get_supervisor_session_state(day)
@router.get("/supervisor/rules")
def api_ai_supervisor_rules():
from settings_store import load_settings
cfg = normalize_supervisor_settings(load_settings().get("supervisor"))
return {"ok": True, "supervisor": cfg}
@router.post("/supervisor/chat/send")
def api_ai_supervisor_chat_send(body: SupervisorChatBody = SupervisorChatBody()):
exchanges = load_all_exchanges()
result = send_supervisor_chat(
exchanges,
body.message,
trading_day=_day(body.trading_day) if body.trading_day.strip() else None,
)
if not result.get("ok"):
raise HTTPException(status_code=502, detail=result.get("msg") or "发送失败")
return result
return router
"""中控 AI FastAPI 路由."""
from __future__ import annotations
import asyncio
from typing import Callable
from fastapi import APIRouter, Body, File, Form, HTTPException, UploadFile
from pydantic import BaseModel, Field
from hub_ai.archive_quote import send_archive_quote_review
from hub_ai.chat import (
get_chat_state,
remove_chat_session,
send_chat_message,
start_new_chat,
switch_chat_session,
)
from hub_ai.client import model_label
from hub_ai.config import trading_day_reset_hour
from hub_ai.context import build_daily_context
from hub_ai.store import get_latest_summary, list_summaries
from hub_ai.supervisor import send_supervisor_chat
from hub_ai.supervisor_store import get_supervisor_session_state
from hub_ai.summary import generate_daily_summary
from lib.hub.hub_trades_lib import current_trading_day
from settings_store import normalize_supervisor_settings
class ChatSendBody(BaseModel):
message: str = ""
trading_day: str = ""
class SummaryGenerateBody(BaseModel):
trading_day: str = ""
force: bool = False
class ChatNewBody(BaseModel):
trading_day: str = ""
bot_mode: str = "trading"
class ChatSwitchBody(BaseModel):
session_id: str = Field(..., min_length=1)
class ArchiveQuoteChatBody(BaseModel):
quote_date: str = ""
content: str = ""
class SupervisorChatBody(BaseModel):
message: str = ""
trading_day: str = ""
def create_hub_ai_router(*, load_all_exchanges: Callable[[], list]) -> APIRouter:
router = APIRouter(prefix="/api/ai", tags=["hub-ai"])
def _day(raw: str = "") -> str:
d = (raw or "").strip()[:10]
return d or current_trading_day(reset_hour=trading_day_reset_hour())
@router.get("/meta")
def api_ai_meta():
return {
"ok": True,
"model": model_label(),
"trading_day_reset_hour": trading_day_reset_hour(),
"trading_day": current_trading_day(reset_hour=trading_day_reset_hour()),
"storage": {
"summaries": "hub_ai_summaries.json",
"chat": "hub_ai_chat.json",
},
}
@router.get("/context")
def api_ai_context(trading_day: str = ""):
exchanges = load_all_exchanges()
ctx = build_daily_context(exchanges, trading_day=_day(trading_day))
return {"ok": True, **ctx}
@router.get("/summary")
def api_ai_summary_list(trading_day: str = ""):
day = _day(trading_day) if trading_day.strip() else ""
items = list_summaries(trading_day=day or None, limit=20)
latest = get_latest_summary(_day(trading_day)) if trading_day.strip() else (
items[0] if items else None
)
return {
"ok": True,
"trading_day": _day(trading_day) if trading_day.strip() else None,
"summaries": items,
"latest": latest,
"model": model_label(),
}
@router.post("/summary/generate")
def api_ai_summary_generate(body: SummaryGenerateBody = SummaryGenerateBody()):
exchanges = load_all_exchanges()
result = generate_daily_summary(
exchanges,
trading_day=_day(body.trading_day) if body.trading_day.strip() else None,
force=bool(body.force),
)
if not result.get("ok"):
raise HTTPException(status_code=502, detail=result.get("msg") or "生成失败")
result.pop("context", None)
return result
@router.get("/chat/session")
def api_ai_chat_session():
state = get_chat_state()
return {"ok": True, **state, "model": model_label()}
@router.post("/chat/new")
def api_ai_chat_new(body: ChatNewBody = ChatNewBody()):
day = _day(body.trading_day)
return start_new_chat(trading_day=day, bot_mode=body.bot_mode or "trading")
@router.post("/chat/switch")
def api_ai_chat_switch(body: ChatSwitchBody):
try:
return switch_chat_session(body.session_id.strip())
except KeyError:
raise HTTPException(status_code=404, detail="会话不存在")
@router.delete("/chat/session/{session_id}")
def api_ai_chat_delete(session_id: str):
result = remove_chat_session(session_id.strip())
if not result.get("ok"):
raise HTTPException(status_code=404, detail="会话不存在")
return result
@router.post("/chat/archive-quote")
def api_ai_chat_archive_quote(body: ArchiveQuoteChatBody = Body(...)):
result = send_archive_quote_review(
quote_date=body.quote_date,
content=body.content,
)
if not result.get("ok"):
raise HTTPException(status_code=502, detail=result.get("msg") or "发送失败")
return result
@router.post("/chat/send")
async def api_ai_chat_send(
message: str = Form(""),
trading_day: str = Form(""),
files: list[UploadFile] = File(default=[]),
):
exchanges = load_all_exchanges()
raw_attachments = []
for f in files or []:
if not f or not f.filename:
continue
data = await f.read()
raw_attachments.append(
{
"filename": f.filename,
"content_type": f.content_type or "",
"data": data,
}
)
result = await asyncio.to_thread(
send_chat_message,
exchanges,
message,
trading_day=_day(trading_day) if trading_day.strip() else None,
raw_attachments=raw_attachments,
)
if not result.get("ok"):
raise HTTPException(status_code=502, detail=result.get("msg") or "发送失败")
return result
@router.get("/supervisor/session")
def api_ai_supervisor_session(trading_day: str = ""):
day = _day(trading_day)
return get_supervisor_session_state(day)
@router.get("/supervisor/rules")
def api_ai_supervisor_rules():
from settings_store import load_settings
cfg = normalize_supervisor_settings(load_settings().get("supervisor"))
return {"ok": True, "supervisor": cfg}
@router.post("/supervisor/chat/send")
def api_ai_supervisor_chat_send(body: SupervisorChatBody = SupervisorChatBody()):
exchanges = load_all_exchanges()
result = send_supervisor_chat(
exchanges,
body.message,
trading_day=_day(body.trading_day) if body.trading_day.strip() else None,
)
if not result.get("ok"):
raise HTTPException(status_code=502, detail=result.get("msg") or "发送失败")
return result
return router
+1 -1
View File
@@ -1,4 +1,4 @@
"""中控 AIJSON 持久化与 hub_settings.json 同目录)。"""
"""中控 AI:JSON 持久化(与 hub_settings.json 同目录)."""
from __future__ import annotations
import json
+1 -1
View File
@@ -1,4 +1,4 @@
"""中控 AI今日总结生成"""
"""中控 AI:今日总结生成."""
from __future__ import annotations
from typing import Any
+125 -125
View File
@@ -1,125 +1,125 @@
"""交易监管AI 评语与用户回聊"""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any, Optional
_REPO_ROOT = Path(__file__).resolve().parents[2]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
from lib.ai.ai_client import ai_generate # noqa: E402
from hub_ai.client import generate_text, model_label
from hub_ai.config import (
CHAT_MAX_OUTPUT_TOKENS,
CHAT_TEMPERATURE,
trading_day_reset_hour,
)
from hub_ai.context import build_chat_context, format_chat_context_for_chat, format_chat_position_overview
from hub_ai.prompts import SUPERVISOR_SYSTEM, build_supervisor_ai_prompt, build_supervisor_chat_prompt
from hub_ai.supervisor_store import (
append_supervisor_ai_message,
ensure_supervisor_session,
get_supervisor_session_state,
)
from hub_ai.store import append_chat_message
from hub_ai.text_util import is_ai_error_reply
from hub_supervisor_lib import build_supervisor_fallback_reply
from lib.hub.hub_trades_lib import current_trading_day
SUPERVISOR_AI_MAX_TOKENS = 320
def generate_supervisor_ai_reply(
*,
event: dict,
warnings: list[dict],
trading_day: str,
session_id: str,
exchanges: list[dict],
) -> str:
ctx = build_chat_context(exchanges, trading_day=trading_day)
brief = format_chat_position_overview(ctx) + "\n" + format_chat_context_for_chat(
ctx, max_chars=2400
)
user_prompt = build_supervisor_ai_prompt(
context_text=brief,
trading_day=trading_day,
event=event,
warnings=warnings,
)
prompt = f"{SUPERVISOR_SYSTEM.strip()}\n\n---\n\n{user_prompt.strip()}"
text = ai_generate(prompt, temperature=0.35, max_tokens=SUPERVISOR_AI_MAX_TOKENS)
text = str(text or "").strip()
if not text or is_ai_error_reply(text):
return build_supervisor_fallback_reply(event, warnings)
return text
def make_supervisor_ai_reply_fn(exchanges: list[dict]):
def _fn(*, event: dict, warnings: list[dict], trading_day: str, session_id: str) -> str:
return generate_supervisor_ai_reply(
event=event,
warnings=warnings or [],
trading_day=trading_day,
session_id=session_id,
exchanges=exchanges,
)
return _fn
def send_supervisor_chat(
exchanges: list[dict],
message: str,
*,
trading_day: str | None = None,
) -> dict[str, Any]:
text = (message or "").strip()
if not text:
return {"ok": False, "msg": "消息不能为空"}
day = (trading_day or "").strip()[:10] or current_trading_day(
reset_hour=trading_day_reset_hour()
)
session = ensure_supervisor_session(day)
sid = str(session.get("id") or "")
prior = session.get("messages") or []
ctx = build_chat_context(exchanges, trading_day=day)
brief = format_chat_context_for_chat(ctx, max_chars=6000)
recent = []
for m in prior[-8:]:
role = m.get("role")
if role not in ("user", "assistant", "system"):
continue
label = {"user": "用户", "assistant": "监管", "system": "系统"}.get(role, role)
recent.append(f"{label}{str(m.get('content') or '').strip()}")
user_prompt = build_supervisor_chat_prompt(
context_text=brief,
trading_day=day,
history_lines="\n".join(recent),
user_message=text,
)
reply = generate_text(
system=SUPERVISOR_SYSTEM,
user=user_prompt,
temperature=min(0.4, CHAT_TEMPERATURE),
max_tokens=min(768, CHAT_MAX_OUTPUT_TOKENS),
max_continuations=1,
)
reply = str(reply or "").strip()
if not reply or is_ai_error_reply(reply):
return {"ok": False, "msg": "AI 暂时不可用请稍后再试", "session_id": sid}
append_chat_message(sid, "user", text)
session = append_supervisor_ai_message(sid, reply)
state = get_supervisor_session_state(day)
return {
"ok": True,
"trading_day": day,
"session": session,
"reply": reply,
"model": model_label(),
"message_count": state.get("message_count"),
"unread_system": state.get("unread_system"),
}
"""交易监管:AI 评语与用户回聊."""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any, Optional
_REPO_ROOT = Path(__file__).resolve().parents[2]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
from lib.ai.ai_client import ai_generate # noqa: E402
from hub_ai.client import generate_text, model_label
from hub_ai.config import (
CHAT_MAX_OUTPUT_TOKENS,
CHAT_TEMPERATURE,
trading_day_reset_hour,
)
from hub_ai.context import build_chat_context, format_chat_context_for_chat, format_chat_position_overview
from hub_ai.prompts import SUPERVISOR_SYSTEM, build_supervisor_ai_prompt, build_supervisor_chat_prompt
from hub_ai.supervisor_store import (
append_supervisor_ai_message,
ensure_supervisor_session,
get_supervisor_session_state,
)
from hub_ai.store import append_chat_message
from hub_ai.text_util import is_ai_error_reply
from hub_supervisor_lib import build_supervisor_fallback_reply
from lib.hub.hub_trades_lib import current_trading_day
SUPERVISOR_AI_MAX_TOKENS = 320
def generate_supervisor_ai_reply(
*,
event: dict,
warnings: list[dict],
trading_day: str,
session_id: str,
exchanges: list[dict],
) -> str:
ctx = build_chat_context(exchanges, trading_day=trading_day)
brief = format_chat_position_overview(ctx) + "\n" + format_chat_context_for_chat(
ctx, max_chars=2400
)
user_prompt = build_supervisor_ai_prompt(
context_text=brief,
trading_day=trading_day,
event=event,
warnings=warnings,
)
prompt = f"{SUPERVISOR_SYSTEM.strip()}\n\n---\n\n{user_prompt.strip()}"
text = ai_generate(prompt, temperature=0.35, max_tokens=SUPERVISOR_AI_MAX_TOKENS)
text = str(text or "").strip()
if not text or is_ai_error_reply(text):
return build_supervisor_fallback_reply(event, warnings)
return text
def make_supervisor_ai_reply_fn(exchanges: list[dict]):
def _fn(*, event: dict, warnings: list[dict], trading_day: str, session_id: str) -> str:
return generate_supervisor_ai_reply(
event=event,
warnings=warnings or [],
trading_day=trading_day,
session_id=session_id,
exchanges=exchanges,
)
return _fn
def send_supervisor_chat(
exchanges: list[dict],
message: str,
*,
trading_day: str | None = None,
) -> dict[str, Any]:
text = (message or "").strip()
if not text:
return {"ok": False, "msg": "消息不能为空"}
day = (trading_day or "").strip()[:10] or current_trading_day(
reset_hour=trading_day_reset_hour()
)
session = ensure_supervisor_session(day)
sid = str(session.get("id") or "")
prior = session.get("messages") or []
ctx = build_chat_context(exchanges, trading_day=day)
brief = format_chat_context_for_chat(ctx, max_chars=6000)
recent = []
for m in prior[-8:]:
role = m.get("role")
if role not in ("user", "assistant", "system"):
continue
label = {"user": "用户", "assistant": "监管", "system": "系统"}.get(role, role)
recent.append(f"{label}:{str(m.get('content') or '').strip()}")
user_prompt = build_supervisor_chat_prompt(
context_text=brief,
trading_day=day,
history_lines="\n".join(recent),
user_message=text,
)
reply = generate_text(
system=SUPERVISOR_SYSTEM,
user=user_prompt,
temperature=min(0.4, CHAT_TEMPERATURE),
max_tokens=min(768, CHAT_MAX_OUTPUT_TOKENS),
max_continuations=1,
)
reply = str(reply or "").strip()
if not reply or is_ai_error_reply(reply):
return {"ok": False, "msg": "AI 暂时不可用,请稍后再试", "session_id": sid}
append_chat_message(sid, "user", text)
session = append_supervisor_ai_message(sid, reply)
state = get_supervisor_session_state(day)
return {
"ok": True,
"trading_day": day,
"session": session,
"reply": reply,
"model": model_label(),
"message_count": state.get("message_count"),
"unread_system": state.get("unread_system"),
}
@@ -1,4 +1,4 @@
"""交易监管专用会话今日长会话bot_mode=supervisor)。"""
"""交易监管专用会话(今日长会话,bot_mode=supervisor)."""
from __future__ import annotations
from typing import Any, Optional
+1 -1
View File
@@ -1,4 +1,4 @@
"""中控 AI 文本小工具"""
"""中控 AI 文本小工具."""
def is_ai_error_reply(text: str) -> bool:
+1 -1
View File
@@ -1,4 +1,4 @@
"""监控区 board后台定时聚合内存快照SSE 版本通知"""
"""监控区 board:后台定时聚合,内存快照,SSE 版本通知."""
from __future__ import annotations
+1 -1
View File
@@ -1,4 +1,4 @@
"""行情区 K 线后台轮询订阅 + SSE 推送尾部 K 线对齐监控区 board)。"""
"""行情区 K 线:后台轮询订阅 + SSE 推送尾部 K 线(对齐监控区 board)."""
from __future__ import annotations
+138 -138
View File
@@ -1,138 +1,138 @@
"""中控数据看板三户当日总览无 AI纯数据聚合)。"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Optional
from hub_ai.context import (
build_daily_context,
collect_closed_trades_snapshot,
format_account_remark,
format_dashboard_account_detail,
)
from hub_ai.config import trading_day_reset_hour
from lib.hub.hub_trades_lib import current_trading_day
LOSS_ALERT_PCT = 5.0
DASHBOARD_POLL_INTERVAL_SEC = 60
def _safe_float(v: Any) -> Optional[float]:
try:
if v is None or v == "":
return None
return float(v)
except (TypeError, ValueError):
return None
def _account_capital_base(ac: dict) -> Optional[float]:
funding = _safe_float(ac.get("funding_usdt"))
trading = _safe_float(ac.get("trading_usdt"))
if funding is not None and trading is not None:
return funding + trading
if funding is not None:
return funding
if trading is not None:
return trading
return None
def _options_layout_enabled(ac: dict) -> bool:
if str(ac.get("key") or "").lower() != "okx":
return False
snap = ac.get("options_snapshot")
if isinstance(snap, dict) and snap.get("enabled") is not False:
return True
return ac.get("options_funding_usdt") is not None or ac.get("options_trading_usdt") is not None
def _perpetual_float_pnl_u(ac: dict) -> Optional[float]:
try:
total = float(ac.get("float_pnl_u") or 0)
except (TypeError, ValueError):
total = 0.0
opt = ac.get("options_float_pnl_u")
if opt is None:
return round(total, 4)
try:
return round(total - float(opt), 4)
except (TypeError, ValueError):
return round(total, 4)
def _enrich_account_row(ac: dict) -> dict:
st = ac.get("trade_stats") or {}
capital = _account_capital_base(ac)
day_pnl = float(st.get("total_pnl_u") or 0)
loss_pct: Optional[float] = None
loss_alert = False
if capital is not None and capital > 0 and day_pnl < -1e-9:
loss_pct = round(abs(day_pnl) / capital * 100.0, 2)
loss_alert = loss_pct >= LOSS_ALERT_PCT
return {
"id": ac.get("id"),
"key": ac.get("key"),
"name": ac.get("name"),
"status": ac.get("status"),
"monitored": ac.get("status") != "未监控",
"funding_usdt": ac.get("funding_usdt"),
"trading_usdt": ac.get("trading_usdt"),
"perpetual_funding_usdt": ac.get("perpetual_funding_usdt"),
"perpetual_trading_usdt": ac.get("perpetual_trading_usdt"),
"options_funding_usdt": ac.get("options_funding_usdt"),
"options_trading_usdt": ac.get("options_trading_usdt"),
"options_float_pnl_u": ac.get("options_float_pnl_u"),
"options_open_position_count": ac.get("options_open_position_count"),
"options_layout": _options_layout_enabled(ac),
"perpetual_float_pnl_u": _perpetual_float_pnl_u(ac),
"capital_total_usdt": round(capital, 4) if capital is not None else None,
"available_trading_usdt": ac.get("available_trading_usdt"),
"pnl_u": st.get("total_pnl_u"),
"closed_count": st.get("closed_count"),
"win_count": st.get("win_count"),
"loss_count": st.get("loss_count"),
"float_pnl_u": ac.get("float_pnl_u"),
"open_position_count": ac.get("open_position_count"),
"remark": format_account_remark(ac),
**format_dashboard_account_detail(ac),
"issues": ac.get("issues") or [],
"daily_loss_pct": loss_pct,
"loss_alert": loss_alert,
}
def build_dashboard_payload(
exchanges: list[dict],
*,
trading_day: str | None = None,
) -> dict[str, Any]:
ctx = build_daily_context(exchanges, trading_day=trading_day)
day = ctx["trading_day"]
accounts_raw = ctx.get("accounts") or []
accounts = [
_enrich_account_row(ac)
for ac in accounts_raw
if ac.get("status") != "未监控"
]
closed_trades = collect_closed_trades_snapshot(
[ac for ac in accounts_raw if ac.get("status") != "未监控"],
today=day,
)
loss_alert_count = sum(1 for ac in accounts if ac.get("loss_alert"))
now = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S")
return {
"ok": True,
"updated_at": now,
"trading_day": day,
"totals": ctx.get("totals"),
"accounts": accounts,
"closed_trades": closed_trades,
"loss_alert_pct_threshold": LOSS_ALERT_PCT,
"loss_alert_count": loss_alert_count,
"poll_interval_sec": DASHBOARD_POLL_INTERVAL_SEC,
}
def default_trading_day() -> str:
return current_trading_day(reset_hour=trading_day_reset_hour())
"""中控数据看板:三户当日总览(无 AI,纯数据聚合)."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Optional
from hub_ai.context import (
build_daily_context,
collect_closed_trades_snapshot,
format_account_remark,
format_dashboard_account_detail,
)
from hub_ai.config import trading_day_reset_hour
from lib.hub.hub_trades_lib import current_trading_day
LOSS_ALERT_PCT = 5.0
DASHBOARD_POLL_INTERVAL_SEC = 60
def _safe_float(v: Any) -> Optional[float]:
try:
if v is None or v == "":
return None
return float(v)
except (TypeError, ValueError):
return None
def _account_capital_base(ac: dict) -> Optional[float]:
funding = _safe_float(ac.get("funding_usdt"))
trading = _safe_float(ac.get("trading_usdt"))
if funding is not None and trading is not None:
return funding + trading
if funding is not None:
return funding
if trading is not None:
return trading
return None
def _options_layout_enabled(ac: dict) -> bool:
if str(ac.get("key") or "").lower() != "okx":
return False
snap = ac.get("options_snapshot")
if isinstance(snap, dict) and snap.get("enabled") is not False:
return True
return ac.get("options_funding_usdt") is not None or ac.get("options_trading_usdt") is not None
def _perpetual_float_pnl_u(ac: dict) -> Optional[float]:
try:
total = float(ac.get("float_pnl_u") or 0)
except (TypeError, ValueError):
total = 0.0
opt = ac.get("options_float_pnl_u")
if opt is None:
return round(total, 4)
try:
return round(total - float(opt), 4)
except (TypeError, ValueError):
return round(total, 4)
def _enrich_account_row(ac: dict) -> dict:
st = ac.get("trade_stats") or {}
capital = _account_capital_base(ac)
day_pnl = float(st.get("total_pnl_u") or 0)
loss_pct: Optional[float] = None
loss_alert = False
if capital is not None and capital > 0 and day_pnl < -1e-9:
loss_pct = round(abs(day_pnl) / capital * 100.0, 2)
loss_alert = loss_pct >= LOSS_ALERT_PCT
return {
"id": ac.get("id"),
"key": ac.get("key"),
"name": ac.get("name"),
"status": ac.get("status"),
"monitored": ac.get("status") != "未监控",
"funding_usdt": ac.get("funding_usdt"),
"trading_usdt": ac.get("trading_usdt"),
"perpetual_funding_usdt": ac.get("perpetual_funding_usdt"),
"perpetual_trading_usdt": ac.get("perpetual_trading_usdt"),
"options_funding_usdt": ac.get("options_funding_usdt"),
"options_trading_usdt": ac.get("options_trading_usdt"),
"options_float_pnl_u": ac.get("options_float_pnl_u"),
"options_open_position_count": ac.get("options_open_position_count"),
"options_layout": _options_layout_enabled(ac),
"perpetual_float_pnl_u": _perpetual_float_pnl_u(ac),
"capital_total_usdt": round(capital, 4) if capital is not None else None,
"available_trading_usdt": ac.get("available_trading_usdt"),
"pnl_u": st.get("total_pnl_u"),
"closed_count": st.get("closed_count"),
"win_count": st.get("win_count"),
"loss_count": st.get("loss_count"),
"float_pnl_u": ac.get("float_pnl_u"),
"open_position_count": ac.get("open_position_count"),
"remark": format_account_remark(ac),
**format_dashboard_account_detail(ac),
"issues": ac.get("issues") or [],
"daily_loss_pct": loss_pct,
"loss_alert": loss_alert,
}
def build_dashboard_payload(
exchanges: list[dict],
*,
trading_day: str | None = None,
) -> dict[str, Any]:
ctx = build_daily_context(exchanges, trading_day=trading_day)
day = ctx["trading_day"]
accounts_raw = ctx.get("accounts") or []
accounts = [
_enrich_account_row(ac)
for ac in accounts_raw
if ac.get("status") != "未监控"
]
closed_trades = collect_closed_trades_snapshot(
[ac for ac in accounts_raw if ac.get("status") != "未监控"],
today=day,
)
loss_alert_count = sum(1 for ac in accounts if ac.get("loss_alert"))
now = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S")
return {
"ok": True,
"updated_at": now,
"trading_day": day,
"totals": ctx.get("totals"),
"accounts": accounts,
"closed_trades": closed_trades,
"loss_alert_pct_threshold": LOSS_ALERT_PCT,
"loss_alert_count": loss_alert_count,
"poll_interval_sec": DASHBOARD_POLL_INTERVAL_SEC,
}
def default_trading_day() -> str:
return current_trading_day(reset_hour=trading_day_reset_hour())
+1 -1
View File
@@ -1,4 +1,4 @@
"""数据看板后台定时聚合内存快照SSE 版本通知"""
"""数据看板:后台定时聚合,内存快照,SSE 版本通知."""
from __future__ import annotations
+1 -1
View File
@@ -1,4 +1,4 @@
"""中控 .env 读写与 PM2 重启"""
"""中控 .env 读写与 PM2 重启."""
from __future__ import annotations
import os
+1 -1
View File
@@ -1,4 +1,4 @@
"""交易监管后台扫描 + SSE 版本通知"""
"""交易监管:后台扫描 + SSE 版本通知."""
from __future__ import annotations
import asyncio
File diff suppressed because it is too large Load Diff
+9 -9
View File
@@ -1,4 +1,4 @@
"""中控 Web 登录HUB_USERNAME + HUB_PASSWORD 配置后启用会话 Cookie"""
"""中控 Web 登录:HUB_USERNAME + HUB_PASSWORD 配置后启用会话 Cookie."""
from __future__ import annotations
@@ -26,7 +26,7 @@ def _env_password() -> str:
def password_required() -> bool:
"""默认启用登录admin / admin123可通过 .env 覆盖)。"""
"""默认启用登录(admin / admin123,可通过 .env 覆盖)."""
return True
@@ -41,7 +41,7 @@ def verify_credentials(username: str, password: str) -> bool:
def verify_password(password: str) -> bool:
"""兼容旧调用仅校验密码用户名用默认值"""
"""兼容旧调用:仅校验密码,用户名用默认值."""
return verify_credentials(expected_username(), password)
@@ -93,7 +93,7 @@ def validate_session_token(token: str | None) -> bool:
def cookie_secure_env_enabled() -> bool:
"""是否在 .env 中启用「HTTPS 时带 Secure Cookie」策略"""
"""是否在 .env 中启用「HTTPS 时带 Secure Cookie」策略."""
return (os.getenv("HUB_COOKIE_SECURE") or "").strip().lower() in (
"1",
"true",
@@ -104,8 +104,8 @@ def cookie_secure_env_enabled() -> bool:
def cookie_secure_for_request(request) -> bool:
"""
仅在实际 HTTPS 访问时设置 Secure Cookie
这样可同时支持域名 HTTPS 反代 + 内网 http://IP:5100 登录
仅在实际 HTTPS 访问时设置 Secure Cookie.
这样可同时支持:域名 HTTPS 反代 + 内网 http://IP:5100 登录.
"""
if not cookie_secure_env_enabled():
return False
@@ -119,7 +119,7 @@ def cookie_secure_for_request(request) -> bool:
def embed_allowed() -> bool:
"""允许被本地导航等页面 iframe 嵌入默认开启内网场景)。"""
"""允许被本地导航等页面 iframe 嵌入(默认开启,内网场景)."""
return (os.getenv("HUB_ALLOW_EMBED") or "true").strip().lower() in (
"1",
"true",
@@ -129,7 +129,7 @@ def embed_allowed() -> bool:
def embed_frame_ancestors() -> str:
"""CSP frame-ancestors默认 *可设 HUB_EMBED_ORIGINS=http://192.168.8.6:5070"""
"""CSP frame-ancestors;默认 *,可设 HUB_EMBED_ORIGINS=http://192.168.8.6:5070"""
raw = (os.getenv("HUB_EMBED_ORIGINS") or "*").strip()
if raw == "*":
return "*"
@@ -139,7 +139,7 @@ def embed_frame_ancestors() -> str:
def set_session_cookie(response, request, token: str, *, embed: bool = False) -> None:
"""
embed=TrueLocalNav 等跨站 iframe 嵌入时须 SameSite=None + Secure HTTPS 有效
embed=True:LocalNav 等跨站 iframe 嵌入时须 SameSite=None + Secure( HTTPS 有效).
"""
secure = cookie_secure_for_request(request)
samesite = "lax"
+3 -3
View File
@@ -1,6 +1,6 @@
"""
OKX 挂单聚合子代理本地副本避免依赖仓库根 PYTHONPATH
普通委托 + 算法单 conditional / oco / trigger
OKX 挂单聚合(子代理本地副本,避免依赖仓库根 PYTHONPATH).
普通委托 + 算法单 conditional / oco / trigger.
"""
from __future__ import annotations
@@ -15,7 +15,7 @@ def _order_dedupe_key(order: dict) -> str:
def fetch_okx_all_open_orders(ex, exchange_symbol: str) -> list[dict]:
"""合并 OKX 普通挂单与算法挂单去重)。"""
"""合并 OKX 普通挂单与算法挂单(去重)."""
if not exchange_symbol:
return []
ex.load_markets()
+1 -1
View File
@@ -5,4 +5,4 @@ httpx>=0.27,<1
ccxt>=4.2,<5
PySocks>=1.7,<2
psutil>=5.9,<8
# 可选服务端 pip install markdown 后渲染更完整无则使用内置轻量渲染
# 可选:服务端 pip install markdown 后渲染更完整;无则使用内置轻量渲染
+4 -4
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# 检查三路子代理端口与 /status在服务器上运行
# 检查三路子代理端口与 /status(在服务器上运行)
set -e
check_one() {
@@ -11,17 +11,17 @@ check_one() {
if command -v curl >/dev/null 2>&1; then
local body
body=$(curl -sf --max-time 8 "http://127.0.0.1:${port}/status" 2>/dev/null) || {
echo " /status: 无法连接agent 未启动或崩溃"
echo " /status: 无法连接(agent 未启动或崩溃)"
return
}
echo " /status: ${body:0:200}"
if echo "${body}" | grep -q '"ok":true'; then
echo " 结果: OK"
else
echo " 结果: ok=false见上 JSON"
echo " 结果: ok=false,见上 JSON"
fi
else
echo " (未安装 curl跳过 HTTP 检测)"
echo " (未安装 curl,跳过 HTTP 检测)"
fi
echo
}
+3 -3
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# 去掉各目录 .env 的 Windows 换行符解决 PM2 agent errored: $'\r': command not found
# 去掉各目录 .env 的 Windows 换行符(解决 PM2 agent errored: $'\r': command not found)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -16,7 +16,7 @@ fixed=0
for d in "${dirs[@]}"; do
f="${d}/.env"
if [[ ! -f "${f}" ]]; then
echo "跳过无文件: ${f}"
echo "跳过(无文件): ${f}"
continue
fi
if grep -q $'\r' "${f}" 2>/dev/null; then
@@ -28,6 +28,6 @@ for d in "${dirs[@]}"; do
fi
done
echo "完成共修复 ${fixed} 个 .env"
echo "完成,共修复 ${fixed} 个 .env."
echo "请重启子代理: cd ${REPO}/manual_trading_hub && pm2 restart manual-agent-gate manual-agent-binance manual-agent-okx"
echo "或: bash scripts/pm2_hub.sh restart"
@@ -1,5 +1,5 @@
# shellcheck shell=bash
# 供 run_agent.sh / run_hub.sh source加载 .env 并去掉 Windows CRLF$'\r'
# 供 run_agent.sh / run_hub.sh source:加载 .env 并去掉 Windows CRLF($'\r')
load_dotenv_file() {
local f="$1"
if [[ ! -f "${f}" ]]; then
+4 -4
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# 仅子代理 PM2中控请用 scripts/pm2_hub.sh 或 ecosystem.config.cjs 一次起全部
# 仅子代理 PM2(中控请用 scripts/pm2_hub.sh 或 ecosystem.config.cjs 一次起全部)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -10,9 +10,9 @@ usage() {
cat <<'EOF'
用法: bash scripts/pm2_agents.sh <start|stop|restart|status|logs|delete>
一般请用: bash scripts/pm2_hub.sh start hub + agent 一起
一般请用: bash scripts/pm2_hub.sh start (hub + agent 一起)
本脚本仅操作 3 路子代理不含中控
本脚本仅操作 3 路子代理(不含中控)
仅启动币安: pm2 start ecosystem.agents.config.cjs --only manual-agent-binance
EOF
@@ -21,7 +21,7 @@ EOF
cmd="${1:-}"
if ! command -v pm2 >/dev/null 2>&1; then
echo "未找到 pm2请先: npm install -g pm2" >&2
echo "未找到 pm2,请先: npm install -g pm2" >&2
exit 1
fi
+4 -4
View File
@@ -18,7 +18,7 @@ usage() {
cat <<'EOF'
用法: bash scripts/pm2_hub.sh <start|stop|restart|status|logs|delete>
start 启动 ecosystem.config.cjs3 路子代理 + 中控已存在则 restart 全部
start 启动 ecosystem.config.cjs(3 路子代理 + 中控,已存在则 restart 全部)
stop 停止全部
restart 重启全部
status pm2 status
@@ -32,7 +32,7 @@ EOF
cmd="${1:-}"
if ! command -v pm2 >/dev/null 2>&1; then
echo "未找到 pm2请先: npm install -g pm2" >&2
echo "未找到 pm2,请先: npm install -g pm2" >&2
exit 1
fi
@@ -57,10 +57,10 @@ case "${cmd}" in
start)
if _any_running; then
pm2 restart "${ECO}"
echo "已重启hub + 全部 agent"
echo "已重启:hub + 全部 agent"
else
pm2 start "${ECO}"
echo "已启动hub + 全部 agent${#PM2_NAMES[@]} 个进程"
echo "已启动:hub + 全部 agent(${#PM2_NAMES[@]} 个进程)"
fi
pm2 save 2>/dev/null || true
;;
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# 仅重启失败的子代理保留 hub / 已 online 的 agent
# 仅重启失败的子代理(保留 hub / 已 online 的 agent)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -20,4 +20,4 @@ pm2 start "${ECO}" --only manual-agent-okx
pm2 start "${ECO}" --only manual-agent-gate
pm2 save 2>/dev/null || true
echo "已重建 binance / okx / gate 子代理请执行: bash scripts/check_agents.sh"
echo "已重建 binance / okx / gate 子代理,请执行: bash scripts/check_agents.sh"
+5 -5
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# PM2 子代理入口在策略目录cwd加载 .env 后启动 agent.py
# PM2 子代理入口:在策略目录(cwd)加载 .env 后启动 agent.py
set -e
set -o pipefail
@@ -17,20 +17,20 @@ _PM2_PORT="${PORT:-}"
_PM2_HOST="${HOST:-}"
if [[ ! -x "${VENV_PY}" ]]; then
echo "未找到 ${VENV_PY}请先在 manual_trading_hub: python3 -m venv .venv && pip install -r requirements.txt" >&2
echo "未找到 ${VENV_PY},请先在 manual_trading_hub: python3 -m venv .venv && pip install -r requirements.txt" >&2
exit 1
fi
if [[ -f .env ]]; then
if grep -q $'\r' .env 2>/dev/null; then
echo "警告: $(pwd)/.env 含 Windows 换行(CRLF)请在仓库根执行: bash manual_trading_hub/scripts/fix_env_crlf.sh" >&2
echo "警告: $(pwd)/.env 含 Windows 换行(CRLF),请在仓库根执行: bash manual_trading_hub/scripts/fix_env_crlf.sh" >&2
fi
if ! load_dotenv_file ".env"; then
echo "错误: $(pwd)/.env 加载失败" >&2
exit 1
fi
else
echo "警告: $(pwd) 下无 .envagent 可能缺少 API 密钥" >&2
echo "警告: $(pwd) 下无 .env,agent 可能缺少 API 密钥" >&2
fi
[[ -n "${_PM2_EXCHANGE}" ]] && export EXCHANGE="${_PM2_EXCHANGE}"
@@ -39,7 +39,7 @@ fi
if command -v ss >/dev/null 2>&1 && [[ -n "${PORT:-}" ]]; then
if ss -tln 2>/dev/null | grep -q ":${PORT} "; then
echo "错误: 端口 ${PORT} 已被占用agent 无法监听exchange=${EXCHANGE:-?}" >&2
echo "错误: 端口 ${PORT} 已被占用,agent 无法监听(exchange=${EXCHANGE:-?})" >&2
exit 1
fi
fi
+2 -2
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# PM2 / 手动启动入口加载 manual_trading_hub/.env 后运行 hub.py
# PM2 / 手动启动入口:加载 manual_trading_hub/.env 后运行 hub.py
set -e
set -o pipefail
@@ -13,7 +13,7 @@ source "${HUB_DIR}/scripts/lib_load_dotenv.sh"
VENV_PY="${HUB_DIR}/.venv/bin/python"
if [[ ! -x "${VENV_PY}" ]]; then
echo "未找到 ${VENV_PY}请先: python3 -m venv .venv && pip install -r requirements.txt" >&2
echo "未找到 ${VENV_PY},请先: python3 -m venv .venv && pip install -r requirements.txt" >&2
exit 1
fi
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# 在服务器上检查中控是否为最新代码无 api_trade_key已装 multipart进程可访问
# 在服务器上检查中控是否为最新代码(无 api_trade_key,已装 multipart,进程可访问)
set -euo pipefail
HUB_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$HUB_DIR"
@@ -13,14 +13,14 @@ PING_URL="http://127.0.0.1:${HUB_PORT}/api/ping"
echo "=== hub.py 检查 ==="
if grep -n 'def api_trade_key' hub.py 2>/dev/null; then
echo "FAIL: 仍是旧版 hub.py含 api_trade_key),请 git pull"
echo "FAIL: 仍是旧版 hub.py(含 api_trade_key),请 git pull"
exit 1
fi
if ! grep -q 'HUB_BUILD' hub.py; then
echo "FAIL: hub.py 缺少 HUB_BUILD 标记"
exit 1
fi
echo "OK: 无 api_trade_key含 HUB_BUILD"
echo "OK: 无 api_trade_key,含 HUB_BUILD"
echo "=== python-multipart ==="
# shellcheck source=/dev/null
@@ -29,7 +29,7 @@ python -c "import multipart; print('OK:', multipart.__version__)"
echo "=== 端口 ${HUB_PORT} ==="
if command -v ss >/dev/null 2>&1; then
ss -ltn | grep -E ":${HUB_PORT}\\b" || echo "WARN: 未监听 ${HUB_PORT}请 pm2 restart manual-trading-hub"
ss -ltn | grep -E ":${HUB_PORT}\\b" || echo "WARN: 未监听 ${HUB_PORT},请 pm2 restart manual-trading-hub"
elif command -v netstat >/dev/null 2>&1; then
netstat -ltn | grep -E ":${HUB_PORT}\\b" || echo "WARN: 未监听 ${HUB_PORT}"
else
@@ -51,10 +51,10 @@ if [[ "${HTTP_CODE}" == "200" ]]; then
if grep -q '20260521-no-trade-ui' /tmp/hub_ping_body.txt; then
echo "OK: build 正确"
else
echo "WARN: build 字段不是 20260521-no-trade-ui请 pm2 restart manual-trading-hub"
echo "WARN: build 字段不是 20260521-no-trade-ui,请 pm2 restart manual-trading-hub"
fi
else
echo "FAIL: ping 未返回 200常见原因进程未启动或崩溃"
echo "FAIL: ping 未返回 200.常见原因:进程未启动或崩溃."
echo " 执行: pm2 restart manual-trading-hub && sleep 2 && bash scripts/verify_hub_deploy.sh"
exit 1
fi
@@ -1,42 +1,42 @@
# 中控与子代理 · 后台常驻Ubuntu
**唯一推荐方式PM2**
请仅使用 PM2 托管 `hub.py``agent.py`勿与 nohup 等方式重复启动同一端口
---
## 启动
```bash
cd /opt/crypto_monitor/manual_trading_hub
source .venv/bin/activate
pip install -r requirements.txt
cp -n .env.example .env # 首次
pm2 start ecosystem.config.cjs
pm2 save
pm2 list
```
一条 `ecosystem.config.cjs` 会拉起 **4 个子代理 + 1 个 hub**
---
## 常用命令
```bash
pm2 logs manual-trading-hub
pm2 restart manual-trading-hub
pm2 restart all
bash scripts/verify_hub_deploy.sh
```
---
## 详细说明
| 文档 | 内容 |
|------|------|
| [../部署文档.md](../部署文档.md) | 端口反代故障排查 |
| [../../docs/ubuntu-server.md](../../docs/ubuntu-server.md) | Python / Node / PM2 版本与三所启动顺序 |
# 中控与子代理 · 后台常驻(Ubuntu)
**唯一推荐方式:PM2.**
请仅使用 PM2 托管 `hub.py``agent.py`,勿与 nohup 等方式重复启动同一端口.
---
## 启动
```bash
cd /opt/crypto_monitor/manual_trading_hub
source .venv/bin/activate
pip install -r requirements.txt
cp -n .env.example .env # 首次
pm2 start ecosystem.config.cjs
pm2 save
pm2 list
```
一条 `ecosystem.config.cjs` 会拉起 **4 个子代理 + 1 个 hub**.
---
## 常用命令
```bash
pm2 logs manual-trading-hub
pm2 restart manual-trading-hub
pm2 restart all
bash scripts/verify_hub_deploy.sh
```
---
## 详细说明
| 文档 | 内容 |
|------|------|
| [../部署文档.md](../部署文档.md) | 端口,反代,故障排查 |
| [../../docs/ubuntu-server.md](../../docs/ubuntu-server.md) | Python / Node / PM2 版本与三所启动顺序 |
+2 -2
View File
@@ -1,4 +1,4 @@
"""中控交易所配置hub_settings.json)。"""
"""中控交易所配置(hub_settings.json)."""
from __future__ import annotations
@@ -72,7 +72,7 @@ def _ids_from_csv(raw: str | None) -> set[str]:
def env_force_disabled_ids() -> set[str]:
# 未设置时默认不强制关闭任何账户要用旧行为可设 HUB_DISABLED_IDS=1
# 未设置时默认不强制关闭任何账户;要用旧行为可设 HUB_DISABLED_IDS=1
raw = (os.getenv("HUB_DISABLED_IDS") or "").strip()
return _ids_from_csv(raw)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+11 -11
View File
@@ -1,5 +1,5 @@
/**
* 内照明心复盘语录 + 当日交易记录 + 按需 K 线
* 内照明心:复盘语录 + 当日交易记录 + 按需 K 线.
*/
(function () {
const page = document.getElementById("page-archive");
@@ -597,7 +597,7 @@
function quotePreview(text) {
const s = String(text || "").replace(/\s+/g, " ").trim();
if (!s) return "(空)";
if (!s) return "(空)";
return s.length > 36 ? s.slice(0, 36) + "…" : s;
}
@@ -649,7 +649,7 @@
elQuotesCount.textContent = quotes.length ? quotes.length + " 条" : "";
}
if (!quotes.length) {
elQuotesList.innerHTML = '<p class="archive-empty">暂无复盘语录可在上方添加</p>';
elQuotesList.innerHTML = '<p class="archive-empty">暂无复盘语录,可在上方添加.</p>';
return;
}
elQuotesList.innerHTML = quotes
@@ -675,7 +675,7 @@
(selected
? '<div class="archive-quote-detail">' +
'<div class="archive-quote-full">' +
esc(q.content || "(空)") +
esc(q.content || "(空)") +
"</div>" +
'<div class="archive-quote-actions">' +
'<button type="button" class="ghost archive-quote-edit-btn" data-id="' +
@@ -778,7 +778,7 @@
const q = findQuote(quoteId);
const content = q && String(q.content || "").trim();
if (!q || !content) {
setStatus("语录内容为空无法发起 AI 对话");
setStatus("语录内容为空,无法发起 AI 对话");
return;
}
try {
@@ -801,7 +801,7 @@
}
async function deleteQuote(id) {
if (!id || !window.confirm("确定删除这条复盘语录")) return;
if (!id || !window.confirm("确定删除这条复盘语录?")) return;
const r = await apiFetch("/api/archive/quotes/" + id, { method: "DELETE" });
if (!r.ok) {
const j = await r.json().catch(function () {
@@ -1170,7 +1170,7 @@
const exKey = String(tr.exchange_key || "").toLowerCase();
const sym = tr.symbol || "";
if (!exKey || !sym) {
setStatus("该笔交易缺少交易所或合约无法切换");
setStatus("该笔交易缺少交易所或合约,无法切换");
return;
}
const key = tradeRowKey(tr);
@@ -1206,7 +1206,7 @@
const exKey = String(tr.exchange_key || "").toLowerCase();
const sym = tr.symbol || "";
if (!exKey || !sym) {
setStatus("该笔交易缺少交易所或合约无法加载图表");
setStatus("该笔交易缺少交易所或合约,无法加载图表");
return;
}
setChartOpen(true);
@@ -1217,7 +1217,7 @@
if (!elTrades) return;
if (!dailyTrades.length) {
elTrades.innerHTML =
'<p class="archive-empty">该日暂无交易记录可调整日期或点击「同步」拉取数据</p>';
'<p class="archive-empty">该日暂无交易记录.可调整日期或点击「同步」拉取数据.</p>';
return;
}
elTrades.innerHTML =
@@ -1390,7 +1390,7 @@
async function deleteTrade(tradeId, exchangeKey) {
const exKey = exchangeKey || (selected && selected.exchange_key);
if (!exKey || tradeId == null) return;
if (!window.confirm("从档案移除该笔交易?(不影响交易所实例里的复盘记录")) return;
if (!window.confirm("从档案移除该笔交易?(不影响交易所实例里的复盘记录)")) return;
const r = await apiFetch("/api/archive/trade/" + exKey + "/" + tradeId, { method: "DELETE" });
if (!r.ok) {
const j = await r.json().catch(function () {
@@ -1510,7 +1510,7 @@
}
async function syncAll() {
setStatus("同步中可能需数分钟…");
setStatus("同步中(可能需数分钟)…");
if (elBtnSync) elBtnSync.disabled = true;
try {
const r = await apiFetch("/api/archive/sync", { method: "POST" });
+11 -11
View File
@@ -61,15 +61,15 @@
const state = (status && status.state) || {};
const root = (status && status.backup_root) || "";
let html = '<div class="backup-meta">';
html += '<div>目录<code>' + esc(root) + '</code></div>';
html += '<div>目录:<code>' + esc(root) + '</code></div>';
if (state.last_backup_at) {
html += '<div>上次备份' + esc(state.last_backup_at) + '' + esc(state.last_trigger || "") + "</div>";
html += '<div>上次备份:' + esc(state.last_backup_at) + '(' + esc(state.last_trigger || "") + ")</div>";
}
if (state.last_auto_at) {
html += '<div>上次自动' + esc(state.last_auto_at) + "</div>";
html += '<div>上次自动:' + esc(state.last_auto_at) + "</div>";
}
if (state.last_restore_at) {
html += '<div>上次恢复' + esc(state.last_restore_at) + " ← " + esc(state.last_restore_from || "") + "</div>";
html += '<div>上次恢复:' + esc(state.last_restore_at) + " ← " + esc(state.last_restore_from || "") + "</div>";
}
html += "</div>";
if (!rows.length) {
@@ -159,14 +159,14 @@
setStatus(data.detail || "备份失败", true);
return;
}
setStatus("完成" + (data.file || "") + "" + fmtBytes(data.size) + "");
setStatus("完成:" + (data.file || "") + "(" + fmtBytes(data.size) + ")");
await loadBackupStatus();
if (typeof showToast === "function") showToast("备份完成");
}
async function restoreLocal(name) {
if (!name) return;
if (!window.confirm("确认从服务器备份 " + name + " 恢复\n恢复前会自动做 pre-restore 快照并重启 PM2")) return;
if (!window.confirm("确认从服务器备份 " + name + " 恢复?\n恢复前会自动做 pre-restore 快照并重启 PM2.")) return;
if (window.prompt('请输入 RESTORE 确认恢复') !== "RESTORE") return;
setStatus("恢复中…");
const r = await fetch("/api/backup/restore-local", {
@@ -182,9 +182,9 @@
setStatus(data.detail || "恢复失败", true);
return;
}
setStatus("恢复完成已恢复 " + ((data.restored && data.restored.length) || 0) + " 个文件");
setStatus("恢复完成,已恢复 " + ((data.restored && data.restored.length) || 0) + " 个文件");
await loadBackupStatus();
if (typeof showToast === "function") showToast("恢复完成请刷新页面");
if (typeof showToast === "function") showToast("恢复完成,请刷新页面");
}
async function restoreUpload() {
@@ -193,7 +193,7 @@
setStatus("请选择 .zip 备份文件", true);
return;
}
if (!window.confirm("确认上传并恢复 " + file.name + "\n恢复前会自动做 pre-restore 快照并重启 PM2")) return;
if (!window.confirm("确认上传并恢复 " + file.name + "?\n恢复前会自动做 pre-restore 快照并重启 PM2.")) return;
if (window.prompt('请输入 RESTORE 确认恢复') !== "RESTORE") return;
setStatus("上传并恢复中…");
const fd = new FormData();
@@ -211,10 +211,10 @@
setStatus(data.detail || "恢复失败", true);
return;
}
setStatus("恢复完成已恢复 " + ((data.restored && data.restored.length) || 0) + " 个文件");
setStatus("恢复完成,已恢复 " + ((data.restored && data.restored.length) || 0) + " 个文件");
if (elRestoreFile) elRestoreFile.value = "";
await loadBackupStatus();
if (typeof showToast === "function") showToast("恢复完成请刷新页面");
if (typeof showToast === "function") showToast("恢复完成,请刷新页面");
}
window.initBackupSettingsUI = async function () {
+3 -3
View File
@@ -1,5 +1,5 @@
/**
* 中控策略计算器趋势回调 / 滚仓历史测算
* 中控策略计算器:趋势回调 / 滚仓历史测算
*/
(function () {
const page = document.getElementById("page-calculator");
@@ -42,7 +42,7 @@
return String(n);
}
/** 去掉尾部多余 0用于乘数/精度展示 */
/** 去掉尾部多余 0,用于乘数/精度展示 */
function fmtTrim(v, maxDigits) {
if (v == null || v === "") return "—";
const n = Number(v);
@@ -376,7 +376,7 @@
"<div><span>单次风险预算</span><strong>" +
fmt(data.risk_budget_u, 2) +
"U</strong></div>" +
"<div><span>首仓张数自动</span><strong>" +
"<div><span>首仓张数(自动)</span><strong>" +
fmt(data.first_contracts, amt) +
"</strong></div>" +
"<div><span>最终累计张数</span><strong>" +
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,5 +1,5 @@
/**
* 行情区左侧画线工具canvas 叠加层坐标与 Lightweight Charts 对齐
* 行情区左侧画线工具(canvas 叠加层,坐标与 Lightweight Charts 对齐).
*/
(function () {
const STORAGE_PREFIX = "hubMarketDraw:";
@@ -1276,7 +1276,7 @@
function setActiveTool(tool) {
if (!TOOL_LABELS[tool]) return;
if (tool === "clear") {
if (drawings.length && window.confirm("清除当前图表上的全部画线")) {
if (drawings.length && window.confirm("清除当前图表上的全部画线?")) {
drawings = [];
selectedId = null;
draft = null;
+1 -1
View File
@@ -1,4 +1,4 @@
/* 数据看板 — 随中控亮/暗主题卡片柔光 */
/* 数据看板 — 随中控亮/暗主题,卡片柔光 */
body.hub-page-dashboard {
--dash-card-bg: var(--panel);
--dash-card-border: var(--border-soft);
+4 -4
View File
@@ -1,5 +1,5 @@
/**
* 中控数据看板后端 SSE 推送版本号前端拉快照刷新无轮询闪烁
* 中控数据看板:后端 SSE 推送版本号,前端拉快照刷新(无轮询闪烁).
*/
(function () {
const page = document.getElementById("page-dashboard");
@@ -362,9 +362,9 @@
const names = (data.accounts || [])
.filter((a) => a.loss_alert)
.map((a) => a.name)
.join("");
.join(",");
elBanner.classList.add("is-on");
elBannerText.textContent = `${alertCount} 户单日平仓亏损超过资金合计 ${threshold}%${names}`;
elBannerText.textContent = `${alertCount} 户单日平仓亏损超过资金合计 ${threshold}%:${names}`;
} else {
elBanner.classList.remove("is-on");
elBannerText.textContent = "";
@@ -426,7 +426,7 @@
});
dashEventSource.onerror = () => {
closeDashboardStream();
setStatus("SSE 断开8s 后重连…", true);
setStatus("SSE 断开,8s 后重连…", true);
dashReconnectTimer = setTimeout(() => {
if (inited) {
connectDashboardStream();
+7 -7
View File
@@ -1,5 +1,5 @@
/**
* 中控资金概况总资金曲线分户资金与回撤资金户+交易户不含浮盈
* 中控资金概况:总资金曲线,分户资金与回撤(资金户+交易户,不含浮盈).
*/
(function () {
const page = document.getElementById("page-funds");
@@ -201,7 +201,7 @@
'"' +
clickable +
' title="' +
esc(monitored ? "点击查看 " + name + " 资金曲线" : "未监控不参与合计") +
esc(monitored ? "点击查看 " + name + " 资金曲线" : "未监控,不参与合计") +
'">' +
'<div class="funds-ac-head">' +
'<h3 class="funds-ac-name">' +
@@ -279,7 +279,7 @@
if (elFsTitle) elFsTitle.textContent = ac.name || ac.key || "—";
if (elFsSub) {
const parts = [
"资金户 + 交易户 + 期权户USDC≈USDT不含浮盈",
"资金户 + 交易户 + 期权户(USDC≈USDT,不含浮盈)",
"交易日 " + (meta.trading_day || "—"),
"自 " + (meta.history_start_day || "—") + " 起",
];
@@ -330,7 +330,7 @@
});
} else if (elFsChartHost) {
elFsChartHost.innerHTML =
'<p class="funds-empty">暂无历史曲线请保持监控板运行以积累快照</p>';
'<p class="funds-empty">暂无历史曲线,请保持监控板运行以积累快照</p>';
}
}
@@ -340,13 +340,13 @@
const hour = data && data.reset_hour != null ? data.reset_hour : 8;
if (elDescBody) {
elDescBody.textContent =
"总资金 = 各监控户永续资金账户 + 交易账户 + 期权账户USDC 按 1:1 计入 USDT);自 " +
"总资金 = 各监控户(永续资金账户 + 交易账户 + 期权账户,USDC 按 1:1 计入 USDT);自 " +
start +
" 起按北京时间 " +
hour +
":00 交易日切日快照最多保留 " +
":00 交易日切日快照,最多保留 " +
keep +
" 天起算日由环境变量 HUB_FUND_HISTORY_START_DAY 配置";
" 天.起算日由环境变量 HUB_FUND_HISTORY_START_DAY 配置.";
}
if (elChartSub) {
elChartSub.textContent = keep + " TRADING DAYS";
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -51,7 +51,7 @@
<p id="login-err" class="login-err" hidden></p>
<p id="login-hint" class="login-foot" hidden></p>
</form>
<p class="login-foot">默认账号 <code>admin</code> / <code>admin123</code>可在 hub <code>.env</code> 修改 <code>HUB_USERNAME</code> / <code>HUB_PASSWORD</code></p>
<p class="login-foot">默认账号 <code>admin</code> / <code>admin123</code>;可在 hub <code>.env</code> 修改 <code>HUB_USERNAME</code> / <code>HUB_PASSWORD</code></p>
</div>
<script>
(function () {
@@ -68,8 +68,8 @@
if (inFrame) {
hint.hidden = false;
hint.textContent = isHttps
? "嵌入模式登录成功后将自动写入会话"
: "嵌入模式需 HTTPS 中控HTTP 时请用本地导航工具栏「中控登录」";
? "嵌入模式:登录成功后将自动写入会话."
: "嵌入模式需 HTTPS 中控;HTTP 时请用本地导航工具栏「中控登录」.";
}
function showErr(msg) {
@@ -81,11 +81,11 @@
const target = dest.startsWith("/") ? dest : "/monitor";
if (inFrame) {
if (!token) {
showErr("登录响应缺少 session_token请升级云端 hub 或使用本地导航「中控登录」");
showErr("登录响应缺少 session_token,请升级云端 hub 或使用本地导航「中控登录」.");
return;
}
if (!isHttps) {
showErr("跨站 iframe 登录需要 HTTPS 中控请改用本地导航「中控登录」按钮");
showErr("跨站 iframe 登录需要 HTTPS 中控;请改用本地导航「中控登录」按钮.");
return;
}
const q = new URLSearchParams({ token, next: target });
@@ -147,12 +147,12 @@
return;
}
if (r.status === 403) {
showErr("访问被拒绝(403)云端 hub 需设置 HUB_ALLOW_PUBLIC=true");
showErr("访问被拒绝(403):云端 hub 需设置 HUB_ALLOW_PUBLIC=true");
} else {
showErr(j.detail || j.msg || "用户名或密码错误 (" + r.status + ")");
}
} catch (ex) {
showErr("网络错误" + ex);
showErr("网络错误:" + ex);
}
submitBtn.disabled = false;
submitBtn.textContent = oldLabel;
+5 -5
View File
@@ -1,5 +1,5 @@
/**
* 开仓计划新建 / 进行中 / 历史 / 胜率统计
* 开仓计划:新建 / 进行中 / 历史 / 胜率统计
*/
(function () {
const page = document.getElementById("page-plan");
@@ -217,7 +217,7 @@
'<label class="plan-field plan-field-inline"><span>盈亏</span>' +
'<input class="plan-close-pnl" data-id="' +
esc(p.id) +
'" type="number" step="any" placeholder="U可选" value="' +
'" type="number" step="any" placeholder="U(可选)" value="' +
(p.pnl_amount != null ? esc(p.pnl_amount) : "") +
'" /></label>' +
'<button type="button" class="primary plan-btn-archive" data-id="' +
@@ -584,7 +584,7 @@
const id = t.getAttribute("data-id");
if (!id) return;
if (t.classList.contains("plan-btn-del")) {
if (!window.confirm("确定删除该进行中的计划")) return;
if (!window.confirm("确定删除该进行中的计划?")) return;
api("/api/entry-plans/" + id, { method: "DELETE" })
.then(function () {
toast("已删除");
@@ -609,12 +609,12 @@
const schemeEl = card && card.querySelector('.plan-active-scheme[data-id="' + id + '"]');
const result = resultEl && resultEl.value;
if (!result) {
toast("请先选择结果盈/亏", true);
toast("请先选择结果(盈/亏)", true);
return;
}
const scheme = schemeEl && schemeEl.value;
if (!scheme) {
toast("请先选择入场方案根据实际进场填写", true);
toast("请先选择入场方案(根据实际进场填写)", true);
return;
}
const payload = { result: result, entry_scheme: scheme };
+2 -2
View File
@@ -1,5 +1,5 @@
/**
* 策略说明三所 MD + 开仓检查清单 JSON
* 策略说明:三所 MD + 开仓检查清单 JSON.
*/
(function () {
const page = document.getElementById("page-strategy");
@@ -109,7 +109,7 @@
if (docBody) docBody.innerHTML = data.strategy_html || "";
if (docSource) {
const ver = data.version ? ` · ${data.version}` : "";
docSource.textContent = `文档${data.md_source || ""}${ver}`;
docSource.textContent = `文档:${data.md_source || ""}${ver}`;
}
renderChecklist(data.checklist);
scheduleHeightSync();
+1 -1
View File
@@ -1,4 +1,4 @@
/** 中控主题暗色默认/ 亮色localStorage hub-theme */
/** 中控主题:暗色(默认)/ 亮色,localStorage hub-theme */
(function (global) {
const KEY = "hub-theme";
const META = { dark: "#0b0e18", light: "#d4dde8" };
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* 时间平仓 + 整点强制清仓表单开关 + 持仓/顶栏倒计时
* 时间平仓 + 整点强制清仓:表单开关 + 持仓/顶栏倒计时.
*/
(function (global) {
"use strict";
+4 -4
View File
@@ -1,4 +1,4 @@
"""将 127.0.0.1 服务地址转为浏览器可访问的外链内网 IP 或域名)。"""
"""将 127.0.0.1 服务地址转为浏览器可访问的外链(内网 IP 或域名)."""
from __future__ import annotations
@@ -10,7 +10,7 @@ _LOCAL_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
def public_origin() -> tuple[str, str] | None:
"""
从环境变量读取对外 Origin
从环境变量读取对外 Origin.
HUB_PUBLIC_ORIGIN=http://192.168.1.10 HUB_PUBLIC_HOST=192.168.1.10
"""
raw = (os.getenv("HUB_PUBLIC_ORIGIN") or os.getenv("HUB_PUBLIC_HOST") or "").strip()
@@ -30,8 +30,8 @@ def public_origin() -> tuple[str, str] | None:
def browser_url(internal_url: str | None) -> str:
"""
中控本机请求仍用 internal_url返回给前端复盘链接用本函数
若未配置 HUB_PUBLIC_* 或原 URL 已是非本机地址则原样返回
中控本机请求仍用 internal_url;返回给前端,复盘链接用本函数.
若未配置 HUB_PUBLIC_* 或原 URL 已是非本机地址,则原样返回.
"""
if not internal_url or not str(internal_url).strip():
return ""
+289 -289
View File
@@ -1,289 +1,289 @@
# 云服务器部署说明
本文说明在 **云服务器VPS** 上部署 `crypto_monitor` 中控与三实例的推荐配置硬件软件防火墙宝塔反代环境变量PM2 启动与验收
云上标准做法**域名 + 宝塔/Nginx 反代 + HTTPS**业务端口5100500050041520015202**不对公网直连**
相关文档
- **[本地数据迁移到云端.md](./本地数据迁移到云端.md)** — 备份 `crypto.db`图片`hub_settings` 与恢复步骤
- [局域网与反代部署说明.md](./局域网与反代部署说明.md) — 局域网 IP:端口 与反代域名对照SSO 行为
- [部署文档.md](./部署文档.md) — PM2依赖安装日常运维
- [使用说明.md](./使用说明.md) — 中控功能说明
- [常见问题.md](./常见问题.md) — 故障排查
- 环境变量模板[.env.example](./.env.example)
---
## 一服务器硬件与系统
| 项目 | 建议 |
|------|------|
| 配置 | **2 核 4G** 起步三实例 + 中控 + PM2 同时运行**4G8G 更稳** |
| 系统 | **Ubuntu 22.04 / 24.04**项目文档按 Linux 编写 |
| 磁盘 | **20G+**日志SQLite上传图片会占空间 |
| 网络 | 需能访问各交易所 API若走代理在对应 `crypto_monitor_*/.env` 配置 `OKX_SOCKS_PROXY``BINANCE_SOCKS_PROXY` 等 |
---
## 二软件环境
```bash
sudo apt update
sudo apt install -y python3 python3-venv python3-pip git curl
# 进程守护推荐
sudo npm i -g pm2
```
**宝塔面板可选但推荐**安装 **Nginx**用于反向代理与 **SSL**Lets Encrypt)。
Python 虚拟环境分开安装互不替代):
| 目录 | 用途 |
|------|------|
| `manual_trading_hub/.venv` | 中控 `hub.py` + 子代理 `agent.py` |
| `crypto_monitor_binance/.venv` | 币安 Flask |
| `crypto_monitor_okx/.venv` | OKX Flask |
| `crypto_monitor_gate/.venv` | Gate Flask |
| `crypto_monitor_gate/.venv` | Gate Flask |
各实例 `ecosystem.config.cjs` 一般已设置 **`PYTHONPATH=..`**仓库根),以便加载 `hub_bridge.py``hub_auth.py`
---
## 三网络与端口云上最重要
**原则公网只暴露 Nginx 的 80/443Flask 与 agent 只监听本机**
| 服务 | 本机端口示例 | 是否对公网开放 |
|------|------------------|----------------|
| 中控 hub | 5100 | **否** → 仅 `https://hub.你的域名` 反代 |
| 币安 Flask | 5001 | **否**`https://binance.你的域名` |
| OKX Flask | 5004 | **否**`https://okx.你的域名` |
| Gate Flask | 5000 | **否**`https://gate.你的域名` |
| 子代理 agent | 1520015202 | **否**必须 **127.0.0.1** |
### 云厂商安全组 / 系统防火墙
- **放行**`80``443`给宝塔/Nginx
- **不要放行**`5100``5000``5004``15200``15202`除非临时本机调试用完即关
---
## 四域名与宝塔反代
**中控 + 每个要对外打开的实例** 各建一个站点子域名示例):
| 站点浏览器访问 | 反代目标 |
|--------------------|----------|
| `https://hub.example.com` | `http://127.0.0.1:5100` |
| `https://okx.example.com` | `http://127.0.0.1:5004` |
| `https://binance.example.com` | `http://127.0.0.1:5001` |
| `https://gate.example.com` | `http://127.0.0.1:5000` |
### 宝塔操作要点
1. 每个域名 → **网站****反向代理** → 目标 `http://127.0.0.1:对应端口`
2. 申请 **SSL**Lets Encrypt),强制 HTTPS
3. **不要**再给实例站加一层宝塔「访问密码」会与 Flask `/login` 重复);直链鉴权用下文 **`APP_USERNAME` / `APP_PASSWORD`**
4. Nginx 建议保留常见代理头宝塔默认通常已带):
```nginx
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
```
中控请求实例 `/api/hub/*` 时会带 **`X-Hub-Token`**一般无需额外配置
---
## 五环境变量必配
### 5.1 中控 `manual_trading_hub/.env`
```env
HUB_HOST=0.0.0.0
HUB_PORT=5100
# 与三实例 .env 完全相同API + SSO 签名
HUB_BRIDGE_TOKEN=请填一长串随机字符
# 中控网页登录公网务必设置
HUB_USERNAME=admin
HUB_PASSWORD=强密码
HUB_SESSION_SECRET=另一串随机字符
# 中控为 HTTPS 时建议 true
HUB_COOKIE_SECURE=true
# 公网用域名访问中控宝塔反代时必设其一
# HUB_ALLOW_PUBLIC=true 推荐反代 + 中控密码
# 或反代目标必须是 http://127.0.0.1:5100 且可保持 HUB_TRUST_LAN=false
HUB_ALLOW_PUBLIC=true
HUB_TRUST_LAN=false
# 从中控打开实例的 SSO 链接有效期(秒),默认 7200 = 2 小时
HUB_SSO_TTL_SEC=7200
# 各实例 hub_settings 里 flask_url 已写 https 域名时一般可不设
# HUB_PUBLIC_ORIGIN=https://hub.example.com
```
完整项见 [`.env.example`](./.env.example)
### 5.2 三个实例 `crypto_monitor_*/.env`
每个目录都要有**直链** `https://okx.域名` 时用这套登录网页):
```env
# 各所 API 密钥按交易所填写
# APP_PORT=5004
# 与中控 manual_trading_hub/.env 中 HUB_BRIDGE_TOKEN 完全一致
HUB_BRIDGE_TOKEN=与中控相同
# 三实例建议统一直链登录用
APP_USERNAME=统一用户名
APP_PASSWORD=统一强密码
# 云服务器切勿开启会跳过网页登录):
# APP_AUTH_DISABLED=true
```
### 5.3 子代理
- `CONTROL_TOKEN` 可与 `HUB_BRIDGE_TOKEN` 相同
- 由 PM2 在对应 `crypto_monitor_*` 目录启动`run_agent.sh` 加载该目录 `.env`
- 只监听 **127.0.0.1:1520x**不映射到公网
---
## 六中控「系统设置」`hub_settings.json`
在网页 **系统设置** 保存或编辑 `manual_trading_hub/hub_settings.json`
云上 **`flask_url` 必须写浏览器能打开的 HTTPS 域名**不要写 `127.0.0.1`除非配合 `HUB_PUBLIC_ORIGIN` 做替换):
| 字段 | 云上填法 | 说明 |
|------|----------|------|
| `flask_url` | `https://okx.example.com` | 用户浏览器SSO 打开实例 |
| `agent_url` | `http://127.0.0.1:15201` | 仅中控本机访问子代理 |
| `enabled` | 按需 | 不参与监控的户可关 |
| `capabilities` | 按需 | `key` / `trend` 等 |
**同机部署的两种写法二选一):**
1. **推荐**每个实例 `flask_url` 直接写该实例的 `https://子域名`
2. **备选**`flask_url``http://127.0.0.1:5004`中控 `.env``HUB_PUBLIC_ORIGIN=https://okx.example.com`适合共用一个 IP靠端口区分时)。
`agent_url` 始终用 **`http://127.0.0.1:1520x`**
---
## 七PM2 启动顺序
代码路径示例`/opt/crypto_monitor/`按实际替换)。
```bash
cd /opt/crypto_monitor
# 1三个实例 Flask各目录 ecosystem.config.cjs进程名以你机器为准
cd crypto_monitor_okx && pm2 start ecosystem.config.cjs
cd ../crypto_monitor_binance && pm2 start ecosystem.config.cjs
cd ../crypto_monitor_gate && pm2 start ecosystem.config.cjs
# 2中控 + 三个子代理一条拉起 4 个进程hub + 3 agent
cd ../manual_trading_hub
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # 编辑填入真实值
chmod +x scripts/run_hub.sh scripts/run_agent.sh
pm2 start ecosystem.config.cjs
pm2 save
pm2 startup # 按提示执行 sudo 命令后再 pm2 save
```
```bash
cd /opt/crypto_monitor/manual_trading_hub
bash scripts/pm2_hub.sh start
```
### PM2 进程一览
| 进程名 | 说明 |
|--------|------|
| `manual-trading-hub` | 中控 :5100 |
| `manual-agent-binance` | :15200 |
| `manual-agent-okx` | :15201 |
| `manual-agent-gate` | :15202 |
| `crypto_*`各目录自定 | 各 Flask `APP_PORT` |
不用 OKX 时可在 `.env``HUB_DISABLED_IDS=1``pm2 stop manual-agent-okx`
---
## 八访问与登录云上行为
| 访问方式 | 地址示例 | 需要什么 |
|----------|----------|----------|
| 中控监控 | `https://hub.example.com/monitor` | **中控** `HUB_USERNAME` / `HUB_PASSWORD` |
| 中控点「实例 / 策略交易 / 复盘」 | 自动打开 `https://okx.example.com/hub-sso?...` | 已登中控即可**2 小时内单次** SSO**免输**实例密码 |
| 浏览器直链实例 | `https://okx.example.com` | 实例 **`APP_USERNAME` / `APP_PASSWORD`**`/login` |
SSO 复用 **`HUB_BRIDGE_TOKEN`** 签名详见 [局域网与反代部署说明.md §五](./局域网与反代部署说明.md)
---
## 九安全建议云服务器必看
1. **SSH**密钥登录关闭密码登录必要时改 SSH 端口
2. **`HUB_BRIDGE_TOKEN`**足够长随机勿提交 Git勿写进前端页面
3. **交易所 API Key**仅放在各实例 `.env`权限尽量最小化勿随意开提币)。
4. **中控**公网必须设 `HUB_PASSWORD``HUB_TRUST_LAN=false`
5. **实例**云上 **`APP_AUTH_DISABLED` 必须为 false**或未设置)。
6. **备份**定期备份各实例数据库 / SQLite 与 `hub_settings.json`
7. **`.env` 换行**Linux 上勿用 Windows CRLF可用 `bash scripts/fix_env_crlf.sh`
---
## 十部署后验收清单
- [ ] `https://hub.你的域名` 能打开并登录中控
- [ ] 监控卡片有持仓/余额子代理在线
- [ ] 已登录中控 → 点「实例」→ **无**实例登录页直接进入
- [ ] 隐身窗口直开 `https://okx.你的域名` → 出现 **`/login`**统一账号密码可进
- [ ] `pm2 status`hub4×agent用到的 `crypto_*` 均为 online
- [ ] 云安全组 **未** 对公网开放 5100500050041520015202
- [ ] 三实例 `.env` 与中控 `HUB_BRIDGE_TOKEN` 一致
- [ ] 实例启动日志无长期 `[hub_bridge] ImportError`
---
## 十一常见问题速查
| 现象 | 处理 |
|------|------|
| 从中控打开仍要实例密码 | 见 [常见问题.md §4.3](./常见问题.md)检查 token重启 Flask`hub_settings``key` |
| 监控无持仓 / 子代理不可用 | `curl http://127.0.0.1:15201/status``.env` CRLFAPI 密钥 |
| 复盘/实例链接是 127.0.0.1 | `flask_url` 改为 https 域名或设 `HUB_PUBLIC_ORIGIN` |
| 仅 Gate 子代理反复重启 | `.env` CRLF`bash manual_trading_hub/scripts/fix_env_crlf.sh` |
---
## 十二与局域网部署的区别简要
| 项目 | 云服务器 | 局域网 |
|------|----------|--------|
| 对外地址 | `https://子域名` | `http://内网IP:端口` |
| `flask_url` | 写 **域名** | 写 **内网 IP:端口** |
| 防火墙 | 只开 80/443 | 内网可开 5100500x |
| SSL | 必须宝塔证书 | 通常 HTTP 即可 |
| `HUB_COOKIE_SECURE` | 建议 `true` | HTTP 时用 `false` |
局域网详细步骤见 [局域网与反代部署说明.md §三](./局域网与反代部署说明.md)
# 云服务器部署说明
本文说明在 **云服务器(VPS)** 上部署 `crypto_monitor` 中控与三实例的推荐配置:硬件,软件,防火墙,宝塔反代,环境变量,PM2 启动与验收.
云上标准做法:**域名 + 宝塔/Nginx 反代 + HTTPS**;业务端口(5100,50005004,1520015202)**不对公网直连**.
相关文档:
- **[本地数据迁移到云端.md](./本地数据迁移到云端.md)** — 备份 `crypto.db`,图片,`hub_settings` 与恢复步骤
- [局域网与反代部署说明.md](./局域网与反代部署说明.md) — 局域网 IP:端口 与反代域名对照,SSO 行为
- [部署文档.md](./部署文档.md) — PM2,依赖安装,日常运维
- [使用说明.md](./使用说明.md) — 中控功能说明
- [常见问题.md](./常见问题.md) — 故障排查
- 环境变量模板:[.env.example](./.env.example)
---
## 一,服务器硬件与系统
| 项目 | 建议 |
|------|------|
| 配置 | **2 核 4G** 起步;三实例 + 中控 + PM2 同时运行,**4G8G 更稳** |
| 系统 | **Ubuntu 22.04 / 24.04**(项目文档按 Linux 编写) |
| 磁盘 | **20G+**;日志,SQLite,上传图片会占空间 |
| 网络 | 需能访问各交易所 API;若走代理,在对应 `crypto_monitor_*/.env` 配置 `OKX_SOCKS_PROXY`,`BINANCE_SOCKS_PROXY` 等 |
---
## 二,软件环境
```bash
sudo apt update
sudo apt install -y python3 python3-venv python3-pip git curl
# 进程守护(推荐)
sudo npm i -g pm2
```
**宝塔面板(可选但推荐)**:安装 **Nginx**,用于反向代理与 **SSL**(Lets Encrypt).
Python 虚拟环境(分开安装,互不替代):
| 目录 | 用途 |
|------|------|
| `manual_trading_hub/.venv` | 中控 `hub.py` + 子代理 `agent.py` |
| `crypto_monitor_binance/.venv` | 币安 Flask |
| `crypto_monitor_okx/.venv` | OKX Flask |
| `crypto_monitor_gate/.venv` | Gate Flask |
| `crypto_monitor_gate/.venv` | Gate Flask |
各实例 `ecosystem.config.cjs` 一般已设置 **`PYTHONPATH=..`**(仓库根),以便加载 `hub_bridge.py`,`hub_auth.py`.
---
## 三,网络与端口(云上最重要)
**原则:公网只暴露 Nginx 的 80/443;Flask 与 agent 只监听本机.**
| 服务 | 本机端口(示例) | 是否对公网开放 |
|------|------------------|----------------|
| 中控 hub | 5100 | **否** → 仅 `https://hub.你的域名` 反代 |
| 币安 Flask | 5001 | **否**`https://binance.你的域名` |
| OKX Flask | 5004 | **否**`https://okx.你的域名` |
| Gate Flask | 5000 | **否**`https://gate.你的域名` |
| 子代理 agent | 1520015202 | **否**,必须 **127.0.0.1** |
### 云厂商安全组 / 系统防火墙
- **放行**:`80`,`443`(给宝塔/Nginx)
- **不要放行**:`5100`,`5000``5004`,`15200``15202`(除非临时本机调试,用完即关)
---
## 四,域名与宝塔反代
**中控 + 每个要对外打开的实例** 各建一个站点(子域名示例):
| 站点(浏览器访问) | 反代目标 |
|--------------------|----------|
| `https://hub.example.com` | `http://127.0.0.1:5100` |
| `https://okx.example.com` | `http://127.0.0.1:5004` |
| `https://binance.example.com` | `http://127.0.0.1:5001` |
| `https://gate.example.com` | `http://127.0.0.1:5000` |
### 宝塔操作要点
1. 每个域名 → **网站****反向代理** → 目标 `http://127.0.0.1:对应端口`.
2. 申请 **SSL**(Lets Encrypt),强制 HTTPS.
3. **不要**再给实例站加一层宝塔「访问密码」(会与 Flask `/login` 重复);直链鉴权用下文 **`APP_USERNAME` / `APP_PASSWORD`**.
4. Nginx 建议保留常见代理头(宝塔默认通常已带):
```nginx
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
```
中控请求实例 `/api/hub/*` 时会带 **`X-Hub-Token`**,一般无需额外配置.
---
## 五,环境变量(必配)
### 5.1 中控 `manual_trading_hub/.env`
```env
HUB_HOST=0.0.0.0
HUB_PORT=5100
# 与三实例 .env 完全相同(API + SSO 签名)
HUB_BRIDGE_TOKEN=请填一长串随机字符
# 中控网页登录(公网务必设置)
HUB_USERNAME=admin
HUB_PASSWORD=强密码
HUB_SESSION_SECRET=另一串随机字符
# 中控为 HTTPS 时建议 true
HUB_COOKIE_SECURE=true
# 公网用域名访问中控(宝塔反代)时必设其一:
# HUB_ALLOW_PUBLIC=true (推荐:反代 + 中控密码)
# 或反代目标必须是 http://127.0.0.1:5100 且可保持 HUB_TRUST_LAN=false
HUB_ALLOW_PUBLIC=true
HUB_TRUST_LAN=false
# 从中控打开实例的 SSO 链接有效期(秒),默认 7200 = 2 小时
HUB_SSO_TTL_SEC=7200
# 各实例 hub_settings 里 flask_url 已写 https 域名时,一般可不设
# HUB_PUBLIC_ORIGIN=https://hub.example.com
```
完整项见 [`.env.example`](./.env.example).
### 5.2 三个实例 `crypto_monitor_*/.env`
每个目录都要有(**直链** `https://okx.域名` 时用这套登录网页):
```env
# 各所 API 密钥(按交易所填写)
# APP_PORT=5004
# 与中控 manual_trading_hub/.env 中 HUB_BRIDGE_TOKEN 完全一致
HUB_BRIDGE_TOKEN=与中控相同
# 三实例建议统一(直链登录用)
APP_USERNAME=统一用户名
APP_PASSWORD=统一强密码
# 云服务器切勿开启(会跳过网页登录):
# APP_AUTH_DISABLED=true
```
### 5.3 子代理
- `CONTROL_TOKEN` 可与 `HUB_BRIDGE_TOKEN` 相同.
- 由 PM2 在对应 `crypto_monitor_*` 目录启动,`run_agent.sh` 加载该目录 `.env`.
- 只监听 **127.0.0.1:1520x**,不映射到公网.
---
## 六,中控「系统设置」`hub_settings.json`
在网页 **系统设置** 保存,或编辑 `manual_trading_hub/hub_settings.json`.
云上 **`flask_url` 必须写浏览器能打开的 HTTPS 域名**(不要写 `127.0.0.1`,除非配合 `HUB_PUBLIC_ORIGIN` 做替换):
| 字段 | 云上填法 | 说明 |
|------|----------|------|
| `flask_url` | `https://okx.example.com` | 用户浏览器,SSO 打开实例 |
| `agent_url` | `http://127.0.0.1:15201` | 仅中控本机访问子代理 |
| `enabled` | 按需 | 不参与监控的户可关 |
| `capabilities` | 按需 | `key` / `trend` 等 |
**同机部署的两种写法(二选一):**
1. **推荐**:每个实例 `flask_url` 直接写该实例的 `https://子域名`.
2. **备选**:`flask_url``http://127.0.0.1:5004`,中控 `.env``HUB_PUBLIC_ORIGIN=https://okx.example.com`(适合共用一个 IP,靠端口区分时).
`agent_url` 始终用 **`http://127.0.0.1:1520x`**.
---
## 七,PM2 启动顺序
代码路径示例:`/opt/crypto_monitor/`(按实际替换).
```bash
cd /opt/crypto_monitor
# 1)三个实例 Flask(各目录 ecosystem.config.cjs,进程名以你机器为准)
cd crypto_monitor_okx && pm2 start ecosystem.config.cjs
cd ../crypto_monitor_binance && pm2 start ecosystem.config.cjs
cd ../crypto_monitor_gate && pm2 start ecosystem.config.cjs
# 2)中控 + 三个子代理(一条拉起 4 个进程:hub + 3 agent)
cd ../manual_trading_hub
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # 编辑填入真实值
chmod +x scripts/run_hub.sh scripts/run_agent.sh
pm2 start ecosystem.config.cjs
pm2 save
pm2 startup # 按提示执行 sudo 命令后再 pm2 save
```
:
```bash
cd /opt/crypto_monitor/manual_trading_hub
bash scripts/pm2_hub.sh start
```
### PM2 进程一览
| 进程名 | 说明 |
|--------|------|
| `manual-trading-hub` | 中控 :5100 |
| `manual-agent-binance` | :15200 |
| `manual-agent-okx` | :15201 |
| `manual-agent-gate` | :15202 |
| `crypto_*`(各目录自定) | 各 Flask `APP_PORT` |
不用 OKX 时可在 `.env``HUB_DISABLED_IDS=1`,`pm2 stop manual-agent-okx`.
---
## 八,访问与登录(云上行为)
| 访问方式 | 地址示例 | 需要什么 |
|----------|----------|----------|
| 中控监控 | `https://hub.example.com/monitor` | **中控** `HUB_USERNAME` / `HUB_PASSWORD` |
| 中控点「实例 / 策略交易 / 复盘」 | 自动打开 `https://okx.example.com/hub-sso?...` | 已登中控即可;**2 小时内,单次** SSO,**免输**实例密码 |
| 浏览器直链实例 | `https://okx.example.com` | 实例 **`APP_USERNAME` / `APP_PASSWORD`**(`/login`) |
SSO 复用 **`HUB_BRIDGE_TOKEN`** 签名,详见 [局域网与反代部署说明.md §五](./局域网与反代部署说明.md).
---
## 九,安全建议(云服务器必看)
1. **SSH**:密钥登录,关闭密码登录;必要时改 SSH 端口.
2. **`HUB_BRIDGE_TOKEN`**:足够长,随机;勿提交 Git,勿写进前端页面.
3. **交易所 API Key**:仅放在各实例 `.env`;权限尽量最小化(勿随意开提币).
4. **中控**:公网必须设 `HUB_PASSWORD`;`HUB_TRUST_LAN=false`.
5. **实例**:云上 **`APP_AUTH_DISABLED` 必须为 false**(或未设置).
6. **备份**:定期备份各实例数据库 / SQLite 与 `hub_settings.json`.
7. **`.env` 换行**:Linux 上勿用 Windows CRLF;可用 `bash scripts/fix_env_crlf.sh`.
---
## 十,部署后验收清单
- [ ] `https://hub.你的域名` 能打开并登录中控
- [ ] 监控卡片有持仓/余额(子代理在线)
- [ ] 已登录中控 → 点「实例」→ **无**实例登录页,直接进入
- [ ] 隐身窗口直开 `https://okx.你的域名` → 出现 **`/login`**,统一账号密码可进
- [ ] `pm2 status`:hub,4×agent,用到的 `crypto_*` 均为 online
- [ ] 云安全组 **未** 对公网开放 5100,50005004,1520015202
- [ ] 三实例 `.env` 与中控 `HUB_BRIDGE_TOKEN` 一致
- [ ] 实例启动日志无长期 `[hub_bridge] ImportError`
---
## 十一,常见问题速查
| 现象 | 处理 |
|------|------|
| 从中控打开仍要实例密码 | 见 [常见问题.md §4.3](./常见问题.md);检查 token,重启 Flask,`hub_settings``key` |
| 监控无持仓 / 子代理不可用 | `curl http://127.0.0.1:15201/status`;`.env` CRLF,API 密钥 |
| 复盘/实例链接是 127.0.0.1 | `flask_url` 改为 https 域名,或设 `HUB_PUBLIC_ORIGIN` |
| 仅 Gate 子代理反复重启 | `.env` CRLF:`bash manual_trading_hub/scripts/fix_env_crlf.sh` |
---
## 十二,与局域网部署的区别(简要)
| 项目 | 云服务器 | 局域网 |
|------|----------|--------|
| 对外地址 | `https://子域名` | `http://内网IP:端口` |
| `flask_url` | 写 **域名** | 写 **内网 IP:端口** |
| 防火墙 | 只开 80/443 | 内网可开 5100,500x |
| SSL | 必须(宝塔证书) | 通常 HTTP 即可 |
| `HUB_COOKIE_SECURE` | 建议 `true` | HTTP 时用 `false` |
局域网详细步骤见 [局域网与反代部署说明.md §三](./局域网与反代部署说明.md).
+84 -84
View File
@@ -1,84 +1,84 @@
# 交易监管AI 教练
中控 **交易监管** 用于防止过度交易与频繁手动操作**手动/中控开平仓****新开仓** 时自动推送至 **今日监管长会话**并可选 **企业微信** 提醒程序止盈/止损按「正常执行」鼓励不计入频繁交易统计
入口**AI 教练**`/ai`→ Tab **交易监管**或微信链接在系统设置中配置)。
## 监管范围
| 类型 | 识别 | 页内推送 | 微信P0 | 频率统计 |
|------|------|----------|------------|----------|
| 实例手动平仓 | `result = 手动平仓` | ✓ | ✓ | ✓ |
| 中控平仓 | `result = 强制清仓` 等 | ✓ | ✓ | ✓ |
| 新开仓 | 监控板持仓 diff0→有仓 / 新合约 | ✓ | ✓ | ✓ |
| 程序止盈 | 止盈 / 保本止盈 / 移动止盈 | ✓ | 可选 | ✗ |
| 程序止损 | 止损 | ✓ | 可选 | ✗ |
| 外部平仓 | 外部平仓时间平仓 | ✗ | ✗ | ✗ |
频率规则间隔过短30 分钟笔数日笔数连亏平后快开**只对手动/中控开平** 叠加 `[监管·频率]` 警告
## 会话
- 每个交易日 **一条长会话**`bot_mode: supervisor`标题 `今日监管 YYYY-MM-DD`)。
- 系统消息`role: system`+ AI 短评`assistant`+ 用户回复`user`同线程
- 与 **交易教练 / 普通聊天** 分离监管会话不支持「新开对话」
## 系统设置
路径**系统设置** → **交易监管 · 企业微信**写入 `hub_settings.json``supervisor`)。
| 字段 | 说明 |
|------|------|
| `enabled` | 总开关 |
| `wechat_webhook` | **监管专用** 企业微信机器人与三所实例 `.env``WECHAT_WEBHOOK` 独立 |
| `wechat_link_base` | 微信消息末尾跳转链接**可单独修改**`https://域名/ai?mode=supervisor` |
| `wechat_prefix` | 消息前缀默认 `【交易监管】` |
| `wechat_on_program_tp_sl` | 程序止盈/止损是否也发微信 |
| `manual_close_daily_warn` | 日手动平警告阈值默认 2 |
| `interval_warn_minutes` | 两笔手动/中控平最短间隔默认 15 分钟 |
| `freq_30m_count` | 30 分钟内笔数阈值默认 2 |
| `reopen_after_close_minutes` | 手动平后再开仓警告间隔默认 30 分钟 |
`.env` 兜底设置页保存优先):
```env
SUPERVISOR_WECHAT_WEBHOOK=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...
SUPERVISOR_WECHAT_LINK=https://你的域名/ai?mode=supervisor
SUPERVISOR_POLL_INTERVAL_SEC=30
```
## API
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/ai/supervisor/session` | 今日监管会话 |
| GET | `/api/ai/supervisor/stream` | SSE 版本推送 |
| POST | `/api/ai/supervisor/chat/send` | 用户回聊JSON `{ "message": "..." }` |
| GET | `/api/ai/supervisor/rules` | 当前阈值 |
| POST | `/api/ai/supervisor/refresh` | 立即扫描 |
## 存储
| 文件 | 内容 |
|------|------|
| `hub_supervisor_state.json` | 已处理事件持仓快照频率统计 |
| `hub_ai_chat.json` | 监管会话`bot_mode: supervisor` |
| `hub_settings.json` | `supervisor` 配置节 |
**首次启用** 会对当前交易日已有平仓做 **种子同步**不补发历史推送),避免部署瞬间刷屏
## 与实例风控
实例 `account_risk_lib`冷静期 / 日冻结**硬拦截**监管为 **软提醒 + 陪聊**不绕过实例开仓限制
## 代码位置
| 模块 | 路径 |
|------|------|
| 规则与推送 | `hub_supervisor_lib.py` |
| 后台扫描 | `hub_supervisor_cache.py` |
| 会话 | `hub_ai/supervisor_store.py` |
| AI 评语/回聊 | `hub_ai/supervisor.py` |
| 提示词 | `hub_ai/prompts.py``SUPERVISOR_SYSTEM` |
部署后重启中控`pm2 restart manual-trading-hub`或你的 hub 进程名)。
# 交易监管(AI 教练)
中控 **交易监管** 用于防止过度交易与频繁手动操作:**手动/中控开平仓****新开仓** 时自动推送至 **今日监管长会话**,并可选 **企业微信** 提醒;程序止盈/止损按「正常执行」鼓励,不计入频繁交易统计.
入口:**AI 教练**(`/ai`)→ Tab **交易监管**,或微信链接(在系统设置中配置).
## 监管范围
| 类型 | 识别 | 页内推送 | 微信(P0) | 频率统计 |
|------|------|----------|------------|----------|
| 实例手动平仓 | `result = 手动平仓` | ✓ | ✓ | ✓ |
| 中控平仓 | `result = 强制清仓` 等 | ✓ | ✓ | ✓ |
| 新开仓 | 监控板持仓 diff(0→有仓 / 新合约) | ✓ | ✓ | ✓ |
| 程序止盈 | 止盈 / 保本止盈 / 移动止盈 | ✓ | 可选 | ✗ |
| 程序止损 | 止损 | ✓ | 可选 | ✗ |
| 外部平仓 | 外部平仓,时间平仓 | ✗ | ✗ | ✗ |
频率规则(间隔过短,30 分钟笔数,日笔数,连亏,平后快开)**只对手动/中控开平** 叠加 `[监管·频率]` 警告.
## 会话
- 每个交易日 **一条长会话**(`bot_mode: supervisor`,标题 `今日监管 YYYY-MM-DD`).
- 系统消息(`role: system`)+ AI 短评(`assistant`)+ 用户回复(`user`)同线程.
- 与 **交易教练 / 普通聊天** 分离;监管会话不支持「新开对话」.
## 系统设置
路径:**系统设置** → **交易监管 · 企业微信**(写入 `hub_settings.json``supervisor`).
| 字段 | 说明 |
|------|------|
| `enabled` | 总开关 |
| `wechat_webhook` | **监管专用** 企业微信机器人(与三所实例 `.env``WECHAT_WEBHOOK` 独立) |
| `wechat_link_base` | 微信消息末尾跳转链接(**可单独修改**,`https://域名/ai?mode=supervisor`) |
| `wechat_prefix` | 消息前缀,默认 `【交易监管】` |
| `wechat_on_program_tp_sl` | 程序止盈/止损是否也发微信 |
| `manual_close_daily_warn` | 日手动平警告阈值(默认 2) |
| `interval_warn_minutes` | 两笔手动/中控平最短间隔(默认 15 分钟) |
| `freq_30m_count` | 30 分钟内笔数阈值(默认 2) |
| `reopen_after_close_minutes` | 手动平后再开仓警告间隔(默认 30 分钟) |
`.env` 兜底(设置页保存优先):
```env
SUPERVISOR_WECHAT_WEBHOOK=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...
SUPERVISOR_WECHAT_LINK=https://你的域名/ai?mode=supervisor
SUPERVISOR_POLL_INTERVAL_SEC=30
```
## API
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/ai/supervisor/session` | 今日监管会话 |
| GET | `/api/ai/supervisor/stream` | SSE 版本推送 |
| POST | `/api/ai/supervisor/chat/send` | 用户回聊(JSON `{ "message": "..." }`) |
| GET | `/api/ai/supervisor/rules` | 当前阈值 |
| POST | `/api/ai/supervisor/refresh` | 立即扫描 |
## 存储
| 文件 | 内容 |
|------|------|
| `hub_supervisor_state.json` | 已处理事件,持仓快照,频率统计 |
| `hub_ai_chat.json` | 监管会话(`bot_mode: supervisor`) |
| `hub_settings.json` | `supervisor` 配置节 |
**首次启用** 会对当前交易日已有平仓做 **种子同步**(不补发历史推送),避免部署瞬间刷屏.
## 与实例风控
实例 `account_risk_lib`(冷静期 / 日冻结)**硬拦截**;监管为 **软提醒 + 陪聊**,不绕过实例开仓限制.
## 代码位置
| 模块 | 路径 |
|------|------|
| 规则与推送 | `hub_supervisor_lib.py` |
| 后台扫描 | `hub_supervisor_cache.py` |
| 会话 | `hub_ai/supervisor_store.py` |
| AI 评语/回聊 | `hub_ai/supervisor.py` |
| 提示词 | `hub_ai/prompts.py``SUPERVISOR_SYSTEM` |
部署后重启中控:`pm2 restart manual-trading-hub`(或你的 hub 进程名).
File diff suppressed because it is too large Load Diff
@@ -1,226 +1,226 @@
# 中控 · 局域网与反代部署说明
本文说明在 **局域网IP + 端口****宝塔/Nginx 反代域名** 两种场景下如何配置中控与各实例并实现
- **从中控** 点「实例 / 策略交易 / 复盘」→ **免输入** 实例网页密码SSO 临时链接默认 **2 小时** 内有效**单次使用**
- **浏览器直链** 实例地址反代域名或 `http://IP:端口`→ 进入 **`/login`**输入统一 **`APP_USERNAME` / `APP_PASSWORD`**
SSO 签名复用 **`HUB_BRIDGE_TOKEN`**与中控调实例 API 相同三所 `.env``manual_trading_hub/.env` 保持一致)。
**云服务器VPS** 的硬件安全组宝塔环境变量与验收清单见 **[云服务器部署说明.md](./云服务器部署说明.md)**
---
## 一两种访问方式对照
| 项目 | 局域网 | 反代域名 |
|------|--------|----------------|
| 中控地址 | `http://内网IP:5100` | `https://hub.你的域名.com` |
| 实例地址浏览器 | `http://内网IP:5004` 等 | `https://okx.你的域名.com` 等 |
| `hub_settings``flask_url` | 建议写 **`http://内网IP:端口`** | 建议写 **`https://该实例域名`**与浏览器一致 |
| 中控本机调实例 API | 可与浏览器相同同机也可用 `http://127.0.0.1:端口` + `HUB_PUBLIC_ORIGIN` | 同机可用 `127.0.0.1:端口` 或域名需 Nginx 转发 `X-Hub-Token` |
| `HUB_PUBLIC_ORIGIN` | 若 `flask_url``127.0.0.1`**必填** `http://内网IP` | 若 `flask_url` 已是完整域名**可不设** |
| 宝塔 | 可不装反代直连端口 | 每实例一个站点 + SSL中控单独站点 |
| 直链登录 | 实例 `/login` | 实例 `/login` |
| 从中控打开 | `/hub-sso?token=...` 自动登录 | 同上 |
---
## 二共用环境变量必配
### 2.1 中控 `manual_trading_hub/.env`
```bash
HUB_BRIDGE_TOKEN=请填一长串随机字符
HUB_USERNAME=admin # 中控登录建议设置
HUB_PASSWORD=你的中控密码
HUB_SSO_TTL_SEC=7200 # 可选默认 7200 = 2 小时
```
### 2.2 三个实例 `crypto_monitor_*/.env`
每个目录相同**直链**时用这套登录实例网页):
```bash
HUB_BRIDGE_TOKEN=与中控完全相同
APP_USERNAME=统一用户名
APP_PASSWORD=统一密码
# 云上切勿 APP_AUTH_DISABLED=true
```
### 2.3 子代理
`CONTROL_TOKEN` 可与 `HUB_BRIDGE_TOKEN` 相同子代理只监听 `127.0.0.1`**不要**对公网暴露 `15200``15202`
---
## 三局域网部署IP + 端口
适用家里/办公室内网例如服务器 `192.168.8.6`
### 3.1 端口约定示例以你实际为准
| 服务 | 端口 |
|------|------|
| 中控 hub | 5100 |
| OKX Flask | 5004 |
| 币安 Flask | 5001 |
| Gate | 5000 |
| agent | 1520015202仅本机 |
### 3.2 系统设置 `hub_settings.json`网页「系统设置」保存
浏览器里你会打开的地址应使用 **内网 IP**不要用 `127.0.0.1`否则别的电脑上的浏览器会连到你本机):
```json
{
"flask_url": "http://192.168.8.6:5004",
"agent_url": "http://127.0.0.1:15201"
}
```
说明
- **`flask_url`**给浏览器用的实例页地址 → 写 **`http://192.168.8.6:端口`**
- **`agent_url`**仅中控服务器访问 → 写 **`http://127.0.0.1:1520x`**
各账户按上表改端口即可
### 3.3 可选`flask_url` 仍写 127.0.0.1 时
若坚持 `flask_url``http://127.0.0.1:5004`仅 hub 与本机 Flask 同机),在中控 `.env` 增加
```bash
HUB_PUBLIC_ORIGIN=http://192.168.8.6
```
中控会把返回给前端的链接从 `127.0.0.1` 替换为 `192.168.8.6`端口保留)。
### 3.4 访问方式
1. 中控`http://192.168.8.6:5100` → 登录中控 → 点「实例」→ 新标签进入 OKX**无需**再输实例密码
2. 直链`http://192.168.8.6:5004` → 出现登录页 → 输入 `APP_USERNAME` / `APP_PASSWORD`
### 3.5 防火墙
内网自用放行 `5100``APP_PORT`**不要**对公网开放 agent 端口
---
## 四反代部署域名 + 宝塔
适用云服务器对外用 HTTPS 域名
### 4.1 域名规划示例
| 站点 | 反代到 |
|------|--------|
| `hub.example.com` | `127.0.0.1:5100` |
| `okx.example.com` | `127.0.0.1:5004` |
| `binance.example.com` | `127.0.0.1:5001` |
| `gate.example.com` | `127.0.0.1:5000` |
Flask / hub 进程仍只监听 **127.0.0.1**`0.0.0.0` 本机端口由 Nginx 对外提供 HTTPS
### 4.2 宝塔操作要点
1. 每个域名 → **反向代理** → 目标 `http://127.0.0.1:对应端口`
2. 申请 **SSL**Lets Encrypt)。
3. **不要**再给实例站加一层宝塔「访问密码」避免与 Flask `/login` 重复);直链鉴权用 **`APP_USERNAME` / `APP_PASSWORD`** 即可
4. 自定义 Nginx 配置中保留 WebSocket/大 body 如需确保代理头
```nginx
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
```
中控请求实例 API 时会带 **`X-Hub-Token`**Nginx 默认会转发请求头一般无需额外配置
### 4.3 `hub_settings` 示例反代
```json
{
"flask_url": "https://okx.example.com",
"agent_url": "http://127.0.0.1:15201"
}
```
- 浏览器与 SSO 链接使用 **`https://okx.example.com`**
- 中控服务器拉 `/api/hub/*` 仍走本机 `agent_url``flask_url` 用域名时hub 会请求 `https://okx.example.com/api/...`同机可通即可)。
同机部署时也可
- `flask_url`: `http://127.0.0.1:5004`
- `HUB_PUBLIC_ORIGIN`: `https://okx.example.com`
仅当**所有实例共用一个对外 IP靠端口区分**时才适合用 `HUB_PUBLIC_ORIGIN`**每实例独立域名**时请直接在 `flask_url` 写该实例域名
### 4.4 中控 `.env`反代建议
```bash
HUB_BRIDGE_TOKEN=...
HUB_USERNAME=...
HUB_PASSWORD=...
HUB_COOKIE_SECURE=true # 中控为 HTTPS 时建议开启
```
### 4.5 访问方式
1. `https://hub.example.com` 登录中控 → 点「打开实例」→ `https://okx.example.com/hub-sso?...` → 进入系统
2. 地址栏直接输入 `https://okx.example.com``/login` → 实例账号密码
---
## 五SSO 行为说明2 小时
| 项 | 说明 |
|----|------|
| 有效期 | 默认 **7200 秒2 小时**`HUB_SSO_TTL_SEC` 可改 |
| 单次使用 | 同一链接成功登录后 **不能再用**需在中控重新点「打开实例」 |
| 密钥 | 复用 **`HUB_BRIDGE_TOKEN`** |
| 直链 | 无 token → 正常 **`/login`** |
---
## 六部署与重启顺序
```bash
cd /opt/crypto_monitor
# 各实例
pm2 restart crypto_okx crypto_binance crypto_gate # 名称以你为准
cd manual_trading_hub
pm2 restart manual-trading-hub manual-agent-binance manual-agent-okx manual-agent-gate
```
`hub_settings``.env` 后重启 **hub + 对应实例 Flask**`hub_bridge``/hub-sso` 在实例进程内)。
---
## 七验收清单
- [ ] 三实例 `.env` 与中控 `HUB_BRIDGE_TOKEN` 一致
- [ ] 三实例 `APP_USERNAME` / `APP_PASSWORD` 一致
- [ ] 局域网`flask_url``http://IP:端口`反代`flask_url``https://域名`
- [ ] 已登录中控 → 点「实例」→ **无**实例登录页
- [ ] 隐身窗口直链实例域名/IP → **有** `/login`
- [ ] 复制「打开实例」完整 URL用过一次后再开 → 失效并回到登录页
---
## 八常见问题
**Q从中控打开仍要登录**
- 检查实例是否已 `git pull` 并重启需有 `/hub-sso`)。
- `HUB_BRIDGE_TOKEN` 是否三所一致
- `hub_settings` 里该账户 `key` 是否与 `install_on_app(exchange=...)` 一致`okx``binance``gate``gate`)。
**Q直链也要登录中控**
- 不应直链只走实例 `/login`若跳到中控检查是否点错链接或 Nginx 配错站点
**Q链接多久失效**
- 签发后 **2 小时**内且 **未使用过**过期或已用需在中控重新点打开
更多故障见 [常见问题.md](./常见问题.md)[部署文档.md](./部署文档.md)
# 中控 · 局域网与反代部署说明
本文说明在 **局域网(IP + 端口)****宝塔/Nginx 反代(域名)** 两种场景下,如何配置中控与各实例,并实现:
- **从中控** 点「实例 / 策略交易 / 复盘」→ **免输入** 实例网页密码(SSO 临时链接,默认 **2 小时** 内有效,**单次使用**)
- **浏览器直链** 实例地址(反代域名或 `http://IP:端口`)→ 进入 **`/login`**,输入统一 **`APP_USERNAME` / `APP_PASSWORD`**
SSO 签名复用 **`HUB_BRIDGE_TOKEN`**(与中控调实例 API 相同,三所 `.env``manual_trading_hub/.env` 保持一致).
**云服务器(VPS)** 的硬件,安全组,宝塔,环境变量与验收清单见 **[云服务器部署说明.md](./云服务器部署说明.md)**.
---
## 一,两种访问方式对照
| 项目 | 局域网 | 反代(域名) |
|------|--------|----------------|
| 中控地址 | `http://内网IP:5100` | `https://hub.你的域名.com` |
| 实例地址(浏览器) | `http://内网IP:5004` 等 | `https://okx.你的域名.com` 等 |
| `hub_settings``flask_url` | 建议写 **`http://内网IP:端口`** | 建议写 **`https://该实例域名`**(与浏览器一致) |
| 中控本机调实例 API | 可与浏览器相同;同机也可用 `http://127.0.0.1:端口` + `HUB_PUBLIC_ORIGIN` | 同机可用 `127.0.0.1:端口` 或域名(需 Nginx 转发 `X-Hub-Token`) |
| `HUB_PUBLIC_ORIGIN` | 若 `flask_url``127.0.0.1`,**必填** `http://内网IP` | 若 `flask_url` 已是完整域名,**可不设** |
| 宝塔 | 可不装反代,直连端口 | 每实例一个站点 + SSL;中控单独站点 |
| 直链登录 | 实例 `/login` | 实例 `/login` |
| 从中控打开 | `/hub-sso?token=...` 自动登录 | 同上 |
---
## 二,共用环境变量(必配)
### 2.1 中控 `manual_trading_hub/.env`
```bash
HUB_BRIDGE_TOKEN=请填一长串随机字符
HUB_USERNAME=admin # 中控登录(建议设置)
HUB_PASSWORD=你的中控密码
HUB_SSO_TTL_SEC=7200 # 可选,默认 7200 = 2 小时
```
### 2.2 三个实例 `crypto_monitor_*/.env`
每个目录相同(**直链**时用这套登录实例网页):
```bash
HUB_BRIDGE_TOKEN=与中控完全相同
APP_USERNAME=统一用户名
APP_PASSWORD=统一密码
# 云上切勿 APP_AUTH_DISABLED=true
```
### 2.3 子代理
`CONTROL_TOKEN` 可与 `HUB_BRIDGE_TOKEN` 相同;子代理只监听 `127.0.0.1`,**不要**对公网暴露 `15200``15202`.
---
## 三,局域网部署(IP + 端口)
适用:家里/办公室内网,例如服务器 `192.168.8.6`.
### 3.1 端口约定(示例,以你实际为准)
| 服务 | 端口 |
|------|------|
| 中控 hub | 5100 |
| OKX Flask | 5004 |
| 币安 Flask | 5001 |
| Gate | 5000 |
| agent | 1520015202(仅本机) |
### 3.2 系统设置 `hub_settings.json`(网页「系统设置」保存)
浏览器里你会打开的地址,应使用 **内网 IP**,不要用 `127.0.0.1`(否则别的电脑上的浏览器会连到你本机):
```json
{
"flask_url": "http://192.168.8.6:5004",
"agent_url": "http://127.0.0.1:15201"
}
```
说明:
- **`flask_url`**:给浏览器用的实例页地址 → 写 **`http://192.168.8.6:端口`**
- **`agent_url`**:仅中控服务器访问 → 写 **`http://127.0.0.1:1520x`**
各账户按上表改端口即可.
### 3.3 可选:`flask_url` 仍写 127.0.0.1 时
若坚持 `flask_url``http://127.0.0.1:5004`(仅 hub 与本机 Flask 同机),在中控 `.env` 增加:
```bash
HUB_PUBLIC_ORIGIN=http://192.168.8.6
```
中控会把返回给前端的链接从 `127.0.0.1` 替换为 `192.168.8.6`(端口保留).
### 3.4 访问方式
1. 中控:`http://192.168.8.6:5100` → 登录中控 → 点「实例」→ 新标签进入 OKX,**无需**再输实例密码.
2. 直链:`http://192.168.8.6:5004` → 出现登录页 → 输入 `APP_USERNAME` / `APP_PASSWORD`.
### 3.5 防火墙
内网自用:放行 `5100`,`APP_PORT`;**不要**对公网开放 agent 端口.
---
## 四,反代部署(域名 + 宝塔)
适用:云服务器,对外用 HTTPS 域名.
### 4.1 域名规划(示例)
| 站点 | 反代到 |
|------|--------|
| `hub.example.com` | `127.0.0.1:5100` |
| `okx.example.com` | `127.0.0.1:5004` |
| `binance.example.com` | `127.0.0.1:5001` |
| `gate.example.com` | `127.0.0.1:5000` |
Flask / hub 进程仍只监听 **127.0.0.1**`0.0.0.0` 本机端口,由 Nginx 对外提供 HTTPS.
### 4.2 宝塔操作要点
1. 每个域名 → **反向代理** → 目标 `http://127.0.0.1:对应端口`.
2. 申请 **SSL**(Lets Encrypt).
3. **不要**再给实例站加一层宝塔「访问密码」(避免与 Flask `/login` 重复);直链鉴权用 **`APP_USERNAME` / `APP_PASSWORD`** 即可.
4. 自定义 Nginx 配置中保留 WebSocket/大 body 如需;确保代理头:
```nginx
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
```
中控请求实例 API 时会带 **`X-Hub-Token`**,Nginx 默认会转发请求头,一般无需额外配置.
### 4.3 `hub_settings` 示例(反代)
```json
{
"flask_url": "https://okx.example.com",
"agent_url": "http://127.0.0.1:15201"
}
```
- 浏览器与 SSO 链接使用 **`https://okx.example.com`**.
- 中控服务器拉 `/api/hub/*` 仍走本机 `agent_url`;`flask_url` 用域名时,hub 会请求 `https://okx.example.com/api/...`(同机可通即可).
同机部署时也可:
- `flask_url`: `http://127.0.0.1:5004`
- `HUB_PUBLIC_ORIGIN`: `https://okx.example.com`
仅当**所有实例共用一个对外 IP,靠端口区分**时才适合用 `HUB_PUBLIC_ORIGIN`;**每实例独立域名**时,请直接在 `flask_url` 写该实例域名.
### 4.4 中控 `.env`(反代建议)
```bash
HUB_BRIDGE_TOKEN=...
HUB_USERNAME=...
HUB_PASSWORD=...
HUB_COOKIE_SECURE=true # 中控为 HTTPS 时建议开启
```
### 4.5 访问方式
1. `https://hub.example.com` 登录中控 → 点「打开实例」→ `https://okx.example.com/hub-sso?...` → 进入系统.
2. 地址栏直接输入 `https://okx.example.com``/login` → 实例账号密码.
---
## 五,SSO 行为说明(2 小时)
| 项 | 说明 |
|----|------|
| 有效期 | 默认 **7200 秒(2 小时)**,`HUB_SSO_TTL_SEC` 可改 |
| 单次使用 | 同一链接成功登录后 **不能再用**;需在中控重新点「打开实例」 |
| 密钥 | 复用 **`HUB_BRIDGE_TOKEN`** |
| 直链 | 无 token → 正常 **`/login`** |
---
## 六,部署与重启顺序
```bash
cd /opt/crypto_monitor
# 各实例
pm2 restart crypto_okx crypto_binance crypto_gate # 名称以你为准
cd manual_trading_hub
pm2 restart manual-trading-hub manual-agent-binance manual-agent-okx manual-agent-gate
```
`hub_settings``.env` 后重启 **hub + 对应实例 Flask**(`hub_bridge``/hub-sso` 在实例进程内).
---
## 七,验收清单
- [ ] 三实例 `.env` 与中控 `HUB_BRIDGE_TOKEN` 一致
- [ ] 三实例 `APP_USERNAME` / `APP_PASSWORD` 一致
- [ ] 局域网:`flask_url``http://IP:端口`;反代:`flask_url``https://域名`
- [ ] 已登录中控 → 点「实例」→ **无**实例登录页
- [ ] 隐身窗口直链实例域名/IP → **有** `/login`
- [ ] 复制「打开实例」完整 URL,用过一次后再开 → 失效并回到登录页
---
## 八,常见问题
**Q:从中控打开仍要登录?**
- 检查实例是否已 `git pull` 并重启(需有 `/hub-sso`).
- `HUB_BRIDGE_TOKEN` 是否三所一致.
- `hub_settings` 里该账户 `key` 是否与 `install_on_app(exchange=...)` 一致(`okx`,`binance`,`gate`,`gate`).
**Q:直链也要登录中控?**
- 不应.直链只走实例 `/login`.若跳到中控,检查是否点错链接或 Nginx 配错站点.
**Q:链接多久失效?**
- 签发后 **2 小时**内且 **未使用过**;过期或已用需在中控重新点打开.
更多故障见 [常见问题.md](./常见问题.md),[部署文档.md](./部署文档.md).
+354 -354
View File
@@ -1,354 +1,354 @@
# 中控与三实例 — 常见问题实录
本文档整理部署与运行 **manual_trading_hub**复盘系统中控及三所 `crypto_monitor_*` 时**实际遇到过**的问题与处理办法操作步骤仍以 [使用说明.md](./使用说明.md)[部署文档.md](./部署文档.md) 为准
---
## 一中控进程与代码版本
### 1.1 PM2 日志仍出现 `api_trade_key``python-multipart` 断言
**现象**`pm2 logs` 里报错 `File "hub.py", line 324, in api_trade_key``The python-multipart library must be installed`
**原因**
- 服务器上的 `hub.py` 仍是**旧版**含已移除的「下单区」接口),或 pull 后**未重启** PM2日志是历史残留
- 旧版「添加关键位」会 `request.form()`未装 `python-multipart` 时直接 500
**处理**
```bash
cd /opt/crypto_monitor
git pull
cd manual_trading_hub
bash scripts/fix_hub_deps.sh # 安装 python-multipart 等
bash scripts/verify_hub_deploy.sh # 应显示无 api_trade_key含 HUB_BUILD
pm2 restart manual-trading-hub
curl -s http://127.0.0.1:5100/api/ping
```
**正常 ping**无需登录应含 `"build":"20260521-no-trade-ui"``"trade_ui":false`
**说明**当前版本**已移除中控下单区**添加关键位人工下单趋势回调请在监控卡片点 **「实例」** 进入各 Flask 网页浏览器请 **Ctrl+F5** 强刷避免旧前端缓存仍请求 `/api/trade/key`
---
### 1.2 `curl /api/ping` 返回 `{"detail":"未登录"}`
**原因**早期版本未把 `/api/ping` 列入免登录白名单已修复)。
**处理**`git pull``pm2 restart manual-trading-hub`再测应直接返回 JSON无需 Cookie
---
### 1.3 `verify_hub_deploy.sh``Expecting value: line 1 column 1`
**原因**5100 端口无进程监听hub 未启动或已崩溃),`curl` 拿到空响应
**处理**
```bash
pm2 restart manual-trading-hub
sleep 2
pm2 logs manual-trading-hub --lines 30 --nostream
ss -ltn | grep 5100
bash scripts/verify_hub_deploy.sh
```
---
### 1.4 `bash scripts/fix_hub_deps.sh` 在仓库根目录找不到
**原因**脚本在 `manual_trading_hub/scripts/`不在 `/opt/crypto_monitor/scripts/`
**处理**
```bash
cd /opt/crypto_monitor/manual_trading_hub
bash scripts/fix_hub_deps.sh
```
---
## 二登录与 Cookie反代 / 域名 / 内网 IP
### 2.1 设了密码后域名能登录`http://内网IP:5100` 不能
**原因**最常见):
- `.env``HUB_COOKIE_SECURE=true`且用 **HTTP** 访问 IP:5100 → 浏览器**不保存**带 `Secure` 的 Cookie表现为登录成功后又跳回登录页
- **域名HTTPS****IP:5100HTTP** 是不同站点Cookie **不共用**需在 IP 上再登一次
**处理**
- 已支持仅在实际 **HTTPS** 请求时发 `Secure` Cookie`X-Forwarded-Proto`),HTTP 内网 IP 可正常登录
- 反代 Nginx 需传`proxy_set_header X-Forwarded-Proto $scheme;`
- 若仍异常HTTPS 域名与 HTTP IP **分别登录**或内网仅用 IP 时可注释 `HUB_COOKIE_SECURE`
### 2.2 登录后接口仍 401
| 检查项 | 说明 |
|--------|------|
| 用户名密码 | `.env``HUB_USERNAME`未设默认为 `admin`)、`HUB_PASSWORD` |
| 改密后 | 需重新登录旧 Cookie 失效 |
| 混用地址 | 不要用 A 浏览器标签登域名B 标签指望 IP 已登录 |
### 2.3 本地导航 iframe 嵌入登录成功但一直「跳转中」/ 进不去
**原因**父页`http://192.168.8.6:5070`跨域 `fetch` 中控 `/api/auth/login`浏览器**不会**把 `Set-Cookie` 写进 iframe 里的中控站点表现为接口 200弹窗「登录成功」但 iframe 仍无会话
**处理**中控 `git pull` 并重启 hub 后):
1. 登录接口会返回 `session_token`父页应把 iframe 指向
`http://中控地址/embed-auth?token=会话token&next=/monitor`
2. 若直接在 iframe 内打开中控 `/login` 登录页面会自动走 `/embed-auth` 写入 Cookie
3. 父页也可监听 `postMessage`事件类型 `hub:login-ok`字段含 `embed_auth_url`
`.env` 可选
```env
HUB_ALLOW_EMBED=true
HUB_EMBED_ORIGINS=http://192.168.8.6:5070
```
---
## 三监控区无数据 / 子代理异常
### 3.1 卡片「子代理不可用」或余额为 —
| 原因 | 处理 |
|------|------|
| agent 未启动 | `pm2 restart ecosystem.config.cjs``pm2 restart manual-agent-*` |
| Agent URL 与端口不符 | 系统设置里应为 `http://127.0.0.1:15200` 等 |
| PM2 未加载策略 `.env` | 须用 `run_agent.sh` 启动`source` 各目录 `.env`),勿裸跑 `agent.py` |
| `.env` 为 Windows CRLF | 日志 `$'\r': command not found``bash scripts/fix_env_crlf.sh` 后重启 |
验证
```bash
curl -s http://127.0.0.1:15202/status | head -c 300
```
`ok: true` 且有 `balance_usdt`
### 3.3 Gate 子代理「一会正常一会连不上」仅 Gate 两户
| 现象 | 说明 |
|------|------|
| 中控某所子代理红 | 本机对应 agent 端口在 PM2 重启间隙连不上 |
| 日志 `$'\r': command not found` | `crypto_monitor_gate*``.env` 为 Windows CRLF |
| `curl` 有时通有时不通 | 与 Gate 外网无关先修 CRLF 并重建 agent |
**修复**服务器):
```bash
cd /opt/crypto_monitor
sed -i 's/\r$//' crypto_monitor_gate/.env crypto_monitor_gate/.env
bash manual_trading_hub/scripts/fix_env_crlf.sh
cd manual_trading_hub && pm2 restart manual-agent-gate
# 仍反复重启时pm2 delete 后按 ecosystem.config.cjs 重新 start见部署文档 §5.6
```
修好后 `pm2 describe manual-agent-gate`**restarts** 应不再疯涨`pm2 flush manual-agent-gate` 可清掉旧 CRLF 日志
**若子代理已绿但挂委托失败**再查 `GATE_SOCKS_PROXY`API 权限止损止盈价格是否合理与各实例策略页相同 `.env` 参数)。
### 3.2 有持仓但无关键位 / 趋势或提示 Flask 404
| 原因 | 处理 |
|------|------|
| 对应 `crypto_*` Flask 未启动 | `pm2 restart crypto_gate` 等 |
| 未注册 `hub_bridge` | 启动日志勿含 `[hub_bridge] ImportError`仓库根需在 `PYTHONPATH`各实例 `ecosystem.config.cjs` 已配 `PYTHONPATH=..` |
| 中控 `ModuleNotFoundError: hub_auth` | 确认仓库根存在 `/opt/crypto_monitor/hub_auth.py``git pull`);`run_hub.sh` / PM2 已设 `PYTHONPATH=仓库根``pm2 restart manual-trading-hub` |
| `HUB_BRIDGE_TOKEN` 不一致 | 中控 `.env` 与三实例 `.env` 设相同令牌或实例 `APP_AUTH_DISABLED=true`仅建议本机 |
```bash
curl -s -H "X-Hub-Token:你的令牌" http://127.0.0.1:5000/api/hub/ping
```
### 3.3 中控监控区打开慢一直转圈
**原因常见**
1. 首屏要等 **`/api/monitor/board`**向 4 个子代理拉持仓/余额并向 4 个 Flask 拉监控与默认关键位行情任一实例慢或超时都会拖住整页
2. 旧版 hub 对每所 Flask **串行**请求3 所 × 3 接口容易累计到十几秒新版已改为**并行**`git pull``pm2 restart manual-trading-hub`)。
3. 各实例 **`/api/price_snapshot`** 会调交易所接口含全量持仓),最耗时内网访问 Google 字体也会拖首屏渲染
4. 子代理 `/status``fetch_balance` / `fetch_positions` / 挂单列表走交易所 API网络差时单次可达数秒
**加快办法**
```env
# manual_trading_hub/.env
HUB_BOARD_KEY_PRICES=false # 不拉 price_snapshot关键位门控显示为「-」首屏明显更快
HUB_AGENT_TIMEOUT=6
HUB_FLASK_TIMEOUT=8
```
并确认三所 `crypto_*``manual-agent-*` 均为 **online**避免等满超时浏览器 **Ctrl+F5** 强刷静态资源版本号含 `20260525-perf`)。
---
## 四云服务器 / 公网反代
**云服务器完整配置安全组宝塔环境变量PM2验收** 见 **[云服务器部署说明.md](./云服务器部署说明.md)**
---
## 五复盘链接与公网反代
### 4.1 监控里点「复盘」打开的是本机 127.0.0.1
**原因**未设 `HUB_PUBLIC_ORIGIN`浏览器拿到的链接仍是 Flask 本机地址
**处理**`manual_trading_hub/.env` 增加示例):
```env
HUB_PUBLIC_ORIGIN=http://192.168.8.6
```
`HUB_PUBLIC_HOST=192.168.8.6`改后 `pm2 restart manual-trading-hub`
**说明**仅反代中控三实例 Flask 仍只监听 127.0.0.1 时其它电脑要能打开复盘还须能访问各实例端口或单独反代
### 4.2 只反代中控不反代三实例
**可以**中控聚合监控与全平复盘下单关键位维护进各实例网页实例 Flask/agent 建议 `127.0.0.1` + 与中控相同的 `HUB_BRIDGE_TOKEN`
### 4.3 从中控「打开实例」仍要输密码
**完整说明**[局域网与反代部署说明.md](./局域网与反代部署说明.md)
**常见原因**
1. 三实例未重启`/hub-sso` 未加载启动日志勿长期 `[hub_bridge] ImportError`)。
2. `HUB_BRIDGE_TOKEN` 与三实例 `.env` 不一致
3. `hub_settings` 里该户 `key` 与实例 `install_on_app(exchange=...)` 不一致`okx``gate`)。
4. **HTTPS 跨域 iframe**中控与实例不同域名时三实例须 `APP_COOKIE_SECURE=true`使 session Cookie 为 `SameSite=None`),否则 SSO 成功仍跳 `/login`
5. **经本地导航打开中控**LocalNav → 中控 iframe → 点实例):旧版会在中控内再嵌一层实例 iframeCookie 易失效请升级 **LocalNav + 中控** 最新代码点实例后由导航页直接打开实例工具栏有「← 中控」须配置 `NAV_HUB_USERNAME` / `NAV_HUB_PASSWORD`三实例 `HUB_EMBED_PARENT_ORIGINS` 含本地导航地址`http://192.168.8.6:5070`)。
6. 浏览器仍用旧书签直链首页未从中控点「实例」直链本来就要登录)。
**直链**`http://IP:端口``https://实例域名` → 使用各实例 **`APP_USERNAME` / `APP_PASSWORD`**三所建议统一)。
---
## 六Gate / 复盘相关实例侧
### 5.1 Gate `/records` 或预览 500`preview_created_at`
**原因**数据库缺列或查询未兼容旧库
**处理**`git pull` 后重启 `crypto_gate`必要时在实例目录执行一次带 `init_db` 的启动或按该目录更新文档迁移
### 5.2 中控监控区 Gate「无关键位」
**说明**若系统设置未勾选「监控关键位」中控不会展示关键位区块策略交易仍在各实例 `/strategy` 操作
---
## 七环境与配置
### 6.1 OKX 默认不显示
`HUB_DISABLED_IDS=1`默认关 OKX)。要用 OKX清空或改掉该变量并在系统设置启用 id=1
### 6.2 公网 IP 直连中控 403
`HUB_TRUST_LAN=true` 时仅允许本机 + RFC1918 私网10/172.16/192.168)。公网 IP 直连 5100 会被拒应走 **Nginx 反代到 127.0.0.1:5100**
### 4.4 浏览器显示 `{"detail":"forbidden"}`
**原因**中控 `local_only` 中间件认为访问来源 IP 不允许常见于云上 `HUB_TRUST_LAN=false` 且反代未指向 `127.0.0.1:5100`)。
**处理**二选一):
1. `manual_trading_hub/.env` 增加 **`HUB_ALLOW_PUBLIC=true`**已设 `HUB_PASSWORD` 时推荐),`pm2 restart manual-trading-hub`
2. 宝塔反代目标改为 **`http://127.0.0.1:5100`**不要用公网 IP:5100 作 upstream)。
改后强刷浏览器再开 `/login`
### 6.3 `.env` 修改不生效
PM2 须重启`pm2 restart manual-trading-hub``run_hub.sh` 每次启动会重读 `.env`)。
### 6.4 `hub_settings.json` 与 Git
网页「系统设置」保存生成**一般不提交 Git**`git pull` **不会覆盖** 该文件与 `.env`
---
## 八功能边界避免误用
| 项目 | 说明 |
|------|------|
| 中控下单区 | **已移除**勿再在中控添加关键位/人工单/趋势预览 |
| 中控能力 | 监控聚合单户/全局紧急全平系统设置登录保护 |
| 下单与关键位 | 各 `crypto_monitor_*` 原网页 |
| 复盘 | 各实例 `/records`中控仅「复盘」外链 |
| 全平 | 市价减仓不可撤销操作前确认 |
---
## 九推荐排障顺序
1. `git pull``manual_trading_hub``bash scripts/fix_hub_deps.sh``bash scripts/verify_hub_deploy.sh`
2. `pm2 restart manual-trading-hub``ecosystem.config.cjs` 若 agent/Flask 也有问题
3. `curl http://127.0.0.1:5100/api/ping` → 确认 `build``trade_ui:false`
4. 浏览器打开 `/login` 登录 → `/monitor` 强刷
5. 逐项 `curl` 子代理 `/status`Flask `/api/hub/ping`
6. 仍不行则查 `pm2 logs manual-trading-hub``pm2 logs crypto_gate` 最近 50 行
---
## 十行情区 K 线
### 10.1 只加载约 300 根目标 1000
**原因**旧版 `hub_ohlcv_lib``since` 分页时OKX/Gate 单次 API 常只返回 ~300 根
**处理**`git pull` 后重启 **hub + 三实例 Flask**行情区点 **强制刷新**浏览器强刷`chart.js` 带版本号)。
### 10.2 6h / 8h 周期错乱已移除
中控行情区 **已不再提供** `6h``8h`以及 `3m`/`10m`/`20m`/`30m`)。若 URL 或旧缓存仍带这些周期会回退为 `5m`请改用 `4h` / `12h` 等当前列表见 [行情区说明.md](./行情区说明.md)
### 10.3 12h 数据异常
**原因**部分交易所无原生 12h或本地 `hub_kline.db` 存有升级前的错误缓存
**处理**强制刷新仍异常可停 hub 后备份并删除 `manual_trading_hub/data/hub_kline.db` 再拉取
### 10.4 快捷键无效
- 全屏请用 **`F`**Win 下 Ctrl+空格常被输入法占用已不作为全屏键)。
- 须在 **行情区** 页面且焦点不在币种输入框
- 升级后确认加载 `chart.js?v=...` 新版本
---
## 十一相关脚本
| 脚本 | 作用 |
|------|------|
| `scripts/fix_hub_deps.sh` | 安装/更新中控 venv 依赖含 python-multipart |
| `scripts/verify_hub_deploy.sh` | 检查代码版本multipartpingPM2 状态 |
| `scripts/fix_env_crlf.sh` | 去除各目录 `.env` 的 Windows 换行 |
| `scripts/run_hub.sh` | PM2 启动 hub加载 `.env` |
| `scripts/run_agent.sh` | PM2 启动 agent加载策略目录 `.env` |
| `scripts/pm2_hub.sh` | 启停/日志 hub+agent 一体 |
---
## 十二文档索引
| 文档 | 内容 |
|------|------|
| [使用说明.md](./使用说明.md) | 架构页面环境变量API |
| [行情区说明.md](./行情区说明.md) | K 线周期缓存快捷键 |
| [部署文档.md](./部署文档.md) | Ubuntu/PM2 安装与运维 |
| [云服务器部署说明.md](./云服务器部署说明.md) | VPS 配置安全组宝塔env验收 |
| [局域网与反代部署说明.md](./局域网与反代部署说明.md) | 内网 IP:端口 / 域名反代SSO |
| [README.md](./README.md) | 速览与快速启动 |
| [.env.example](./.env.example) | 中控环境变量模板 |
# 中控与三实例 — 常见问题实录
本文档整理部署与运行 **manual_trading_hub**(复盘系统中控)及三所 `crypto_monitor_*` 时**实际遇到过**的问题与处理办法.操作步骤仍以 [使用说明.md](./使用说明.md),[部署文档.md](./部署文档.md) 为准.
---
## 一,中控进程与代码版本
### 1.1 PM2 日志仍出现 `api_trade_key`,`python-multipart` 断言
**现象**:`pm2 logs` 里报错 `File "hub.py", line 324, in api_trade_key``The python-multipart library must be installed`.
**原因**:
- 服务器上的 `hub.py` 仍是**旧版**(含已移除的「下单区」接口),或 pull 后**未重启** PM2,日志是历史残留.
- 旧版「添加关键位」会 `request.form()`,未装 `python-multipart` 时直接 500.
**处理**:
```bash
cd /opt/crypto_monitor
git pull
cd manual_trading_hub
bash scripts/fix_hub_deps.sh # 安装 python-multipart 等
bash scripts/verify_hub_deploy.sh # 应显示无 api_trade_key,含 HUB_BUILD
pm2 restart manual-trading-hub
curl -s http://127.0.0.1:5100/api/ping
```
**正常 ping**(无需登录)应含 `"build":"20260521-no-trade-ui"`,`"trade_ui":false`.
**说明**:当前版本**已移除中控下单区**;添加关键位,人工下单,趋势回调请在监控卡片点 **「实例」** 进入各 Flask 网页.浏览器请 **Ctrl+F5** 强刷,避免旧前端缓存仍请求 `/api/trade/key`.
---
### 1.2 `curl /api/ping` 返回 `{"detail":"未登录"}`
**原因**:早期版本未把 `/api/ping` 列入免登录白名单(已修复).
**处理**:`git pull``pm2 restart manual-trading-hub`;再测应直接返回 JSON,无需 Cookie.
---
### 1.3 `verify_hub_deploy.sh``Expecting value: line 1 column 1`
**原因**:5100 端口无进程监听(hub 未启动或已崩溃),`curl` 拿到空响应.
**处理**:
```bash
pm2 restart manual-trading-hub
sleep 2
pm2 logs manual-trading-hub --lines 30 --nostream
ss -ltn | grep 5100
bash scripts/verify_hub_deploy.sh
```
---
### 1.4 `bash scripts/fix_hub_deps.sh` 在仓库根目录找不到
**原因**:脚本在 `manual_trading_hub/scripts/`,不在 `/opt/crypto_monitor/scripts/`.
**处理**:
```bash
cd /opt/crypto_monitor/manual_trading_hub
bash scripts/fix_hub_deps.sh
```
---
## 二,登录与 Cookie(反代 / 域名 / 内网 IP)
### 2.1 设了密码后,域名能登录,`http://内网IP:5100` 不能
**原因**(最常见):
- `.env``HUB_COOKIE_SECURE=true`,且用 **HTTP** 访问 IP:5100 → 浏览器**不保存**带 `Secure` 的 Cookie,表现为登录成功后又跳回登录页.
- **域名(HTTPS)****IP:5100(HTTP)** 是不同站点,Cookie **不共用**,需在 IP 上再登一次.
**处理**:
- 已支持:仅在实际 **HTTPS** 请求时发 `Secure` Cookie(`X-Forwarded-Proto`),HTTP 内网 IP 可正常登录.
- 反代 Nginx 需传:`proxy_set_header X-Forwarded-Proto $scheme;`
- 若仍异常:HTTPS 域名与 HTTP IP **分别登录**;或内网仅用 IP 时可注释 `HUB_COOKIE_SECURE`.
### 2.2 登录后接口仍 401
| 检查项 | 说明 |
|--------|------|
| 用户名密码 | `.env``HUB_USERNAME`(未设默认为 `admin`),`HUB_PASSWORD` |
| 改密后 | 需重新登录;旧 Cookie 失效 |
| 混用地址 | 不要用 A 浏览器标签登域名,B 标签指望 IP 已登录 |
### 2.3 本地导航 iframe 嵌入:登录成功但一直「跳转中」/ 进不去
**原因**:父页(`http://192.168.8.6:5070`)跨域 `fetch` 中控 `/api/auth/login`,浏览器**不会**把 `Set-Cookie` 写进 iframe 里的中控站点,表现为接口 200,弹窗「登录成功」,但 iframe 仍无会话.
**处理**(中控 `git pull` 并重启 hub 后):
1. 登录接口会返回 `session_token`;父页应把 iframe 指向:
`http://中控地址/embed-auth?token=会话token&next=/monitor`
2. 若直接在 iframe 内打开中控 `/login` 登录,页面会自动走 `/embed-auth` 写入 Cookie.
3. 父页也可监听 `postMessage`,事件类型 `hub:login-ok`,字段含 `embed_auth_url`.
`.env` 可选:
```env
HUB_ALLOW_EMBED=true
HUB_EMBED_ORIGINS=http://192.168.8.6:5070
```
---
## 三,监控区无数据 / 子代理异常
### 3.1 卡片「子代理不可用」或余额为 —
| 原因 | 处理 |
|------|------|
| agent 未启动 | `pm2 restart ecosystem.config.cjs``pm2 restart manual-agent-*` |
| Agent URL 与端口不符 | 系统设置里应为 `http://127.0.0.1:15200` 等 |
| PM2 未加载策略 `.env` | 须用 `run_agent.sh` 启动(`source` 各目录 `.env`),勿裸跑 `agent.py` |
| `.env` 为 Windows CRLF | 日志 `$'\r': command not found``bash scripts/fix_env_crlf.sh` 后重启 |
验证:
```bash
curl -s http://127.0.0.1:15202/status | head -c 300
```
`ok: true` 且有 `balance_usdt`.
### 3.3 Gate 子代理「一会正常,一会连不上」(仅 Gate 两户)
| 现象 | 说明 |
|------|------|
| 中控某所子代理红 | 本机对应 agent 端口在 PM2 重启间隙连不上 |
| 日志 `$'\r': command not found` | `crypto_monitor_gate*``.env` 为 Windows CRLF |
| `curl` 有时通有时不通 | 与 Gate 外网无关,先修 CRLF 并重建 agent |
**修复**(服务器):
```bash
cd /opt/crypto_monitor
sed -i 's/\r$//' crypto_monitor_gate/.env crypto_monitor_gate/.env
bash manual_trading_hub/scripts/fix_env_crlf.sh
cd manual_trading_hub && pm2 restart manual-agent-gate
# 仍反复重启时:pm2 delete 后按 ecosystem.config.cjs 重新 start(见部署文档 §5.6)
```
修好后 `pm2 describe manual-agent-gate`**restarts** 应不再疯涨;`pm2 flush manual-agent-gate` 可清掉旧 CRLF 日志.
**若子代理已绿但挂委托失败**:再查 `GATE_SOCKS_PROXY`,API 权限,止损止盈价格是否合理(与各实例策略页相同 `.env` 参数).
### 3.2 有持仓但无关键位 / 趋势,或提示 Flask 404
| 原因 | 处理 |
|------|------|
| 对应 `crypto_*` Flask 未启动 | `pm2 restart crypto_gate` 等 |
| 未注册 `hub_bridge` | 启动日志勿含 `[hub_bridge] ImportError`;仓库根需在 `PYTHONPATH`(各实例 `ecosystem.config.cjs` 已配 `PYTHONPATH=..`) |
| 中控 `ModuleNotFoundError: hub_auth` | 确认仓库根存在 `/opt/crypto_monitor/hub_auth.py`(`git pull`);`run_hub.sh` / PM2 已设 `PYTHONPATH=仓库根`;`pm2 restart manual-trading-hub` |
| `HUB_BRIDGE_TOKEN` 不一致 | 中控 `.env` 与三实例 `.env` 设相同令牌,或实例 `APP_AUTH_DISABLED=true`(仅建议本机) |
```bash
curl -s -H "X-Hub-Token:你的令牌" http://127.0.0.1:5000/api/hub/ping
```
### 3.3 中控监控区打开慢,一直转圈
**原因(常见)**:
1. 首屏要等 **`/api/monitor/board`**:向 4 个子代理拉持仓/余额,并向 4 个 Flask 拉监控与(默认)关键位行情;任一实例慢或超时都会拖住整页.
2. 旧版 hub 对每所 Flask **串行**请求,3 所 × 3 接口容易累计到十几秒;新版已改为**并行**(`git pull``pm2 restart manual-trading-hub`).
3. 各实例 **`/api/price_snapshot`** 会调交易所接口(含全量持仓),最耗时;内网访问 Google 字体也会拖首屏渲染.
4. 子代理 `/status``fetch_balance` / `fetch_positions` / 挂单列表走交易所 API,网络差时单次可达数秒.
**加快办法**:
```env
# manual_trading_hub/.env
HUB_BOARD_KEY_PRICES=false # 不拉 price_snapshot,关键位门控显示为「-」,首屏明显更快
HUB_AGENT_TIMEOUT=6
HUB_FLASK_TIMEOUT=8
```
并确认三所 `crypto_*``manual-agent-*` 均为 **online**,避免等满超时.浏览器 **Ctrl+F5** 强刷静态资源(版本号含 `20260525-perf`).
---
## 四,云服务器 / 公网反代
**云服务器完整配置(安全组,宝塔,环境变量,PM2,验收)** 见 **[云服务器部署说明.md](./云服务器部署说明.md)**.
---
## 五,复盘链接与公网反代
### 4.1 监控里点「复盘」打开的是本机 127.0.0.1
**原因**:未设 `HUB_PUBLIC_ORIGIN`,浏览器拿到的链接仍是 Flask 本机地址.
**处理**:`manual_trading_hub/.env` 增加(示例):
```env
HUB_PUBLIC_ORIGIN=http://192.168.8.6
```
`HUB_PUBLIC_HOST=192.168.8.6`.改后 `pm2 restart manual-trading-hub`.
**说明**:仅反代中控,三实例 Flask 仍只监听 127.0.0.1 时,其它电脑要能打开复盘,还须能访问各实例端口或单独反代.
### 4.2 只反代中控,不反代三实例
**可以**.中控聚合监控与全平;复盘,下单,关键位维护进各实例网页.实例 Flask/agent 建议 `127.0.0.1` + 与中控相同的 `HUB_BRIDGE_TOKEN`.
### 4.3 从中控「打开实例」仍要输密码
**完整说明**:[局域网与反代部署说明.md](./局域网与反代部署说明.md)
**常见原因**:
1. 三实例未重启,`/hub-sso` 未加载(启动日志勿长期 `[hub_bridge] ImportError`).
2. `HUB_BRIDGE_TOKEN` 与三实例 `.env` 不一致.
3. `hub_settings` 里该户 `key` 与实例 `install_on_app(exchange=...)` 不一致(`okx`,`gate`).
4. **HTTPS 跨域 iframe**:中控与实例不同域名时,三实例须 `APP_COOKIE_SECURE=true`(使 session Cookie 为 `SameSite=None`),否则 SSO 成功仍跳 `/login`.
5. **经本地导航打开中控**(LocalNav → 中控 iframe → 点实例):旧版会在中控内再嵌一层实例 iframe,Cookie 易失效.请升级 **LocalNav + 中控** 最新代码:点实例后由导航页直接打开实例,工具栏有「← 中控」;须配置 `NAV_HUB_USERNAME` / `NAV_HUB_PASSWORD`,三实例 `HUB_EMBED_PARENT_ORIGINS` 含本地导航地址(`http://192.168.8.6:5070`).
6. 浏览器仍用旧书签直链首页,未从中控点「实例」(直链本来就要登录).
**直链**:`http://IP:端口``https://实例域名` → 使用各实例 **`APP_USERNAME` / `APP_PASSWORD`**(三所建议统一).
---
## 六,Gate / 复盘相关(实例侧)
### 5.1 Gate `/records` 或预览 500(`preview_created_at`)
**原因**:数据库缺列或查询未兼容旧库.
**处理**:`git pull` 后重启 `crypto_gate`;必要时在实例目录执行一次带 `init_db` 的启动或按该目录更新文档迁移.
### 5.2 中控监控区 Gate「无关键位」
**说明**:若系统设置未勾选「监控关键位」,中控不会展示关键位区块;策略交易仍在各实例 `/strategy` 操作.
---
## 七,环境与配置
### 6.1 OKX 默认不显示
`HUB_DISABLED_IDS=1`(默认关 OKX).要用 OKX:清空或改掉该变量,并在系统设置启用 id=1.
### 6.2 公网 IP 直连中控 403
`HUB_TRUST_LAN=true` 时仅允许本机 + RFC1918 私网(10/172.16/192.168).公网 IP 直连 5100 会被拒;应走 **Nginx 反代到 127.0.0.1:5100**.
### 4.4 浏览器显示 `{"detail":"forbidden"}`
**原因**:中控 `local_only` 中间件认为访问来源 IP 不允许(常见于云上 `HUB_TRUST_LAN=false` 且反代未指向 `127.0.0.1:5100`).
**处理**(二选一):
1. `manual_trading_hub/.env` 增加 **`HUB_ALLOW_PUBLIC=true`**(已设 `HUB_PASSWORD` 时推荐),`pm2 restart manual-trading-hub`.
2. 宝塔反代目标改为 **`http://127.0.0.1:5100`**(不要用公网 IP:5100 作 upstream).
改后强刷浏览器再开 `/login`.
### 6.3 `.env` 修改不生效
PM2 须重启:`pm2 restart manual-trading-hub`(`run_hub.sh` 每次启动会重读 `.env`).
### 6.4 `hub_settings.json` 与 Git
网页「系统设置」保存生成,**一般不提交 Git**.`git pull` **不会覆盖** 该文件与 `.env`.
---
## 八,功能边界(避免误用)
| 项目 | 说明 |
|------|------|
| 中控下单区 | **已移除**;勿再在中控添加关键位/人工单/趋势预览 |
| 中控能力 | 监控聚合,单户/全局紧急全平,系统设置,登录保护 |
| 下单与关键位 | 各 `crypto_monitor_*` 原网页 |
| 复盘 | 各实例 `/records`;中控仅「复盘」外链 |
| 全平 | 市价减仓,不可撤销,操作前确认 |
---
## 九,推荐排障顺序
1. `git pull``manual_trading_hub``bash scripts/fix_hub_deps.sh``bash scripts/verify_hub_deploy.sh`
2. `pm2 restart manual-trading-hub`(`ecosystem.config.cjs` 若 agent/Flask 也有问题)
3. `curl http://127.0.0.1:5100/api/ping` → 确认 `build``trade_ui:false`
4. 浏览器打开 `/login` 登录 → `/monitor` 强刷
5. 逐项 `curl` 子代理 `/status`,Flask `/api/hub/ping`
6. 仍不行则查 `pm2 logs manual-trading-hub`,`pm2 logs crypto_gate` 最近 50 行
---
## 十,行情区 K 线
### 10.1 只加载约 300 根(目标 1000)
**原因**:旧版 `hub_ohlcv_lib``since` 分页时,OKX/Gate 单次 API 常只返回 ~300 根.
**处理**:`git pull` 后重启 **hub + 三实例 Flask**,行情区点 **强制刷新**;浏览器强刷(`chart.js` 带版本号).
### 10.2 6h / 8h 周期错乱(已移除)
中控行情区 **已不再提供** `6h`,`8h`(以及 `3m`/`10m`/`20m`/`30m`).若 URL 或旧缓存仍带这些周期,会回退为 `5m`.请改用 `4h` / `12h` 等当前列表,见 [行情区说明.md](./行情区说明.md).
### 10.3 12h 数据异常
**原因**:部分交易所无原生 12h;或本地 `hub_kline.db` 存有升级前的错误缓存.
**处理**:强制刷新;仍异常可停 hub 后备份并删除 `manual_trading_hub/data/hub_kline.db` 再拉取.
### 10.4 快捷键无效
- 全屏请用 **`F`**(Win 下 Ctrl+空格常被输入法占用,已不作为全屏键).
- 须在 **行情区** 页面且焦点不在币种输入框.
- 升级后确认加载 `chart.js?v=...` 新版本.
---
## 十一,相关脚本
| 脚本 | 作用 |
|------|------|
| `scripts/fix_hub_deps.sh` | 安装/更新中控 venv 依赖(含 python-multipart) |
| `scripts/verify_hub_deploy.sh` | 检查代码版本,multipart,ping,PM2 状态 |
| `scripts/fix_env_crlf.sh` | 去除各目录 `.env` 的 Windows 换行 |
| `scripts/run_hub.sh` | PM2 启动 hub(加载 `.env`) |
| `scripts/run_agent.sh` | PM2 启动 agent(加载策略目录 `.env`) |
| `scripts/pm2_hub.sh` | 启停/日志 hub+agent 一体 |
---
## 十二,文档索引
| 文档 | 内容 |
|------|------|
| [使用说明.md](./使用说明.md) | 架构,页面,环境变量,API |
| [行情区说明.md](./行情区说明.md) | K 线周期,缓存,快捷键 |
| [部署文档.md](./部署文档.md) | Ubuntu/PM2 安装与运维 |
| [云服务器部署说明.md](./云服务器部署说明.md) | VPS 配置,安全组,宝塔,env,验收 |
| [局域网与反代部署说明.md](./局域网与反代部署说明.md) | 内网 IP:端口 / 域名反代,SSO |
| [README.md](./README.md) | 速览与快速启动 |
| [.env.example](./.env.example) | 中控环境变量模板 |
+27 -27
View File
@@ -1,52 +1,52 @@
# 开仓计划
中控顶栏 **开仓计划**`/plan`用于记录开仓前的计划跟踪进行中条目并在填写结果后归档支持按币种趋势周期入场方案统计胜率
中控顶栏 **开仓计划**(`/plan`)用于记录开仓前的计划,跟踪进行中条目,并在填写结果后归档;支持按币种,趋势周期,入场方案统计胜率.
## 入口
- 顶栏**资金概况** 与 **监控区** 之间 → **开仓计划**
- 路由`/plan`
- 顶栏:**资金概况** 与 **监控区** 之间 → **开仓计划**
- 路由:`/plan`
## 页面结构
| 区域 | 功能 |
|------|------|
| 左侧 · 新建计划 | 填写计划字段保存后进入「进行中」 |
| 左侧 · 进行中 | 修改删除填写结果并归档 |
| 右侧 · 计划历史 | 一行一条摘要点击查看详情 |
| 右侧 · 数据统计 | 胜率表可切换维度与时间范围 |
| 左侧 · 新建计划 | 填写计划字段,保存后进入「进行中」 |
| 左侧 · 进行中 | 修改,删除,填写结果并归档 |
| 右侧 · 计划历史 | 一行一条摘要,点击查看详情 |
| 右侧 · 数据统计 | 胜率表(可切换维度与时间范围) |
## 字段说明
| 字段 | 说明 |
|------|------|
| 日期 | 计划日期日期选择器可手输 `YYYY-MM-DD` |
| 交易所 | 三所binance / okx / gate来自 hub 已启用账户 |
| 币种 | 输入 `BTC``BTC/USDT`自动规范为 `XXX/USDT` |
| 日期 | 计划日期(日期选择器,可手输 `YYYY-MM-DD`) |
| 交易所 | 三所:binance / okx / gate(来自 hub 已启用账户) |
| 币种 | 输入 `BTC``BTC/USDT`,自动规范为 `XXX/USDT` |
| 类型 | 趋势单 / 波段单 / 日内短线 |
| 趋势周期 | 5m / 15m / 30m / 1h / 4h / 1d |
| 入场周期 | 1m / 5m / 15m / 30m / 1h |
| 方向 | 多 / 空 |
| 目标位 | 文本 |
| 当前区间 | 文本 |
| 入场方案 | **仅进行中**填写突破 / 假突破 / 箱体拐点根据实际进场选择归档前必选 |
| 结果 | **仅进行中**可填盈 / 亏**必选其一才归档** |
| 盈亏 | **可选**数字U),不参与是否归档 |
| 入场方案 | **仅进行中**填写:突破 / 假突破 / 箱体拐点(根据实际进场选择;归档前必选) |
| 结果 | **仅进行中**可填:盈 / 亏;**必选其一才归档** |
| 盈亏 | **可选**数字(U),不参与是否归档 |
| 备注 | 文本 |
## 业务流程
1. **新建** → 状态 `active`进行中),**不含入场方案**
2. **进行中** → 选择/修改 **入场方案**根据实际进场填写),可改备注价位等
1. **新建** → 状态 `active`(进行中),**不含入场方案**
2. **进行中** → 选择/修改 **入场方案**(根据实际进场填写),可改备注,价位等
3. **删除** → 仅 **未填结果** 的进行中计划可删
4. **归档** → 在进行中选择 **盈/亏** 并点「填写结果并归档」→ 状态 `archived`移入计划历史
4. **归档** → 在进行中选择 **盈/亏** 并点「填写结果并归档」→ 状态 `archived`,移入计划历史
## 数据统计
- **默认**全部历史
- **时间**全部 / 本周 / 本月 / 自选区间
- **维度 Tab**币种 | 趋势周期 | 入场方案
- **胜率**盈利 ÷ (盈利 + 亏损)仅统计已归档且结果=盈/亏 的计划
- **默认**:全部历史
- **时间**:全部 / 本周 / 本月 / 自选区间
- **维度 Tab**:币种 | 趋势周期 | 入场方案
- **胜率**:盈利 ÷ (盈利 + 亏损),仅统计已归档且结果=盈/亏 的计划
## API
@@ -56,14 +56,14 @@
| GET | `/api/entry-plans?status=active\|archived` | 列表 |
| GET | `/api/entry-plans/{id}` | 详情 |
| POST | `/api/entry-plans` | 新建 |
| PATCH | `/api/entry-plans/{id}` | 更新写入 `result` 时自动归档 |
| DELETE | `/api/entry-plans/{id}` | 删除仅 active |
| GET | `/api/entry-plans/stats` | 统计参数 `dimension``period``date_from``date_to` |
| PATCH | `/api/entry-plans/{id}` | 更新;写入 `result` 时自动归档 |
| DELETE | `/api/entry-plans/{id}` | 删除(仅 active) |
| GET | `/api/entry-plans/stats` | 统计;参数 `dimension`,`period`,`date_from`,`date_to` |
## 存储
- SQLite`manual_trading_hub/data/hub_entry_plans.db`
- 环境变量`HUB_ENTRY_PLAN_DB_PATH`可选自定义路径
- SQLite:`manual_trading_hub/data/hub_entry_plans.db`
- 环境变量:`HUB_ENTRY_PLAN_DB_PATH`(可选自定义路径)
## 部署
@@ -72,13 +72,13 @@ git pull
pm2 restart manual-trading-hub
```
浏览器访问 `/plan`**Ctrl+F5** 强刷静态资源
浏览器访问 `/plan`**Ctrl+F5** 强刷静态资源.
## 相关代码
| 文件 | 说明 |
|------|------|
| `hub_entry_plan_lib.py` | 库表CRUD统计 |
| `hub_entry_plan_lib.py` | 库表,CRUD,统计 |
| `manual_trading_hub/hub.py` | REST API |
| `manual_trading_hub/static/plan.js` | 前端逻辑 |
| `manual_trading_hub/static/index.html` | 页面 DOM |
+50 -50
View File
@@ -1,50 +1,50 @@
# 中控数据看板说明
入口**`/dashboard`**顶栏「数据看板」)。
## 能力
| 区块 | 说明 |
|------|------|
| **总览 KPI** | 交易日平仓盈亏笔数浮盈亏资金合计实盘持仓 |
| **分户明细** | 三户资金/交易账户今日盈亏浮盈亏备注未启用显示「未监控」 |
| **平仓明细** | 当日平仓流水合约方向结果盈亏时间 |
| **风险预警** | 单户单日平仓亏损 ≥ 资金合计 **5%** 时横幅 + 卡片高亮 |
纯数据聚合**不调用 AI**交易日口径与实例一致`TRADING_DAY_RESET_HOUR`默认 8 点)。
## 刷新机制SSE
与监控区 board 类似采用 **后台聚合 + SSE 推送版本号**
1. `hub.py` 启动后 `dashboard_store`**60s**`DASHBOARD_POLL_INTERVAL_SEC`聚合三户数据到内存快照
2. 浏览器打开看板页后连接 `GET /api/dashboard/stream``event: dashboard`)。
3. 收到新版本号后拉取 `GET /api/dashboard/daily` 快照并局部渲染**无整页轮询闪烁**
4. 监控区触发 board 刷新全平撤单等)时,会一并 `request_refresh` 看板尽量与实盘同步
5. 「立即刷新」→ `POST /api/dashboard/refresh` 触发下一轮聚合
可选环境变量`HUB_DASHBOARD_SSE_HEARTBEAT_SEC`默认 25SSE 心跳间隔)。
## 主题与样式
- 跟随中控顶栏 **亮/暗主题**`theme.js`),使用 `--panel` / `--border` / `--accent` 等变量
- 卡片采用 **柔光阴影**非霓虹渐变背景);亮色主题下为浅灰投影暗色主题为轻微内高光
- 盈亏仍用绿/红语义色与全局一致
## API
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/dashboard/daily` | 当前交易日快照`dashboard_version` |
| GET | `/api/dashboard/stream` | SSE 版本推送 |
| POST | `/api/dashboard/refresh` | 请求立即重聚合 |
`GET /api/ping``dashboard_version``dashboard_poll_interval_sec` 等字段
## 相关文件
- `hub_dashboard.py` — 聚合逻辑
- `hub_dashboard_cache.py` — 后台轮询 + SSE
- `static/dashboard.js` / `dashboard.css` — 前端
部署后 `git pull``pm2 restart manual-trading-hub`
# 中控数据看板说明
入口:**`/dashboard`**(顶栏「数据看板」).
## 能力
| 区块 | 说明 |
|------|------|
| **总览 KPI** | 交易日,平仓盈亏,笔数,浮盈亏,资金合计,实盘持仓 |
| **分户明细** | 三户资金/交易账户,今日盈亏,浮盈亏,备注;未启用显示「未监控」 |
| **平仓明细** | 当日平仓流水(合约,方向,结果,盈亏,时间) |
| **风险预警** | 单户单日平仓亏损 ≥ 资金合计 **5%** 时横幅 + 卡片高亮 |
纯数据聚合,**不调用 AI**.交易日口径与实例一致(`TRADING_DAY_RESET_HOUR`,默认 8 点).
## 刷新机制(SSE)
与监控区 board 类似,采用 **后台聚合 + SSE 推送版本号**:
1. `hub.py` 启动后 `dashboard_store`**60s**(`DASHBOARD_POLL_INTERVAL_SEC`)聚合三户数据到内存快照.
2. 浏览器打开看板页后连接 `GET /api/dashboard/stream`(`event: dashboard`).
3. 收到新版本号后拉取 `GET /api/dashboard/daily` 快照并局部渲染,**无整页轮询闪烁**.
4. 监控区触发 board 刷新(全平,撤单等)时,会一并 `request_refresh` 看板,尽量与实盘同步.
5. 「立即刷新」→ `POST /api/dashboard/refresh` 触发下一轮聚合.
可选环境变量:`HUB_DASHBOARD_SSE_HEARTBEAT_SEC`(默认 25,SSE 心跳间隔).
## 主题与样式
- 跟随中控顶栏 **亮/暗主题**(`theme.js`),使用 `--panel` / `--border` / `--accent` 等变量.
- 卡片采用 **柔光阴影**(非霓虹渐变背景);亮色主题下为浅灰投影,暗色主题为轻微内高光.
- 盈亏仍用绿/红语义色,与全局一致.
## API
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/dashboard/daily` | 当前交易日快照(`dashboard_version`) |
| GET | `/api/dashboard/stream` | SSE 版本推送 |
| POST | `/api/dashboard/refresh` | 请求立即重聚合 |
`GET /api/ping``dashboard_version`,`dashboard_poll_interval_sec` 等字段.
## 相关文件
- `hub_dashboard.py` — 聚合逻辑
- `hub_dashboard_cache.py` — 后台轮询 + SSE
- `static/dashboard.js` / `dashboard.css` — 前端
部署后 `git pull``pm2 restart manual-trading-hub`.
+268 -268
View File
@@ -1,268 +1,268 @@
# 本地数据备份与迁移到云服务器
本文说明如何把 **本机** 上运行的 `crypto_monitor`三实例 + 中控的**业务数据**迁到 **云 VPS**并正确改配置
**不迁移** 本机 Python 虚拟环境`.venv`),云上重新 `pip install` 即可
相关[云服务器部署说明.md](./云服务器部署说明.md) · [部署文档.md](./部署文档.md)
---
## 一要迁什么不迁什么
### 必须迁移业务数据
| 路径每个实例目录下 | 内容 |
|------------------------|------|
| `crypto.db``.env``DB_PATH` 指向的文件 | 监控单关键位交易记录复盘运行时开关等 **SQLite 全库** |
| `static/images/``UPLOAD_DIR` | 上传图复盘截图等 |
| `static/images/order_charts/``ORDER_CHART_DIR` | 订单 K 线图若开启 |
三个实例 **各有一份独立库**
- `crypto_monitor_binance/crypto.db`
- `crypto_monitor_okx/crypto.db`
- `crypto_monitor_gate/crypto.db`
- `crypto_monitor_gate/crypto.db`
### 中控额外迁移
| 路径 | 内容 |
|------|------|
| `manual_trading_hub/hub_settings.json` | 账户 URL启用状态能力勾选网页「系统设置」保存的文件 |
| `manual_trading_hub/hub_ai_summaries.json` | 中控 AI 今日总结`/ai` |
| `manual_trading_hub/hub_ai_chat.json` | 中控 AI 聊天会话 |
### 不要直接覆盖拷贝需在云上重写
| 文件 | 说明 |
|------|------|
| 各目录 `.env` | 含 API 密钥可在云上**手工新建**从本机抄密钥但须改 **`flask_url`代理公网相关项**见下文 |
| `.venv/``__pycache__/` | 云上重建 |
| PM2 日志 | 无需迁 |
### 可选
- 本机 `manual_trading_hub/.env` 里的 `HUB_BRIDGE_TOKEN``HUB_PASSWORD`记下后在云上填入**不要**把含密钥的 `.env` 发到公开网盘
---
## 二迁移前准备本地
### 1. 停服务避免数据库半写入
```bash
# 本机停中控与子代理
cd manual_trading_hub
pm2 stop manual-trading-hub manual-agent-binance manual-agent-okx manual-agent-gate
# 本机停三个 Flask进程名以你 pm2 list 为准
pm2 stop crypto_okx crypto_binance crypto_gate
# 或各目录 ecosystem 里的名字
```
未用 PM2 时结束对应 Python/Flask 进程后再备份
### 2. 确认数据库文件位置
各实例目录下查看 `.env``DB_PATH`默认 `crypto.db`)。若存在 `crypto.db-wal``crypto.db-shm`**必须先停服务** 再备份
---
## 三本地备份推荐用自带脚本
每个实例目录执行会备份 **库 + static/images**):
```bash
cd crypto_monitor_okx
bash scripts/backup_data.sh
# 默认输出到 /root/backups/crypto_monitor_okx/YYYY-MM-DD/
# 本机可改环境变量BACKUP_ROOT=~/crypto_backups bash scripts/backup_data.sh
```
`crypto_monitor_binance``crypto_monitor_gate``crypto_monitor_gate` **各执行一次**
脚本产物示例
```text
~/crypto_backups/crypto_monitor_okx/2026-05-21/
crypto.db
static_images.tar.gz
manifest.txt
```
### 手工打包不用脚本时
在仓库根目录示例
```bash
BACKUP=~/crypto_migrate_$(date +%Y%m%d)
mkdir -p "$BACKUP"
for dir in crypto_monitor_okx crypto_monitor_binance crypto_monitor_gate crypto_monitor_gate; do
tar -czf "$BACKUP/${dir}.tar.gz" \
-C "$dir" crypto.db static/images 2>/dev/null || \
tar -czf "$BACKUP/${dir}.tar.gz" -C "$dir" crypto.db
done
cp manual_trading_hub/hub_settings.json "$BACKUP/" 2>/dev/null || true
cp manual_trading_hub/hub_ai_summaries.json "$BACKUP/" 2>/dev/null || true
cp manual_trading_hub/hub_ai_chat.json "$BACKUP/" 2>/dev/null || true
```
---
## 四上传到云服务器
在**你电脑**上`USER``云IP` 换成实际值):
```bash
# 打包整个备份目录
tar -czf crypto_migrate.tar.gz -C ~ crypto_backups # 或你的 BACKUP 路径
scp crypto_migrate.tar.gz USER@云IP:/tmp/
scp manual_trading_hub/hub_settings.json USER@云IP:/tmp/ # 若单独备份
```
大文件可用 **rsync**支持断点续传):
```bash
rsync -avz --progress ~/crypto_backups/ USER@云IP:/tmp/crypto_backups/
```
---
## 五云上恢复数据
假设代码已在 `/opt/crypto_monitor``git clone``rsync` 代码均可**代码与数据分开**)。
```bash
ssh USER@云IP
cd /opt/crypto_monitor
# 解压若用 scp 单包
tar -xzf /tmp/crypto_migrate.tar.gz -C /tmp
# 按实例恢复示例OKX
pm2 stop crypto_okx 2>/dev/null || true
cp /tmp/crypto_backups/crypto_monitor_okx/2026-05-21/crypto.db crypto_monitor_okx/crypto.db
tar -xzf /tmp/crypto_backups/crypto_monitor_okx/2026-05-21/static_images.tar.gz -C crypto_monitor_okx/
# 若 tar 里是 static/images 目录结构确认解压后路径为 crypto_monitor_okx/static/images
# 对其余三所重复同样步骤
```
恢复中控设置
```bash
cp /tmp/hub_settings.json manual_trading_hub/hub_settings.json
# 或解压备份里带的 hub_settings.json
```
**权限**避免 Flask 写库失败):
```bash
sudo chown -R 运行用户:运行用户 /opt/crypto_monitor/crypto_monitor_*/crypto.db
sudo chown -R 运行用户:运行用户 /opt/crypto_monitor/crypto_monitor_*/static/images
```
---
## 六云上必须改的配置比迁移本身更重要
数据文件原样拷过去不够**.env 与 hub_settings 要按云环境改**
### 1. 各实例 `crypto_monitor_*/.env`
从本机**抄写** API 密钥等并调整
| 项 | 本地常见 | 云上建议 |
|----|----------|----------|
| `OKX_SOCKS_PROXY` 等 | `socks5h://127.0.0.1:1080` | **留空**直连),除非云上仍访问不了交易所 |
| `APP_AUTH_DISABLED` | 可能为 true本机 | **false** 或未设置 |
| `APP_USERNAME` / `APP_PASSWORD` | 可有 | 设统一强密码直链登录 |
| `HUB_BRIDGE_TOKEN` | 有 | 与中控 **完全一致** |
### 2. `manual_trading_hub/.env`
见 [云服务器部署说明.md](./云服务器部署说明.md)`HUB_PASSWORD``HUB_BRIDGE_TOKEN``HUB_COOKIE_SECURE=true`
### 3. `hub_settings.json` 里的 URL
**必须**改成浏览器能打开的地址
| 字段 | 云上 |
|------|------|
| `flask_url` | `https://okx.你的域名.com`每实例不同子域 |
| `agent_url` | `http://127.0.0.1:15201`保持本机勿写公网 IP |
本机若是 `http://192.168.x.x:5004``http://127.0.0.1:5004`上云后**一定要改**否则「打开实例」会指错地址
---
## 七云上启动与验收
```bash
# 依赖各目录 venv + manual_trading_hub
# 见 云服务器部署说明.md部署文档.md
cd /opt/crypto_monitor
# 先三实例 Flask再 manual_trading_hub ecosystem
pm2 start ...
pm2 save
```
验收
- [ ] 各实例网页能登录**交易记录 / 关键位 / 监控单** 与本地一致
- [ ] 复盘图片能显示`static/images` 路径正确
- [ ] 中控监控卡片能读到持仓`hub_settings` 账户 URL 正确
- [ ] 本机已 **停止** 或不再用同一 API Key 同时跑两套避免重复下单
---
## 八迁移策略建议
### 方案 A一次性切换简单
1. 本地停 PM2 → 备份 → 上传 → 云上恢复 → 改配置 → 只跑云端
2. 适合能接受 **短暂停机**几十分钟)。
### 方案 B先云后停本地稳一点
1. 云上先部署代码空库跑通
2. 临近切换时再备份本地**最新**库覆盖云上
3. 切换时刻停本地启云上
4. 减少「备份到上线」之间的数据空窗
### 注意
- **同一交易所 API Key 不要本地和云上同时自动交易**以免重复挂单
- 迁移后第一次在云上打开建议先看监控单持仓是否与预期一致再放开自动逻辑
---
## 九常见问题
**Q只拷 `crypto.db` 不够吗**
- 复盘上传相关功能还依赖 `static/images`建议库 + 图片一起迁
**Q迁移后 OKX 监控单没了**
- 查是否拷错目录三所各一个库)、或恢复后用了空库路径`DB_PATH` 不一致)。
**Q图片 404**
- 检查 `static/images` 是否解压到实例目录下数据库里路径若为相对路径一般与目录结构一致即可
**Q本地还用 SOCKS云上要不要**
- 云上通常 **不需要** SSH 隧道见 [云服务器部署说明.md](./云服务器部署说明.md) 与此前说明直连稳定后去掉 `*_SOCKS_PROXY`
---
## 十相关脚本
各实例目录
```bash
bash scripts/backup_data.sh
```
环境变量`BACKUP_ROOT``BACKUP_RETENTION_DAYS``BACKUP_INSTANCE`见脚本内注释)。
# 本地数据备份与迁移到云服务器
本文说明如何把 **本机** 上运行的 `crypto_monitor`(三实例 + 中控)的**业务数据**迁到 **云 VPS**,并正确改配置.
**不迁移** 本机 Python 虚拟环境(`.venv`),云上重新 `pip install` 即可.
相关:[云服务器部署说明.md](./云服务器部署说明.md) · [部署文档.md](./部署文档.md)
---
## 一,要迁什么,不迁什么
### 必须迁移(业务数据)
| 路径(每个实例目录下) | 内容 |
|------------------------|------|
| `crypto.db`(`.env``DB_PATH` 指向的文件) | 监控单,关键位,交易记录,复盘,运行时开关等 **SQLite 全库** |
| `static/images/`(`UPLOAD_DIR`) | 上传图,复盘截图等 |
| `static/images/order_charts/`(`ORDER_CHART_DIR`) | 订单 K 线图(若开启) |
三个实例 **各有一份独立库**:
- `crypto_monitor_binance/crypto.db`
- `crypto_monitor_okx/crypto.db`
- `crypto_monitor_gate/crypto.db`
- `crypto_monitor_gate/crypto.db`
### 中控额外迁移
| 路径 | 内容 |
|------|------|
| `manual_trading_hub/hub_settings.json` | 账户 URL,启用状态,能力勾选(网页「系统设置」保存的文件) |
| `manual_trading_hub/hub_ai_summaries.json` | 中控 AI 今日总结(`/ai`) |
| `manual_trading_hub/hub_ai_chat.json` | 中控 AI 聊天会话 |
### 不要直接覆盖拷贝(需在云上重写)
| 文件 | 说明 |
|------|------|
| 各目录 `.env` | 含 API 密钥:可在云上**手工新建**,从本机抄密钥,但须改 **`flask_url`,代理,公网相关项**(见下文) |
| `.venv/`,`__pycache__/` | 云上重建 |
| PM2 日志 | 无需迁 |
### 可选
- 本机 `manual_trading_hub/.env` 里的 `HUB_BRIDGE_TOKEN`,`HUB_PASSWORD`:记下后在云上填入,**不要**把含密钥的 `.env` 发到公开网盘.
---
## 二,迁移前准备(本地)
### 1. 停服务(避免数据库半写入)
```bash
# 本机:停中控与子代理
cd manual_trading_hub
pm2 stop manual-trading-hub manual-agent-binance manual-agent-okx manual-agent-gate
# 本机:停三个 Flask(进程名以你 pm2 list 为准)
pm2 stop crypto_okx crypto_binance crypto_gate
# 或各目录 ecosystem 里的名字
```
未用 PM2 时,结束对应 Python/Flask 进程后再备份.
### 2. 确认数据库文件位置
各实例目录下查看 `.env``DB_PATH`(默认 `crypto.db`).若存在 `crypto.db-wal`,`crypto.db-shm`,**必须先停服务** 再备份.
---
## 三,本地备份(推荐用自带脚本)
每个实例目录执行(会备份 **库 + static/images**):
```bash
cd crypto_monitor_okx
bash scripts/backup_data.sh
# 默认输出到 /root/backups/crypto_monitor_okx/YYYY-MM-DD/
# 本机可改环境变量:BACKUP_ROOT=~/crypto_backups bash scripts/backup_data.sh
```
`crypto_monitor_binance`,`crypto_monitor_gate`,`crypto_monitor_gate` **各执行一次**.
脚本产物示例:
```text
~/crypto_backups/crypto_monitor_okx/2026-05-21/
crypto.db
static_images.tar.gz
manifest.txt
```
### 手工打包(不用脚本时)
在仓库根目录示例:
```bash
BACKUP=~/crypto_migrate_$(date +%Y%m%d)
mkdir -p "$BACKUP"
for dir in crypto_monitor_okx crypto_monitor_binance crypto_monitor_gate crypto_monitor_gate; do
tar -czf "$BACKUP/${dir}.tar.gz" \
-C "$dir" crypto.db static/images 2>/dev/null || \
tar -czf "$BACKUP/${dir}.tar.gz" -C "$dir" crypto.db
done
cp manual_trading_hub/hub_settings.json "$BACKUP/" 2>/dev/null || true
cp manual_trading_hub/hub_ai_summaries.json "$BACKUP/" 2>/dev/null || true
cp manual_trading_hub/hub_ai_chat.json "$BACKUP/" 2>/dev/null || true
```
---
## 四,上传到云服务器
在**你电脑**上(`USER`,`云IP` 换成实际值):
```bash
# 打包整个备份目录
tar -czf crypto_migrate.tar.gz -C ~ crypto_backups # 或你的 BACKUP 路径
scp crypto_migrate.tar.gz USER@云IP:/tmp/
scp manual_trading_hub/hub_settings.json USER@云IP:/tmp/ # 若单独备份
```
大文件可用 **rsync**(支持断点续传):
```bash
rsync -avz --progress ~/crypto_backups/ USER@云IP:/tmp/crypto_backups/
```
---
## 五,云上恢复数据
假设代码已在 `/opt/crypto_monitor`(`git clone``rsync` 代码均可,**代码与数据分开**).
```bash
ssh USER@云IP
cd /opt/crypto_monitor
# 解压(若用 scp 单包)
tar -xzf /tmp/crypto_migrate.tar.gz -C /tmp
# 按实例恢复(示例:OKX)
pm2 stop crypto_okx 2>/dev/null || true
cp /tmp/crypto_backups/crypto_monitor_okx/2026-05-21/crypto.db crypto_monitor_okx/crypto.db
tar -xzf /tmp/crypto_backups/crypto_monitor_okx/2026-05-21/static_images.tar.gz -C crypto_monitor_okx/
# 若 tar 里是 static/images 目录结构,确认解压后路径为 crypto_monitor_okx/static/images
# 对其余三所重复同样步骤
```
恢复中控设置:
```bash
cp /tmp/hub_settings.json manual_trading_hub/hub_settings.json
# 或解压备份里带的 hub_settings.json
```
**权限**(避免 Flask 写库失败):
```bash
sudo chown -R 运行用户:运行用户 /opt/crypto_monitor/crypto_monitor_*/crypto.db
sudo chown -R 运行用户:运行用户 /opt/crypto_monitor/crypto_monitor_*/static/images
```
---
## 六,云上必须改的配置(比迁移本身更重要)
数据文件原样拷过去不够,**.env 与 hub_settings 要按云环境改**.
### 1. 各实例 `crypto_monitor_*/.env`
从本机**抄写** API 密钥等,并调整:
| 项 | 本地常见 | 云上建议 |
|----|----------|----------|
| `OKX_SOCKS_PROXY` 等 | `socks5h://127.0.0.1:1080` | **留空**(直连),除非云上仍访问不了交易所 |
| `APP_AUTH_DISABLED` | 可能为 true(本机) | **false** 或未设置 |
| `APP_USERNAME` / `APP_PASSWORD` | 可有 | 设统一强密码(直链登录) |
| `HUB_BRIDGE_TOKEN` | 有 | 与中控 **完全一致** |
### 2. `manual_trading_hub/.env`
见 [云服务器部署说明.md](./云服务器部署说明.md):`HUB_PASSWORD`,`HUB_BRIDGE_TOKEN`,`HUB_COOKIE_SECURE=true`.
### 3. `hub_settings.json` 里的 URL
**必须**改成浏览器能打开的地址:
| 字段 | 云上 |
|------|------|
| `flask_url` | `https://okx.你的域名.com`(每实例不同子域) |
| `agent_url` | `http://127.0.0.1:15201`(保持本机,勿写公网 IP) |
本机若是 `http://192.168.x.x:5004``http://127.0.0.1:5004`,上云后**一定要改**,否则「打开实例」会指错地址.
---
## 七,云上启动与验收
```bash
# 依赖(各目录 venv + manual_trading_hub)
# 见 云服务器部署说明.md,部署文档.md
cd /opt/crypto_monitor
# 先三实例 Flask,再 manual_trading_hub ecosystem
pm2 start ...
pm2 save
```
验收:
- [ ] 各实例网页能登录,**交易记录 / 关键位 / 监控单** 与本地一致
- [ ] 复盘图片能显示(`static/images` 路径正确)
- [ ] 中控监控卡片能读到持仓;`hub_settings` 账户 URL 正确
- [ ] 本机已 **停止** 或不再用同一 API Key 同时跑两套(避免重复下单)
---
## 八,迁移策略建议
### 方案 A:一次性切换(简单)
1. 本地停 PM2 → 备份 → 上传 → 云上恢复 → 改配置 → 只跑云端.
2. 适合能接受 **短暂停机**(几十分钟).
### 方案 B:先云后停本地(稳一点)
1. 云上先部署代码,空库跑通;
2. 临近切换时再备份本地**最新**库覆盖云上;
3. 切换时刻停本地,启云上.
4. 减少「备份到上线」之间的数据空窗.
### 注意
- **同一交易所 API Key 不要本地和云上同时自动交易**,以免重复挂单.
- 迁移后第一次在云上打开,建议先看监控单,持仓是否与预期一致,再放开自动逻辑.
---
## 九,常见问题
**Q:只拷 `crypto.db` 不够吗?**
- 复盘,上传相关功能还依赖 `static/images`;建议库 + 图片一起迁.
**Q:迁移后 OKX 监控单没了?**
- 查是否拷错目录(三所各一个库),或恢复后用了空库路径(`DB_PATH` 不一致).
**Q:图片 404?**
- 检查 `static/images` 是否解压到实例目录下;数据库里路径若为相对路径,一般与目录结构一致即可.
**Q:本地还用 SOCKS,云上要不要?**
- 云上通常 **不需要** SSH 隧道;见 [云服务器部署说明.md](./云服务器部署说明.md) 与此前说明:直连稳定后去掉 `*_SOCKS_PROXY`.
---
## 十,相关脚本
各实例目录:
```bash
bash scripts/backup_data.sh
```
环境变量:`BACKUP_ROOT`,`BACKUP_RETENTION_DAYS`,`BACKUP_INSTANCE`(见脚本内注释).
+130 -130
View File
@@ -1,130 +1,130 @@
# 行情区K 线说明
中控 **行情区** `/market` 提供多交易所 K 线查看按需拉取本地 SQLite 缓存可选技术指标与持仓价格线数据经各实例 Flask 的 `/api/hub/ohlcv`底层 `hub_ohlcv_lib` + ccxt获取
相关代码`manual_trading_hub/static/chart.js``hub_kline_store.py`仓库根目录)、`hub.py``/api/chart/*`
---
## 1. 入口与导航
| 方式 | 说明 |
|------|------|
| 顶栏 **行情区** | 打开 `/market` |
| 监控区持仓 | 点击合约名**打开行情区**→ 跳转 `/market?exchange_key=...&symbol=...`并带入入场/止损/止盈等标记`sessionStorage` |
| 全屏工具条 | K 线全屏时可在顶部切换交易所币种周期并 **加载** |
---
## 2. 支持的周期
下拉框与后端 `CHART_TIMEFRAMES` 一致
| 周期 | 数字快捷键分钟 |
|------|-------------------|
| 1m | `1`稍停或 Enter 确认连按 `1``5` 为 15m |
| 5m | `5` |
| 15m | `15` |
| 1h | `60` |
| 2h | `120` |
| 4h | `240` |
| 12h | `720` |
| 1d | `1440` |
| 1w | `10080` |
- 快捷键仅在行情页且焦点不在输入框/下拉框时生效
- **全屏****`F`** 切换全屏时 **`Esc`** 退出
- 无效或已移除的周期如 URL 带 `6h`会回退为默认 **5m**
---
## 3. 数据拉取与本地库
| 项 | 说明 |
|------|------|
| **策略** | 先读本地库不足或过期则向对应实例拉取并写入库Hub **后台轮询** 增量更新尾部 K 线 |
| **库文件** | 默认 `manual_trading_hub/data/hub_kline.db`不纳入 Git |
| **保留** | 默认 **15 天**`HUB_KLINE_RETENTION_DAYS`),每次请求顺带清理更早数据 |
| **根数** | 日内周期约 **1000**`1d` / `1w`**500** 根 |
| **刷新** | Hub 约 **5 秒** 轮询① 监控区**有持仓**的合约默认周期 `5m`② 行情页 **watch** 的交易所+币种+周期页面打开时每 25s 续期)。浏览器经 **SSE**`chart_version` 后拉 `/api/chart/ohlcv`**加载** 读库**强制刷新** 全量重拉 |
| **分页** | OKX/Gate 等单次常限 ~300 根中控会自动分页补全 |
| **12h** | 若交易所无原生 12h 或 K 线间隔异常会从 **1h** 聚合生成 |
环境变量`manual_trading_hub/.env`):
```bash
# HUB_KLINE_RETENTION_DAYS=15
# HUB_KLINE_DB_PATH=/opt/crypto_monitor/manual_trading_hub/data/hub_kline.db
# HUB_CHART_POLL_INTERVAL=5
# HUB_CHART_POSITION_TIMEFRAME=5m
# HUB_CHART_WATCH_TTL_SEC=45
```
---
## 4. 图表功能
- **主图**K 线 + 成交量Lightweight Charts)。
- **价格轴**「自动」切换是否跟随最新价缩放
- **技术指标**可选勾选):EMA 21/55MACDRSI含 30/70 参考线);副图自上而下为 MACDRSI
- **持仓标记**从监控跳转时):展示入场止损止盈张数**浮盈亏**约 5 秒随监控快照刷新)、委托摘要K 线上绘制对应价格线趋势回调若止盈为程序监控止盈栏显示「程序监控」且不与止损同价误显
- **趋势保本移交**移交到下单监控后持仓卡止盈/止损与「交易所止盈止损」与实例 **下单监控** 计划价一致不再清空为程序监控占位);交易所仅市价只减仓单时也会按价格推断展示
- **拖动止损线**鼠标靠近红色止损线(⟷)可上下拖动松手确认后调用与监控区相同的 **挂止盈/止损** API先撤全部条件单再挂新止损+止盈)。须已有有效止盈价交易所条件单或计划止盈);仅改止损不改止盈时止盈价沿用当前上下文
- **背离**MACD/RSI 与价格简易背离标注箭头 + 图例说明)。
---
## 5. HTTP API中控
须登录与监控区相同`/api/ping` 等白名单除外)。
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/chart/meta` | 已启用交易所列表周期列表各周期 limit保留天数 |
| GET | `/api/chart/ohlcv` | 查询参数`exchange_key``symbol``timeframe`可选 `refresh=1` 强制刷新 |
| POST | `/api/chart/watch` | 行情页订阅JSON`exchange_key``symbol``timeframe`),45s 内需续期 |
| POST | `/api/chart/unwatch` | 离开行情页取消订阅 |
| GET | `/api/chart/stream` | SSE`event: chart``chart_version` 与各 `series` 版本 |
| GET | `/api/chart/poll/meta` | 当前轮询状态与各 series 版本 |
实例侧中控转发):
| 路径 | 说明 |
|------|------|
| GET | `/api/hub/ohlcv` | 各 `crypto_monitor_*``hub_bridge` 注册参数 `symbol``timeframe``since_ms``limit` |
---
## 6. 部署与升级注意
1. **hub****三实例 Flask** 均需 `git pull` 到含 `hub_ohlcv_lib.py``hub_kline_store.py` 的版本
2. 重启`pm2 restart manual-trading-hub``crypto_binance``crypto_okx``crypto_gate```名称以你环境为准)。
3. 浏览器 **强刷**`chart.js` 带版本 query避免旧前端缓存)。
4. 周期或拉取逻辑升级后对异常图表点一次 **强制刷新**必要时可删 `data/hub_kline.db` 后重拉会丢失本地缓存不影响策略库)。
回滚标签说明见 [SNAPSHOT_ROLLBACK.md](./SNAPSHOT_ROLLBACK.md)
---
## 7. 常见问题
| 现象 | 处理 |
|------|------|
| 只显示约 300 根 | `git pull` 实例与 hub强制刷新确认 `hub_ohlcv_lib` 已含分页逻辑 |
| 12h 错乱或过少 | 强制刷新Gate 等无原生 12h 时依赖 1h 聚合需实例 OHLCV 正常 |
| 周期下拉无某项 | 以当前 `CHART_TIMEFRAMES` 为准已移除 3m/10m/20m/30m/6h/8h 等 |
| 快捷键无效 | 确认在行情页全屏用 **F**数字键勿在币种输入框内按 |
| 持仓线不显示 | 须从监控区点击合约进入或清除标记后重新跳转 |
更多中控共性问题见 [常见问题.md](./常见问题.md)
---
## 8. 文档索引
| 文档 | 内容 |
|------|------|
| [使用说明.md](./使用说明.md) | 中控总览含行情区摘要 |
| [行情区说明.md](./行情区说明.md) | 本文 |
| [部署文档.md](./部署文档.md) | PM2 / 反代 / 验收 |
| [.env.example](./.env.example) | `HUB_KLINE_*` 等变量 |
# 行情区(K 线)说明
中控 **行情区** `/market` 提供多交易所 K 线查看:按需拉取,本地 SQLite 缓存,可选技术指标与持仓价格线.数据经各实例 Flask 的 `/api/hub/ohlcv`(底层 `hub_ohlcv_lib` + ccxt)获取.
相关代码:`manual_trading_hub/static/chart.js`,`hub_kline_store.py`(仓库根目录),`hub.py``/api/chart/*`.
---
## 1. 入口与导航
| 方式 | 说明 |
|------|------|
| 顶栏 **行情区** | 打开 `/market` |
| 监控区持仓 | 点击合约名(**打开行情区**)→ 跳转 `/market?exchange_key=...&symbol=...`,并带入入场/止损/止盈等标记(`sessionStorage`) |
| 全屏工具条 | K 线全屏时可在顶部切换交易所,币种,周期并 **加载** |
---
## 2. 支持的周期
下拉框与后端 `CHART_TIMEFRAMES` 一致:
| 周期 | 数字快捷键(分钟) |
|------|-------------------|
| 1m | `1`(稍停或 Enter 确认;连按 `1``5` 为 15m) |
| 5m | `5` |
| 15m | `15` |
| 1h | `60` |
| 2h | `120` |
| 4h | `240` |
| 12h | `720` |
| 1d | `1440` |
| 1w | `10080` |
- 快捷键仅在行情页,且焦点不在输入框/下拉框时生效.
- **全屏**:**`F`** 切换;全屏时 **`Esc`** 退出.
- 无效或已移除的周期(如 URL 带 `6h`)会回退为默认 **5m**.
---
## 3. 数据拉取与本地库
| 项 | 说明 |
|------|------|
| **策略** | 先读本地库,不足或过期则向对应实例拉取并写入库;Hub **后台轮询** 增量更新尾部 K 线 |
| **库文件** | 默认 `manual_trading_hub/data/hub_kline.db`(不纳入 Git) |
| **保留** | 默认 **15 天**(`HUB_KLINE_RETENTION_DAYS`),每次请求顺带清理更早数据 |
| **根数** | 日内周期约 **1000**;`1d` / `1w`**500** 根 |
| **刷新** | Hub 约 **5 秒** 轮询:① 监控区**有持仓**的合约(默认周期 `5m`)② 行情页 **watch** 的交易所+币种+周期(页面打开时每 25s 续期).浏览器经 **SSE**`chart_version` 后拉 `/api/chart/ohlcv`.**加载** 读库;**强制刷新** 全量重拉 |
| **分页** | OKX/Gate 等单次常限 ~300 根,中控会自动分页补全 |
| **12h** | 若交易所无原生 12h 或 K 线间隔异常,会从 **1h** 聚合生成 |
环境变量(`manual_trading_hub/.env`):
```bash
# HUB_KLINE_RETENTION_DAYS=15
# HUB_KLINE_DB_PATH=/opt/crypto_monitor/manual_trading_hub/data/hub_kline.db
# HUB_CHART_POLL_INTERVAL=5
# HUB_CHART_POSITION_TIMEFRAME=5m
# HUB_CHART_WATCH_TTL_SEC=45
```
---
## 4. 图表功能
- **主图**:K 线 + 成交量(Lightweight Charts).
- **价格轴**:「自动」切换是否跟随最新价缩放.
- **技术指标**(可选勾选):EMA 21/55,MACD,RSI(含 30/70 参考线);副图自上而下为 MACD,RSI.
- **持仓标记**(从监控跳转时):展示入场,止损,止盈,张数,**浮盈亏**(约 5 秒随监控快照刷新),委托摘要;K 线上绘制对应价格线.趋势回调若止盈为程序监控,止盈栏显示「程序监控」且不与止损同价误显.
- **趋势保本移交**:移交到下单监控后,持仓卡止盈/止损与「交易所止盈止损」与实例 **下单监控** 计划价一致(不再清空为程序监控占位);交易所仅市价只减仓单时也会按价格推断展示.
- **拖动止损线**:鼠标靠近红色止损线(⟷)可上下拖动;松手确认后调用与监控区相同的 **挂止盈/止损** API(先撤全部条件单再挂新止损+止盈).须已有有效止盈价(交易所条件单或计划止盈);仅改止损,不改止盈时止盈价沿用当前上下文.
- **背离**:MACD/RSI 与价格简易背离标注(箭头 + 图例说明).
---
## 5. HTTP API(中控)
须登录(与监控区相同,`/api/ping` 等白名单除外).
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/chart/meta` | 已启用交易所列表,周期列表,各周期 limit,保留天数 |
| GET | `/api/chart/ohlcv` | 查询参数:`exchange_key`,`symbol`,`timeframe`,可选 `refresh=1` 强制刷新 |
| POST | `/api/chart/watch` | 行情页订阅(JSON:`exchange_key`,`symbol`,`timeframe`),45s 内需续期 |
| POST | `/api/chart/unwatch` | 离开行情页取消订阅 |
| GET | `/api/chart/stream` | SSE:`event: chart`,`chart_version` 与各 `series` 版本 |
| GET | `/api/chart/poll/meta` | 当前轮询状态与各 series 版本 |
实例侧(中控转发):
| 路径 | 说明 |
|------|------|
| GET | `/api/hub/ohlcv` | 各 `crypto_monitor_*``hub_bridge` 注册;参数 `symbol`,`timeframe`,`since_ms`,`limit` |
---
## 6. 部署与升级注意
1. **hub****三实例 Flask** 均需 `git pull` 到含 `hub_ohlcv_lib.py`,`hub_kline_store.py` 的版本.
2. 重启:`pm2 restart manual-trading-hub``crypto_binance`,`crypto_okx`,`crypto_gate`,``(名称以你环境为准).
3. 浏览器 **强刷**(`chart.js` 带版本 query,避免旧前端缓存).
4. 周期或拉取逻辑升级后,对异常图表点一次 **强制刷新**,必要时可删 `data/hub_kline.db` 后重拉(会丢失本地缓存,不影响策略库).
回滚标签说明见 [SNAPSHOT_ROLLBACK.md](./SNAPSHOT_ROLLBACK.md).
---
## 7. 常见问题
| 现象 | 处理 |
|------|------|
| 只显示约 300 根 | `git pull` 实例与 hub,强制刷新;确认 `hub_ohlcv_lib` 已含分页逻辑 |
| 12h 错乱或过少 | 强制刷新;Gate 等无原生 12h 时依赖 1h 聚合,需实例 OHLCV 正常 |
| 周期下拉无某项 | 以当前 `CHART_TIMEFRAMES` 为准;已移除 3m/10m/20m/30m/6h/8h 等 |
| 快捷键无效 | 确认在行情页;全屏用 **F**;数字键勿在币种输入框内按 |
| 持仓线不显示 | 须从监控区点击合约进入;或清除标记后重新跳转 |
更多中控共性问题见 [常见问题.md](./常见问题.md).
---
## 8. 文档索引
| 文档 | 内容 |
|------|------|
| [使用说明.md](./使用说明.md) | 中控总览(含行情区摘要) |
| [行情区说明.md](./行情区说明.md) | 本文 |
| [部署文档.md](./部署文档.md) | PM2 / 反代 / 验收 |
| [.env.example](./.env.example) | `HUB_KLINE_*` 等变量 |
+91 -91
View File
@@ -1,91 +1,91 @@
# 资金概况 — 使用说明
中控顶栏 **资金概况**`/funds`汇总三所账户的 **资金账户 + 交易账户** 余额不含浮盈亏未监控账户不参与合计但仍会在分户列表中灰显展示
---
## 1. 口径
| 项目 | 规则 |
|------|------|
| **单户总资金** | `资金账户 USDT + 交易账户 USDT` |
| **总资金** | 所有 **已启用且未被环境强制关闭** 的账户之和 |
| **未监控** | 设置页未勾选「启用」或 `HUB_DISABLED_IDS` 强制关闭 → **跳过合计** |
| **缺数据** | 资金户交易户任一侧缺失 → 该户当日快照 **跳过**不估不补 0 |
| **交易日** | 北京时间 `TRADING_DAY_RESET_HOUR`默认 **8:00**切日与三所统计一致 |
| **曲线粒度** | 每个交易日 **1 个点** |
| **统计起点** | 默认 **2026-06-09**`HUB_FUND_HISTORY_START_DAY`);此前不记不展示 |
| **历史保留** | 自起点起最多 **180** 个交易日`HUB_FUND_HISTORY_DAYS` |
| **最大回撤** | 基于 **总资金曲线**分户同理),峰值到谷底的最大跌幅U 与 % |
> 与实例统计页「最大回撤」不同实例统计来自 **平仓盈亏累计**资金概况来自 **账户余额曲线**
---
## 2. 页面说明
### 总览
- **总资金**当前监控板最新一轮聚合的实时合计资金户+交易户齐全才计入
- **较昨日**相对上一交易日快照点的变动U
- **最大回撤**总资金历史曲线的峰值回撤U / %
- **总资金曲线**近 180 交易日
### 分户卡片
每户展示总资金资金户交易户最大回撤迷你曲线
- **已监控**正常统计
- **未监控**显示「未参与合计」无曲线
- **余额未齐**已监控但 API 未返回完整资金/交易户
---
## 3. 数据从哪来
```
监控板每 5 秒聚合board_store
└→ 各实例 GET /api/hub/account
funding_usdt / trading_usdt
└→ 写入 hub_fund_history.json按交易日去重更新当日
资金概况页 GET /api/hub/fund-overview
├→ 实时读 board 缓存
└→ 曲线/回撤读 hub_fund_history.json
```
- 存储文件`manual_trading_hub/hub_fund_history.json`不在 Git 中
- 旧 AI 快照 `hub_ai_fund_history.json` 会在首次读取时 **自动合并** 到新文件
- AI 教练生成上下文时也会写入同日快照与监控板共用逻辑
---
## 4. 环境变量
| 变量 | 默认 | 说明 |
|------|------|------|
| `HUB_FUND_HISTORY_DAYS` | `180` | 资金快照保留交易日数与起点取较晚边界 |
| `HUB_FUND_HISTORY_START_DAY` | `2026-06-09` | 曲线/回撤统计起始交易日 |
| `TRADING_DAY_RESET_HOUR` | `8` | 切日整点北京),与三所 `.env` 建议一致 |
| `HUB_BOARD_POLL_INTERVAL` | `5` | 监控聚合间隔(秒),影响快照刷新频率 |
---
## 5. API
`GET /api/hub/fund-overview`需中控登录与监控区相同
返回字段概要
- `totals.total_usdt` — 当前总资金
- `totals.series[]``{ day, total_usdt }` 总曲线
- `totals.drawdown``{ peak_usdt, max_drawdown_u, max_drawdown_pct }`
- `accounts[]` — 分户实时余额曲线回撤`monitored` 标记
---
## 6. 相关文档
- [使用说明.md](./使用说明.md) — 中控总览
- [AI教练说明.md](./AI教练说明.md) — AI 上下文中的资金快照文本
- [部署文档.md](./部署文档.md) — 重启 `manual-trading-hub` 后生效
# 资金概况 — 使用说明
中控顶栏 **资金概况**(`/funds`)汇总三所账户的 **资金账户 + 交易账户** 余额,不含浮盈亏;未监控账户不参与合计,但仍会在分户列表中灰显展示.
---
## 1. 口径
| 项目 | 规则 |
|------|------|
| **单户总资金** | `资金账户 USDT + 交易账户 USDT` |
| **总资金** | 所有 **已启用且未被环境强制关闭** 的账户之和 |
| **未监控** | 设置页未勾选「启用」或 `HUB_DISABLED_IDS` 强制关闭 → **跳过合计** |
| **缺数据** | 资金户,交易户任一侧缺失 → 该户当日快照 **跳过**(不估,不补 0) |
| **交易日** | 北京时间 `TRADING_DAY_RESET_HOUR`(默认 **8:00**)切日,与三所统计一致 |
| **曲线粒度** | 每个交易日 **1 个点** |
| **统计起点** | 默认 **2026-06-09**(`HUB_FUND_HISTORY_START_DAY`);此前不记,不展示 |
| **历史保留** | 自起点起最多 **180** 个交易日(`HUB_FUND_HISTORY_DAYS`) |
| **最大回撤** | 基于 **总资金曲线**(分户同理),峰值到谷底的最大跌幅(U 与 %) |
> 与实例统计页「最大回撤」不同:实例统计来自 **平仓盈亏累计**;资金概况来自 **账户余额曲线**.
---
## 2. 页面说明
### 总览
- **总资金**:当前监控板最新一轮聚合的实时合计(资金户+交易户齐全才计入)
- **较昨日**:相对上一交易日快照点的变动(U)
- **最大回撤**:总资金历史曲线的峰值回撤(U / %)
- **总资金曲线**:近 180 交易日
### 分户卡片
每户展示:总资金,资金户,交易户,最大回撤,迷你曲线.
- **已监控**:正常统计
- **未监控**:显示「未参与合计」,无曲线
- **余额未齐**:已监控但 API 未返回完整资金/交易户
---
## 3. 数据从哪来
```
监控板每 5 秒聚合(board_store)
└→ 各实例 GET /api/hub/account
funding_usdt / trading_usdt
└→ 写入 hub_fund_history.json(按交易日去重更新当日)
资金概况页 GET /api/hub/fund-overview
├→ 实时:读 board 缓存
└→ 曲线/回撤:读 hub_fund_history.json
```
- 存储文件:`manual_trading_hub/hub_fund_history.json`(不在 Git 中)
- 旧 AI 快照 `hub_ai_fund_history.json` 会在首次读取时 **自动合并** 到新文件
- AI 教练生成上下文时也会写入同日快照(与监控板共用逻辑)
---
## 4. 环境变量
| 变量 | 默认 | 说明 |
|------|------|------|
| `HUB_FUND_HISTORY_DAYS` | `180` | 资金快照保留交易日数(与起点取较晚边界) |
| `HUB_FUND_HISTORY_START_DAY` | `2026-06-09` | 曲线/回撤统计起始交易日 |
| `TRADING_DAY_RESET_HOUR` | `8` | 切日整点(北京),与三所 `.env` 建议一致 |
| `HUB_BOARD_POLL_INTERVAL` | `5` | 监控聚合间隔(秒),影响快照刷新频率 |
---
## 5. API
`GET /api/hub/fund-overview`(需中控登录,与监控区相同)
返回字段概要:
- `totals.total_usdt` — 当前总资金
- `totals.series[]``{ day, total_usdt }` 总曲线
- `totals.drawdown``{ peak_usdt, max_drawdown_u, max_drawdown_pct }`
- `accounts[]` — 分户实时余额,曲线,回撤,`monitored` 标记
---
## 6. 相关文档
- [使用说明.md](./使用说明.md) — 中控总览
- [AI教练说明.md](./AI教练说明.md) — AI 上下文中的资金快照文本
- [部署文档.md](./部署文档.md) — 重启 `manual-trading-hub` 后生效
+336 -336
View File
@@ -1,336 +1,336 @@
# 多账户交易中控 — 部署文档含 PM2
本文档说明在 **Ubuntu / Linux** 上部署 **manual_trading_hub**复盘系统中控监控区系统设置登录保护的推荐步骤
- 功能与界面[使用说明.md](./使用说明.md)
- **云服务器VPS完整配置**[云服务器部署说明.md](./云服务器部署说明.md)
- **本地备份并迁到云上**[本地数据迁移到云端.md](./本地数据迁移到云端.md)
- **局域网 IP:端口 / 反代域名中控打开实例免登录**[局域网与反代部署说明.md](./局域网与反代部署说明.md)
- 故障实录[常见问题.md](./常见问题.md)
- 环境变量模板[.env.example](./.env.example)
---
## 一部署目标
| 组件 | 作用 | 默认监听 |
|------|------|----------|
| **hub.py** | 中控 Web + API | `0.0.0.0:5100` |
| **agent.py × N** | 各账户持仓 / 紧急全平 | `127.0.0.1:15200``15202` |
| **crypto_monitor_*.app** | 策略关键位下单逻辑 | 各目录 `.env``APP_PORT` |
- 账户列表与 URL 由 **`hub_settings.json`**网页「系统设置」保存或内置默认维护**不再使用** `HUB_AGENTS`
- 三实例 Flask **无需为中控改业务代码**已注册 `hub_bridge`);与中控并行运行
---
## 二前置条件
1. **Python 3.10+**`python3-venv``pip`
2. **Node.js + npm**用于安装 PM2):`sudo npm i -g pm2`
3. 各 `crypto_monitor_*` 目录已 **`cp .env.example .env`** 并填好 API 密钥
4. 端口无冲突`5100``15200``15202`各实例 `APP_PORT`5000/5001/5004)。
5. 建议代码路径`/opt/crypto_monitor/`下文用此示例请按实际路径替换)。
---
## 三安装中控依赖
```bash
cd /opt/crypto_monitor/manual_trading_hub
python3 -m venv .venv
source .venv/bin/activate # 激活 venv当前终端后续 pip/python 走虚拟环境
pip install -r requirements.txt
cp .env.example .env
# 编辑 .envHUB_PORTHUB_DISABLED_IDS公网时 HUB_BRIDGE_TOKEN 等
chmod +x scripts/run_hub.sh scripts/run_agent.sh scripts/pm2_hub.sh scripts/pm2_agents.sh
chmod +x scripts/fix_hub_deps.sh scripts/verify_hub_deploy.sh scripts/fix_env_crlf.sh
deactivate # 可选交给 PM2 时不必保持激活
```
**依赖说明**`requirements.txt`**`python-multipart`**FastAPI 表单解析旧版若保留下单接口时需要)、**`psutil`**监控区服务器状态条)。当前中控**已移除下单区**但仍建议安装完整依赖
说明
- **安装依赖**必须在 **`manual_trading_hub/.venv`** 内执行勿对系统 Python 直接 `pip install`Debian/Ubuntu 会报 `externally-managed-environment`)。`source .venv/bin/activate` 后用 `pip install` 即可不写 `activate` 时也可用 **`.venv/bin/pip install -r requirements.txt`**效果相同)。快捷`bash scripts/fix_hub_deps.sh`
- **PM2 启动**`scripts/run_hub.sh` 固定调用 **`.venv/bin/python`****不依赖**你是否在 shell 里激活过 venv
- **监控磁盘**可选环境变量 **`HUB_HOST_DISK_PATH`**`/opt/crypto_monitor`),未设时 Linux 为 `/`Windows 为系统盘
子代理也使用 **本目录 `.venv`** 里的 Python与各策略 Flask 的 venv 可分开)。
---
## 四推荐启动顺序
```
1. 各实例 FlaskAPP_PORT ← 各 crypto_monitor_* 目录 ecosystem.config.cjs
2. 中控 + 子代理5100 + 1520015202 ← 本目录一条 PM2 命令同时启动
```
**`ecosystem.config.cjs` 会一次拉起 3 个 agent + 1 个 hub**无需再单独 `pm2 start` 子代理
仅反代中控到公网时Flask / agent 仍只监听 **127.0.0.1**系统设置里 URL 填 `http://127.0.0.1:端口`
---
## 五PM2 托管hub + agent 一起启动推荐
### 5.1 一条命令启动全部
| 文件 | 包含进程 |
|------|----------|
| `ecosystem.config.cjs` | `manual-agent-binance` / `okx` / `gate` + **`manual-trading-hub`** |
`run_hub.sh` 加载 **`manual_trading_hub/.env`** 后执行 `hub.py`各 agent 经 **`run_agent.sh`** 在对应策略目录加载 **`.env`**含 API 密钥),再执行 `agent.py`
```bash
cd /opt/crypto_monitor/manual_trading_hub
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
pm2 start ecosystem.config.cjs # 4 个进程一起起
pm2 save
# 或
bash scripts/pm2_hub.sh start
```
### 5.2 PM2 进程一览
| 进程名 | 工作目录 | 端口/说明 |
|--------|----------|-----------|
| manual-agent-binance | crypto_monitor_binance | agent `15200` |
| manual-agent-okx | crypto_monitor_okx | agent `15201` |
| manual-agent-gate | crypto_monitor_gate | agent `15202` |
| manual-trading-hub | manual_trading_hub | hub `5100` |
OKX 子代理会启动不用 OKX 可 `pm2 stop manual-agent-okx`
### 5.3 常用运维命令
```bash
pm2 status
pm2 logs manual-trading-hub --lines 200
pm2 restart ecosystem.config.cjs # 重启 hub + 全部 agent
bash scripts/pm2_hub.sh restart # 同上
bash scripts/pm2_hub.sh stop
bash scripts/pm2_hub.sh logs
```
仅重启中控不动 agent
```bash
pm2 restart manual-trading-hub
```
仅重启子代理
```bash
pm2 restart manual-agent-binance manual-agent-gate manual-agent-okx
# 或
bash scripts/pm2_agents.sh restart
```
### 5.4 开机自启
```bash
pm2 save
pm2 startup
# 按终端提示执行一行 sudo 命令后再 pm2 save
```
### 5.5 与各实例 Flask 一起查看
```bash
pm2 status
# 示例同时存在
# manual-trading-hubmanual-agent-*
# crypto_binance / crypto_gate …各策略目录自有 ecosystem.config.cjs
```
### 5.6 Gate 子代理「一会能连一会子代理不可用」Windows `.env` 换行
**现象**Gate 卡片红字「子代理不可用」`pm2 logs manual-agent-gate` 反复出现
```text
./.env: line 22: $'\r': command not found
agent start: exchange=gate port=15202 ...
```
**原因**在 Windows 编辑的 `crypto_monitor_gate/.env`**CRLF**Linux 上 `source` 失败PM2 反复重启中控轮询时偶发连不上**不是外网问题**)。
**处理**在服务器仓库根执行):
```bash
cd /opt/crypto_monitor
sed -i 's/\r$//' crypto_monitor_gate/.env
bash manual_trading_hub/scripts/fix_env_crlf.sh
cd manual_trading_hub
pm2 delete manual-agent-gate 2>/dev/null || true
pm2 start ecosystem.config.cjs --only manual-agent-gate
pm2 save
curl -s http://127.0.0.1:15202/status | head -c 200 # 应 ok:true
```
**预防**`.env` 保存为 **LF**勿在 Windows 记事本直接保存 CRLF)。子代理须经 **`scripts/run_agent.sh`** 启动内置去 CRLF 的 `load_dotenv_file`),勿裸跑 `python agent.py`
详见 [常见问题.md](./常见问题.md) **§3.1****§3.3**
---
## 六手动启动不用 PM2 时
需**分别**起 agent 与 hub与 PM2 合并启动不同):
```bash
# 子代理由 ecosystem.config.cjs 经 run_agent.sh 启动勿用手动多终端
# 中控
cd /opt/crypto_monitor/manual_trading_hub
bash scripts/run_hub.sh
```
---
## 七浏览器验收
1. **http://127.0.0.1:5100/login** — 若 `.env` 已设 `HUB_PASSWORD``HUB_USERNAME` / `HUB_PASSWORD` 登录
2. **http://127.0.0.1:5100/monitor** — 已启用账户显示持仓Flask 已起时有关键位/趋势信息
3. **http://127.0.0.1:5100/market** — 行情区可选交易所与周期拉 K 线升级后强刷浏览器详见 [行情区说明.md](./行情区说明.md)
4. **http://127.0.0.1:5100/ai** — AI 教练三户今日总结 + 聊天);`manual_trading_hub/.env` 配与三实例相同的 `AI_*` 变量见 [AI教练说明.md](./AI教练说明.md)
5. **http://127.0.0.1:5100/settings** — 保存后生成 `hub_settings.json`增加第五户Gate 子账户等见 [使用说明.md §4.5](./使用说明.md#45-增加账户例如再挂一个-gate))。
5. 监控卡片 **「实例」** — 在各 `crypto_monitor_*` 网页做下单关键位趋势中控**不提供**下单表单
**命令行验收**推荐):
```bash
cd /opt/crypto_monitor/manual_trading_hub
bash scripts/verify_hub_deploy.sh
```
应看到`OK: 无 api_trade_key``HTTP 200`JSON 含 `"build":"20260521-no-trade-ui"`
```bash
curl -s http://127.0.0.1:5100/api/ping
curl -s http://127.0.0.1:15200/status | head -c 200
```
---
## 八仅反代中控到公网实例不反代
1. Nginx/Caddy 反代到 **`127.0.0.1:5100`**配置 **HTTPS**
2. 反代需传递登录 Cookie 正确识别 HTTPS):
```nginx
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
```
3. `manual_trading_hub/.env` **务必设置**
```env
HUB_USERNAME=你的用户名
HUB_PASSWORD=你的强密码
HUB_SESSION_SECRET=随机长串
HUB_COOKIE_SECURE=true
```
4. `hub_settings.json` 中 Flask/Agent 保持 **`http://127.0.0.1:...`**中控本机调 API)。
5. 三实例 **`APP_AUTH_DISABLED=false`** + 与中控相同 **`HUB_BRIDGE_TOKEN`**
6. 子代理 **`HOST=127.0.0.1`**防火墙勿对公网开放 `15200``15202``APP_PORT`
7. **复盘/实例外链**`HUB_PUBLIC_ORIGIN=https://你的域名` 或内网 IP否则其它设备点「复盘」会跳到 `127.0.0.1`
**说明**HTTPS 域名与 HTTP `内网IP:5100` Cookie **不共用**内网访问 IP 需在 IP 地址再登录一次见 [常见问题.md](./常见问题.md) §2.1)。
---
## 九环境变量中控 `.env`
| 变量 | 默认 | 说明 |
|------|------|------|
| `HUB_HOST` | `0.0.0.0` | 监听地址 |
| `HUB_PORT` | `5100` | 端口 |
| `HUB_DISABLED_IDS` | `1` | 强制关闭的账户 idOKX |
| `HUB_TRUST_LAN` | `true` | 私网可访问仅本机可 `false` |
| `HUB_PUBLIC_ORIGIN` | 空 | 浏览器用复盘链接`http://192.168.1.100`**内网其它电脑访问中控时建议设置** |
| `HUB_BRIDGE_TOKEN` | 空 | 与三实例一致公网建议配置 |
| `HUB_USERNAME` | `admin` | Web 登录用户名 |
| `HUB_PASSWORD` | 空 | 非空即启用登录 |
| `HUB_SESSION_SECRET` | — | 会话签名 |
| `HUB_COOKIE_SECURE` | `false` | HTTPS 反代建议 `true` |
| `HUB_SESSION_DAYS` | `7` | 登录保持天数 |
本地联调实例 `APP_AUTH_DISABLED=true` 时可不配 `HUB_BRIDGE_TOKEN`本机不设 `HUB_PASSWORD` 则无需登录页
---
## 十升级与回滚
```bash
cd /opt/crypto_monitor
git pull
cd manual_trading_hub
bash scripts/fix_hub_deps.sh
bash scripts/verify_hub_deploy.sh
pm2 restart ecosystem.config.cjs
# 若只改了中控pm2 restart manual-trading-hub
```
- **`hub_settings.json`****`hub_ai_summaries.json`****`hub_ai_chat.json`****`.env`** 不在 Git 中`git pull` 不会覆盖
- 升级前可备份`cp hub_settings.json hub_settings.json.bak``cp hub_ai_*.json hub_ai_backup/``cp .env .env.bak`
**升级后自检**`curl -s http://127.0.0.1:5100/api/ping` 须含 `"trade_ui":false`若仍见 `api_trade_key` 报错说明代码未更新或未重启见 [常见问题.md](./常见问题.md) §1
---
## 十一故障排查速查
**完整实录**见 **[常见问题.md](./常见问题.md)**
| 现象 | 处理 |
|------|------|
| PM2 启动后立刻退出 | `pm2 logs manual-trading-hub`检查 `.venv``.env``run_hub.sh` |
| `api_trade_key` / `python-multipart` | `git pull``bash scripts/fix_hub_deps.sh``verify_hub_deploy.sh` → 重启 hub |
| `verify` ping 解析失败 | hub 未起`pm2 restart manual-trading-hub` |
| 余额显示 — | agent 未加载 `.env``fix_env_crlf.sh``run_agent.sh` |
| agent `$'\r': command not found` | `bash scripts/fix_env_crlf.sh` |
| 监控无持仓 | `curl http://127.0.0.1:15200/status` |
| 无关键位 / 401 | 启动 Flask核对 `HUB_BRIDGE_TOKEN` / `hub_bridge` |
| 域名能登IP 不能登 | 见常见问题 §2.1Cookie / HTTP vs HTTPS |
| 公网访问中控 403 | 反代到 `127.0.0.1:5100`勿公网直连 5100 |
| 改 `.env` 不生效 | `pm2 restart manual-trading-hub` |
---
## 十二进程托管说明
中控与子代理 **仅使用 PM2**`ecosystem.config.cjs`)。勿再用 screen / systemd / nohup 启动 `hub.py``agent.py`以免端口冲突
环境要求见 **[docs/ubuntu-server.md](../docs/ubuntu-server.md)**
---
## 十三安全清单
- [ ] 公网仅暴露反代端口不暴露 Flask/agent 端口
- [ ] 已设 `HUB_USERNAME` + `HUB_PASSWORD`中控 Web 登录
- [ ] HTTPS 反代已设 `HUB_COOKIE_SECURE=true` 且传递 `X-Forwarded-Proto`
- [ ] 公网已配置 `HUB_BRIDGE_TOKEN` + 实例关闭 `APP_AUTH_DISABLED`
- [ ] API Key 最小权限交易所 IP 白名单
- [ ] 已告知操作人员「全局全平」不可撤销中控**不在网页下单**
---
## 十四文档索引
| 文档 | 内容 |
|------|------|
| [使用说明.md](./使用说明.md) | 功能页面API环境变量 |
| [常见问题.md](./常见问题.md) | 故障实录 |
| [README.md](./README.md) | 速览 |
| [.env.example](./.env.example) | 环境变量模板 |
| [scripts/后台运行-Ubuntu.md](./scripts/后台运行-Ubuntu.md) | PM2 常驻唯一推荐 |
| [docs/ubuntu-server.md](../docs/ubuntu-server.md) | Ubuntu / Python / Node / PM2 |
| `scripts/fix_hub_deps.sh` | 安装依赖 |
| `scripts/verify_hub_deploy.sh` | 部署验收 |
| `scripts/fix_env_crlf.sh` | 修复 .env 换行 |
# 多账户交易中控 — 部署文档(含 PM2)
本文档说明在 **Ubuntu / Linux** 上部署 **manual_trading_hub**(复盘系统中控:监控区,系统设置,登录保护)的推荐步骤.
- 功能与界面:[使用说明.md](./使用说明.md)
- **云服务器(VPS)完整配置**:[云服务器部署说明.md](./云服务器部署说明.md)
- **本地备份并迁到云上**:[本地数据迁移到云端.md](./本地数据迁移到云端.md)
- **局域网 IP:端口 / 反代域名,中控打开实例免登录**:[局域网与反代部署说明.md](./局域网与反代部署说明.md)
- 故障实录:[常见问题.md](./常见问题.md)
- 环境变量模板:[.env.example](./.env.example)
---
## 一,部署目标
| 组件 | 作用 | 默认监听 |
|------|------|----------|
| **hub.py** | 中控 Web + API | `0.0.0.0:5100` |
| **agent.py × N** | 各账户持仓 / 紧急全平 | `127.0.0.1:15200``15202` |
| **crypto_monitor_*.app** | 策略,关键位,下单逻辑 | 各目录 `.env``APP_PORT` |
- 账户列表与 URL 由 **`hub_settings.json`**(网页「系统设置」保存)或内置默认维护;**不再使用** `HUB_AGENTS`.
- 三实例 Flask **无需为中控改业务代码**(已注册 `hub_bridge`);与中控并行运行.
---
## 二,前置条件
1. **Python 3.10+**,`python3-venv`,`pip`.
2. **Node.js + npm**(用于安装 PM2):`sudo npm i -g pm2`.
3. 各 `crypto_monitor_*` 目录已 **`cp .env.example .env`** 并填好 API 密钥.
4. 端口无冲突:`5100`,`15200``15202`,各实例 `APP_PORT`(5000/5001/5004).
5. 建议代码路径:`/opt/crypto_monitor/`(下文用此示例,请按实际路径替换).
---
## 三,安装中控依赖
```bash
cd /opt/crypto_monitor/manual_trading_hub
python3 -m venv .venv
source .venv/bin/activate # 激活 venv(当前终端后续 pip/python 走虚拟环境)
pip install -r requirements.txt
cp .env.example .env
# 编辑 .env:HUB_PORT,HUB_DISABLED_IDS,公网时 HUB_BRIDGE_TOKEN 等
chmod +x scripts/run_hub.sh scripts/run_agent.sh scripts/pm2_hub.sh scripts/pm2_agents.sh
chmod +x scripts/fix_hub_deps.sh scripts/verify_hub_deploy.sh scripts/fix_env_crlf.sh
deactivate # 可选;交给 PM2 时不必保持激活
```
**依赖说明**:`requirements.txt`**`python-multipart`**(FastAPI 表单解析;旧版若保留下单接口时需要),**`psutil`**(监控区服务器状态条).当前中控**已移除下单区**,但仍建议安装完整依赖.
说明:
- **安装依赖**:必须在 **`manual_trading_hub/.venv`** 内执行,勿对系统 Python 直接 `pip install`(Debian/Ubuntu 会报 `externally-managed-environment`).`source .venv/bin/activate` 后用 `pip install` 即可;不写 `activate` 时也可用 **`.venv/bin/pip install -r requirements.txt`**(效果相同).快捷:`bash scripts/fix_hub_deps.sh`.
- **PM2 启动**:`scripts/run_hub.sh` 固定调用 **`.venv/bin/python`**,**不依赖**你是否在 shell 里激活过 venv.
- **监控磁盘**:可选环境变量 **`HUB_HOST_DISK_PATH`**(`/opt/crypto_monitor`),未设时 Linux 为 `/`,Windows 为系统盘.
子代理也使用 **本目录 `.venv`** 里的 Python(与各策略 Flask 的 venv 可分开).
---
## 四,推荐启动顺序
```
1. 各实例 Flask(APP_PORT) ← 各 crypto_monitor_* 目录 ecosystem.config.cjs
2. 中控 + 子代理(5100 + 1520015202) ← 本目录一条 PM2 命令同时启动
```
**`ecosystem.config.cjs` 会一次拉起 3 个 agent + 1 个 hub**,无需再单独 `pm2 start` 子代理.
仅反代中控到公网时:Flask / agent 仍只监听 **127.0.0.1**;系统设置里 URL 填 `http://127.0.0.1:端口`.
---
## 五,PM2 托管(hub + agent 一起启动,推荐)
### 5.1 一条命令启动全部
| 文件 | 包含进程 |
|------|----------|
| `ecosystem.config.cjs` | `manual-agent-binance` / `okx` / `gate` + **`manual-trading-hub`** |
`run_hub.sh` 加载 **`manual_trading_hub/.env`** 后执行 `hub.py`;各 agent 经 **`run_agent.sh`** 在对应策略目录加载 **`.env`**(含 API 密钥),再执行 `agent.py`.
```bash
cd /opt/crypto_monitor/manual_trading_hub
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
pm2 start ecosystem.config.cjs # 4 个进程一起起
pm2 save
# 或
bash scripts/pm2_hub.sh start
```
### 5.2 PM2 进程一览
| 进程名 | 工作目录 | 端口/说明 |
|--------|----------|-----------|
| manual-agent-binance | crypto_monitor_binance | agent `15200` |
| manual-agent-okx | crypto_monitor_okx | agent `15201` |
| manual-agent-gate | crypto_monitor_gate | agent `15202` |
| manual-trading-hub | manual_trading_hub | hub `5100` |
OKX 子代理会启动;不用 OKX 可 `pm2 stop manual-agent-okx`.
### 5.3 常用运维命令
```bash
pm2 status
pm2 logs manual-trading-hub --lines 200
pm2 restart ecosystem.config.cjs # 重启 hub + 全部 agent
bash scripts/pm2_hub.sh restart # 同上
bash scripts/pm2_hub.sh stop
bash scripts/pm2_hub.sh logs
```
仅重启中控,不动 agent:
```bash
pm2 restart manual-trading-hub
```
仅重启子代理:
```bash
pm2 restart manual-agent-binance manual-agent-gate manual-agent-okx
# 或
bash scripts/pm2_agents.sh restart
```
### 5.4 开机自启
```bash
pm2 save
pm2 startup
# 按终端提示执行一行 sudo 命令后,再 pm2 save
```
### 5.5 与各实例 Flask 一起查看
```bash
pm2 status
# 示例同时存在:
# manual-trading-hub,manual-agent-*
# crypto_binance / crypto_gate …(各策略目录自有 ecosystem.config.cjs)
```
### 5.6 Gate 子代理「一会能连,一会子代理不可用」(Windows `.env` 换行)
**现象**:Gate 卡片红字「子代理不可用」;`pm2 logs manual-agent-gate` 反复出现:
```text
./.env: line 22: $'\r': command not found
agent start: exchange=gate port=15202 ...
```
**原因**:在 Windows 编辑的 `crypto_monitor_gate/.env`**CRLF**,Linux 上 `source` 失败;PM2 反复重启,中控轮询时偶发连不上(**不是外网问题**).
**处理**(在服务器仓库根执行):
```bash
cd /opt/crypto_monitor
sed -i 's/\r$//' crypto_monitor_gate/.env
bash manual_trading_hub/scripts/fix_env_crlf.sh
cd manual_trading_hub
pm2 delete manual-agent-gate 2>/dev/null || true
pm2 start ecosystem.config.cjs --only manual-agent-gate
pm2 save
curl -s http://127.0.0.1:15202/status | head -c 200 # 应 ok:true
```
**预防**:`.env` 保存为 **LF**(勿在 Windows 记事本直接保存 CRLF).子代理须经 **`scripts/run_agent.sh`** 启动(内置去 CRLF 的 `load_dotenv_file`),勿裸跑 `python agent.py`.
详见 [常见问题.md](./常见问题.md) **§3.1**,**§3.3**.
---
## 六,手动启动(不用 PM2 时)
需**分别**起 agent 与 hub(与 PM2 合并启动不同):
```bash
# 子代理:由 ecosystem.config.cjs 经 run_agent.sh 启动(勿用手动多终端)
# 中控:
cd /opt/crypto_monitor/manual_trading_hub
bash scripts/run_hub.sh
```
---
## 七,浏览器验收
1. **http://127.0.0.1:5100/login** — 若 `.env` 已设 `HUB_PASSWORD`,`HUB_USERNAME` / `HUB_PASSWORD` 登录.
2. **http://127.0.0.1:5100/monitor** — 已启用账户显示持仓;Flask 已起时有关键位/趋势信息.
3. **http://127.0.0.1:5100/market** — 行情区可选交易所与周期拉 K 线;升级后强刷浏览器,详见 [行情区说明.md](./行情区说明.md).
4. **http://127.0.0.1:5100/ai** — AI 教练(三户今日总结 + 聊天);`manual_trading_hub/.env` 配与三实例相同的 `AI_*` 变量,见 [AI教练说明.md](./AI教练说明.md).
5. **http://127.0.0.1:5100/settings** — 保存后生成 `hub_settings.json`(增加第五户,Gate 子账户等见 [使用说明.md §4.5](./使用说明.md#45-增加账户例如再挂一个-gate)).
5. 监控卡片 **「实例」** — 在各 `crypto_monitor_*` 网页做下单,关键位,趋势;中控**不提供**下单表单.
**命令行验收**(推荐):
```bash
cd /opt/crypto_monitor/manual_trading_hub
bash scripts/verify_hub_deploy.sh
```
应看到:`OK: 无 api_trade_key`,`HTTP 200`,JSON 含 `"build":"20260521-no-trade-ui"`.
```bash
curl -s http://127.0.0.1:5100/api/ping
curl -s http://127.0.0.1:15200/status | head -c 200
```
---
## 八,仅反代中控到公网(实例不反代)
1. Nginx/Caddy 反代到 **`127.0.0.1:5100`**,配置 **HTTPS**.
2. 反代需传递(登录 Cookie 正确识别 HTTPS):
```nginx
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
```
3. `manual_trading_hub/.env` **务必设置**:
```env
HUB_USERNAME=你的用户名
HUB_PASSWORD=你的强密码
HUB_SESSION_SECRET=随机长串
HUB_COOKIE_SECURE=true
```
4. `hub_settings.json` 中 Flask/Agent 保持 **`http://127.0.0.1:...`**(中控本机调 API).
5. 三实例 **`APP_AUTH_DISABLED=false`** + 与中控相同 **`HUB_BRIDGE_TOKEN`**.
6. 子代理 **`HOST=127.0.0.1`**;防火墙勿对公网开放 `15200``15202`,`APP_PORT`.
7. **复盘/实例外链**:`HUB_PUBLIC_ORIGIN=https://你的域名` 或内网 IP;否则其它设备点「复盘」会跳到 `127.0.0.1`.
**说明**:HTTPS 域名与 HTTP `内网IP:5100` Cookie **不共用**;内网访问 IP 需在 IP 地址再登录一次(见 [常见问题.md](./常见问题.md) §2.1).
---
## 九,环境变量(中控 `.env`)
| 变量 | 默认 | 说明 |
|------|------|------|
| `HUB_HOST` | `0.0.0.0` | 监听地址 |
| `HUB_PORT` | `5100` | 端口 |
| `HUB_DISABLED_IDS` | `1` | 强制关闭的账户 id(OKX) |
| `HUB_TRUST_LAN` | `true` | 私网可访问;仅本机可 `false` |
| `HUB_PUBLIC_ORIGIN` | 空 | 浏览器用复盘链接;`http://192.168.1.100`(**内网其它电脑访问中控时建议设置**) |
| `HUB_BRIDGE_TOKEN` | 空 | 与三实例一致;公网建议配置 |
| `HUB_USERNAME` | `admin` | Web 登录用户名 |
| `HUB_PASSWORD` | 空 | 非空即启用登录 |
| `HUB_SESSION_SECRET` | — | 会话签名 |
| `HUB_COOKIE_SECURE` | `false` | HTTPS 反代建议 `true` |
| `HUB_SESSION_DAYS` | `7` | 登录保持天数 |
本地联调,实例 `APP_AUTH_DISABLED=true` 时可不配 `HUB_BRIDGE_TOKEN`;本机不设 `HUB_PASSWORD` 则无需登录页.
---
## 十,升级与回滚
```bash
cd /opt/crypto_monitor
git pull
cd manual_trading_hub
bash scripts/fix_hub_deps.sh
bash scripts/verify_hub_deploy.sh
pm2 restart ecosystem.config.cjs
# 若只改了中控:pm2 restart manual-trading-hub
```
- **`hub_settings.json`**,**`hub_ai_summaries.json`**,**`hub_ai_chat.json`**,**`.env`** 不在 Git 中,`git pull` 不会覆盖.
- 升级前可备份:`cp hub_settings.json hub_settings.json.bak`,`cp hub_ai_*.json hub_ai_backup/`,`cp .env .env.bak`.
**升级后自检**:`curl -s http://127.0.0.1:5100/api/ping` 须含 `"trade_ui":false`.若仍见 `api_trade_key` 报错,说明代码未更新或未重启,见 [常见问题.md](./常见问题.md) §1.
---
## 十一,故障排查(速查)
**完整实录**见 **[常见问题.md](./常见问题.md)**.
| 现象 | 处理 |
|------|------|
| PM2 启动后立刻退出 | `pm2 logs manual-trading-hub`;检查 `.venv`,`.env`,`run_hub.sh` |
| `api_trade_key` / `python-multipart` | `git pull``bash scripts/fix_hub_deps.sh``verify_hub_deploy.sh` → 重启 hub |
| `verify` ping 解析失败 | hub 未起:`pm2 restart manual-trading-hub` |
| 余额显示 — | agent 未加载 `.env`;`fix_env_crlf.sh`;`run_agent.sh` |
| agent `$'\r': command not found` | `bash scripts/fix_env_crlf.sh` |
| 监控无持仓 | `curl http://127.0.0.1:15200/status` |
| 无关键位 / 401 | 启动 Flask;核对 `HUB_BRIDGE_TOKEN` / `hub_bridge` |
| 域名能登,IP 不能登 | 见常见问题 §2.1(Cookie / HTTP vs HTTPS) |
| 公网访问中控 403 | 反代到 `127.0.0.1:5100`,勿公网直连 5100 |
| 改 `.env` 不生效 | `pm2 restart manual-trading-hub` |
---
## 十二,进程托管说明
中控与子代理 **仅使用 PM2**(`ecosystem.config.cjs`).勿再用 screen / systemd / nohup 启动 `hub.py``agent.py`,以免端口冲突.
环境要求见 **[docs/ubuntu-server.md](../docs/ubuntu-server.md)**.
---
## 十三,安全清单
- [ ] 公网仅暴露反代端口,不暴露 Flask/agent 端口
- [ ] 已设 `HUB_USERNAME` + `HUB_PASSWORD`(中控 Web 登录)
- [ ] HTTPS 反代已设 `HUB_COOKIE_SECURE=true` 且传递 `X-Forwarded-Proto`
- [ ] 公网已配置 `HUB_BRIDGE_TOKEN` + 实例关闭 `APP_AUTH_DISABLED`
- [ ] API Key 最小权限;交易所 IP 白名单
- [ ] 已告知操作人员「全局全平」不可撤销;中控**不在网页下单**
---
## 十四,文档索引
| 文档 | 内容 |
|------|------|
| [使用说明.md](./使用说明.md) | 功能,页面,API,环境变量 |
| [常见问题.md](./常见问题.md) | 故障实录 |
| [README.md](./README.md) | 速览 |
| [.env.example](./.env.example) | 环境变量模板 |
| [scripts/后台运行-Ubuntu.md](./scripts/后台运行-Ubuntu.md) | PM2 常驻(唯一推荐) |
| [docs/ubuntu-server.md](../docs/ubuntu-server.md) | Ubuntu / Python / Node / PM2 |
| `scripts/fix_hub_deps.sh` | 安装依赖 |
| `scripts/verify_hub_deploy.sh` | 部署验收 |
| `scripts/fix_env_crlf.sh` | 修复 .env 换行 |