增加大模型

This commit is contained in:
dekun
2026-05-26 09:49:43 +08:00
parent 27031ab676
commit 86aa804c21
8 changed files with 547 additions and 90 deletions
+149 -23
View File
@@ -13,10 +13,12 @@
| 项目 | 说明 | | 项目 | 说明 |
|------|------| |------|------|
| 系统 | Linux(推荐 Ubuntu 22.04+ / Debian 12+ | | 系统 | Linux(推荐 Ubuntu 22.04+ / Debian 12+ |
| 时区 | `Asia/Shanghai`(定时任务 08:00 / 08:10 北京时间) | | 时区 | `Asia/Shanghai`(定时任务 08:00 / 08:05 / 08:10 北京时间) |
| 网络 | 能访问 `fapi.binance.com`若不能,需开启 SOCKS5 代理(见第四节 | | 网络 | 能访问 `fapi.binance.com`大模型需能访问 `LLM_BASE_URL`(默认 `http://op.bz121.com` |
| 内存 | 建议 ≥ 512MB | | 代理 | 币安不可直连时开启 SOCKS5(见第六节) |
| 磁盘 | ≥ 500MB(含日志与 SQLite | | 内存 | 建议 ≥ **1GB**(含 `matplotlib` 生成日 K 图供大模型 |
| 磁盘 | ≥ 500MB(含日志、SQLite、日 K 缓存) |
| Python | 3.10+**PM2 部署必须使用项目内 `.venv`** 安装依赖 |
--- ---
@@ -34,7 +36,7 @@ sudo chown -R $USER:$USER /opt/Binance_Altcoin_Monitor
# 3. 配置环境变量 # 3. 配置环境变量
cd /opt/Binance_Altcoin_Monitor cd /opt/Binance_Altcoin_Monitor
cp .env.example .env cp .env.example .env
nano .env # 至少填写 WECOM_WEBHOOK_URL nano .env # 至少填写 WECOM_WEBHOOK_URL;大模型解读需填 LLM_API_KEY
``` ```
`.env` 常用项: `.env` 常用项:
@@ -44,12 +46,63 @@ WECOM_WEBHOOK_URL=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=你的key
HOST=0.0.0.0 HOST=0.0.0.0
PORT=21450 PORT=21450
# 今日每 4 小时自动刷新(另支持 Web 页脚手动刷新)
REFRESH_MINUTES=240
# 代理默认关闭 # 代理默认关闭
PROXY_ENABLED=false PROXY_ENABLED=false
PROXY_URL=socks5h://192.168.8.4:1081 PROXY_URL=socks5h://192.168.8.4:1081
PROXY_FOR=binance PROXY_FOR=binance
# 大模型解读(OpenAI 兼容网关)
LLM_BASE_URL=http://op.bz121.com
LLM_API_KEY=sk-你的密钥
LLM_MODEL=gemma4:e4b
LLM_SYMBOL_INTERVAL_SEC=180
LLM_AUTO_ON_STARTUP=true
``` ```
完整变量说明见仓库 [`.env.example`](./.env.example) 与 [README.md](./README.md)。
---
## 二点五、Python 依赖与虚拟环境(重要)
本项目 **所有 Python 依赖** 均来自 `backend/requirements.txt`(含 `matplotlib`,用于服务端渲染日 K PNG 并喂给大模型)。
| 部署方式 | 依赖安装位置 | 说明 |
|----------|--------------|------|
| **PM2** | 项目目录 **`.venv`** | `deploy/pm2-deploy.sh` 自动 `python3 -m venv .venv``pip install -r backend/requirements.txt`PM2 使用 `.venv/bin/python` 启动 |
| **Docker** | 镜像内 | `Dockerfile` 构建时 `pip install`,无需在宿主机建 venv |
| **本机调试** | 建议同样使用 `.venv` | 与生产一致,避免「系统 Python 有包、PM2 进程没有」 |
### PM2:手动安装 / 更新依赖
```bash
cd /opt/Binance_Altcoin_Monitor
# 若尚无虚拟环境
python3 -m venv .venv
# 依赖必须装进 .venv(不要只用系统 pip)
.venv/bin/pip install -U pip
.venv/bin/pip install -r backend/requirements.txt
pm2 restart binance-altcoin-monitor
```
### 验证依赖是否装在 venv 内
```bash
.venv/bin/python -c "import matplotlib; import fastapi; print('ok')"
```
若报错 `No module named matplotlib`,说明未在 **`.venv`** 中安装,请执行上节 `pip install` 后重启 PM2。
### Docker:更新依赖
修改 `requirements.txt` 后需 **重新构建镜像**(见第十节),容器内才会包含新包。
--- ---
## 三、方式 A — Docker 一键部署(推荐) ## 三、方式 A — Docker 一键部署(推荐)
@@ -93,6 +146,9 @@ docker compose down
# 手动测试企微推送 # 手动测试企微推送
curl -X POST http://127.0.0.1:21450/api/push/test curl -X POST http://127.0.0.1:21450/api/push/test
# 查看大模型解读状态(需已配置 LLM_API_KEY
curl http://127.0.0.1:21450/api/llm/status
``` ```
### 3.4 访问 Web ### 3.4 访问 Web
@@ -105,10 +161,10 @@ curl -X POST http://127.0.0.1:21450/api/push/test
## 四、方式 B — PM2 一键部署 ## 四、方式 B — PM2 一键部署
### 4.1 安装依赖 ### 4.1 安装系统软件
```bash ```bash
# Python 3.10+(部署脚本会自动创建 .venv;若无 venv 模块需先装 # Python 3.10+(部署脚本会自动创建 .venv 并把 requirements 装进 venv
sudo apt install -y python3 python3-venv python3-pip sudo apt install -y python3 python3-venv python3-pip
# Node.js + PM2 # Node.js + PM2
@@ -117,6 +173,8 @@ sudo apt install -y nodejs
sudo npm install -g pm2 sudo npm install -g pm2
``` ```
> **注意**:业务依赖(`fastapi`、`matplotlib` 等)**不要**只执行 `sudo pip install`,应交给脚本或 `.venv/bin/pip`(见 **二点五**)。
### 4.2 一键部署 ### 4.2 一键部署
```bash ```bash
@@ -126,6 +184,8 @@ chmod +x deploy/pm2-deploy.sh
./deploy/pm2-deploy.sh ./deploy/pm2-deploy.sh
``` ```
脚本将:拉取代码 → **创建/更新 `.venv`**`pip install -r backend/requirements.txt``pm2 start`(解释器为 `.venv/bin/python`)。
### 4.3 常用命令 ### 4.3 常用命令
```bash ```bash
@@ -140,7 +200,53 @@ pm2 save
--- ---
## 五、SOCKS5 代理(默认关闭 ## 五、大模型解读(可选
**连续三日成交额均为 Top30 的交集币种** 进行 AI 简析(涨跌幅不限)。服务端用 `matplotlib` 生成日 K+成交量 PNG,通过 OpenAI 兼容接口发给网关。
| 变量 | 说明 |
|------|------|
| `LLM_BASE_URL` | 网关根地址,默认 `http://op.bz121.com` |
| `LLM_API_KEY` | `Authorization: Bearer sk-...`**勿提交 git** |
| `LLM_MODEL` | 与网关模型 ID 一致,默认 `gemma4:e4b` |
| `LLM_SYMBOL_INTERVAL_SEC` | 批量解读时每币间隔,默认 `180`3 分钟) |
| `LLM_AUTO_ON_STARTUP` | 服务启动后是否后台自动跑一轮,默认 `true` |
### 定时与行为
| 时间(北京时间) | 行为 |
|------------------|------|
| **08:05** | 自动对三日交集币种排队解读 |
| **启动时** | 若已配置 `LLM_API_KEY``LLM_AUTO_ON_STARTUP=true`,后台自动启动一轮 |
Web「数据统计」页可查看解读结果,或点击 **「开始解读」** 手动触发。
### API 速查
```bash
# 任务状态(是否在跑、当前币种、进度)
curl http://127.0.0.1:21450/api/llm/status
# 手动启动批量解读(后台异步,勿重复点击)
curl -X POST http://127.0.0.1:21450/api/llm/interpret/run
# 最近一批解读文本
curl http://127.0.0.1:21450/api/llm/interpretations
# 单币日 K PNG(调试用)
curl -o chart.png http://127.0.0.1:21450/api/chart/BTCUSDT/daily.png
```
### 配置后生效
**PM2** 修改 `.env``pm2 restart binance-altcoin-monitor`
**Docker** `docker compose up -d --force-recreate`
若网关不支持 vision,服务会降级为纯文本解读(日志中有 warning)。
---
## 六、SOCKS5 代理(默认关闭)
当服务器**无法直连**币安 API 时,可通过内网 SOCKS5 代理访问。 当服务器**无法直连**币安 API 时,可通过内网 SOCKS5 代理访问。
@@ -204,7 +310,7 @@ curl http://127.0.0.1:21450/api/today/top30
--- ---
## 、防火墙与 Nginx(可选) ## 、防火墙与 Nginx(可选)
```bash ```bash
# 开放 21450(若直接对外) # 开放 21450(若直接对外)
@@ -228,35 +334,41 @@ server {
--- ---
## 、定时任务说明 ## 、定时任务说明
| 时间(北京时间) | 行为 | | 时间(北京时间) | 行为 |
|------------------|------| |------------------|------|
| 08:00 | 固化昨日周期数据 | | 08:00 | 固化昨日、前日周期快照 |
| 08:10 | 企业微信推送昨日 Top30 | | 08:05 | 大模型解读三日 Top30 交集(需 `LLM_API_KEY` |
| 每 5 分钟 | 刷新今日数据(`REFRESH_MINUTES` 可改 | | 08:10 | 企业微信推送 **三日 Top30 交集**(卡片列表,非宽表格 |
| 每 4 小时(0/4/8/12/16/20 点) | 刷新今日数据(`REFRESH_MINUTES=240` |
Web 今日表 **不会** 每 60 秒自动轮询;除上述定时外,使用页脚 **「立即刷新今日」** 手动更新。
**进程需常驻**Docker `restart: unless-stopped` 或 PM2 `autorestart`)。 **进程需常驻**Docker `restart: unless-stopped` 或 PM2 `autorestart`)。
--- ---
## 、目录与数据 ## 、目录与数据
``` ```
/opt/Binance_Altcoin_Monitor/ /opt/Binance_Altcoin_Monitor/
├── .env # 配置(勿提交 git) ├── .env # 配置(勿提交 git)
├── data/monitor.db # SQLite 数据 ├── .venv/ # Python 虚拟环境(PM2 专用,勿删)
├── data/monitor.db # SQLite(周期快照、日 K、资金费率、LLM 解读)
├── logs/ # PM2 日志(Docker 用 docker compose logs ├── logs/ # PM2 日志(Docker 用 docker compose logs
├── deploy/ # 一键脚本 ├── deploy/ # 一键脚本
├── docker-compose.yml ├── docker-compose.yml
└── ecosystem.config.cjs └── ecosystem.config.cjs # PM2 解释器: .venv/bin/python
``` ```
备份建议:定期备份 `data/monitor.db``.env` 备份建议:定期备份 `data/monitor.db``.env`
--- ---
## 、更新版本 ## 、更新版本
拉取代码后,**务必**按部署方式重装 Python 依赖(`requirements.txt` 变更时尤其重要,例如新增 `matplotlib`)。
**Docker** **Docker**
@@ -267,43 +379,57 @@ docker compose build --no-cache
docker compose up -d docker compose up -d
``` ```
**PM2** **PM2(推荐一键脚本,会自动更新 .venv)**
```bash ```bash
cd /opt/Binance_Altcoin_Monitor cd /opt/Binance_Altcoin_Monitor
git pull git pull
./deploy/pm2-deploy.sh ./deploy/pm2-deploy.sh
# 或手动: .venv/bin/pip install -r backend/requirements.txt && pm2 restart binance-altcoin-monitor ```
**PM2(仅手动更新依赖):**
```bash
cd /opt/Binance_Altcoin_Monitor
git pull
.venv/bin/pip install -r backend/requirements.txt
pm2 restart binance-altcoin-monitor
``` ```
--- ---
## 十、故障排查 ## 十、故障排查
| 现象 | 处理 | | 现象 | 处理 |
|------|------| |------|------|
| `bash\r: No such file or directory` | 脚本为 Windows 换行,执行:`sed -i 's/\r$//' deploy/*.sh && chmod +x deploy/*.sh` | | `bash\r: No such file or directory` | 脚本为 Windows 换行,执行:`sed -i 's/\r$//' deploy/*.sh && chmod +x deploy/*.sh` |
| `cannot pull with rebase: unstaged changes` | 执行 `git stash` 后重试;或 `DEPLOY_SKIP_GIT_PULL=1 ./deploy/pm2-deploy.sh` 跳过拉取 | | `cannot pull with rebase: unstaged changes` | 执行 `git stash` 后重试;或 `DEPLOY_SKIP_GIT_PULL=1 ./deploy/pm2-deploy.sh` 跳过拉取 |
| `No module named pip` | 执行 `sudo apt install -y python3-venv` 后重新 `./deploy/pm2-deploy.sh`(脚本会用 .venv | | `No module named pip` | 执行 `sudo apt install -y python3-venv` 后重新 `./deploy/pm2-deploy.sh`(脚本会用 .venv |
| `No module named matplotlib`(或其它包) | 依赖未装进 **`.venv`**:执行 `.venv/bin/pip install -r backend/requirements.txt``pm2 restart`;勿只装系统 Python |
| Web 无数据 | 检查能否访问币安;国内服务器尝试 `PROXY_ENABLED=true` | | Web 无数据 | 检查能否访问币安;国内服务器尝试 `PROXY_ENABLED=true` |
| 大量 `418 I'm a teapot` | IP 被封禁;**不要反复 restart**。日 K 已存 SQLite,图表优先读本地;仅过期或首次才请求币安 | | 大量 `418 I'm a teapot` | IP 被封禁;**不要反复 restart**。日 K 已存 SQLite,图表优先读本地;仅过期或首次才请求币安 |
| 企微收不到 | 检查 `WECOM_WEBHOOK_URL``curl -X POST .../api/push/test` | | 企微收不到 | 检查 `WECOM_WEBHOOK_URL``curl -X POST .../api/push/test` |
| 08:10 未推送 | 确认容器/PM2 在 08:10 前已运行;查日志 | | 08:10 未推送 | 确认容器/PM2 在 08:10 前已运行;查日志 |
| 大模型无解读 / 状态 `enabled: false` | 检查 `.env``LLM_API_KEY``curl .../api/llm/status`;确认服务器能访问 `LLM_BASE_URL` |
| 解读一直 `running` | 查看 `pm2 logs`;可能某币请求超时;可重启进程后手动 `POST /api/llm/interpret/run` |
| 端口占用 | `ss -tlnp \| grep 21450` 或改 `.env``PORT` | | 端口占用 | `ss -tlnp \| grep 21450` 或改 `.env``PORT` |
| Docker 代理连不上 | 确认 `192.168.8.4:1081` 从容器内可达,必要时改宿主机 IP | | Docker 代理连不上 | 确认 `192.168.8.4:1081` 从容器内可达,必要时改宿主机 IP |
--- ---
## 十、快速命令速查 ## 十、快速命令速查
```bash ```bash
# Docker 一键 # Docker 一键
./deploy/docker-deploy.sh ./deploy/docker-deploy.sh
# PM2 一键 # PM2 一键(含 .venv 依赖安装)
./deploy/pm2-deploy.sh ./deploy/pm2-deploy.sh
# 开启代理后重启 # 仅更新 Python 依赖(PM2
.venv/bin/pip install -r backend/requirements.txt && pm2 restart binance-altcoin-monitor
# 开启代理 / 修改 .env 后重启
# Docker: docker compose up -d --force-recreate # Docker: docker compose up -d --force-recreate
# PM2: pm2 restart binance-altcoin-monitor # PM2: pm2 restart binance-altcoin-monitor
``` ```
+95 -27
View File
@@ -2,43 +2,69 @@
仓库:[Binance_Altcoin_Monitor](https://git.bz121.com/dekun/Binance_Altcoin_Monitor.git) 仓库:[Binance_Altcoin_Monitor](https://git.bz121.com/dekun/Binance_Altcoin_Monitor.git)
**北京时间 08:00** 切日,统计 U 本位永续合约成交额 Top30;每日 **08:10** 通过企业微信推送昨日完整周期数据;Web 展示昨日快照与今日实时累计 **北京时间 08:00** 切日,统计 U 本位永续合约成交额 Top30;每日 **08:10** 企业微信推送 **三日 Top30 交集** 币种(列表排版,非宽表格);Web 数据统计页可预览同款推送内容
> **Linux 生产部署(/opt、Docker、PM2、虚拟环境、SOCKS5 代理、大模型)请参阅 [DEPLOY.md](./DEPLOY.md)**
> **Linux 生产部署(/opt、Docker、PM2、SOCKS5 代理)请参阅 [DEPLOY.md](./DEPLOY.md)**
## 功能 ## 功能
- 成交额排名 Top30USDT 计价) - 成交额排名 Top30USDT 计价)
- 高亮标记(不改变排名):成交额 ≥ 1000万 USDT、|涨跌幅| ≥ 5% - 高亮标记(不改变排名):成交额 ≥ 1000万 USDT、|涨跌幅| ≥ 5%
- 昨日周期:`[D-1 08:00, D 08:00)` - 昨日 / 前日周期:按 08:00 切日固化快照
- 今日周期:`[D 08:00, 当前)`,每 **4 小时**后台刷新 + 页脚手动刷新;K线/周期数据服务端 SQLite + 浏览器缓存 - 今日周期:`[D 08:00, 当前)`后台**4 小时**自动刷新 + 页脚 **手动刷新**
- 数据统计:连续三日 Top30 交集(涨跌幅不限) - 日 K + 成交量迷你图,点击 **全屏**查看;K 线优先读服务端 SQLite,浏览器 `localStorage` 缓存约 1 小时
- 大模型解读(`gemma4:e4b`):每日 **08:05** 对三日交集逐币解读(每币 3 分钟),启动自动一轮;需配置 `LLM_API_KEY` - 资金费率当前值 + 历史迷你曲线
- **数据统计**:连续三日均为成交额 Top30 的 **交集**(涨跌幅 **不限**
- **大模型解读**`gemma4:e4b`):对三日交集币种生成日 K 图 + 数据简析;每日 **08:05** 自动排队,每币间隔 **3 分钟**;服务启动后自动跑一轮(可关);需在 `.env` 配置 `LLM_API_KEY`
## 环境要求 ## 环境要求
- Python 3.10+ - Python 3.10+**推荐项目内虚拟环境 `.venv`**PM2 生产亦使用 `.venv`
- 可访问 `fapi.binance.com` - 可访问 `fapi.binance.com`(国内服务器可配 SOCKS5,见 [DEPLOY.md](./DEPLOY.md)
- 企业微信群机器人 Webhook(可选,用于推送) - 企业微信群机器人 Webhook(可选,用于 08:10 推送)
- 大模型网关(可选):默认 `http://op.bz121.com`OpenAI 兼容 `/v1/chat/completions`
## 快速开始 ## 快速开始
```bash ### Windows / 本机开发
```powershell
# 1. 进入项目目录 # 1. 进入项目目录
cd 币安排名 cd 币安排名
# 2. 安装依赖 # 2. 创建并激活虚拟环境(依赖必须装进 venv,勿只装系统 Python
python -m venv .venv
.\.venv\Scripts\Activate.ps1
# 3. 安装依赖(含 matplotlib,用于服务端生成日 K 图供大模型)
pip install -U pip
pip install -r backend/requirements.txt pip install -r backend/requirements.txt
# 3. 配置环境变量 # 4. 配置环境变量
copy .env.example .env copy .env.example .env
# 编辑 .env,填入 WECOM_WEBHOOK_URL # 编辑 .envWECOM_WEBHOOK_URL、LLM_API_KEY 等
# 4. 启动服务(需保持进程常驻) # 5. 启动(需保持进程常驻)
python run.py python run.py
``` ```
### Linux 本机(与生产一致)
```bash
cd /path/to/Binance_Altcoin_Monitor
python3 -m venv .venv
source .venv/bin/activate
pip install -U pip
pip install -r backend/requirements.txt
cp .env.example .env
nano .env
.venv/bin/python run.py
```
浏览器打开:http://127.0.0.1:21450 浏览器打开:http://127.0.0.1:21450
> **生产环境**请用 [DEPLOY.md](./DEPLOY.md) 中的 Docker 或 PM2 脚本;PM2 使用 `.venv/bin/python`,更新代码后需执行 `.venv/bin/pip install -r backend/requirements.txt` 再重启。
## 配置说明(.env ## 配置说明(.env
| 变量 | 说明 | 默认 | | 变量 | 说明 | 默认 |
@@ -47,22 +73,39 @@ python run.py
| `TOP_N` | 排名数量 | 30 | | `TOP_N` | 排名数量 | 30 |
| `VOLUME_THRESHOLD` | 高亮成交额阈值 (USDT) | 10000000 | | `VOLUME_THRESHOLD` | 高亮成交额阈值 (USDT) | 10000000 |
| `CHANGE_THRESHOLD` | 高亮涨跌幅阈值 (%) | 5 | | `CHANGE_THRESHOLD` | 高亮涨跌幅阈值 (%) | 5 |
| `REFRESH_MINUTES` | 今日数据刷新间隔 | 5 | | `REFRESH_MINUTES` | 今日自动刷新间隔(分钟);`240` = 每 4 小时 | 240 |
| `HOST` / `PORT` | 服务监听 | 127.0.0.1:21450 | | `HOST` / `PORT` | 服务监听 | 0.0.0.0:21450 |
| `PROXY_ENABLED` | 是否启用 SOCKS5 代理 | false | | `PROXY_ENABLED` | 是否启用 SOCKS5 代理 | false |
| `PROXY_URL` | 代理地址 | socks5h://192.168.8.4:1081 | | `PROXY_URL` | 代理地址 | socks5h://192.168.8.4:1081 |
| `PROXY_FOR` | 代理范围 binance/wecom/all | binance | | `PROXY_FOR` | 代理范围 binance/wecom/all | binance |
| `MAX_CONCURRENCY` | 币安 K 线并发数(过大易 418 封禁) | 3 | | `MAX_CONCURRENCY` | 币安 K 线并发数(过大易 418 封禁) | 3 |
| `CANDIDATE_POOL` | 预筛候选合约数(按 24h 成交额) | 150 | | `CANDIDATE_POOL` | 预筛候选合约数(按 24h 成交额) | 150 |
| `CHART_KLINE_LIMIT` | 日 K 存储/展示根数 | 300 | | `CHART_KLINE_LIMIT` | 日 K 存储/展示根数 | 300 |
| `CHART_CACHE_MINUTES` | 本地日 K 视为新鲜的时间(分钟内不请求币安) | 60 | | `CHART_CACHE_MINUTES` | 服务端日 K 视为新鲜的时间(分钟内不请求币安) | 60 |
| `LLM_BASE_URL` | 大模型网关根地址 | http://op.bz121.com |
| `LLM_API_KEY` | Bearer 密钥(`sk-...`,勿提交 git | 空 |
| `LLM_MODEL` | 模型 ID,须与网关一致 | gemma4:e4b |
| `LLM_SYMBOL_INTERVAL_SEC` | 批量解读时每币间隔(秒) | 180 |
| `LLM_AUTO_ON_STARTUP` | 启动后是否自动跑一轮三日交集解读 | true |
完整示例见仓库根目录 [`.env.example`](./.env.example)。
## API ## API
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
|------|------|------| |------|------|------|
| GET | `/api/today/top30` | 今日周期 Top30 |
| GET | `/api/yesterday/top30` | 昨日周期 Top30 | | GET | `/api/yesterday/top30` | 昨日周期 Top30 |
| GET | `/api/today/top30` | 日周期 Top30(缓存) | | GET | `/api/daybefore/top30` | 日周期 Top30 |
| POST | `/api/push/test` | 手动测试企业微信推送 | | GET | `/api/stats/three-day` | 三日 Top30 交集统计 |
| GET | `/api/chart/{symbol}/daily` | 日 K JSONSQLite 优先) |
| GET | `/api/chart/{symbol}/daily.png` | 日 K PNG(大模型/预览) |
| GET | `/api/funding/{symbol}/history` | 资金费率历史 |
| GET | `/api/llm/status` | 解读任务状态 |
| GET | `/api/llm/interpretations` | 最近一批解读结果 |
| POST | `/api/llm/interpret/run` | 手动启动三日交集解读队列 |
| GET | `/api/push/preview` | 预览企微推送(三日交集) |
| POST | `/api/push/test` | 手动测试企业微信推送(仅交集币种) |
| POST | `/api/refresh/today` | 立即刷新今日数据 | | POST | `/api/refresh/today` | 立即刷新今日数据 |
| POST | `/api/refresh/yesterday` | 重新计算昨日快照 | | POST | `/api/refresh/yesterday` | 重新计算昨日快照 |
@@ -70,11 +113,21 @@ python run.py
| 时间 (北京时间) | 任务 | | 时间 (北京时间) | 任务 |
|-----------------|------| |-----------------|------|
| 08:00 | 固化昨日周期数据到 SQLite | | 08:00 | 固化昨日、前日周期快照到 SQLite |
| 08:10 | 企业微信推送昨日 Top30 | | 08:05 | 大模型解读「三日 Top30 交集」各币种(需 `LLM_API_KEY` |
| 每 N 分钟 | 刷新今日周期(N = REFRESH_MINUTES | | 08:10 | 企业微信推送三日 Top30 交集(列表排版 |
| 每 4 小时(整点 0/4/8/12/16/20 | 刷新今日周期(由 `REFRESH_MINUTES=240` 控制) |
进程重启后:若已过 08:10 且当日尚未推送成功,会自动补推一次 进程重启后:若已过 08:10 且当日尚未推送成功,会自动补推;若已配置 `LLM_API_KEY``LLM_AUTO_ON_STARTUP=true`,会在后台自动启动一轮解读
## Web 界面
| 页签 | 说明 |
|------|------|
| 今日 / 昨日 / 前日 | Top30 表、日 K、资金费率;支持排序与 CSV 导出 |
| 数据统计 | 三日交集列表 + 大模型解读区;可「开始解读」「刷新解读」 |
今日数据 **不会** 在浏览器里每 60 秒轮询;请依赖 4 小时后台任务或页脚 **「立即刷新今日」**。
## Windows 常驻运行 ## Windows 常驻运行
@@ -93,17 +146,32 @@ curl -X POST http://127.0.0.1:21450/api/push/test
## 数据说明 ## 数据说明
- 使用币安合约 `1h` K 线按时间戳聚合 USDT 成交额(第 7 字段) - 昨日 / 前日:按 08:00 切日,用 `1h` K 线聚合成交额
- 今日:默认 `TODAY_DATA_MODE=ticker24h`(单次 API,滚动 24h 口径)
- 涨跌幅 = (周期末价 - 周期开盘价) / 开盘价 × 100% - 涨跌幅 = (周期末价 - 周期开盘价) / 开盘价 × 100%
- 今日末价优先使用实时 ticker 价格 - 日 K:最多 300 根存 `daily_klines`;浏览器 `localStorage` 缓存周期表约 4 小时、K 线约 1 小时
## 依赖说明
所有 Python 包(含 `matplotlib``fastapi``httpx` 等)写在 [`backend/requirements.txt`](./backend/requirements.txt)
| 部署方式 | 安装位置 |
|----------|----------|
| 本机 / PM2 | 项目目录 **`.venv`**`pip install -r backend/requirements.txt` |
| Docker | 镜像构建时 `pip install`(见 `Dockerfile` |
**不要**只装到系统 PythonPM2 的 `ecosystem.config.cjs` 指定解释器为 `.venv/bin/python`,系统环境缺包会导致 `No module named matplotlib` 等错误。
## 目录结构 ## 目录结构
``` ```
币安排名/ 币安排名/
├── backend/app/ # 后端逻辑 ├── backend/app/ # 后端逻辑(含 llm_service、chart_image
├── backend/requirements.txt
├── web/ # 前端静态页 ├── web/ # 前端静态页
├── data/ # SQLite(自动创建) ├── data/monitor.db # SQLite(自动创建)
├── .venv/ # 虚拟环境(本地/PM2,勿提交)
├── deploy/ # 一键部署脚本
├── run.py # 启动入口 ├── run.py # 启动入口
└── .env # 本地配置(勿提交) └── .env # 本地配置(勿提交)
``` ```
+16 -4
View File
@@ -18,7 +18,7 @@ from .llm_service import get_interpret_state, run_interpretation_batch
from .scheduler import job_finalize_yesterday, job_push_wecom, job_refresh_today, start_scheduler, startup_tasks, stop_scheduler from .scheduler import job_finalize_yesterday, job_push_wecom, job_refresh_today, start_scheduler, startup_tasks, stop_scheduler
from .stats import compute_three_day_stats from .stats import compute_three_day_stats
from .aggregator import aggregate_period from .aggregator import aggregate_period
from .wecom import build_markdown, send_wecom_markdown from .wecom import build_markdown, build_push_payload, send_wecom_markdown
from .state import get_today_cache from .state import get_today_cache
logging.basicConfig( logging.basicConfig(
@@ -93,8 +93,17 @@ async def api_stats_three_day():
return compute_three_day_stats() return compute_three_day_stats()
@app.get("/api/push/preview")
async def api_push_preview():
"""预览企微推送内容(三日交集,列表排版)。"""
return build_push_payload()
@app.post("/api/push/test") @app.post("/api/push/test")
async def api_push_test(): async def api_push_test():
payload = build_push_payload()
if not payload.get("ok"):
raise HTTPException(400, payload.get("message") or "三日交集数据未就绪")
snap = get_latest_snapshot("yesterday") snap = get_latest_snapshot("yesterday")
if not snap: if not snap:
start, end = get_yesterday_period() start, end = get_yesterday_period()
@@ -104,12 +113,15 @@ async def api_push_test():
snap = get_latest_snapshot("yesterday") snap = get_latest_snapshot("yesterday")
if not snap: if not snap:
raise HTTPException(500, "无法生成昨日数据") raise HTTPException(500, "无法生成昨日数据")
content = build_markdown(snap) ok, msg = await send_wecom_markdown(payload["markdown"])
ok, msg = await send_wecom_markdown(content)
log_push(snap["period_start"], snap["period_end"], ok, msg) log_push(snap["period_start"], snap["period_end"], ok, msg)
if not ok: if not ok:
raise HTTPException(500, f"推送失败: {msg}") raise HTTPException(500, f"推送失败: {msg}")
return {"success": True, "message": "推送成功"} return {
"success": True,
"message": f"已推送 {payload.get('count', 0)} 个三日交集币种",
"count": payload.get("count", 0),
}
@app.post("/api/refresh/yesterday") @app.post("/api/refresh/yesterday")
+11 -4
View File
@@ -15,7 +15,7 @@ from .funding_store import prefetch_funding
from .kline_store import prefetch_symbols from .kline_store import prefetch_symbols
from .llm_service import run_interpretation_batch, schedule_interpret_background from .llm_service import run_interpretation_batch, schedule_interpret_background
from .stats import compute_three_day_stats from .stats import compute_three_day_stats
from .wecom import build_markdown, send_wecom_markdown from .wecom import build_push_payload, send_wecom_markdown
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -84,7 +84,11 @@ async def job_finalize_yesterday() -> None:
async def job_push_wecom() -> None: async def job_push_wecom() -> None:
logger.info("Job: WeCom push") logger.info("Job: WeCom push (three-day intersection)")
try:
await job_refresh_today()
except Exception as e:
logger.warning("Pre-push today refresh failed: %s", e)
start, end = get_yesterday_period() start, end = get_yesterday_period()
snapshot = get_latest_snapshot("yesterday") snapshot = get_latest_snapshot("yesterday")
if not snapshot and not binance_client.is_rate_limited(): if not snapshot and not binance_client.is_rate_limited():
@@ -106,8 +110,11 @@ async def job_push_wecom() -> None:
logger.info("Already pushed for period %s ~ %s", ps, pe) logger.info("Already pushed for period %s ~ %s", ps, pe)
return return
content = build_markdown(snapshot) payload = build_push_payload()
ok, msg = await send_wecom_markdown(content) if not payload.get("ok"):
logger.warning("WeCom push skipped: %s", payload.get("message"))
return
ok, msg = await send_wecom_markdown(payload["markdown"])
log_push(ps, pe, ok, msg) log_push(ps, pe, ok, msg)
if ok: if ok:
logger.info("WeCom push succeeded") logger.info("WeCom push succeeded")
+89 -27
View File
@@ -1,9 +1,11 @@
import logging import logging
from typing import Any
import httpx import httpx
from .config import settings from .config import settings
from .http_client import httpx_client_kwargs from .http_client import httpx_client_kwargs
from .stats import compute_three_day_stats
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -14,36 +16,96 @@ def _format_period_label(period_start: str, period_end: str) -> str:
return f"{start} ~ {end}" return f"{start} ~ {end}"
def build_markdown(snapshot: dict) -> str: def _day_line(label: str, row: dict | None) -> str:
items = snapshot.get("items", []) if not row or row.get("rank") is None:
period_label = _format_period_label( return f"> {label}:—"
snapshot.get("period_start", ""),
snapshot.get("period_end", ""),
)
lines = [
"## 币安 U本位合约 成交额 Top30",
f"> 统计周期(北京时间 8:00 切日)",
f"> **{period_label}**",
"",
"| 排名 | 合约 | 成交额(USDT) | 涨跌幅 | 资金费率 | 标记 |",
"| --- | --- | --- | --- | --- | --- |",
]
for row in items:
tags = []
if row.get("is_high_volume"):
tags.append("千万+")
if row.get("is_high_change"):
tags.append("涨跌5%+")
tag_str = " ".join(tags) if tags else "-"
vol = row.get("quote_volume_fmt") or f"{row.get('quote_volume', 0):.0f}"
pct = row.get("price_change_pct_fmt") or f"{row.get('price_change_pct', 0):+.2f}%" pct = row.get("price_change_pct_fmt") or f"{row.get('price_change_pct', 0):+.2f}%"
fr = row.get("funding_rate_fmt") or "-" vol = row.get("quote_volume_fmt") or str(row.get("quote_volume", ""))
lines.append( fr = row.get("funding_rate_fmt") or ""
f"| {row['rank']} | {row['symbol']} | {vol} | {pct} | {fr} | {tag_str} |" return f"> {label}#{row['rank']} · 额 {vol} · 涨跌 {pct} · 费率 {fr}"
def build_push_payload() -> dict[str, Any]:
"""构建企微推送内容:仅三日 Top30 交集,列表排版(非表格)。"""
stats = compute_three_day_stats()
periods = stats.get("periods") or {}
y_meta = periods.get("yesterday") or {}
period_label = ""
if y_meta.get("ready"):
period_label = _format_period_label(
y_meta.get("period_start", ""),
y_meta.get("period_end", ""),
) )
if not stats.get("ok"):
md = "\n".join(
[
"## 币安 U本位 · 三日Top30交集",
"",
f"> 昨日周期 {period_label or ''}",
"",
f"**暂无法推送**{stats.get('message', '数据未就绪')}",
]
)
return {
"ok": False,
"message": stats.get("message", ""),
"count": 0,
"period_label": period_label,
"markdown": md,
"items": [],
}
items = stats.get("items") or []
lines = [
"## 币安 U本位 · 三日Top30交集",
"",
f"> **昨日周期**(北京时间 8:00 切日)",
f"> {period_label}",
f"> 连续三日成交额均为 Top{settings.top_n},共 **{len(items)}** 个",
"",
]
preview_items: list[dict] = []
for i, row in enumerate(items, 1):
sym = row["symbol"]
t, y, b = row.get("today"), row.get("yesterday"), row.get("daybefore")
lines.append(f"### {i}. {sym}")
lines.append(_day_line("昨日", y))
lines.append(_day_line("今日", t))
lines.append(_day_line("前日", b))
lines.append("") lines.append("")
lines.append("> 标记说明:千万+ = 成交额≥1000万 USDT;涨跌5%+ = |涨跌幅|≥5%") preview_items.append(
return "\n".join(lines) {
"rank": i,
"symbol": sym,
"today": t,
"yesterday": y,
"daybefore": b,
"total_quote_volume": row.get("total_quote_volume"),
}
)
if not items:
lines.append("**暂无交集币种**(请确认今日/昨日/前日快照均已生成)")
lines.append("---")
lines.append("> 说明:仅推送三日均为成交额 Top30 的合约;涨跌不限")
return {
"ok": True,
"message": stats.get("criteria", ""),
"count": len(items),
"period_label": period_label,
"markdown": "\n".join(lines),
"items": preview_items,
}
def build_markdown(snapshot: dict | None = None) -> str:
"""兼容旧调用:返回企微 Markdown 文本(忽略 snapshot,以三日交集为准)。"""
_ = snapshot
return build_push_payload()["markdown"]
async def send_wecom_markdown(content: str) -> tuple[bool, str]: async def send_wecom_markdown(content: str) -> tuple[bool, str]:
+87
View File
@@ -422,6 +422,90 @@ async function runLlmInterpret() {
} }
} }
function renderWecomDayRow(label, row) {
if (!row?.rank) {
return `<div class="wecom-day muted"><span class="wecom-day-label">${label}</span>—</div>`;
}
const pct = row.price_change_pct ?? 0;
return `<div class="wecom-day">
<span class="wecom-day-label">${label}</span>
<span class="wecom-rank-num">#${row.rank}</span>
<span class="wecom-vol">${row.quote_volume_fmt || row.quote_volume}</span>
<span class="${pctClass(pct)}">${row.price_change_pct_fmt || pct.toFixed(2) + "%"}</span>
<span class="wecom-fr">${row.funding_rate_fmt || "—"}</span>
</div>`;
}
function renderWecomPreview(payload) {
const panel = document.getElementById("wecom-preview-panel");
const cards = document.getElementById("wecom-preview-cards");
const meta = document.getElementById("wecom-preview-meta");
if (!panel || !cards) return;
panel.classList.remove("hidden");
if (!payload?.ok) {
if (meta) meta.textContent = "未就绪";
cards.innerHTML = `<p class="error">${escapeHtml(payload?.message || "无法生成预览")}</p>`;
return;
}
if (meta) {
meta.textContent = `${payload.period_label || "—"} · ${payload.count} 个币种`;
}
if (!payload.items?.length) {
cards.innerHTML = '<p class="loading">暂无三日交集币种</p>';
return;
}
cards.innerHTML = payload.items
.map(
(it) => `
<article class="wecom-card">
<header class="wecom-card-head">
<span class="wecom-seq">${it.rank}</span>
<strong class="wecom-symbol">${it.symbol}</strong>
</header>
${renderWecomDayRow("昨日", it.yesterday)}
${renderWecomDayRow("今日", it.today)}
${renderWecomDayRow("前日", it.daybefore)}
</article>`
)
.join("");
}
async function loadWecomPreview() {
const cards = document.getElementById("wecom-preview-cards");
if (cards) cards.innerHTML = '<p class="loading">生成预览…</p>';
try {
const res = await fetch("/api/push/preview");
const data = await res.json();
renderWecomPreview(data);
} catch (e) {
if (cards) cards.innerHTML = `<p class="error">${e.message}</p>`;
}
}
async function testWecomPush() {
const el = document.getElementById("push-status");
if (el) el.textContent = "推送中…";
try {
const res = await fetch("/api/push/test", { method: "POST" });
const data = await res.json();
if (!res.ok) {
const detail = data.detail;
const msg =
typeof detail === "string"
? detail
: Array.isArray(detail)
? detail.map((x) => x.msg).join("; ")
: data.message || res.statusText;
throw new Error(msg);
}
if (el) el.textContent = data.message || "推送成功";
await loadWecomPreview();
} catch (e) {
if (el) el.textContent = "推送失败";
alert(e.message);
}
}
async function loadStats() { async function loadStats() {
document.getElementById("stats-table-wrap").innerHTML = document.getElementById("stats-table-wrap").innerHTML =
'<p class="loading">统计中…</p>'; '<p class="loading">统计中…</p>';
@@ -431,6 +515,7 @@ async function loadStats() {
await loadLlmInterpretations(); await loadLlmInterpretations();
renderStatsTable(); renderStatsTable();
await refreshLlmStatus(); await refreshLlmStatus();
await loadWecomPreview();
} catch (e) { } catch (e) {
document.getElementById("stats-table-wrap").innerHTML = `<p class="error">${e.message}</p>`; document.getElementById("stats-table-wrap").innerHTML = `<p class="error">${e.message}</p>`;
} }
@@ -513,6 +598,8 @@ document.getElementById("btn-reload-stats")?.addEventListener("click", () => {
loadStats(); loadStats();
}); });
document.getElementById("btn-export-stats")?.addEventListener("click", exportStatsCsv); document.getElementById("btn-export-stats")?.addEventListener("click", exportStatsCsv);
document.getElementById("btn-push-preview")?.addEventListener("click", loadWecomPreview);
document.getElementById("btn-push-test")?.addEventListener("click", testWecomPush);
loadPeriod("today"); loadPeriod("today");
loadPeriod("yesterday"); loadPeriod("yesterday");
+8
View File
@@ -73,9 +73,16 @@
<div class="panel-actions"> <div class="panel-actions">
<button type="button" class="btn-secondary" id="btn-reload-stats">重新统计</button> <button type="button" class="btn-secondary" id="btn-reload-stats">重新统计</button>
<button type="button" class="btn-secondary" id="btn-export-stats">导出 CSV</button> <button type="button" class="btn-secondary" id="btn-export-stats">导出 CSV</button>
<button type="button" class="btn-secondary" id="btn-push-preview">预览企微推送</button>
<button type="button" class="btn-secondary" id="btn-push-test">测试推送企微</button>
</div> </div>
</div> </div>
<p class="stats-desc" id="stats-desc"></p> <p class="stats-desc" id="stats-desc"></p>
<section class="wecom-preview-panel hidden" id="wecom-preview-panel">
<h3>企微推送预览 <span class="wecom-preview-meta" id="wecom-preview-meta"></span></h3>
<p class="stats-desc">仅包含「三日 Top30 交集」币种;实际发到企微为下方同款列表排版(非宽表格)。</p>
<div id="wecom-preview-cards" class="wecom-preview-cards"></div>
</section>
<div class="table-wrap" id="stats-table-wrap"></div> <div class="table-wrap" id="stats-table-wrap"></div>
</section> </section>
<section class="panel llm-panel"> <section class="panel llm-panel">
@@ -95,6 +102,7 @@
<footer> <footer>
<button type="button" id="btn-refresh">立即刷新今日</button> <button type="button" id="btn-refresh">立即刷新今日</button>
<span id="status"></span> <span id="status"></span>
<span id="push-status" class="push-status"></span>
</footer> </footer>
<script src="/static/charts.js"></script> <script src="/static/charts.js"></script>
+87
View File
@@ -413,6 +413,93 @@ button:hover {
color: var(--muted); color: var(--muted);
} }
.wecom-preview-panel {
margin: 1rem 0 0;
padding: 1rem;
background: #121a26;
border: 1px solid var(--border);
border-radius: 10px;
}
.wecom-preview-panel.hidden {
display: none;
}
.wecom-preview-panel h3 {
margin: 0 0 0.5rem;
font-size: 1rem;
}
.wecom-preview-meta {
font-size: 0.8rem;
color: var(--muted);
font-weight: normal;
}
.wecom-preview-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 0.75rem;
margin-top: 0.75rem;
}
.wecom-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.65rem 0.75rem;
}
.wecom-card-head {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
padding-bottom: 0.4rem;
border-bottom: 1px solid var(--border);
}
.wecom-seq {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.5rem;
height: 1.5rem;
border-radius: 4px;
background: var(--accent);
color: #0d1118;
font-size: 0.75rem;
font-weight: 700;
}
.wecom-symbol {
font-size: 1rem;
}
.wecom-day {
display: grid;
grid-template-columns: 2.5rem 2.5rem 1fr auto auto;
gap: 0.35rem 0.5rem;
align-items: center;
font-size: 0.82rem;
padding: 0.2rem 0;
}
.wecom-day-label {
color: var(--muted);
}
.wecom-fr {
color: var(--muted);
font-size: 0.78rem;
}
.push-status {
margin-left: 1rem;
font-size: 0.85rem;
color: var(--accent);
}
.chart-modal-inner h3 { .chart-modal-inner h3 {
margin: 0 0 0.25rem; margin: 0 0 0.25rem;
font-size: 1.15rem; font-size: 1.15rem;