增加大模型
This commit is contained in:
@@ -13,10 +13,12 @@
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 系统 | Linux(推荐 Ubuntu 22.04+ / Debian 12+) |
|
||||
| 时区 | `Asia/Shanghai`(定时任务 08:00 / 08:10 北京时间) |
|
||||
| 网络 | 能访问 `fapi.binance.com`;若不能,需开启 SOCKS5 代理(见第四节) |
|
||||
| 内存 | 建议 ≥ 512MB |
|
||||
| 磁盘 | ≥ 500MB(含日志与 SQLite) |
|
||||
| 时区 | `Asia/Shanghai`(定时任务 08:00 / 08:05 / 08:10 北京时间) |
|
||||
| 网络 | 能访问 `fapi.binance.com`;大模型需能访问 `LLM_BASE_URL`(默认 `http://op.bz121.com`) |
|
||||
| 代理 | 币安不可直连时开启 SOCKS5(见第六节) |
|
||||
| 内存 | 建议 ≥ **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. 配置环境变量
|
||||
cd /opt/Binance_Altcoin_Monitor
|
||||
cp .env.example .env
|
||||
nano .env # 至少填写 WECOM_WEBHOOK_URL
|
||||
nano .env # 至少填写 WECOM_WEBHOOK_URL;大模型解读需填 LLM_API_KEY
|
||||
```
|
||||
|
||||
`.env` 常用项:
|
||||
@@ -44,12 +46,63 @@ WECOM_WEBHOOK_URL=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=你的key
|
||||
HOST=0.0.0.0
|
||||
PORT=21450
|
||||
|
||||
# 今日每 4 小时自动刷新(另支持 Web 页脚手动刷新)
|
||||
REFRESH_MINUTES=240
|
||||
|
||||
# 代理默认关闭
|
||||
PROXY_ENABLED=false
|
||||
PROXY_URL=socks5h://192.168.8.4:1081
|
||||
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 一键部署(推荐)
|
||||
@@ -93,6 +146,9 @@ docker compose down
|
||||
|
||||
# 手动测试企微推送
|
||||
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
|
||||
@@ -105,10 +161,10 @@ curl -X POST http://127.0.0.1:21450/api/push/test
|
||||
|
||||
## 四、方式 B — PM2 一键部署
|
||||
|
||||
### 4.1 安装依赖
|
||||
### 4.1 安装系统软件
|
||||
|
||||
```bash
|
||||
# Python 3.10+(部署脚本会自动创建 .venv;若无 venv 模块需先装)
|
||||
# Python 3.10+(部署脚本会自动创建 .venv 并把 requirements 装进 venv)
|
||||
sudo apt install -y python3 python3-venv python3-pip
|
||||
|
||||
# Node.js + PM2
|
||||
@@ -117,6 +173,8 @@ sudo apt install -y nodejs
|
||||
sudo npm install -g pm2
|
||||
```
|
||||
|
||||
> **注意**:业务依赖(`fastapi`、`matplotlib` 等)**不要**只执行 `sudo pip install`,应交给脚本或 `.venv/bin/pip`(见 **二点五**)。
|
||||
|
||||
### 4.2 一键部署
|
||||
|
||||
```bash
|
||||
@@ -126,6 +184,8 @@ chmod +x deploy/pm2-deploy.sh
|
||||
./deploy/pm2-deploy.sh
|
||||
```
|
||||
|
||||
脚本将:拉取代码 → **创建/更新 `.venv`** → `pip install -r backend/requirements.txt` → `pm2 start`(解释器为 `.venv/bin/python`)。
|
||||
|
||||
### 4.3 常用命令
|
||||
|
||||
```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 代理访问。
|
||||
|
||||
@@ -204,7 +310,7 @@ curl http://127.0.0.1:21450/api/today/top30
|
||||
|
||||
---
|
||||
|
||||
## 六、防火墙与 Nginx(可选)
|
||||
## 七、防火墙与 Nginx(可选)
|
||||
|
||||
```bash
|
||||
# 开放 21450(若直接对外)
|
||||
@@ -228,35 +334,41 @@ server {
|
||||
|
||||
---
|
||||
|
||||
## 七、定时任务说明
|
||||
## 八、定时任务说明
|
||||
|
||||
| 时间(北京时间) | 行为 |
|
||||
|------------------|------|
|
||||
| 08:00 | 固化昨日周期数据 |
|
||||
| 08:10 | 企业微信推送昨日 Top30 |
|
||||
| 每 5 分钟 | 刷新今日数据(`REFRESH_MINUTES` 可改) |
|
||||
| 08:00 | 固化昨日、前日周期快照 |
|
||||
| 08:05 | 大模型解读三日 Top30 交集(需 `LLM_API_KEY`) |
|
||||
| 08:10 | 企业微信推送 **三日 Top30 交集**(卡片列表,非宽表格) |
|
||||
| 每 4 小时(0/4/8/12/16/20 点) | 刷新今日数据(`REFRESH_MINUTES=240`) |
|
||||
|
||||
Web 今日表 **不会** 每 60 秒自动轮询;除上述定时外,使用页脚 **「立即刷新今日」** 手动更新。
|
||||
|
||||
**进程需常驻**(Docker `restart: unless-stopped` 或 PM2 `autorestart`)。
|
||||
|
||||
---
|
||||
|
||||
## 八、目录与数据
|
||||
## 九、目录与数据
|
||||
|
||||
```
|
||||
/opt/Binance_Altcoin_Monitor/
|
||||
├── .env # 配置(勿提交 git)
|
||||
├── data/monitor.db # SQLite 数据
|
||||
├── .venv/ # Python 虚拟环境(PM2 专用,勿删)
|
||||
├── data/monitor.db # SQLite(周期快照、日 K、资金费率、LLM 解读)
|
||||
├── logs/ # PM2 日志(Docker 用 docker compose logs)
|
||||
├── deploy/ # 一键脚本
|
||||
├── docker-compose.yml
|
||||
└── ecosystem.config.cjs
|
||||
└── ecosystem.config.cjs # PM2 解释器: .venv/bin/python
|
||||
```
|
||||
|
||||
备份建议:定期备份 `data/monitor.db` 与 `.env`。
|
||||
|
||||
---
|
||||
|
||||
## 九、更新版本
|
||||
## 十、更新版本
|
||||
|
||||
拉取代码后,**务必**按部署方式重装 Python 依赖(`requirements.txt` 变更时尤其重要,例如新增 `matplotlib`)。
|
||||
|
||||
**Docker:**
|
||||
|
||||
@@ -267,43 +379,57 @@ docker compose build --no-cache
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**PM2:**
|
||||
**PM2(推荐一键脚本,会自动更新 .venv):**
|
||||
|
||||
```bash
|
||||
cd /opt/Binance_Altcoin_Monitor
|
||||
git pull
|
||||
./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` |
|
||||
| `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 matplotlib`(或其它包) | 依赖未装进 **`.venv`**:执行 `.venv/bin/pip install -r backend/requirements.txt` 后 `pm2 restart`;勿只装系统 Python |
|
||||
| Web 无数据 | 检查能否访问币安;国内服务器尝试 `PROXY_ENABLED=true` |
|
||||
| 大量 `418 I'm a teapot` | IP 被封禁;**不要反复 restart**。日 K 已存 SQLite,图表优先读本地;仅过期或首次才请求币安 |
|
||||
| 企微收不到 | 检查 `WECOM_WEBHOOK_URL`;`curl -X POST .../api/push/test` |
|
||||
| 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` |
|
||||
| Docker 代理连不上 | 确认 `192.168.8.4:1081` 从容器内可达,必要时改宿主机 IP |
|
||||
|
||||
---
|
||||
|
||||
## 十一、快速命令速查
|
||||
## 十二、快速命令速查
|
||||
|
||||
```bash
|
||||
# Docker 一键
|
||||
./deploy/docker-deploy.sh
|
||||
|
||||
# PM2 一键
|
||||
# PM2 一键(含 .venv 依赖安装)
|
||||
./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
|
||||
# PM2: pm2 restart binance-altcoin-monitor
|
||||
```
|
||||
|
||||
@@ -2,43 +2,69 @@
|
||||
|
||||
仓库:[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 计价)
|
||||
- 高亮标记(不改变排名):成交额 ≥ 1000万 USDT、|涨跌幅| ≥ 5%
|
||||
- 昨日周期:`[D-1 08:00, D 08:00)`
|
||||
- 今日周期:`[D 08:00, 当前)`,每 **4 小时**后台刷新 + 页脚手动刷新;K线/周期数据服务端 SQLite + 浏览器缓存
|
||||
- 数据统计:连续三日 Top30 交集(涨跌幅不限)
|
||||
- 大模型解读(`gemma4:e4b`):每日 **08:05** 对三日交集逐币解读(每币 3 分钟),启动自动一轮;需配置 `LLM_API_KEY`
|
||||
- 昨日 / 前日周期:按 08:00 切日固化快照
|
||||
- 今日周期:`[D 08:00, 当前)`,后台每 **4 小时**自动刷新 + 页脚 **手动刷新**
|
||||
- 日 K + 成交量迷你图,点击 **全屏**查看;K 线优先读服务端 SQLite,浏览器 `localStorage` 缓存约 1 小时
|
||||
- 资金费率当前值 + 历史迷你曲线
|
||||
- **数据统计**:连续三日均为成交额 Top30 的 **交集**(涨跌幅 **不限**)
|
||||
- **大模型解读**(`gemma4:e4b`):对三日交集币种生成日 K 图 + 数据简析;每日 **08:05** 自动排队,每币间隔 **3 分钟**;服务启动后自动跑一轮(可关);需在 `.env` 配置 `LLM_API_KEY`
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Python 3.10+
|
||||
- 可访问 `fapi.binance.com`
|
||||
- 企业微信群机器人 Webhook(可选,用于推送)
|
||||
- Python 3.10+(**推荐项目内虚拟环境 `.venv`**,PM2 生产亦使用 `.venv`)
|
||||
- 可访问 `fapi.binance.com`(国内服务器可配 SOCKS5,见 [DEPLOY.md](./DEPLOY.md))
|
||||
- 企业微信群机器人 Webhook(可选,用于 08:10 推送)
|
||||
- 大模型网关(可选):默认 `http://op.bz121.com`,OpenAI 兼容 `/v1/chat/completions`
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
### Windows / 本机开发
|
||||
|
||||
```powershell
|
||||
# 1. 进入项目目录
|
||||
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
|
||||
|
||||
# 3. 配置环境变量
|
||||
# 4. 配置环境变量
|
||||
copy .env.example .env
|
||||
# 编辑 .env,填入 WECOM_WEBHOOK_URL
|
||||
# 编辑 .env:WECOM_WEBHOOK_URL、LLM_API_KEY 等
|
||||
|
||||
# 4. 启动服务(需保持进程常驻)
|
||||
# 5. 启动(需保持进程常驻)
|
||||
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
|
||||
|
||||
> **生产环境**请用 [DEPLOY.md](./DEPLOY.md) 中的 Docker 或 PM2 脚本;PM2 使用 `.venv/bin/python`,更新代码后需执行 `.venv/bin/pip install -r backend/requirements.txt` 再重启。
|
||||
|
||||
## 配置说明(.env)
|
||||
|
||||
| 变量 | 说明 | 默认 |
|
||||
@@ -47,22 +73,39 @@ python run.py
|
||||
| `TOP_N` | 排名数量 | 30 |
|
||||
| `VOLUME_THRESHOLD` | 高亮成交额阈值 (USDT) | 10000000 |
|
||||
| `CHANGE_THRESHOLD` | 高亮涨跌幅阈值 (%) | 5 |
|
||||
| `REFRESH_MINUTES` | 今日数据刷新间隔 | 5 |
|
||||
| `HOST` / `PORT` | 服务监听 | 127.0.0.1:21450 |
|
||||
| `REFRESH_MINUTES` | 今日自动刷新间隔(分钟);`240` = 每 4 小时 | 240 |
|
||||
| `HOST` / `PORT` | 服务监听 | 0.0.0.0:21450 |
|
||||
| `PROXY_ENABLED` | 是否启用 SOCKS5 代理 | false |
|
||||
| `PROXY_URL` | 代理地址 | socks5h://192.168.8.4:1081 |
|
||||
| `PROXY_FOR` | 代理范围 binance/wecom/all | binance |
|
||||
| `MAX_CONCURRENCY` | 币安 K 线并发数(过大易 418 封禁) | 3 |
|
||||
| `CANDIDATE_POOL` | 预筛候选合约数(按 24h 成交额) | 150 |
|
||||
| `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
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/today/top30` | 今日周期 Top30 |
|
||||
| GET | `/api/yesterday/top30` | 昨日周期 Top30 |
|
||||
| GET | `/api/today/top30` | 今日周期 Top30(缓存) |
|
||||
| POST | `/api/push/test` | 手动测试企业微信推送 |
|
||||
| GET | `/api/daybefore/top30` | 前日周期 Top30 |
|
||||
| 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/yesterday` | 重新计算昨日快照 |
|
||||
|
||||
@@ -70,11 +113,21 @@ python run.py
|
||||
|
||||
| 时间 (北京时间) | 任务 |
|
||||
|-----------------|------|
|
||||
| 08:00 | 固化昨日周期数据到 SQLite |
|
||||
| 08:10 | 企业微信推送昨日 Top30 |
|
||||
| 每 N 分钟 | 刷新今日周期(N = REFRESH_MINUTES) |
|
||||
| 08:00 | 固化昨日、前日周期快照到 SQLite |
|
||||
| 08:05 | 大模型解读「三日 Top30 交集」各币种(需 `LLM_API_KEY`) |
|
||||
| 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 常驻运行
|
||||
|
||||
@@ -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%
|
||||
- 今日末价优先使用实时 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/ # 前端静态页
|
||||
├── data/ # SQLite(自动创建)
|
||||
├── data/monitor.db # SQLite(自动创建)
|
||||
├── .venv/ # 虚拟环境(本地/PM2,勿提交)
|
||||
├── deploy/ # 一键部署脚本
|
||||
├── run.py # 启动入口
|
||||
└── .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 .stats import compute_three_day_stats
|
||||
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
|
||||
|
||||
logging.basicConfig(
|
||||
@@ -93,8 +93,17 @@ async def api_stats_three_day():
|
||||
return compute_three_day_stats()
|
||||
|
||||
|
||||
@app.get("/api/push/preview")
|
||||
async def api_push_preview():
|
||||
"""预览企微推送内容(三日交集,列表排版)。"""
|
||||
return build_push_payload()
|
||||
|
||||
|
||||
@app.post("/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")
|
||||
if not snap:
|
||||
start, end = get_yesterday_period()
|
||||
@@ -104,12 +113,15 @@ async def api_push_test():
|
||||
snap = get_latest_snapshot("yesterday")
|
||||
if not snap:
|
||||
raise HTTPException(500, "无法生成昨日数据")
|
||||
content = build_markdown(snap)
|
||||
ok, msg = await send_wecom_markdown(content)
|
||||
ok, msg = await send_wecom_markdown(payload["markdown"])
|
||||
log_push(snap["period_start"], snap["period_end"], ok, msg)
|
||||
if not ok:
|
||||
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")
|
||||
|
||||
@@ -15,7 +15,7 @@ from .funding_store import prefetch_funding
|
||||
from .kline_store import prefetch_symbols
|
||||
from .llm_service import run_interpretation_batch, schedule_interpret_background
|
||||
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__)
|
||||
|
||||
@@ -84,7 +84,11 @@ async def job_finalize_yesterday() -> 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()
|
||||
snapshot = get_latest_snapshot("yesterday")
|
||||
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)
|
||||
return
|
||||
|
||||
content = build_markdown(snapshot)
|
||||
ok, msg = await send_wecom_markdown(content)
|
||||
payload = build_push_payload()
|
||||
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)
|
||||
if ok:
|
||||
logger.info("WeCom push succeeded")
|
||||
|
||||
+89
-27
@@ -1,9 +1,11 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import settings
|
||||
from .http_client import httpx_client_kwargs
|
||||
from .stats import compute_three_day_stats
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -14,36 +16,96 @@ def _format_period_label(period_start: str, period_end: str) -> str:
|
||||
return f"{start} ~ {end}"
|
||||
|
||||
|
||||
def build_markdown(snapshot: dict) -> str:
|
||||
items = snapshot.get("items", [])
|
||||
period_label = _format_period_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}"
|
||||
def _day_line(label: str, row: dict | None) -> str:
|
||||
if not row or row.get("rank") is None:
|
||||
return f"> {label}:—"
|
||||
pct = row.get("price_change_pct_fmt") or f"{row.get('price_change_pct', 0):+.2f}%"
|
||||
fr = row.get("funding_rate_fmt") or "-"
|
||||
lines.append(
|
||||
f"| {row['rank']} | {row['symbol']} | {vol} | {pct} | {fr} | {tag_str} |"
|
||||
vol = row.get("quote_volume_fmt") or str(row.get("quote_volume", ""))
|
||||
fr = row.get("funding_rate_fmt") or "—"
|
||||
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("> 标记说明:千万+ = 成交额≥1000万 USDT;涨跌5%+ = |涨跌幅|≥5%")
|
||||
return "\n".join(lines)
|
||||
preview_items.append(
|
||||
{
|
||||
"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]:
|
||||
|
||||
+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() {
|
||||
document.getElementById("stats-table-wrap").innerHTML =
|
||||
'<p class="loading">统计中…</p>';
|
||||
@@ -431,6 +515,7 @@ async function loadStats() {
|
||||
await loadLlmInterpretations();
|
||||
renderStatsTable();
|
||||
await refreshLlmStatus();
|
||||
await loadWecomPreview();
|
||||
} catch (e) {
|
||||
document.getElementById("stats-table-wrap").innerHTML = `<p class="error">${e.message}</p>`;
|
||||
}
|
||||
@@ -513,6 +598,8 @@ document.getElementById("btn-reload-stats")?.addEventListener("click", () => {
|
||||
loadStats();
|
||||
});
|
||||
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("yesterday");
|
||||
|
||||
@@ -73,9 +73,16 @@
|
||||
<div class="panel-actions">
|
||||
<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-push-preview">预览企微推送</button>
|
||||
<button type="button" class="btn-secondary" id="btn-push-test">测试推送企微</button>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</section>
|
||||
<section class="panel llm-panel">
|
||||
@@ -95,6 +102,7 @@
|
||||
<footer>
|
||||
<button type="button" id="btn-refresh">立即刷新今日</button>
|
||||
<span id="status"></span>
|
||||
<span id="push-status" class="push-status"></span>
|
||||
</footer>
|
||||
|
||||
<script src="/static/charts.js"></script>
|
||||
|
||||
@@ -413,6 +413,93 @@ button:hover {
|
||||
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 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1.15rem;
|
||||
|
||||
Reference in New Issue
Block a user