去掉大模型
This commit is contained in:
@@ -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
|
|
||||||
|
|||||||
@@ -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 一键
|
||||||
|
|||||||
@@ -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
|
||||||
# 编辑 .env:WECOM_WEBHOOK_URL、LLM_API_KEY 等
|
# 编辑 .env:WECOM_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 JSON(SQLite 优先) |
|
| GET | `/api/chart/{symbol}/daily` | 日 K JSON(SQLite 优先) |
|
||||||
| 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`) |
|
|
||||||
|
|
||||||
**不要**只装到系统 Python:PM2 的 `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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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_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()
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 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 到本地库。"""
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user