去掉大模型

This commit is contained in:
dekun
2026-05-26 10:20:45 +08:00
parent 1845018151
commit b91721d315
13 changed files with 39 additions and 962 deletions
-9
View File
@@ -25,12 +25,3 @@ CHART_KLINE_LIMIT=300
CHART_CACHE_MINUTES=60 CHART_CACHE_MINUTES=60
FUNDING_HISTORY_LIMIT=90 FUNDING_HISTORY_LIMIT=90
FUNDING_CACHE_MINUTES=30 FUNDING_CACHE_MINUTES=30
# 大模型解读(OpenAI 兼容,网关 http://op.bz121.com
LLM_BASE_URL=http://op.bz121.com
LLM_API_KEY=sk-your-key-here
LLM_MODEL=gemma4:e4b
# 每个币种间隔秒数(默认 180 = 3 分钟)
LLM_SYMBOL_INTERVAL_SEC=180
# 服务启动后若已配置 KEY,自动对三日交集解读一轮
LLM_AUTO_ON_STARTUP=true
+19 -77
View File
@@ -13,10 +13,10 @@
| 项目 | 说明 | | 项目 | 说明 |
|------|------| |------|------|
| 系统 | Linux(推荐 Ubuntu 22.04+ / Debian 12+ | | 系统 | Linux(推荐 Ubuntu 22.04+ / Debian 12+ |
| 时区 | `Asia/Shanghai`(定时任务 08:00 / 08:05 / 08:10 北京时间) | | 时区 | `Asia/Shanghai`(定时任务 08:00 / 08:10 北京时间) |
| 网络 | 能访问 `fapi.binance.com`;大模型需能访问 `LLM_BASE_URL`(默认 `http://op.bz121.com` | | 网络 | 能访问 `fapi.binance.com` |
| 代理 | 币安不可直连时开启 SOCKS5(见第节) | | 代理 | 币安不可直连时开启 SOCKS5(见第节) |
| 内存 | 建议 ≥ **1GB**(含 `matplotlib` 生成日 K 图供大模型) | | 内存 | 建议 ≥ 512MB |
| 磁盘 | ≥ 500MB(含日志、SQLite、日 K 缓存) | | 磁盘 | ≥ 500MB(含日志、SQLite、日 K 缓存) |
| Python | 3.10+**PM2 部署必须使用项目内 `.venv`** 安装依赖 | | Python | 3.10+**PM2 部署必须使用项目内 `.venv`** 安装依赖 |
@@ -36,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;大模型解读需填 LLM_API_KEY nano .env # 至少填写 WECOM_WEBHOOK_URL
``` ```
`.env` 常用项: `.env` 常用项:
@@ -53,13 +53,6 @@ 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)。 完整变量说明见仓库 [`.env.example`](./.env.example) 与 [README.md](./README.md)。
@@ -68,7 +61,7 @@ LLM_AUTO_ON_STARTUP=true
## 二点五、Python 依赖与虚拟环境(重要) ## 二点五、Python 依赖与虚拟环境(重要)
本项目 **所有 Python 依赖** 均来自 `backend/requirements.txt`(含 `matplotlib`,用于服务端渲染日 K PNG 并喂给大模型) 本项目 **所有 Python 依赖** 均来自 `backend/requirements.txt`
| 部署方式 | 依赖安装位置 | 说明 | | 部署方式 | 依赖安装位置 | 说明 |
|----------|--------------|------| |----------|--------------|------|
@@ -94,10 +87,10 @@ pm2 restart binance-altcoin-monitor
### 验证依赖是否装在 venv 内 ### 验证依赖是否装在 venv 内
```bash ```bash
.venv/bin/python -c "import matplotlib; import fastapi; print('ok')" .venv/bin/python -c "import fastapi; print('ok')"
``` ```
若报 `No module named matplotlib`,说明未在 **`.venv`** 中安装,请执行上节 `pip install` 后重启 PM2。 若报 `No module named ...`,说明未在 **`.venv`** 中安装,请执行上节 `pip install` 后重启 PM2。
### Docker:更新依赖 ### Docker:更新依赖
@@ -147,8 +140,6 @@ 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
@@ -173,7 +164,7 @@ sudo apt install -y nodejs
sudo npm install -g pm2 sudo npm install -g pm2
``` ```
> **注意**:业务依赖(`fastapi`、`matplotlib` 等)**不要**只执行 `sudo pip install`,应交给脚本或 `.venv/bin/pip`(见 **二点五**)。 > **注意**:业务依赖(`fastapi`、`httpx` 等)**不要**只执行 `sudo pip install`,应交给脚本或 `.venv/bin/pip`(见 **二点五**)。
### 4.2 一键部署 ### 4.2 一键部署
@@ -200,53 +191,7 @@ 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 代理访问。
@@ -310,7 +255,7 @@ curl http://127.0.0.1:21450/api/today/top30
--- ---
## 、防火墙与 Nginx(可选) ## 、防火墙与 Nginx(可选)
```bash ```bash
# 开放 21450(若直接对外) # 开放 21450(若直接对外)
@@ -334,12 +279,11 @@ server {
--- ---
## 、定时任务说明 ## 、定时任务说明
| 时间(北京时间) | 行为 | | 时间(北京时间) | 行为 |
|------------------|------| |------------------|------|
| 08:00 | 固化昨日、前日周期快照 | | 08:00 | 固化昨日、前日周期快照 |
| 08:05 | 大模型解读三日 Top30 交集(需 `LLM_API_KEY` |
| 08:10 | 企业微信推送 **三日 Top30 交集**(卡片列表,非宽表格) | | 08:10 | 企业微信推送 **三日 Top30 交集**(卡片列表,非宽表格) |
| 每 4 小时(0/4/8/12/16/20 点) | 刷新今日数据(`REFRESH_MINUTES=240` | | 每 4 小时(0/4/8/12/16/20 点) | 刷新今日数据(`REFRESH_MINUTES=240` |
@@ -349,13 +293,13 @@ Web 今日表 **不会** 每 60 秒自动轮询;除上述定时外,使用页
--- ---
## 、目录与数据 ## 、目录与数据
``` ```
/opt/Binance_Altcoin_Monitor/ /opt/Binance_Altcoin_Monitor/
├── .env # 配置(勿提交 git) ├── .env # 配置(勿提交 git)
├── .venv/ # Python 虚拟环境(PM2 专用,勿删) ├── .venv/ # Python 虚拟环境(PM2 专用,勿删)
├── data/monitor.db # SQLite(周期快照、日 K、资金费率、LLM 解读 ├── data/monitor.db # SQLite(周期快照、日 K、资金费率)
├── logs/ # PM2 日志(Docker 用 docker compose logs ├── logs/ # PM2 日志(Docker 用 docker compose logs
├── deploy/ # 一键脚本 ├── deploy/ # 一键脚本
├── docker-compose.yml ├── docker-compose.yml
@@ -366,9 +310,9 @@ Web 今日表 **不会** 每 60 秒自动轮询;除上述定时外,使用页
--- ---
## 、更新版本 ## 、更新版本
拉取代码后,**务必**按部署方式重装 Python 依赖(`requirements.txt` 变更时尤其重要,例如新增 `matplotlib`)。 拉取代码后,**务必**按部署方式重装 Python 依赖(`requirements.txt` 变更时)。
**Docker** **Docker**
@@ -398,26 +342,24 @@ 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 | | `No module named ...` | 依赖未装进 **`.venv`**:执行 `.venv/bin/pip install -r backend/requirements.txt``pm2 restart` |
| 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 一键
+15 -48
View File
@@ -4,7 +4,7 @@
**北京时间 08:00** 切日,统计 U 本位永续合约成交额 Top30;每日 **08:10** 企业微信仅推送 **三日 Top30 交集** 币种(列表排版,非宽表格);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)**
## 功能 ## 功能
@@ -15,36 +15,25 @@
- 日 K + 成交量迷你图,点击 **全屏**查看;K 线优先读服务端 SQLite,浏览器 `localStorage` 缓存约 1 小时 - 日 K + 成交量迷你图,点击 **全屏**查看;K 线优先读服务端 SQLite,浏览器 `localStorage` 缓存约 1 小时
- 资金费率当前值 + 历史迷你曲线 - 资金费率当前值 + 历史迷你曲线
- **数据统计**:连续三日均为成交额 Top30 的 **交集**(涨跌幅 **不限** - **数据统计**:连续三日均为成交额 Top30 的 **交集**(涨跌幅 **不限**
- **大模型解读**`gemma4:e4b`):对三日交集币种生成日 K 图 + 数据简析;每日 **08:05** 自动排队,每币间隔 **3 分钟**;服务启动后自动跑一轮(可关);需在 `.env` 配置 `LLM_API_KEY`
## 环境要求 ## 环境要求
- Python 3.10+**推荐项目内虚拟环境 `.venv`**PM2 生产亦使用 `.venv` - Python 3.10+**推荐项目内虚拟环境 `.venv`**PM2 生产亦使用 `.venv`
- 可访问 `fapi.binance.com`(国内服务器可配 SOCKS5,见 [DEPLOY.md](./DEPLOY.md) - 可访问 `fapi.binance.com`(国内服务器可配 SOCKS5,见 [DEPLOY.md](./DEPLOY.md)
- 企业微信群机器人 Webhook(可选,用于 08:10 推送) - 企业微信群机器人 Webhook(可选,用于 08:10 推送)
- 大模型网关(可选):默认 `http://op.bz121.com`OpenAI 兼容 `/v1/chat/completions`
## 快速开始 ## 快速开始
### Windows / 本机开发 ### Windows / 本机开发
```powershell ```powershell
# 1. 进入项目目录
cd 币安排名 cd 币安排名
# 2. 创建并激活虚拟环境(依赖必须装进 venv,勿只装系统 Python
python -m venv .venv python -m venv .venv
.\.venv\Scripts\Activate.ps1 .\.venv\Scripts\Activate.ps1
# 3. 安装依赖(含 matplotlib,用于服务端生成日 K 图供大模型)
pip install -U pip pip install -U pip
pip install -r backend/requirements.txt pip install -r backend/requirements.txt
# 4. 配置环境变量
copy .env.example .env copy .env.example .env
# 编辑 .envWECOM_WEBHOOK_URL、LLM_API_KEY # 编辑 .envWECOM_WEBHOOK_URL 等
# 5. 启动(需保持进程常驻)
python run.py python run.py
``` ```
@@ -63,7 +52,7 @@ nano .env
浏览器打开: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` 再重启。 > **生产环境**请用 [DEPLOY.md](./DEPLOY.md) 中的 Docker 或 PM2 脚本;PM2 使用 `.venv/bin/python`,更新代码后执行 `.venv/bin/pip install -r backend/requirements.txt` 再重启。
## 配置说明(.env ## 配置说明(.env
@@ -82,13 +71,8 @@ nano .env
| `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)。 完整示例见 [`.env.example`](./.env.example)。
## API ## API
@@ -99,11 +83,7 @@ nano .env
| GET | `/api/daybefore/top30` | 前日周期 Top30 | | GET | `/api/daybefore/top30` | 前日周期 Top30 |
| GET | `/api/stats/three-day` | 三日 Top30 交集统计 | | GET | `/api/stats/three-day` | 三日 Top30 交集统计 |
| GET | `/api/chart/{symbol}/daily` | 日 K JSONSQLite 优先) | | GET | `/api/chart/{symbol}/daily` | 日 K JSONSQLite 优先) |
| GET | `/api/chart/{symbol}/daily.png` | 日 K PNG(大模型/预览) |
| GET | `/api/funding/{symbol}/history` | 资金费率历史 | | GET | `/api/funding/{symbol}/history` | 资金费率历史 |
| GET | `/api/llm/status` | 解读任务状态 |
| GET | `/api/llm/interpretations` | 最近一批解读结果 |
| POST | `/api/llm/interpret/run` | 手动启动三日交集解读队列 |
| GET | `/api/push/preview` | 预览企微推送(三日交集) | | GET | `/api/push/preview` | 预览企微推送(三日交集) |
| POST | `/api/push/test` | 手动测试企业微信推送(仅交集币种) | | POST | `/api/push/test` | 手动测试企业微信推送(仅交集币种) |
| POST | `/api/refresh/today` | 立即刷新今日数据 | | POST | `/api/refresh/today` | 立即刷新今日数据 |
@@ -114,31 +94,25 @@ nano .env
| 时间 (北京时间) | 任务 | | 时间 (北京时间) | 任务 |
|-----------------|------| |-----------------|------|
| 08:00 | 固化昨日、前日周期快照到 SQLite | | 08:00 | 固化昨日、前日周期快照到 SQLite |
| 08:05 | 大模型解读「三日 Top30 交集」各币种(需 `LLM_API_KEY` |
| 08:10 | 企业微信推送三日 Top30 交集(列表排版) | | 08:10 | 企业微信推送三日 Top30 交集(列表排版) |
| 每 4 小时(整点 0/4/8/12/16/20 | 刷新今日周期(由 `REFRESH_MINUTES=240` 控制) | | 每 4 小时(整点 0/4/8/12/16/20 | 刷新今日周期(由 `REFRESH_MINUTES=240` 控制) |
进程重启后:若已过 08:10 且当日尚未推送成功,会自动补推;若已配置 `LLM_API_KEY``LLM_AUTO_ON_STARTUP=true`,会在后台自动启动一轮解读 进程重启后:若已过 08:10 且当日尚未推送成功,会自动补推。
## Web 界面 ## Web 界面
| 页签 | 说明 | | 页签 | 说明 |
|------|------| |------|------|
| 今日 / 昨日 / 前日 | Top30 表、日 K、资金费率;支持排序与 CSV 导出 | | 今日 / 昨日 / 前日 | Top30 表、日 K、资金费率;支持排序与 CSV 导出 |
| 数据统计 | 三日交集列表 + 大模型解读区;可「开始解读」「刷新解读」 | | 数据统计 | 三日交集列表;可预览 / 测试企微推送 |
今日数据 **不会** 在浏览器里每 60 秒轮询;请依赖 4 小时后台任务或页脚 **「立即刷新今日」**。 今日数据 **不会** 在浏览器里每 60 秒轮询;请依赖 4 小时后台任务或页脚 **「立即刷新今日」**。
## Windows 常驻运行
1. **任务计划程序**:触发器「登录时」或「计算机启动时」,操作运行 `pythonw.exe` 完整路径的 `run.py`,起始于项目目录。
2. 或使用云服务器 / VPS 用 `nssm`、pm2 等托管。
## 企业微信配置 ## 企业微信配置
1. 企业微信群 → 群设置 → 群机器人 → 添加 1. 企业微信群 → 群设置 → 群机器人 → 添加
2. 复制 Webhook 地址到 `.env``WECOM_WEBHOOK_URL` 2. 复制 Webhook 地址到 `.env``WECOM_WEBHOOK_URL`
3. 启动后访问 `POST http://127.0.0.1:21450/api/push/test` 测试(可用 Postman 或 curl 3. 启动后访问 `POST http://127.0.0.1:21450/api/push/test` 测试
```bash ```bash
curl -X POST http://127.0.0.1:21450/api/push/test curl -X POST http://127.0.0.1:21450/api/push/test
@@ -153,25 +127,18 @@ curl -X POST http://127.0.0.1:21450/api/push/test
## 依赖说明 ## 依赖说明
所有 Python 包(含 `matplotlib``fastapi``httpx` 等)写在 [`backend/requirements.txt`](./backend/requirements.txt) Python 包 [`backend/requirements.txt`](./backend/requirements.txt)。PM2 使用 `.venv/bin/python`,依赖须装进 **`.venv`**,勿只装系统 Python。
| 部署方式 | 安装位置 |
|----------|----------|
| 本机 / 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/ # 后端逻辑(含 llm_service、chart_image ├── backend/app/
├── backend/requirements.txt ├── backend/requirements.txt
├── web/ # 前端静态页 ├── web/
├── data/monitor.db # SQLite(自动创建) ├── data/monitor.db
├── .venv/ # 虚拟环境(本地/PM2,勿提交) ├── .venv/
├── deploy/ # 一键部署脚本 ├── deploy/
├── run.py # 启动入口 ├── run.py
└── .env # 本地配置(勿提交) └── .env
``` ```
-66
View File
@@ -1,66 +0,0 @@
"""服务端生成日K+成交量 PNG,供大模型视觉解读。"""
import io
from datetime import datetime
from .kline_store import get_daily_candles
async def render_daily_chart_png_async(symbol: str, limit: int = 300) -> bytes:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
candles, _ = await get_daily_candles(symbol, limit)
if not candles:
raise ValueError(f"no klines for {symbol}")
times = [datetime.fromtimestamp(c["time"] / 1000) for c in candles]
opens = [c["open"] for c in candles]
highs = [c["high"] for c in candles]
lows = [c["low"] for c in candles]
closes = [c["close"] for c in candles]
vols = [c.get("quote_volume") or c.get("volume") or 0 for c in candles]
fig, (ax1, ax2) = plt.subplots(
2, 1, figsize=(12, 7), gridspec_kw={"height_ratios": [3, 1]}, facecolor="#0d1118"
)
fig.subplots_adjust(hspace=0.08)
for i in range(len(candles)):
t = times[i]
o, h, l, cl = opens[i], highs[i], lows[i], closes[i]
color = "#0ecb81" if cl >= o else "#f6465d"
ax1.plot([t, t], [l, h], color=color, linewidth=0.8)
ax1.add_patch(
plt.Rectangle(
(mdates.date2num(t) - 0.3, min(o, cl)),
0.6,
abs(cl - o) or 0.001,
facecolor=color,
edgecolor=color,
)
)
ax1.set_facecolor("#0d1118")
ax1.tick_params(colors="#8b9cb3")
ax1.set_title(f"{symbol} 日K + 成交量", color="#e7ecf3", fontsize=14)
ax1.grid(True, alpha=0.2)
colors_vol = ["#0ecb81" if closes[i] >= opens[i] else "#f6465d" for i in range(len(candles))]
ax2.bar(times, vols, color=colors_vol, alpha=0.7, width=0.8)
ax2.set_facecolor("#0d1118")
ax2.tick_params(colors="#8b9cb3")
ax2.set_ylabel("成交额", color="#8b9cb3")
ax2.grid(True, alpha=0.2)
for ax in (ax1, ax2):
ax.xaxis.set_major_formatter(mdates.DateFormatter("%m-%d"))
fig.autofmt_xdate()
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=120, facecolor="#0d1118")
plt.close(fig)
buf.seek(0)
return buf.read()
-5
View File
@@ -37,11 +37,6 @@ class Settings(BaseSettings):
proxy_enabled: bool = False proxy_enabled: bool = False
proxy_url: str = "socks5h://192.168.8.4:1081" proxy_url: str = "socks5h://192.168.8.4:1081"
proxy_for: str = "binance" # binance | wecom | all proxy_for: str = "binance" # binance | wecom | all
llm_base_url: str = "http://op.bz121.com"
llm_api_key: str = ""
llm_model: str = "gemma4:e4b"
llm_symbol_interval_sec: int = 180
llm_auto_on_startup: bool = True
settings = Settings() settings = Settings()
-91
View File
@@ -97,16 +97,6 @@ def init_db() -> None:
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS llm_interpretations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
symbol TEXT NOT NULL,
batch_id TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_llm_symbol_batch
ON llm_interpretations(symbol, batch_id);
""" """
) )
@@ -364,87 +354,6 @@ def save_funding_current_bulk(data: dict[str, dict[str, Any]]) -> None:
) )
def save_llm_interpretation(symbol: str, batch_id: str, content: str) -> None:
with get_conn() as conn:
conn.execute(
"""
INSERT INTO llm_interpretations (symbol, batch_id, content, created_at)
VALUES (?, ?, ?, ?)
""",
(symbol.upper(), batch_id, content, datetime.now().isoformat()),
)
def get_llm_interpretations(batch_id: str | None = None, limit: int = 50) -> list[dict[str, Any]]:
with get_conn() as conn:
if batch_id:
rows = conn.execute(
"""
SELECT symbol, batch_id, content, created_at
FROM llm_interpretations
WHERE batch_id = ?
ORDER BY id DESC
LIMIT ?
""",
(batch_id, limit),
).fetchall()
else:
rows = conn.execute(
"""
SELECT symbol, batch_id, content, created_at
FROM llm_interpretations
WHERE batch_id = (
SELECT batch_id FROM llm_interpretations ORDER BY id DESC LIMIT 1
)
ORDER BY id ASC
LIMIT ?
""",
(limit,),
).fetchall()
return [
{
"symbol": r["symbol"],
"batch_id": r["batch_id"],
"content": r["content"],
"created_at": r["created_at"],
}
for r in rows
]
def get_llm_interpretation(symbol: str, batch_id: str | None = None) -> dict[str, Any] | None:
sym = symbol.upper()
with get_conn() as conn:
if batch_id:
row = conn.execute(
"""
SELECT symbol, batch_id, content, created_at
FROM llm_interpretations
WHERE symbol = ? AND batch_id = ?
ORDER BY id DESC LIMIT 1
""",
(sym, batch_id),
).fetchone()
else:
row = conn.execute(
"""
SELECT symbol, batch_id, content, created_at
FROM llm_interpretations
WHERE symbol = ?
ORDER BY id DESC LIMIT 1
""",
(sym,),
).fetchone()
if not row:
return None
return {
"symbol": row["symbol"],
"batch_id": row["batch_id"],
"content": row["content"],
"created_at": row["created_at"],
}
def was_pushed_today(period_start: str, period_end: str) -> bool: def was_pushed_today(period_start: str, period_end: str) -> bool:
with get_conn() as conn: with get_conn() as conn:
row = conn.execute( row = conn.execute(
-206
View File
@@ -1,206 +0,0 @@
"""大模型解读(OpenAI 兼容接口 + 图表图片)。"""
import asyncio
import base64
import logging
from datetime import datetime
import httpx
from .chart_image import render_daily_chart_png_async
from .config import settings
from .db import save_llm_interpretation
from .stats import compute_three_day_stats
logger = logging.getLogger(__name__)
_interpret_lock = asyncio.Lock()
_interpret_state: dict = {
"running": False,
"current_symbol": "",
"done": 0,
"total": 0,
"batch_id": "",
"last_error": "",
}
def get_interpret_state() -> dict:
return dict(_interpret_state)
def init_interpret_batch() -> dict:
"""同步初始化批次(API 立即返回 batch_id,避免前端刷新拉错旧批次)。"""
if _interpret_lock.locked() or _interpret_state.get("running"):
return {"ok": False, "message": "解读任务进行中", **get_interpret_state()}
stats = compute_three_day_stats()
if not stats.get("ok"):
return {"ok": False, "message": stats.get("message", "统计数据未就绪")}
sym_list = stats.get("symbols") or [x["symbol"] for x in stats.get("items", [])]
if not sym_list:
return {"ok": False, "message": "三日交集为空"}
bid = datetime.now().strftime("%Y-%m-%d-%H%M")
_interpret_state.update(
{
"running": True,
"current_symbol": "",
"done": 0,
"total": len(sym_list),
"batch_id": bid,
"last_error": "",
}
)
return {"ok": True, "batch_id": bid, "total": len(sym_list), **get_interpret_state()}
def _api_url() -> str:
base = settings.llm_base_url.rstrip("/")
if base.endswith("/v1"):
return f"{base}/chat/completions"
return f"{base}/v1/chat/completions"
def _build_prompt(symbol: str, stats_row: dict | None) -> str:
lines = [
f"你是加密货币合约分析师。请根据附图({symbol} 近300日K+成交量)及数据给出中文简析。",
"关注:趋势、关键支撑阻力、成交量变化、资金费率含义、未来1-3日可能节奏。",
"控制在 200-350 字,条理清晰,不要废话。",
]
if stats_row:
t, y, b = stats_row.get("today", {}), stats_row.get("yesterday", {}), stats_row.get("daybefore", {})
lines.append(
f"\n三日均为成交额Top30交集:"
f"\n今日 排名{t.get('rank')} 涨跌{t.get('price_change_pct_fmt')}{t.get('quote_volume_fmt')}"
f"\n昨日 排名{y.get('rank')} 涨跌{y.get('price_change_pct_fmt')}{y.get('quote_volume_fmt')}"
f"\n前日 排名{b.get('rank')} 涨跌{b.get('price_change_pct_fmt')}{b.get('quote_volume_fmt')}"
f"\n资金费率(当前){t.get('funding_rate_fmt', '')}"
)
return "\n".join(lines)
async def interpret_symbol(
symbol: str,
stats_row: dict | None = None,
batch_id: str | None = None,
) -> str:
if not settings.llm_api_key.strip():
raise RuntimeError("LLM_API_KEY 未配置")
png = await render_daily_chart_png_async(symbol, settings.chart_kline_limit)
b64 = base64.standard_b64encode(png).decode("ascii")
prompt = _build_prompt(symbol, stats_row)
payload = {
"model": settings.llm_model,
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{b64}"},
},
],
}
],
"max_tokens": 800,
"temperature": 0.4,
}
headers = {
"Authorization": f"Bearer {settings.llm_api_key}",
"Content-Type": "application/json",
}
async with httpx.AsyncClient(timeout=120.0) as client:
resp = await client.post(_api_url(), json=payload, headers=headers)
if resp.status_code >= 400:
# 部分模型不支持 vision,降级纯文本
logger.warning("LLM vision failed %s, fallback text", resp.status_code)
payload["messages"] = [{"role": "user", "content": prompt + "\n(附图日K+成交量未能传入,请基于数据简析)"}]
resp = await client.post(_api_url(), json=payload, headers=headers)
resp.raise_for_status()
data = resp.json()
content = data["choices"][0]["message"]["content"]
bid = batch_id or datetime.now().strftime("%Y-%m-%d-%H%M")
save_llm_interpretation(symbol, bid, content)
return content
async def run_interpretation_batch(
symbols: list[str] | None = None,
*,
batch_id: str | None = None,
) -> dict:
if _interpret_lock.locked():
return {"ok": False, "message": "解读任务进行中"}
stats = compute_three_day_stats()
if not stats.get("ok"):
_interpret_state["running"] = False
return {"ok": False, "message": stats.get("message", "统计数据未就绪")}
sym_list = symbols or stats.get("symbols") or [x["symbol"] for x in stats.get("items", [])]
if not sym_list:
_interpret_state["running"] = False
return {"ok": False, "message": "三日交集为空"}
stats_map = {x["symbol"]: x for x in stats.get("items", [])}
bid = batch_id or _interpret_state.get("batch_id") or datetime.now().strftime("%Y-%m-%d-%H%M")
interval = settings.llm_symbol_interval_sec
async with _interpret_lock:
_interpret_state.update(
{
"running": True,
"current_symbol": "",
"done": _interpret_state.get("done", 0),
"total": len(sym_list),
"batch_id": bid,
}
)
for i, sym in enumerate(sym_list):
_interpret_state["current_symbol"] = sym
try:
await interpret_symbol(sym, stats_map.get(sym), bid)
logger.info("LLM interpreted %s (%d/%d)", sym, i + 1, len(sym_list))
except Exception as e:
_interpret_state["last_error"] = str(e)
logger.error("LLM %s failed: %s", sym, e)
save_llm_interpretation(sym, bid, f"[解读失败] {e}")
_interpret_state["done"] = i + 1
if i < len(sym_list) - 1:
await asyncio.sleep(interval)
_interpret_state["running"] = False
_interpret_state["current_symbol"] = ""
return {
"ok": True,
"batch_id": bid,
"count": len(sym_list),
"interval_sec": interval,
}
def schedule_interpret_background(symbols: list[str] | None = None) -> None:
"""后台启动解读,不阻塞请求。"""
info = init_interpret_batch()
if not info.get("ok"):
logger.info("Startup LLM skip: %s", info.get("message"))
return
bid = info.get("batch_id")
async def _run():
try:
await run_interpretation_batch(symbols, batch_id=bid)
except Exception as e:
logger.error("Background LLM batch failed: %s", e)
_interpret_state["running"] = False
asyncio.create_task(_run())
+3 -67
View File
@@ -2,19 +2,17 @@ import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from fastapi import BackgroundTasks, FastAPI, HTTPException from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, Response from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from .config import ROOT_DIR, settings from .config import ROOT_DIR, settings
from .funding_store import get_funding_bundle from .funding_store import get_funding_bundle
from .kline_store import get_daily_candles, sync_daily_klines from .kline_store import get_daily_candles, sync_daily_klines
from .db import get_latest_snapshot, get_llm_interpretations, init_db, log_push, save_snapshot from .db import get_latest_snapshot, init_db, log_push, save_snapshot
from .exceptions import BinanceRateLimitedError from .exceptions import BinanceRateLimitedError
from .period_api import get_period_top30 from .period_api import get_period_top30
from .periods import get_daybefore_period, get_today_period, get_yesterday_period from .periods import get_daybefore_period, get_today_period, get_yesterday_period
from .chart_image import render_daily_chart_png_async
from .llm_service import get_interpret_state, init_interpret_batch, 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
@@ -179,68 +177,6 @@ async def api_funding_history(symbol: str, limit: int | None = None, refresh: bo
raise HTTPException(502, "资金费率获取失败") from e raise HTTPException(502, "资金费率获取失败") from e
@app.get("/api/chart/{symbol}/daily.png")
async def api_chart_daily_png(symbol: str, limit: int | None = None):
sym = symbol.upper().strip()
if not sym.endswith("USDT"):
raise HTTPException(400, "invalid symbol")
try:
png = await render_daily_chart_png_async(sym, limit or settings.chart_kline_limit)
return Response(content=png, media_type="image/png")
except ValueError as e:
raise HTTPException(404, str(e)) from e
except Exception as e:
logger.error("chart png %s failed: %s", sym, e)
raise HTTPException(502, "图表生成失败") from e
@app.get("/api/llm/status")
async def api_llm_status():
state = get_interpret_state()
return {
**state,
"enabled": bool(settings.llm_api_key.strip()),
"model": settings.llm_model,
"base_url": settings.llm_base_url,
"interval_sec": settings.llm_symbol_interval_sec,
}
@app.get("/api/llm/interpretations")
async def api_llm_interpretations(batch_id: str | None = None, limit: int = 100):
"""返回解读列表;进行中时优先当前批次(即使尚无记录)。"""
st = get_interpret_state()
bid = batch_id or (st.get("batch_id") if st.get("running") else None)
items = get_llm_interpretations(bid, limit) if bid else get_llm_interpretations(None, limit)
if not bid and items:
bid = items[0].get("batch_id", "")
return {
"items": items,
"batch_id": bid or st.get("batch_id", ""),
"running": st.get("running", False),
"done": st.get("done", 0),
"total": st.get("total", 0),
"current_symbol": st.get("current_symbol", ""),
}
@app.post("/api/llm/interpret/run")
async def api_llm_interpret_run(background_tasks: BackgroundTasks):
if not settings.llm_api_key.strip():
raise HTTPException(400, "LLM_API_KEY 未配置")
info = init_interpret_batch()
if not info.get("ok"):
return info
bid = info.get("batch_id")
background_tasks.add_task(run_interpretation_batch, batch_id=bid)
return {
"ok": True,
"message": "已启动三日交集解读队列",
"batch_id": bid,
**get_interpret_state(),
}
@app.post("/api/chart/{symbol}/daily/refresh") @app.post("/api/chart/{symbol}/daily/refresh")
async def api_chart_daily_refresh(symbol: str, limit: int | None = None): async def api_chart_daily_refresh(symbol: str, limit: int | None = None):
"""强制从币安同步日 K 到本地库。""" """强制从币安同步日 K 到本地库。"""
+1 -32
View File
@@ -13,8 +13,6 @@ from .periods import get_daybefore_period, get_today_period, get_yesterday_perio
from .state import get_today_cache, set_today_cache from .state import get_today_cache, set_today_cache
from .funding_store import prefetch_funding 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 .stats import compute_three_day_stats
from .wecom import build_push_payload, send_wecom_markdown from .wecom import build_push_payload, send_wecom_markdown
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -156,18 +154,6 @@ async def job_refresh_today() -> None:
_restore_today_from_db() _restore_today_from_db()
async def job_llm_interpret() -> None:
"""08:05 对三日交集币种逐个大模型解读(每币间隔 3 分钟)。"""
logger.info("Job: LLM interpret three-day intersection")
if not settings.llm_api_key.strip():
logger.info("LLM_API_KEY not set, skip")
return
try:
await run_interpretation_batch()
except Exception as e:
logger.error("LLM job failed: %s", e)
async def startup_tasks() -> None: async def startup_tasks() -> None:
init_db() init_db()
now = now_shanghai() now = now_shanghai()
@@ -215,13 +201,6 @@ async def startup_tasks() -> None:
except Exception as e: except Exception as e:
logger.error("Startup catch-up push failed: %s", e) logger.error("Startup catch-up push failed: %s", e)
if settings.llm_api_key.strip() and settings.llm_auto_on_startup:
stats = compute_three_day_stats()
if stats.get("ok") and stats.get("symbols"):
logger.info("Startup: schedule one LLM interpret batch")
schedule_interpret_background()
def start_scheduler() -> None: def start_scheduler() -> None:
scheduler.add_job( scheduler.add_job(
job_finalize_yesterday, job_finalize_yesterday,
@@ -242,19 +221,9 @@ def start_scheduler() -> None:
id="refresh_today", id="refresh_today",
replace_existing=True, replace_existing=True,
) )
scheduler.add_job(
job_llm_interpret,
CronTrigger(hour=8, minute=5, timezone="Asia/Shanghai"),
id="llm_interpret",
replace_existing=True,
)
if not scheduler.running: if not scheduler.running:
scheduler.start() scheduler.start()
logger.info( logger.info("Scheduler started (today every %dh)", refresh_hours)
"Scheduler started (today every %dh, LLM 08:05, interval %ds)",
refresh_hours,
settings.llm_symbol_interval_sec,
)
def stop_scheduler() -> None: def stop_scheduler() -> None:
-1
View File
@@ -4,4 +4,3 @@ httpx[socks]>=0.27.0
apscheduler>=3.10.4 apscheduler>=3.10.4
python-dotenv>=1.0.1 python-dotenv>=1.0.1
pydantic-settings>=2.6.0 pydantic-settings>=2.6.0
matplotlib>=3.8.0
-237
View File
@@ -15,19 +15,6 @@ const PERIOD_TTL_MS = 4 * 60 * 60 * 1000;
let statsData = null; let statsData = null;
let currentView = "today"; let currentView = "today";
let llmPollTimer = null;
let llmInterpretMap = {};
let llmRunState = {
running: false,
batch_id: "",
current_symbol: "",
done: 0,
total: 0,
};
let llmSymbolOrder = [];
const llmExpandedSymbols = new Set();
const llmSeenDone = new Set();
const SORT_KEYS = { const SORT_KEYS = {
rank: (r) => Number(r.rank) || 0, rank: (r) => Number(r.rank) || 0,
symbol: (r) => String(r.symbol || ""), symbol: (r) => String(r.symbol || ""),
@@ -297,8 +284,6 @@ function renderStatsTable() {
return; return;
} }
llmSymbolOrder = items.map((r) => r.symbol);
wrap.innerHTML = ` wrap.innerHTML = `
<table data-table="stats" class="stats-table"> <table data-table="stats" class="stats-table">
<thead><tr> <thead><tr>
@@ -307,7 +292,6 @@ function renderStatsTable() {
<th>昨日排名</th><th></th><th></th> <th>昨日排名</th><th></th><th></th>
<th>前日排名</th><th></th><th></th> <th>前日排名</th><th></th><th></th>
<th>三日总成交额</th> <th>三日总成交额</th>
<th class="llm-col-head">AI解读</th>
</tr></thead> </tr></thead>
<tbody id="stats-body"></tbody> <tbody id="stats-body"></tbody>
</table>`; </table>`;
@@ -327,97 +311,9 @@ function renderStatsTable() {
${cell("yesterday", "rank")}${cell("yesterday", "pct")}${cell("yesterday", "vol")} ${cell("yesterday", "rank")}${cell("yesterday", "pct")}${cell("yesterday", "vol")}
${cell("daybefore", "rank")}${cell("daybefore", "pct")}${cell("daybefore", "vol")} ${cell("daybefore", "rank")}${cell("daybefore", "pct")}${cell("daybefore", "vol")}
<td class="stats-total-vol">${formatVol(row.total_quote_volume)}</td> <td class="stats-total-vol">${formatVol(row.total_quote_volume)}</td>
<td class="llm-col">${buildLlmCellHtml(row.symbol)}</td>
</tr>`; </tr>`;
}) })
.join(""); .join("");
bindLlmFoldHandlers();
}
function getLlmCellState(symbol) {
const llm = llmInterpretMap[symbol];
if (llm?.content) {
const failed = String(llm.content).startsWith("[解读失败]");
return { kind: failed ? "failed" : "done", llm };
}
if (!llmRunState.running) return { kind: "idle" };
if (symbol === llmRunState.current_symbol) return { kind: "running" };
const idx = llmSymbolOrder.indexOf(symbol);
if (idx >= 0 && idx < llmRunState.done) return { kind: "done", llm: null };
return { kind: "pending" };
}
function buildLlmCellHtml(symbol) {
const st = getLlmCellState(symbol);
if (st.kind === "done" && !st.llm) {
return `<div class="llm-placeholder done">已完成,点「刷新解读」</div>`;
}
if (st.kind === "done" && st.llm) {
const t = (st.llm.created_at || "").replace("T", " ").slice(0, 19);
const open = llmExpandedSymbols.has(symbol) ? " open" : "";
return `<details class="llm-fold"${open} data-symbol="${symbol}">
<summary class="llm-summary done">查看解读 <span class="llm-time">${t}</span></summary>
<div class="llm-text">${escapeHtml(st.llm.content)}</div>
</details>`;
}
if (st.kind === "failed" && st.llm) {
return `<details class="llm-fold" data-symbol="${symbol}">
<summary class="llm-summary failed">解读失败</summary>
<div class="llm-text llm-err">${escapeHtml(st.llm.content)}</div>
</details>`;
}
if (st.kind === "running") {
return `<div class="llm-placeholder running">解读中…</div>`;
}
if (st.kind === "pending") {
return `<div class="llm-placeholder pending">排队等待</div>`;
}
return `<span class="muted">—</span>`;
}
function bindLlmFoldHandlers() {
document.querySelectorAll("details.llm-fold").forEach((el) => {
el.ontoggle = () => {
const sym = el.dataset.symbol;
if (!sym) return;
if (el.open) llmExpandedSymbols.add(sym);
else llmExpandedSymbols.delete(sym);
};
});
}
function updateStatsLlmRows() {
const body = document.getElementById("stats-body");
if (!body) return;
document.querySelectorAll("tr.stats-row[data-symbol]").forEach((tr) => {
const sym = tr.dataset.symbol;
const td = tr.querySelector("td.llm-col");
if (!td) return;
const wasOpen = td.querySelector("details.llm-fold")?.open;
td.innerHTML = buildLlmCellHtml(sym);
const det = td.querySelector("details.llm-fold");
if (det) {
det.ontoggle = () => {
if (det.open) llmExpandedSymbols.add(sym);
else llmExpandedSymbols.delete(sym);
};
if (wasOpen || llmExpandedSymbols.has(sym)) det.open = true;
}
});
for (const sym of Object.keys(llmInterpretMap)) {
if (llmSeenDone.has(sym)) continue;
const llm = llmInterpretMap[sym];
if (llm?.content && !String(llm.content).startsWith("[解读失败]")) {
llmSeenDone.add(sym);
const det = document.querySelector(`details.llm-fold[data-symbol="${sym}"]`);
if (det) {
det.open = true;
llmExpandedSymbols.add(sym);
}
}
}
} }
function escapeHtml(s) { function escapeHtml(s) {
@@ -434,134 +330,6 @@ function formatVol(v) {
return String(Math.round(v)); return String(Math.round(v));
} }
function applyLlmPayload(data) {
if (data.batch_id) llmRunState.batch_id = data.batch_id;
if (data.running != null) {
llmRunState.running = !!data.running;
llmRunState.batch_id = data.batch_id || llmRunState.batch_id;
llmRunState.current_symbol = data.current_symbol || "";
llmRunState.done = data.done ?? llmRunState.done;
llmRunState.total = data.total ?? llmRunState.total;
}
const nextMap = { ...llmInterpretMap };
for (const item of data.items || []) {
if (item.symbol) nextMap[item.symbol] = item;
}
llmInterpretMap = nextMap;
if (document.getElementById("stats-body")) {
updateStatsLlmRows();
} else if (statsData?.ok) {
renderStatsTable();
}
}
async function loadLlmInterpretations() {
const q = llmRunState.batch_id
? `?batch_id=${encodeURIComponent(llmRunState.batch_id)}`
: "";
const res = await fetch(`/api/llm/interpretations${q}`);
if (!res.ok) throw new Error("加载解读失败");
const data = await res.json();
applyLlmPayload(data);
return data;
}
function updateLlmStatusText(st) {
const label = document.getElementById("llm-model-label");
const text = document.getElementById("llm-status-text");
if (label) label.textContent = st.enabled ? st.model : "未配置";
if (!text) return;
if (st.running) {
text.textContent = `解读中 ${st.done}/${st.total} · 当前 ${st.current_symbol || "—"}`;
} else {
text.textContent = st.enabled
? `就绪 · 已完成 ${Object.keys(llmInterpretMap).length} 条 · 批次 ${st.batch_id || dataBatchId(st)}`
: "请在 .env 配置 LLM_API_KEY";
}
}
function dataBatchId(st) {
const keys = Object.keys(llmInterpretMap);
return keys.length ? llmInterpretMap[keys[0]]?.batch_id || "—" : "—";
}
async function refreshLlmStatus() {
const res = await fetch("/api/llm/status");
if (!res.ok) throw new Error("状态获取失败");
const st = await res.json();
llmRunState.running = !!st.running;
llmRunState.batch_id = st.batch_id || llmRunState.batch_id;
llmRunState.current_symbol = st.current_symbol || "";
llmRunState.done = st.done ?? 0;
llmRunState.total = st.total ?? 0;
updateLlmStatusText(st);
return st;
}
function startLlmPolling() {
if (llmPollTimer) return;
llmPollTimer = setInterval(async () => {
try {
await refreshLlmStatus();
await loadLlmInterpretations();
const st = await fetch("/api/llm/status").then((r) => r.json());
if (!st.running) {
clearInterval(llmPollTimer);
llmPollTimer = null;
await loadLlmInterpretations();
}
} catch (e) {
console.warn("LLM poll:", e);
}
}, 3000);
}
function stopLlmPolling() {
if (llmPollTimer) {
clearInterval(llmPollTimer);
llmPollTimer = null;
}
}
async function refreshLlmAll() {
const text = document.getElementById("llm-status-text");
if (text) text.textContent = "刷新中…";
try {
const st = await refreshLlmStatus();
await loadLlmInterpretations();
if (st.running) startLlmPolling();
else stopLlmPolling();
} catch (e) {
if (text) text.textContent = "刷新失败";
console.error(e);
}
}
async function runLlmInterpret() {
const btn = document.getElementById("btn-llm-run");
if (btn) btn.disabled = true;
try {
const res = await fetch("/api/llm/interpret/run", { method: "POST" });
const data = await res.json();
if (!data.ok) {
alert(data.message || "启动失败");
return;
}
llmInterpretMap = {};
llmSeenDone.clear();
if (data.batch_id) llmRunState.batch_id = data.batch_id;
llmRunState.running = true;
await refreshLlmStatus();
await loadLlmInterpretations();
startLlmPolling();
if (document.getElementById("stats-body")) updateStatsLlmRows();
} catch (e) {
alert(e.message);
} finally {
if (btn) btn.disabled = false;
}
}
function renderWecomDayRow(label, row) { function renderWecomDayRow(label, row) {
if (!row?.rank) { if (!row?.rank) {
return `<div class="wecom-day muted"><span class="wecom-day-label">${label}</span>—</div>`; return `<div class="wecom-day muted"><span class="wecom-day-label">${label}</span>—</div>`;
@@ -653,7 +421,6 @@ async function loadStats() {
const res = await fetch("/api/stats/three-day"); const res = await fetch("/api/stats/three-day");
statsData = await res.json(); statsData = await res.json();
renderStatsTable(); renderStatsTable();
await refreshLlmAll();
await loadWecomPreview(); 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>`;
@@ -690,7 +457,6 @@ function switchView(view) {
if (view === "stats") { if (view === "stats") {
if (!statsData) loadStats(); if (!statsData) loadStats();
else refreshLlmAll();
return; return;
} }
@@ -723,9 +489,6 @@ document.getElementById("btn-refresh").addEventListener("click", async () => {
if (currentView === "stats") await loadStats(); if (currentView === "stats") await loadStats();
}); });
document.getElementById("btn-llm-run")?.addEventListener("click", runLlmInterpret);
document.getElementById("btn-llm-refresh")?.addEventListener("click", () => refreshLlmAll());
document.getElementById("btn-reload-stats")?.addEventListener("click", () => { document.getElementById("btn-reload-stats")?.addEventListener("click", () => {
statsData = null; statsData = null;
loadStats(); loadStats();
+1 -7
View File
@@ -9,7 +9,7 @@
<body> <body>
<header class="site-header"> <header class="site-header">
<h1>币安 U本位合约 · 成交额排名</h1> <h1>币安 U本位合约 · 成交额排名</h1>
<p class="subtitle">北京时间 08:00 切日 · Top30 · 今日每4小时自动刷新+手动 · 08:05 AI解读三日交集</p> <p class="subtitle">北京时间 08:00 切日 · Top30 · 今日每4小时自动刷新+手动 · 08:10 企微推送三日交集</p>
</header> </header>
<nav class="main-nav" id="main-nav"> <nav class="main-nav" id="main-nav">
@@ -75,15 +75,9 @@
<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-preview">预览企微推送</button>
<button type="button" class="btn-secondary" id="btn-push-test">测试推送企微</button> <button type="button" class="btn-secondary" id="btn-push-test">测试推送企微</button>
<button type="button" class="btn-secondary" id="btn-llm-run">开始解读</button>
<button type="button" class="btn-secondary" id="btn-llm-refresh">刷新解读</button>
</div> </div>
</div> </div>
<p class="stats-desc" id="stats-desc"></p> <p class="stats-desc" id="stats-desc"></p>
<p class="stats-desc llm-status-line">
大模型 <span class="llm-model" id="llm-model-label"></span>
· <span id="llm-status-text"></span>
</p>
<section class="wecom-preview-panel hidden" id="wecom-preview-panel"> <section class="wecom-preview-panel hidden" id="wecom-preview-panel">
<h3>企微推送预览 <span class="wecom-preview-meta" id="wecom-preview-meta"></span></h3> <h3>企微推送预览 <span class="wecom-preview-meta" id="wecom-preview-meta"></span></h3>
<p class="stats-desc">仅包含「三日 Top30 交集」币种;实际发到企微为下方同款列表排版(非宽表格)。</p> <p class="stats-desc">仅包含「三日 Top30 交集」币种;实际发到企微为下方同款列表排版(非宽表格)。</p>
-116
View File
@@ -357,127 +357,11 @@ button:hover {
overflow: hidden; overflow: hidden;
} }
.llm-panel {
margin-top: 1rem;
}
.llm-model {
font-size: 0.75rem;
color: var(--accent);
font-weight: normal;
}
.llm-list {
display: grid;
gap: 0.75rem;
max-height: 420px;
overflow-y: auto;
}
.llm-card {
background: #121a26;
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.75rem 1rem;
}
.llm-card h4 {
margin: 0 0 0.5rem;
font-size: 0.95rem;
}
.llm-card h4 small {
color: var(--muted);
font-weight: normal;
}
.llm-text {
font-size: 0.88rem;
line-height: 1.55;
color: var(--text);
white-space: pre-wrap;
}
.llm-status-line {
margin-top: 0.25rem;
}
.stats-table .stats-total-vol { .stats-table .stats-total-vol {
font-weight: 600; font-weight: 600;
white-space: nowrap; white-space: nowrap;
} }
.llm-col-head,
.llm-col {
min-width: 200px;
max-width: 360px;
font-size: 0.82rem;
vertical-align: top;
}
.llm-fold {
margin-top: 0.15rem;
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.35rem 0.5rem;
background: #0f1520;
}
.llm-fold .llm-text {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--border);
font-size: 0.84rem;
line-height: 1.55;
max-height: 220px;
overflow-y: auto;
}
.llm-summary {
cursor: pointer;
color: var(--accent);
font-size: 0.85rem;
list-style: none;
}
.llm-summary::-webkit-details-marker {
display: none;
}
.llm-summary.done::before {
content: "▸ ";
}
details[open] > .llm-summary.done::before {
content: "▾ ";
}
.llm-summary.failed {
color: #f6465d;
}
.llm-time {
color: var(--muted);
font-size: 0.75rem;
}
.llm-placeholder {
font-size: 0.82rem;
padding: 0.25rem 0;
}
.llm-placeholder.running {
color: var(--accent);
}
.llm-placeholder.pending {
color: var(--muted);
}
.llm-err {
color: #f6465d;
}
.muted { .muted {
color: var(--muted); color: var(--muted);
} }