去掉大模型
This commit is contained in:
@@ -25,12 +25,3 @@ CHART_KLINE_LIMIT=300
|
||||
CHART_CACHE_MINUTES=60
|
||||
FUNDING_HISTORY_LIMIT=90
|
||||
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
|
||||
|
||||
@@ -13,10 +13,10 @@
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 系统 | Linux(推荐 Ubuntu 22.04+ / Debian 12+) |
|
||||
| 时区 | `Asia/Shanghai`(定时任务 08:00 / 08:05 / 08:10 北京时间) |
|
||||
| 网络 | 能访问 `fapi.binance.com`;大模型需能访问 `LLM_BASE_URL`(默认 `http://op.bz121.com`) |
|
||||
| 代理 | 币安不可直连时开启 SOCKS5(见第六节) |
|
||||
| 内存 | 建议 ≥ **1GB**(含 `matplotlib` 生成日 K 图供大模型) |
|
||||
| 时区 | `Asia/Shanghai`(定时任务 08:00 / 08:10 北京时间) |
|
||||
| 网络 | 能访问 `fapi.binance.com` |
|
||||
| 代理 | 币安不可直连时开启 SOCKS5(见第五节) |
|
||||
| 内存 | 建议 ≥ 512MB |
|
||||
| 磁盘 | ≥ 500MB(含日志、SQLite、日 K 缓存) |
|
||||
| Python | 3.10+;**PM2 部署必须使用项目内 `.venv`** 安装依赖 |
|
||||
|
||||
@@ -36,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;大模型解读需填 LLM_API_KEY
|
||||
nano .env # 至少填写 WECOM_WEBHOOK_URL
|
||||
```
|
||||
|
||||
`.env` 常用项:
|
||||
@@ -53,13 +53,6 @@ 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)。
|
||||
@@ -68,7 +61,7 @@ LLM_AUTO_ON_STARTUP=true
|
||||
|
||||
## 二点五、Python 依赖与虚拟环境(重要)
|
||||
|
||||
本项目 **所有 Python 依赖** 均来自 `backend/requirements.txt`(含 `matplotlib`,用于服务端渲染日 K PNG 并喂给大模型)。
|
||||
本项目 **所有 Python 依赖** 均来自 `backend/requirements.txt`。
|
||||
|
||||
| 部署方式 | 依赖安装位置 | 说明 |
|
||||
|----------|--------------|------|
|
||||
@@ -94,10 +87,10 @@ pm2 restart binance-altcoin-monitor
|
||||
### 验证依赖是否装在 venv 内
|
||||
|
||||
```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:更新依赖
|
||||
|
||||
@@ -147,8 +140,6 @@ 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
|
||||
@@ -173,7 +164,7 @@ sudo apt install -y nodejs
|
||||
sudo npm install -g pm2
|
||||
```
|
||||
|
||||
> **注意**:业务依赖(`fastapi`、`matplotlib` 等)**不要**只执行 `sudo pip install`,应交给脚本或 `.venv/bin/pip`(见 **二点五**)。
|
||||
> **注意**:业务依赖(`fastapi`、`httpx` 等)**不要**只执行 `sudo pip install`,应交给脚本或 `.venv/bin/pip`(见 **二点五**)。
|
||||
|
||||
### 4.2 一键部署
|
||||
|
||||
@@ -200,53 +191,7 @@ pm2 save
|
||||
|
||||
---
|
||||
|
||||
## 五、大模型解读(可选)
|
||||
|
||||
对 **连续三日成交额均为 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 代理(默认关闭)
|
||||
## 五、SOCKS5 代理(默认关闭)
|
||||
|
||||
当服务器**无法直连**币安 API 时,可通过内网 SOCKS5 代理访问。
|
||||
|
||||
@@ -310,7 +255,7 @@ curl http://127.0.0.1:21450/api/today/top30
|
||||
|
||||
---
|
||||
|
||||
## 七、防火墙与 Nginx(可选)
|
||||
## 六、防火墙与 Nginx(可选)
|
||||
|
||||
```bash
|
||||
# 开放 21450(若直接对外)
|
||||
@@ -334,12 +279,11 @@ server {
|
||||
|
||||
---
|
||||
|
||||
## 八、定时任务说明
|
||||
## 七、定时任务说明
|
||||
|
||||
| 时间(北京时间) | 行为 |
|
||||
|------------------|------|
|
||||
| 08:00 | 固化昨日、前日周期快照 |
|
||||
| 08:05 | 大模型解读三日 Top30 交集(需 `LLM_API_KEY`) |
|
||||
| 08:10 | 企业微信推送 **三日 Top30 交集**(卡片列表,非宽表格) |
|
||||
| 每 4 小时(0/4/8/12/16/20 点) | 刷新今日数据(`REFRESH_MINUTES=240`) |
|
||||
|
||||
@@ -349,13 +293,13 @@ Web 今日表 **不会** 每 60 秒自动轮询;除上述定时外,使用页
|
||||
|
||||
---
|
||||
|
||||
## 九、目录与数据
|
||||
## 八、目录与数据
|
||||
|
||||
```
|
||||
/opt/Binance_Altcoin_Monitor/
|
||||
├── .env # 配置(勿提交 git)
|
||||
├── .venv/ # Python 虚拟环境(PM2 专用,勿删)
|
||||
├── data/monitor.db # SQLite(周期快照、日 K、资金费率、LLM 解读)
|
||||
├── data/monitor.db # SQLite(周期快照、日 K、资金费率)
|
||||
├── logs/ # PM2 日志(Docker 用 docker compose logs)
|
||||
├── deploy/ # 一键脚本
|
||||
├── docker-compose.yml
|
||||
@@ -366,9 +310,9 @@ Web 今日表 **不会** 每 60 秒自动轮询;除上述定时外,使用页
|
||||
|
||||
---
|
||||
|
||||
## 十、更新版本
|
||||
## 九、更新版本
|
||||
|
||||
拉取代码后,**务必**按部署方式重装 Python 依赖(`requirements.txt` 变更时尤其重要,例如新增 `matplotlib`)。
|
||||
拉取代码后,**务必**按部署方式重装 Python 依赖(`requirements.txt` 有变更时)。
|
||||
|
||||
**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` |
|
||||
| `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 |
|
||||
| `No module named ...` | 依赖未装进 **`.venv`**:执行 `.venv/bin/pip install -r backend/requirements.txt` 后 `pm2 restart` |
|
||||
| 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 一键
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
按 **北京时间 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 小时
|
||||
- 资金费率当前值 + 历史迷你曲线
|
||||
- **数据统计**:连续三日均为成交额 Top30 的 **交集**(涨跌幅 **不限**)
|
||||
- **大模型解读**(`gemma4:e4b`):对三日交集币种生成日 K 图 + 数据简析;每日 **08:05** 自动排队,每币间隔 **3 分钟**;服务启动后自动跑一轮(可关);需在 `.env` 配置 `LLM_API_KEY`
|
||||
|
||||
## 环境要求
|
||||
|
||||
- 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`
|
||||
|
||||
## 快速开始
|
||||
|
||||
### Windows / 本机开发
|
||||
|
||||
```powershell
|
||||
# 1. 进入项目目录
|
||||
cd 币安排名
|
||||
|
||||
# 2. 创建并激活虚拟环境(依赖必须装进 venv,勿只装系统 Python)
|
||||
python -m venv .venv
|
||||
.\.venv\Scripts\Activate.ps1
|
||||
|
||||
# 3. 安装依赖(含 matplotlib,用于服务端生成日 K 图供大模型)
|
||||
pip install -U pip
|
||||
pip install -r backend/requirements.txt
|
||||
|
||||
# 4. 配置环境变量
|
||||
copy .env.example .env
|
||||
# 编辑 .env:WECOM_WEBHOOK_URL、LLM_API_KEY 等
|
||||
|
||||
# 5. 启动(需保持进程常驻)
|
||||
# 编辑 .env:WECOM_WEBHOOK_URL 等
|
||||
python run.py
|
||||
```
|
||||
|
||||
@@ -63,7 +52,7 @@ nano .env
|
||||
|
||||
浏览器打开: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)
|
||||
|
||||
@@ -82,13 +71,8 @@ nano .env
|
||||
| `CANDIDATE_POOL` | 预筛候选合约数(按 24h 成交额) | 150 |
|
||||
| `CHART_KLINE_LIMIT` | 日 K 存储/展示根数 | 300 |
|
||||
| `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
|
||||
|
||||
@@ -99,11 +83,7 @@ nano .env
|
||||
| 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` | 立即刷新今日数据 |
|
||||
@@ -114,31 +94,25 @@ nano .env
|
||||
| 时间 (北京时间) | 任务 |
|
||||
|-----------------|------|
|
||||
| 08:00 | 固化昨日、前日周期快照到 SQLite |
|
||||
| 08:05 | 大模型解读「三日 Top30 交集」各币种(需 `LLM_API_KEY`) |
|
||||
| 08:10 | 企业微信推送三日 Top30 交集(列表排版) |
|
||||
| 每 4 小时(整点 0/4/8/12/16/20) | 刷新今日周期(由 `REFRESH_MINUTES=240` 控制) |
|
||||
|
||||
进程重启后:若已过 08:10 且当日尚未推送成功,会自动补推;若已配置 `LLM_API_KEY` 且 `LLM_AUTO_ON_STARTUP=true`,会在后台自动启动一轮解读。
|
||||
进程重启后:若已过 08:10 且当日尚未推送成功,会自动补推。
|
||||
|
||||
## Web 界面
|
||||
|
||||
| 页签 | 说明 |
|
||||
|------|------|
|
||||
| 今日 / 昨日 / 前日 | Top30 表、日 K、资金费率;支持排序与 CSV 导出 |
|
||||
| 数据统计 | 三日交集列表 + 大模型解读区;可「开始解读」「刷新解读」 |
|
||||
| 数据统计 | 三日交集列表;可预览 / 测试企微推送 |
|
||||
|
||||
今日数据 **不会** 在浏览器里每 60 秒轮询;请依赖 4 小时后台任务或页脚 **「立即刷新今日」**。
|
||||
|
||||
## Windows 常驻运行
|
||||
|
||||
1. **任务计划程序**:触发器「登录时」或「计算机启动时」,操作运行 `pythonw.exe` 完整路径的 `run.py`,起始于项目目录。
|
||||
2. 或使用云服务器 / VPS 用 `nssm`、pm2 等托管。
|
||||
|
||||
## 企业微信配置
|
||||
|
||||
1. 企业微信群 → 群设置 → 群机器人 → 添加
|
||||
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
|
||||
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):
|
||||
|
||||
| 部署方式 | 安装位置 |
|
||||
|----------|----------|
|
||||
| 本机 / PM2 | 项目目录 **`.venv`**(`pip install -r backend/requirements.txt`) |
|
||||
| Docker | 镜像构建时 `pip install`(见 `Dockerfile`) |
|
||||
|
||||
**不要**只装到系统 Python:PM2 的 `ecosystem.config.cjs` 指定解释器为 `.venv/bin/python`,系统环境缺包会导致 `No module named matplotlib` 等错误。
|
||||
Python 包见 [`backend/requirements.txt`](./backend/requirements.txt)。PM2 使用 `.venv/bin/python`,依赖须装进 **`.venv`**,勿只装系统 Python。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
币安排名/
|
||||
├── backend/app/ # 后端逻辑(含 llm_service、chart_image)
|
||||
├── backend/app/
|
||||
├── backend/requirements.txt
|
||||
├── web/ # 前端静态页
|
||||
├── data/monitor.db # SQLite(自动创建)
|
||||
├── .venv/ # 虚拟环境(本地/PM2,勿提交)
|
||||
├── deploy/ # 一键部署脚本
|
||||
├── run.py # 启动入口
|
||||
└── .env # 本地配置(勿提交)
|
||||
├── web/
|
||||
├── data/monitor.db
|
||||
├── .venv/
|
||||
├── deploy/
|
||||
├── run.py
|
||||
└── .env
|
||||
```
|
||||
|
||||
@@ -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()
|
||||
@@ -37,11 +37,6 @@ class Settings(BaseSettings):
|
||||
proxy_enabled: bool = False
|
||||
proxy_url: str = "socks5h://192.168.8.4:1081"
|
||||
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()
|
||||
|
||||
@@ -97,16 +97,6 @@ def init_db() -> None:
|
||||
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:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
|
||||
@@ -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
@@ -2,19 +2,17 @@ import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import BackgroundTasks, FastAPI, HTTPException
|
||||
from fastapi.responses import FileResponse, Response
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from .config import ROOT_DIR, settings
|
||||
from .funding_store import get_funding_bundle
|
||||
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 .period_api import get_period_top30
|
||||
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 .stats import compute_three_day_stats
|
||||
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
|
||||
|
||||
|
||||
@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")
|
||||
async def api_chart_daily_refresh(symbol: str, limit: int | None = None):
|
||||
"""强制从币安同步日 K 到本地库。"""
|
||||
|
||||
@@ -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 .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_push_payload, send_wecom_markdown
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -156,18 +154,6 @@ async def job_refresh_today() -> None:
|
||||
_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:
|
||||
init_db()
|
||||
now = now_shanghai()
|
||||
@@ -215,13 +201,6 @@ async def startup_tasks() -> None:
|
||||
except Exception as 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:
|
||||
scheduler.add_job(
|
||||
job_finalize_yesterday,
|
||||
@@ -242,19 +221,9 @@ def start_scheduler() -> None:
|
||||
id="refresh_today",
|
||||
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:
|
||||
scheduler.start()
|
||||
logger.info(
|
||||
"Scheduler started (today every %dh, LLM 08:05, interval %ds)",
|
||||
refresh_hours,
|
||||
settings.llm_symbol_interval_sec,
|
||||
)
|
||||
logger.info("Scheduler started (today every %dh)", refresh_hours)
|
||||
|
||||
|
||||
def stop_scheduler() -> None:
|
||||
|
||||
@@ -4,4 +4,3 @@ httpx[socks]>=0.27.0
|
||||
apscheduler>=3.10.4
|
||||
python-dotenv>=1.0.1
|
||||
pydantic-settings>=2.6.0
|
||||
matplotlib>=3.8.0
|
||||
|
||||
-237
@@ -15,19 +15,6 @@ const PERIOD_TTL_MS = 4 * 60 * 60 * 1000;
|
||||
|
||||
let statsData = null;
|
||||
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 = {
|
||||
rank: (r) => Number(r.rank) || 0,
|
||||
symbol: (r) => String(r.symbol || ""),
|
||||
@@ -297,8 +284,6 @@ function renderStatsTable() {
|
||||
return;
|
||||
}
|
||||
|
||||
llmSymbolOrder = items.map((r) => r.symbol);
|
||||
|
||||
wrap.innerHTML = `
|
||||
<table data-table="stats" class="stats-table">
|
||||
<thead><tr>
|
||||
@@ -307,7 +292,6 @@ function renderStatsTable() {
|
||||
<th>昨日排名</th><th>昨日涨跌</th><th>昨日成交额</th>
|
||||
<th>前日排名</th><th>前日涨跌</th><th>前日成交额</th>
|
||||
<th>三日总成交额</th>
|
||||
<th class="llm-col-head">AI解读</th>
|
||||
</tr></thead>
|
||||
<tbody id="stats-body"></tbody>
|
||||
</table>`;
|
||||
@@ -327,97 +311,9 @@ function renderStatsTable() {
|
||||
${cell("yesterday", "rank")}${cell("yesterday", "pct")}${cell("yesterday", "vol")}
|
||||
${cell("daybefore", "rank")}${cell("daybefore", "pct")}${cell("daybefore", "vol")}
|
||||
<td class="stats-total-vol">${formatVol(row.total_quote_volume)}</td>
|
||||
<td class="llm-col">${buildLlmCellHtml(row.symbol)}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.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) {
|
||||
@@ -434,134 +330,6 @@ function formatVol(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) {
|
||||
if (!row?.rank) {
|
||||
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");
|
||||
statsData = await res.json();
|
||||
renderStatsTable();
|
||||
await refreshLlmAll();
|
||||
await loadWecomPreview();
|
||||
} catch (e) {
|
||||
document.getElementById("stats-table-wrap").innerHTML = `<p class="error">${e.message}</p>`;
|
||||
@@ -690,7 +457,6 @@ function switchView(view) {
|
||||
|
||||
if (view === "stats") {
|
||||
if (!statsData) loadStats();
|
||||
else refreshLlmAll();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -723,9 +489,6 @@ document.getElementById("btn-refresh").addEventListener("click", async () => {
|
||||
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", () => {
|
||||
statsData = null;
|
||||
loadStats();
|
||||
|
||||
+1
-7
@@ -9,7 +9,7 @@
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<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>
|
||||
|
||||
<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-push-preview">预览企微推送</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>
|
||||
<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">
|
||||
<h3>企微推送预览 <span class="wecom-preview-meta" id="wecom-preview-meta"></span></h3>
|
||||
<p class="stats-desc">仅包含「三日 Top30 交集」币种;实际发到企微为下方同款列表排版(非宽表格)。</p>
|
||||
|
||||
-116
@@ -357,127 +357,11 @@ button:hover {
|
||||
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 {
|
||||
font-weight: 600;
|
||||
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 {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user