From b91721d315f4f8e537319675e0ec0d01480d1dfb Mon Sep 17 00:00:00 2001 From: dekun Date: Tue, 26 May 2026 10:20:45 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8E=BB=E6=8E=89=E5=A4=A7=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 9 -- DEPLOY.md | 96 +++------------ README.md | 63 +++------- backend/app/chart_image.py | 66 ----------- backend/app/config.py | 5 - backend/app/db.py | 91 -------------- backend/app/llm_service.py | 206 -------------------------------- backend/app/main.py | 70 +---------- backend/app/scheduler.py | 33 +----- backend/requirements.txt | 1 - web/app.js | 237 ------------------------------------- web/index.html | 8 +- web/style.css | 116 ------------------ 13 files changed, 39 insertions(+), 962 deletions(-) delete mode 100644 backend/app/chart_image.py delete mode 100644 backend/app/llm_service.py diff --git a/.env.example b/.env.example index c550e87..460c8a7 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/DEPLOY.md b/DEPLOY.md index 29bc8bc..7d7d335 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -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 一键 diff --git a/README.md b/README.md index b57b410..638af5a 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/backend/app/chart_image.py b/backend/app/chart_image.py deleted file mode 100644 index cbfad88..0000000 --- a/backend/app/chart_image.py +++ /dev/null @@ -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() diff --git a/backend/app/config.py b/backend/app/config.py index c64b712..b538701 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/db.py b/backend/app/db.py index 05c2c7d..816ab44 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -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( diff --git a/backend/app/llm_service.py b/backend/app/llm_service.py deleted file mode 100644 index 8d797f2..0000000 --- a/backend/app/llm_service.py +++ /dev/null @@ -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()) diff --git a/backend/app/main.py b/backend/app/main.py index ee61e3c..236c272 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 到本地库。""" diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py index 9eb0a62..e3ff6d8 100644 --- a/backend/app/scheduler.py +++ b/backend/app/scheduler.py @@ -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: diff --git a/backend/requirements.txt b/backend/requirements.txt index 7f9a05e..a66c0f3 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/web/app.js b/web/app.js index 4fa8f80..41eb1aa 100644 --- a/web/app.js +++ b/web/app.js @@ -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 = ` @@ -307,7 +292,6 @@ function renderStatsTable() { -
昨日排名昨日涨跌昨日成交额 前日排名前日涨跌前日成交额 三日总成交额AI解读
`; @@ -327,97 +311,9 @@ function renderStatsTable() { ${cell("yesterday", "rank")}${cell("yesterday", "pct")}${cell("yesterday", "vol")} ${cell("daybefore", "rank")}${cell("daybefore", "pct")}${cell("daybefore", "vol")} ${formatVol(row.total_quote_volume)} - ${buildLlmCellHtml(row.symbol)} `; }) .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 `
已完成,点「刷新解读」
`; - } - if (st.kind === "done" && st.llm) { - const t = (st.llm.created_at || "").replace("T", " ").slice(0, 19); - const open = llmExpandedSymbols.has(symbol) ? " open" : ""; - return `
- 查看解读 ${t} -
${escapeHtml(st.llm.content)}
-
`; - } - if (st.kind === "failed" && st.llm) { - return `
- 解读失败 -
${escapeHtml(st.llm.content)}
-
`; - } - if (st.kind === "running") { - return `
解读中…
`; - } - if (st.kind === "pending") { - return `
排队等待
`; - } - return ``; -} - -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 `
${label}
`; @@ -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 = `

${e.message}

`; @@ -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(); diff --git a/web/index.html b/web/index.html index de09a43..2103e00 100644 --- a/web/index.html +++ b/web/index.html @@ -9,7 +9,7 @@