增加大模型
This commit is contained in:
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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)**
|
|
||||||
## 功能
|
## 功能
|
||||||
|
|
||||||
- 成交额排名 Top30(USDT 计价)
|
- 成交额排名 Top30(USDT 计价)
|
||||||
- 高亮标记(不改变排名):成交额 ≥ 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
|
# 编辑 .env:WECOM_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 JSON(SQLite 优先) |
|
||||||
|
| 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`) |
|
||||||
|
|
||||||
|
**不要**只装到系统 Python:PM2 的 `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
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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");
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user