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:
@@ -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 Cookie;http://内网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
|
||||
# 本机 Ollama(AI_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
|
||||
|
||||
@@ -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
@@ -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 (:15200~15202) 持仓、全平
|
||||
└→ 各 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`(15200~15202),密钥来自**该目录 `.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) | 环境变量模板 |
|
||||
# 复盘系统中控(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 (:15200~15202) 持仓,全平
|
||||
└→ 各 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`(15200~15202),密钥来自**该目录 `.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) | 环境变量模板 |
|
||||
|
||||
@@ -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`,回滚代码不会自动删除该文件.
|
||||
|
||||
+910
-910
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");
|
||||
|
||||
@@ -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
|
||||
* 仅某 agent:pm2 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,4 +1,4 @@
|
||||
"""加载 manual_trading_hub/.env(Windows 直接 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
File diff suppressed because it is too large
Load Diff
@@ -1 +1 @@
|
||||
"""中控 AI 模块:今日总结 + 交易员聊天(与实例 ai_review 分离)。"""
|
||||
"""中控 AI 模块:今日总结 + 交易员聊天(与实例 ai_review 分离)."""
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 [],
|
||||
}
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
"""中控 AI 配置(读 hub .env,与实例同名 AI 变量)。"""
|
||||
"""中控 AI 配置(读 hub .env,与实例同名 AI 变量)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
+1161
-1161
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,4 +1,4 @@
|
||||
"""中控 AI:JSON 持久化(与 hub_settings.json 同目录)。"""
|
||||
"""中控 AI:JSON 持久化(与 hub_settings.json 同目录)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""中控 AI:今日总结生成。"""
|
||||
"""中控 AI:今日总结生成."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
"""中控 AI 文本小工具。"""
|
||||
"""中控 AI 文本小工具."""
|
||||
|
||||
|
||||
def is_ai_error_reply(text: str) -> bool:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""监控区 board:后台定时聚合、内存快照、SSE 版本通知。"""
|
||||
"""监控区 board:后台定时聚合,内存快照,SSE 版本通知."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""行情区 K 线:后台轮询订阅 + SSE 推送尾部 K 线(对齐监控区 board)。"""
|
||||
"""行情区 K 线:后台轮询订阅 + SSE 推送尾部 K 线(对齐监控区 board)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
+138
-138
@@ -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,4 +1,4 @@
|
||||
"""数据看板:后台定时聚合、内存快照、SSE 版本通知。"""
|
||||
"""数据看板:后台定时聚合,内存快照,SSE 版本通知."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""中控 .env 读写与 PM2 重启。"""
|
||||
"""中控 .env 读写与 PM2 重启."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""交易监管:后台扫描 + SSE 版本通知。"""
|
||||
"""交易监管:后台扫描 + SSE 版本通知."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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=True:LocalNav 等跨站 iframe 嵌入时须 SameSite=None + Secure(仅 HTTPS 有效)。
|
||||
embed=True:LocalNav 等跨站 iframe 嵌入时须 SameSite=None + Secure(仅 HTTPS 有效).
|
||||
"""
|
||||
secure = cookie_secure_for_request(request)
|
||||
samesite = "lax"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 后渲染更完整;无则使用内置轻量渲染
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ usage() {
|
||||
cat <<'EOF'
|
||||
用法: bash scripts/pm2_hub.sh <start|stop|restart|status|logs|delete>
|
||||
|
||||
start 启动 ecosystem.config.cjs(3 路子代理 + 中控,已存在则 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"
|
||||
|
||||
@@ -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) 下无 .env,agent 可能缺少 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
|
||||
|
||||
@@ -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 版本与三所启动顺序 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+8323
-8323
File diff suppressed because it is too large
Load Diff
+5581
-5581
File diff suppressed because it is too large
Load Diff
@@ -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" });
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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>" +
|
||||
|
||||
+3546
-3546
File diff suppressed because it is too large
Load Diff
@@ -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,4 +1,4 @@
|
||||
/* 数据看板 — 随中控亮/暗主题,卡片柔光 */
|
||||
/* 数据看板 — 随中控亮/暗主题,卡片柔光 */
|
||||
body.hub-page-dashboard {
|
||||
--dash-card-bg: var(--panel);
|
||||
--dash-card-border: var(--border-soft);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
|
||||
+1197
-1197
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
/** 中控主题:暗色(默认)/ 亮色,localStorage hub-theme */
|
||||
/** 中控主题:暗色(默认)/ 亮色,localStorage hub-theme */
|
||||
(function (global) {
|
||||
const KEY = "hub-theme";
|
||||
const META = { dark: "#0b0e18", light: "#d4dde8" };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 时间平仓 + 整点强制清仓:表单开关 + 持仓/顶栏倒计时。
|
||||
* 时间平仓 + 整点强制清仓:表单开关 + 持仓/顶栏倒计时.
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
@@ -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
@@ -1,289 +1,289 @@
|
||||
# 云服务器部署说明
|
||||
|
||||
本文说明在 **云服务器(VPS)** 上部署 `crypto_monitor` 中控与三实例的推荐配置:硬件、软件、防火墙、宝塔反代、环境变量、PM2 启动与验收。
|
||||
|
||||
云上标准做法:**域名 + 宝塔/Nginx 反代 + HTTPS**;业务端口(5100、5000~5004、15200~15202)**不对公网直连**。
|
||||
|
||||
相关文档:
|
||||
|
||||
- **[本地数据迁移到云端.md](./本地数据迁移到云端.md)** — 备份 `crypto.db`、图片、`hub_settings` 与恢复步骤
|
||||
- [局域网与反代部署说明.md](./局域网与反代部署说明.md) — 局域网 IP:端口 与反代域名对照、SSO 行为
|
||||
- [部署文档.md](./部署文档.md) — PM2、依赖安装、日常运维
|
||||
- [使用说明.md](./使用说明.md) — 中控功能说明
|
||||
- [常见问题.md](./常见问题.md) — 故障排查
|
||||
- 环境变量模板:[.env.example](./.env.example)
|
||||
|
||||
---
|
||||
|
||||
## 一、服务器硬件与系统
|
||||
|
||||
| 项目 | 建议 |
|
||||
|------|------|
|
||||
| 配置 | **2 核 4G** 起步;三实例 + 中控 + PM2 同时运行,**4G~8G 更稳** |
|
||||
| 系统 | **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**(Let’s 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 | 15200~15202 | **否**,必须 **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**(Let’s 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、5000~5004、15200~15202
|
||||
- [ ] 三实例 `.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)。
|
||||
# 云服务器部署说明
|
||||
|
||||
本文说明在 **云服务器(VPS)** 上部署 `crypto_monitor` 中控与三实例的推荐配置:硬件,软件,防火墙,宝塔反代,环境变量,PM2 启动与验收.
|
||||
|
||||
云上标准做法:**域名 + 宝塔/Nginx 反代 + HTTPS**;业务端口(5100,5000~5004,15200~15202)**不对公网直连**.
|
||||
|
||||
相关文档:
|
||||
|
||||
- **[本地数据迁移到云端.md](./本地数据迁移到云端.md)** — 备份 `crypto.db`,图片,`hub_settings` 与恢复步骤
|
||||
- [局域网与反代部署说明.md](./局域网与反代部署说明.md) — 局域网 IP:端口 与反代域名对照,SSO 行为
|
||||
- [部署文档.md](./部署文档.md) — PM2,依赖安装,日常运维
|
||||
- [使用说明.md](./使用说明.md) — 中控功能说明
|
||||
- [常见问题.md](./常见问题.md) — 故障排查
|
||||
- 环境变量模板:[.env.example](./.env.example)
|
||||
|
||||
---
|
||||
|
||||
## 一,服务器硬件与系统
|
||||
|
||||
| 项目 | 建议 |
|
||||
|------|------|
|
||||
| 配置 | **2 核 4G** 起步;三实例 + 中控 + PM2 同时运行,**4G~8G 更稳** |
|
||||
| 系统 | **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**(Let’s 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 | 15200~15202 | **否**,必须 **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**(Let’s 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,5000~5004,15200~15202
|
||||
- [ ] 三实例 `.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).
|
||||
|
||||
@@ -1,84 +1,84 @@
|
||||
# 交易监管(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 进程名)。
|
||||
# 交易监管(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 进程名).
|
||||
|
||||
+518
-518
File diff suppressed because it is too large
Load Diff
+226
-226
@@ -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 | 15200~15202(仅本机) |
|
||||
|
||||
### 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**(Let’s 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 | 15200~15202(仅本机) |
|
||||
|
||||
### 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**(Let’s 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
@@ -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: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) | 中控环境变量模板 |
|
||||
# 中控与三实例 — 常见问题实录
|
||||
|
||||
本文档整理部署与运行 **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) | 中控环境变量模板 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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`(默认 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`。
|
||||
# 中控数据看板说明
|
||||
|
||||
入口:**`/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
@@ -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
@@ -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/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_*` 等变量 |
|
||||
# 行情区(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_*` 等变量 |
|
||||
|
||||
@@ -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
@@ -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
|
||||
# 编辑 .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 + 15200~15202) ← 本目录一条 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 换行 |
|
||||
# 多账户交易中控 — 部署文档(含 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 + 15200~15202) ← 本目录一条 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 换行 |
|
||||
|
||||
Reference in New Issue
Block a user