From 86aa804c21b6af744b1de501fd745633f32fea39 Mon Sep 17 00:00:00 2001 From: dekun Date: Tue, 26 May 2026 09:49:43 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=A4=A7=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DEPLOY.md | 172 +++++++++++++++++++++++++++++++++------ README.md | 128 ++++++++++++++++++++++------- backend/app/main.py | 20 ++++- backend/app/scheduler.py | 15 +++- backend/app/wecom.py | 120 ++++++++++++++++++++------- web/app.js | 87 ++++++++++++++++++++ web/index.html | 8 ++ web/style.css | 87 ++++++++++++++++++++ 8 files changed, 547 insertions(+), 90 deletions(-) diff --git a/DEPLOY.md b/DEPLOY.md index 0aaa5ee..29bc8bc 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -13,10 +13,12 @@ | 项目 | 说明 | |------|------| | 系统 | Linux(推荐 Ubuntu 22.04+ / Debian 12+) | -| 时区 | `Asia/Shanghai`(定时任务 08:00 / 08:10 北京时间) | -| 网络 | 能访问 `fapi.binance.com`;若不能,需开启 SOCKS5 代理(见第四节) | -| 内存 | 建议 ≥ 512MB | -| 磁盘 | ≥ 500MB(含日志与 SQLite) | +| 时区 | `Asia/Shanghai`(定时任务 08:00 / 08:05 / 08:10 北京时间) | +| 网络 | 能访问 `fapi.binance.com`;大模型需能访问 `LLM_BASE_URL`(默认 `http://op.bz121.com`) | +| 代理 | 币安不可直连时开启 SOCKS5(见第六节) | +| 内存 | 建议 ≥ **1GB**(含 `matplotlib` 生成日 K 图供大模型) | +| 磁盘 | ≥ 500MB(含日志、SQLite、日 K 缓存) | +| Python | 3.10+;**PM2 部署必须使用项目内 `.venv`** 安装依赖 | --- @@ -34,7 +36,7 @@ sudo chown -R $USER:$USER /opt/Binance_Altcoin_Monitor # 3. 配置环境变量 cd /opt/Binance_Altcoin_Monitor cp .env.example .env -nano .env # 至少填写 WECOM_WEBHOOK_URL +nano .env # 至少填写 WECOM_WEBHOOK_URL;大模型解读需填 LLM_API_KEY ``` `.env` 常用项: @@ -44,12 +46,63 @@ WECOM_WEBHOOK_URL=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=你的key HOST=0.0.0.0 PORT=21450 +# 今日每 4 小时自动刷新(另支持 Web 页脚手动刷新) +REFRESH_MINUTES=240 + # 代理默认关闭 PROXY_ENABLED=false PROXY_URL=socks5h://192.168.8.4:1081 PROXY_FOR=binance + +# 大模型解读(OpenAI 兼容网关) +LLM_BASE_URL=http://op.bz121.com +LLM_API_KEY=sk-你的密钥 +LLM_MODEL=gemma4:e4b +LLM_SYMBOL_INTERVAL_SEC=180 +LLM_AUTO_ON_STARTUP=true ``` +完整变量说明见仓库 [`.env.example`](./.env.example) 与 [README.md](./README.md)。 + +--- + +## 二点五、Python 依赖与虚拟环境(重要) + +本项目 **所有 Python 依赖** 均来自 `backend/requirements.txt`(含 `matplotlib`,用于服务端渲染日 K PNG 并喂给大模型)。 + +| 部署方式 | 依赖安装位置 | 说明 | +|----------|--------------|------| +| **PM2** | 项目目录 **`.venv`** | `deploy/pm2-deploy.sh` 自动 `python3 -m venv .venv` 并 `pip install -r backend/requirements.txt`;PM2 使用 `.venv/bin/python` 启动 | +| **Docker** | 镜像内 | `Dockerfile` 构建时 `pip install`,无需在宿主机建 venv | +| **本机调试** | 建议同样使用 `.venv` | 与生产一致,避免「系统 Python 有包、PM2 进程没有」 | + +### PM2:手动安装 / 更新依赖 + +```bash +cd /opt/Binance_Altcoin_Monitor + +# 若尚无虚拟环境 +python3 -m venv .venv + +# 依赖必须装进 .venv(不要只用系统 pip) +.venv/bin/pip install -U pip +.venv/bin/pip install -r backend/requirements.txt + +pm2 restart binance-altcoin-monitor +``` + +### 验证依赖是否装在 venv 内 + +```bash +.venv/bin/python -c "import matplotlib; import fastapi; print('ok')" +``` + +若报错 `No module named matplotlib`,说明未在 **`.venv`** 中安装,请执行上节 `pip install` 后重启 PM2。 + +### Docker:更新依赖 + +修改 `requirements.txt` 后需 **重新构建镜像**(见第十节),容器内才会包含新包。 + --- ## 三、方式 A — Docker 一键部署(推荐) @@ -93,6 +146,9 @@ docker compose down # 手动测试企微推送 curl -X POST http://127.0.0.1:21450/api/push/test + +# 查看大模型解读状态(需已配置 LLM_API_KEY) +curl http://127.0.0.1:21450/api/llm/status ``` ### 3.4 访问 Web @@ -105,10 +161,10 @@ curl -X POST http://127.0.0.1:21450/api/push/test ## 四、方式 B — PM2 一键部署 -### 4.1 安装依赖 +### 4.1 安装系统软件 ```bash -# Python 3.10+(部署脚本会自动创建 .venv;若无 venv 模块需先装) +# Python 3.10+(部署脚本会自动创建 .venv 并把 requirements 装进 venv) sudo apt install -y python3 python3-venv python3-pip # Node.js + PM2 @@ -117,6 +173,8 @@ sudo apt install -y nodejs sudo npm install -g pm2 ``` +> **注意**:业务依赖(`fastapi`、`matplotlib` 等)**不要**只执行 `sudo pip install`,应交给脚本或 `.venv/bin/pip`(见 **二点五**)。 + ### 4.2 一键部署 ```bash @@ -126,6 +184,8 @@ chmod +x deploy/pm2-deploy.sh ./deploy/pm2-deploy.sh ``` +脚本将:拉取代码 → **创建/更新 `.venv`** → `pip install -r backend/requirements.txt` → `pm2 start`(解释器为 `.venv/bin/python`)。 + ### 4.3 常用命令 ```bash @@ -140,7 +200,53 @@ pm2 save --- -## 五、SOCKS5 代理(默认关闭) +## 五、大模型解读(可选) + +对 **连续三日成交额均为 Top30 的交集币种** 进行 AI 简析(涨跌幅不限)。服务端用 `matplotlib` 生成日 K+成交量 PNG,通过 OpenAI 兼容接口发给网关。 + +| 变量 | 说明 | +|------|------| +| `LLM_BASE_URL` | 网关根地址,默认 `http://op.bz121.com` | +| `LLM_API_KEY` | `Authorization: Bearer sk-...`(**勿提交 git**) | +| `LLM_MODEL` | 与网关模型 ID 一致,默认 `gemma4:e4b` | +| `LLM_SYMBOL_INTERVAL_SEC` | 批量解读时每币间隔,默认 `180`(3 分钟) | +| `LLM_AUTO_ON_STARTUP` | 服务启动后是否后台自动跑一轮,默认 `true` | + +### 定时与行为 + +| 时间(北京时间) | 行为 | +|------------------|------| +| **08:05** | 自动对三日交集币种排队解读 | +| **启动时** | 若已配置 `LLM_API_KEY` 且 `LLM_AUTO_ON_STARTUP=true`,后台自动启动一轮 | + +Web「数据统计」页可查看解读结果,或点击 **「开始解读」** 手动触发。 + +### API 速查 + +```bash +# 任务状态(是否在跑、当前币种、进度) +curl http://127.0.0.1:21450/api/llm/status + +# 手动启动批量解读(后台异步,勿重复点击) +curl -X POST http://127.0.0.1:21450/api/llm/interpret/run + +# 最近一批解读文本 +curl http://127.0.0.1:21450/api/llm/interpretations + +# 单币日 K PNG(调试用) +curl -o chart.png http://127.0.0.1:21450/api/chart/BTCUSDT/daily.png +``` + +### 配置后生效 + +**PM2:** 修改 `.env` 后 `pm2 restart binance-altcoin-monitor` +**Docker:** `docker compose up -d --force-recreate` + +若网关不支持 vision,服务会降级为纯文本解读(日志中有 warning)。 + +--- + +## 六、SOCKS5 代理(默认关闭) 当服务器**无法直连**币安 API 时,可通过内网 SOCKS5 代理访问。 @@ -204,7 +310,7 @@ curl http://127.0.0.1:21450/api/today/top30 --- -## 六、防火墙与 Nginx(可选) +## 七、防火墙与 Nginx(可选) ```bash # 开放 21450(若直接对外) @@ -228,35 +334,41 @@ server { --- -## 七、定时任务说明 +## 八、定时任务说明 | 时间(北京时间) | 行为 | |------------------|------| -| 08:00 | 固化昨日周期数据 | -| 08:10 | 企业微信推送昨日 Top30 | -| 每 5 分钟 | 刷新今日数据(`REFRESH_MINUTES` 可改) | +| 08:00 | 固化昨日、前日周期快照 | +| 08:05 | 大模型解读三日 Top30 交集(需 `LLM_API_KEY`) | +| 08:10 | 企业微信推送 **三日 Top30 交集**(卡片列表,非宽表格) | +| 每 4 小时(0/4/8/12/16/20 点) | 刷新今日数据(`REFRESH_MINUTES=240`) | + +Web 今日表 **不会** 每 60 秒自动轮询;除上述定时外,使用页脚 **「立即刷新今日」** 手动更新。 **进程需常驻**(Docker `restart: unless-stopped` 或 PM2 `autorestart`)。 --- -## 八、目录与数据 +## 九、目录与数据 ``` /opt/Binance_Altcoin_Monitor/ ├── .env # 配置(勿提交 git) -├── data/monitor.db # SQLite 数据 +├── .venv/ # Python 虚拟环境(PM2 专用,勿删) +├── data/monitor.db # SQLite(周期快照、日 K、资金费率、LLM 解读) ├── logs/ # PM2 日志(Docker 用 docker compose logs) ├── deploy/ # 一键脚本 ├── docker-compose.yml -└── ecosystem.config.cjs +└── ecosystem.config.cjs # PM2 解释器: .venv/bin/python ``` 备份建议:定期备份 `data/monitor.db` 与 `.env`。 --- -## 九、更新版本 +## 十、更新版本 + +拉取代码后,**务必**按部署方式重装 Python 依赖(`requirements.txt` 变更时尤其重要,例如新增 `matplotlib`)。 **Docker:** @@ -267,43 +379,57 @@ docker compose build --no-cache docker compose up -d ``` -**PM2:** +**PM2(推荐一键脚本,会自动更新 .venv):** ```bash cd /opt/Binance_Altcoin_Monitor git pull ./deploy/pm2-deploy.sh -# 或手动: .venv/bin/pip install -r backend/requirements.txt && pm2 restart binance-altcoin-monitor +``` + +**PM2(仅手动更新依赖):** + +```bash +cd /opt/Binance_Altcoin_Monitor +git pull +.venv/bin/pip install -r backend/requirements.txt +pm2 restart binance-altcoin-monitor ``` --- -## 十、故障排查 +## 十一、故障排查 | 现象 | 处理 | |------|------| | `bash\r: No such file or directory` | 脚本为 Windows 换行,执行:`sed -i 's/\r$//' deploy/*.sh && chmod +x deploy/*.sh` | | `cannot pull with rebase: unstaged changes` | 执行 `git stash` 后重试;或 `DEPLOY_SKIP_GIT_PULL=1 ./deploy/pm2-deploy.sh` 跳过拉取 | | `No module named pip` | 执行 `sudo apt install -y python3-venv` 后重新 `./deploy/pm2-deploy.sh`(脚本会用 .venv) | +| `No module named matplotlib`(或其它包) | 依赖未装进 **`.venv`**:执行 `.venv/bin/pip install -r backend/requirements.txt` 后 `pm2 restart`;勿只装系统 Python | | Web 无数据 | 检查能否访问币安;国内服务器尝试 `PROXY_ENABLED=true` | | 大量 `418 I'm a teapot` | IP 被封禁;**不要反复 restart**。日 K 已存 SQLite,图表优先读本地;仅过期或首次才请求币安 | | 企微收不到 | 检查 `WECOM_WEBHOOK_URL`;`curl -X POST .../api/push/test` | | 08:10 未推送 | 确认容器/PM2 在 08:10 前已运行;查日志 | +| 大模型无解读 / 状态 `enabled: false` | 检查 `.env` 中 `LLM_API_KEY`;`curl .../api/llm/status`;确认服务器能访问 `LLM_BASE_URL` | +| 解读一直 `running` | 查看 `pm2 logs`;可能某币请求超时;可重启进程后手动 `POST /api/llm/interpret/run` | | 端口占用 | `ss -tlnp \| grep 21450` 或改 `.env` 中 `PORT` | | Docker 代理连不上 | 确认 `192.168.8.4:1081` 从容器内可达,必要时改宿主机 IP | --- -## 十一、快速命令速查 +## 十二、快速命令速查 ```bash # Docker 一键 ./deploy/docker-deploy.sh -# PM2 一键 +# PM2 一键(含 .venv 依赖安装) ./deploy/pm2-deploy.sh -# 开启代理后重启 +# 仅更新 Python 依赖(PM2) +.venv/bin/pip install -r backend/requirements.txt && pm2 restart binance-altcoin-monitor + +# 开启代理 / 修改 .env 后重启 # Docker: docker compose up -d --force-recreate # PM2: pm2 restart binance-altcoin-monitor ``` diff --git a/README.md b/README.md index 67b1b26..b57b410 100644 --- a/README.md +++ b/README.md @@ -2,43 +2,69 @@ 仓库:[Binance_Altcoin_Monitor](https://git.bz121.com/dekun/Binance_Altcoin_Monitor.git) -按 **北京时间 08:00** 切日,统计 U 本位永续合约成交额 Top30;每日 **08:10** 通过企业微信推送昨日完整周期数据;Web 展示昨日快照与今日实时累计。 +按 **北京时间 08:00** 切日,统计 U 本位永续合约成交额 Top30;每日 **08:10** 企业微信仅推送 **三日 Top30 交集** 币种(列表排版,非宽表格);Web 数据统计页可预览同款推送内容。 + +> **Linux 生产部署(/opt、Docker、PM2、虚拟环境、SOCKS5 代理、大模型)请参阅 [DEPLOY.md](./DEPLOY.md)** -> **Linux 生产部署(/opt、Docker、PM2、SOCKS5 代理)请参阅 [DEPLOY.md](./DEPLOY.md)** ## 功能 - 成交额排名 Top30(USDT 计价) - 高亮标记(不改变排名):成交额 ≥ 1000万 USDT、|涨跌幅| ≥ 5% -- 昨日周期:`[D-1 08:00, D 08:00)` -- 今日周期:`[D 08:00, 当前)`,每 **4 小时**后台刷新 + 页脚手动刷新;K线/周期数据服务端 SQLite + 浏览器缓存 -- 数据统计:连续三日 Top30 交集(涨跌幅不限) -- 大模型解读(`gemma4:e4b`):每日 **08:05** 对三日交集逐币解读(每币 3 分钟),启动自动一轮;需配置 `LLM_API_KEY` +- 昨日 / 前日周期:按 08:00 切日固化快照 +- 今日周期:`[D 08:00, 当前)`,后台每 **4 小时**自动刷新 + 页脚 **手动刷新** +- 日 K + 成交量迷你图,点击 **全屏**查看;K 线优先读服务端 SQLite,浏览器 `localStorage` 缓存约 1 小时 +- 资金费率当前值 + 历史迷你曲线 +- **数据统计**:连续三日均为成交额 Top30 的 **交集**(涨跌幅 **不限**) +- **大模型解读**(`gemma4:e4b`):对三日交集币种生成日 K 图 + 数据简析;每日 **08:05** 自动排队,每币间隔 **3 分钟**;服务启动后自动跑一轮(可关);需在 `.env` 配置 `LLM_API_KEY` ## 环境要求 -- Python 3.10+ -- 可访问 `fapi.binance.com` -- 企业微信群机器人 Webhook(可选,用于推送) +- Python 3.10+(**推荐项目内虚拟环境 `.venv`**,PM2 生产亦使用 `.venv`) +- 可访问 `fapi.binance.com`(国内服务器可配 SOCKS5,见 [DEPLOY.md](./DEPLOY.md)) +- 企业微信群机器人 Webhook(可选,用于 08:10 推送) +- 大模型网关(可选):默认 `http://op.bz121.com`,OpenAI 兼容 `/v1/chat/completions` ## 快速开始 -```bash +### Windows / 本机开发 + +```powershell # 1. 进入项目目录 cd 币安排名 -# 2. 安装依赖 +# 2. 创建并激活虚拟环境(依赖必须装进 venv,勿只装系统 Python) +python -m venv .venv +.\.venv\Scripts\Activate.ps1 + +# 3. 安装依赖(含 matplotlib,用于服务端生成日 K 图供大模型) +pip install -U pip pip install -r backend/requirements.txt -# 3. 配置环境变量 +# 4. 配置环境变量 copy .env.example .env -# 编辑 .env,填入 WECOM_WEBHOOK_URL +# 编辑 .env:WECOM_WEBHOOK_URL、LLM_API_KEY 等 -# 4. 启动服务(需保持进程常驻) +# 5. 启动(需保持进程常驻) python run.py ``` +### Linux 本机(与生产一致) + +```bash +cd /path/to/Binance_Altcoin_Monitor +python3 -m venv .venv +source .venv/bin/activate +pip install -U pip +pip install -r backend/requirements.txt +cp .env.example .env +nano .env +.venv/bin/python run.py +``` + 浏览器打开:http://127.0.0.1:21450 +> **生产环境**请用 [DEPLOY.md](./DEPLOY.md) 中的 Docker 或 PM2 脚本;PM2 使用 `.venv/bin/python`,更新代码后需执行 `.venv/bin/pip install -r backend/requirements.txt` 再重启。 + ## 配置说明(.env) | 变量 | 说明 | 默认 | @@ -47,22 +73,39 @@ python run.py | `TOP_N` | 排名数量 | 30 | | `VOLUME_THRESHOLD` | 高亮成交额阈值 (USDT) | 10000000 | | `CHANGE_THRESHOLD` | 高亮涨跌幅阈值 (%) | 5 | -| `REFRESH_MINUTES` | 今日数据刷新间隔 | 5 | -| `HOST` / `PORT` | 服务监听 | 127.0.0.1:21450 | +| `REFRESH_MINUTES` | 今日自动刷新间隔(分钟);`240` = 每 4 小时 | 240 | +| `HOST` / `PORT` | 服务监听 | 0.0.0.0:21450 | | `PROXY_ENABLED` | 是否启用 SOCKS5 代理 | false | | `PROXY_URL` | 代理地址 | socks5h://192.168.8.4:1081 | | `PROXY_FOR` | 代理范围 binance/wecom/all | binance | | `MAX_CONCURRENCY` | 币安 K 线并发数(过大易 418 封禁) | 3 | | `CANDIDATE_POOL` | 预筛候选合约数(按 24h 成交额) | 150 | | `CHART_KLINE_LIMIT` | 日 K 存储/展示根数 | 300 | -| `CHART_CACHE_MINUTES` | 本地日 K 视为新鲜的时间(分钟内不请求币安) | 60 | +| `CHART_CACHE_MINUTES` | 服务端日 K 视为新鲜的时间(分钟内不请求币安) | 60 | +| `LLM_BASE_URL` | 大模型网关根地址 | http://op.bz121.com | +| `LLM_API_KEY` | Bearer 密钥(`sk-...`,勿提交 git) | 空 | +| `LLM_MODEL` | 模型 ID,须与网关一致 | gemma4:e4b | +| `LLM_SYMBOL_INTERVAL_SEC` | 批量解读时每币间隔(秒) | 180 | +| `LLM_AUTO_ON_STARTUP` | 启动后是否自动跑一轮三日交集解读 | true | + +完整示例见仓库根目录 [`.env.example`](./.env.example)。 + ## API | 方法 | 路径 | 说明 | |------|------|------| +| GET | `/api/today/top30` | 今日周期 Top30 | | GET | `/api/yesterday/top30` | 昨日周期 Top30 | -| GET | `/api/today/top30` | 今日周期 Top30(缓存) | -| POST | `/api/push/test` | 手动测试企业微信推送 | +| GET | `/api/daybefore/top30` | 前日周期 Top30 | +| GET | `/api/stats/three-day` | 三日 Top30 交集统计 | +| GET | `/api/chart/{symbol}/daily` | 日 K JSON(SQLite 优先) | +| GET | `/api/chart/{symbol}/daily.png` | 日 K PNG(大模型/预览) | +| GET | `/api/funding/{symbol}/history` | 资金费率历史 | +| GET | `/api/llm/status` | 解读任务状态 | +| GET | `/api/llm/interpretations` | 最近一批解读结果 | +| POST | `/api/llm/interpret/run` | 手动启动三日交集解读队列 | +| GET | `/api/push/preview` | 预览企微推送(三日交集) | +| POST | `/api/push/test` | 手动测试企业微信推送(仅交集币种) | | POST | `/api/refresh/today` | 立即刷新今日数据 | | POST | `/api/refresh/yesterday` | 重新计算昨日快照 | @@ -70,11 +113,21 @@ python run.py | 时间 (北京时间) | 任务 | |-----------------|------| -| 08:00 | 固化昨日周期数据到 SQLite | -| 08:10 | 企业微信推送昨日 Top30 | -| 每 N 分钟 | 刷新今日周期(N = REFRESH_MINUTES) | +| 08:00 | 固化昨日、前日周期快照到 SQLite | +| 08:05 | 大模型解读「三日 Top30 交集」各币种(需 `LLM_API_KEY`) | +| 08:10 | 企业微信推送三日 Top30 交集(列表排版) | +| 每 4 小时(整点 0/4/8/12/16/20) | 刷新今日周期(由 `REFRESH_MINUTES=240` 控制) | -进程重启后:若已过 08:10 且当日尚未推送成功,会自动补推一次。 +进程重启后:若已过 08:10 且当日尚未推送成功,会自动补推;若已配置 `LLM_API_KEY` 且 `LLM_AUTO_ON_STARTUP=true`,会在后台自动启动一轮解读。 + +## Web 界面 + +| 页签 | 说明 | +|------|------| +| 今日 / 昨日 / 前日 | Top30 表、日 K、资金费率;支持排序与 CSV 导出 | +| 数据统计 | 三日交集列表 + 大模型解读区;可「开始解读」「刷新解读」 | + +今日数据 **不会** 在浏览器里每 60 秒轮询;请依赖 4 小时后台任务或页脚 **「立即刷新今日」**。 ## Windows 常驻运行 @@ -93,17 +146,32 @@ curl -X POST http://127.0.0.1:21450/api/push/test ## 数据说明 -- 使用币安合约 `1h` K 线按时间戳聚合 USDT 成交额(第 7 字段) +- 昨日 / 前日:按 08:00 切日,用 `1h` K 线聚合成交额 +- 今日:默认 `TODAY_DATA_MODE=ticker24h`(单次 API,滚动 24h 口径) - 涨跌幅 = (周期末价 - 周期开盘价) / 开盘价 × 100% -- 今日末价优先使用实时 ticker 价格 +- 日 K:最多 300 根存 `daily_klines`;浏览器 `localStorage` 缓存周期表约 4 小时、K 线约 1 小时 + +## 依赖说明 + +所有 Python 包(含 `matplotlib`、`fastapi`、`httpx` 等)写在 [`backend/requirements.txt`](./backend/requirements.txt): + +| 部署方式 | 安装位置 | +|----------|----------| +| 本机 / PM2 | 项目目录 **`.venv`**(`pip install -r backend/requirements.txt`) | +| Docker | 镜像构建时 `pip install`(见 `Dockerfile`) | + +**不要**只装到系统 Python:PM2 的 `ecosystem.config.cjs` 指定解释器为 `.venv/bin/python`,系统环境缺包会导致 `No module named matplotlib` 等错误。 ## 目录结构 ``` 币安排名/ -├── backend/app/ # 后端逻辑 -├── web/ # 前端静态页 -├── data/ # SQLite(自动创建) -├── run.py # 启动入口 -└── .env # 本地配置(勿提交) +├── backend/app/ # 后端逻辑(含 llm_service、chart_image) +├── backend/requirements.txt +├── web/ # 前端静态页 +├── data/monitor.db # SQLite(自动创建) +├── .venv/ # 虚拟环境(本地/PM2,勿提交) +├── deploy/ # 一键部署脚本 +├── run.py # 启动入口 +└── .env # 本地配置(勿提交) ``` diff --git a/backend/app/main.py b/backend/app/main.py index 92ea5ef..486f63f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -18,7 +18,7 @@ from .llm_service import get_interpret_state, run_interpretation_batch from .scheduler import job_finalize_yesterday, job_push_wecom, job_refresh_today, start_scheduler, startup_tasks, stop_scheduler from .stats import compute_three_day_stats from .aggregator import aggregate_period -from .wecom import build_markdown, send_wecom_markdown +from .wecom import build_markdown, build_push_payload, send_wecom_markdown from .state import get_today_cache logging.basicConfig( @@ -93,8 +93,17 @@ async def api_stats_three_day(): return compute_three_day_stats() +@app.get("/api/push/preview") +async def api_push_preview(): + """预览企微推送内容(三日交集,列表排版)。""" + return build_push_payload() + + @app.post("/api/push/test") async def api_push_test(): + payload = build_push_payload() + if not payload.get("ok"): + raise HTTPException(400, payload.get("message") or "三日交集数据未就绪") snap = get_latest_snapshot("yesterday") if not snap: start, end = get_yesterday_period() @@ -104,12 +113,15 @@ async def api_push_test(): snap = get_latest_snapshot("yesterday") if not snap: raise HTTPException(500, "无法生成昨日数据") - content = build_markdown(snap) - ok, msg = await send_wecom_markdown(content) + ok, msg = await send_wecom_markdown(payload["markdown"]) log_push(snap["period_start"], snap["period_end"], ok, msg) if not ok: raise HTTPException(500, f"推送失败: {msg}") - return {"success": True, "message": "推送成功"} + return { + "success": True, + "message": f"已推送 {payload.get('count', 0)} 个三日交集币种", + "count": payload.get("count", 0), + } @app.post("/api/refresh/yesterday") diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py index d0ab7a1..9eb0a62 100644 --- a/backend/app/scheduler.py +++ b/backend/app/scheduler.py @@ -15,7 +15,7 @@ from .funding_store import prefetch_funding from .kline_store import prefetch_symbols from .llm_service import run_interpretation_batch, schedule_interpret_background from .stats import compute_three_day_stats -from .wecom import build_markdown, send_wecom_markdown +from .wecom import build_push_payload, send_wecom_markdown logger = logging.getLogger(__name__) @@ -84,7 +84,11 @@ async def job_finalize_yesterday() -> None: async def job_push_wecom() -> None: - logger.info("Job: WeCom push") + logger.info("Job: WeCom push (three-day intersection)") + try: + await job_refresh_today() + except Exception as e: + logger.warning("Pre-push today refresh failed: %s", e) start, end = get_yesterday_period() snapshot = get_latest_snapshot("yesterday") if not snapshot and not binance_client.is_rate_limited(): @@ -106,8 +110,11 @@ async def job_push_wecom() -> None: logger.info("Already pushed for period %s ~ %s", ps, pe) return - content = build_markdown(snapshot) - ok, msg = await send_wecom_markdown(content) + payload = build_push_payload() + if not payload.get("ok"): + logger.warning("WeCom push skipped: %s", payload.get("message")) + return + ok, msg = await send_wecom_markdown(payload["markdown"]) log_push(ps, pe, ok, msg) if ok: logger.info("WeCom push succeeded") diff --git a/backend/app/wecom.py b/backend/app/wecom.py index 904f4d5..1ac1946 100644 --- a/backend/app/wecom.py +++ b/backend/app/wecom.py @@ -1,9 +1,11 @@ import logging +from typing import Any import httpx from .config import settings from .http_client import httpx_client_kwargs +from .stats import compute_three_day_stats logger = logging.getLogger(__name__) @@ -14,36 +16,96 @@ def _format_period_label(period_start: str, period_end: str) -> str: return f"{start} ~ {end}" -def build_markdown(snapshot: dict) -> str: - items = snapshot.get("items", []) - period_label = _format_period_label( - snapshot.get("period_start", ""), - snapshot.get("period_end", ""), - ) - lines = [ - "## 币安 U本位合约 成交额 Top30", - f"> 统计周期(北京时间 8:00 切日)", - f"> **{period_label}**", - "", - "| 排名 | 合约 | 成交额(USDT) | 涨跌幅 | 资金费率 | 标记 |", - "| --- | --- | --- | --- | --- | --- |", - ] - for row in items: - tags = [] - if row.get("is_high_volume"): - tags.append("千万+") - if row.get("is_high_change"): - tags.append("涨跌5%+") - tag_str = " ".join(tags) if tags else "-" - vol = row.get("quote_volume_fmt") or f"{row.get('quote_volume', 0):.0f}" - pct = row.get("price_change_pct_fmt") or f"{row.get('price_change_pct', 0):+.2f}%" - fr = row.get("funding_rate_fmt") or "-" - lines.append( - f"| {row['rank']} | {row['symbol']} | {vol} | {pct} | {fr} | {tag_str} |" +def _day_line(label: str, row: dict | None) -> str: + if not row or row.get("rank") is None: + return f"> {label}:—" + pct = row.get("price_change_pct_fmt") or f"{row.get('price_change_pct', 0):+.2f}%" + vol = row.get("quote_volume_fmt") or str(row.get("quote_volume", "")) + fr = row.get("funding_rate_fmt") or "—" + return f"> {label}:#{row['rank']} · 额 {vol} · 涨跌 {pct} · 费率 {fr}" + + +def build_push_payload() -> dict[str, Any]: + """构建企微推送内容:仅三日 Top30 交集,列表排版(非表格)。""" + stats = compute_three_day_stats() + periods = stats.get("periods") or {} + y_meta = periods.get("yesterday") or {} + period_label = "" + if y_meta.get("ready"): + period_label = _format_period_label( + y_meta.get("period_start", ""), + y_meta.get("period_end", ""), ) - lines.append("") - lines.append("> 标记说明:千万+ = 成交额≥1000万 USDT;涨跌5%+ = |涨跌幅|≥5%") - return "\n".join(lines) + + if not stats.get("ok"): + md = "\n".join( + [ + "## 币安 U本位 · 三日Top30交集", + "", + f"> 昨日周期 {period_label or '—'}", + "", + f"**暂无法推送**:{stats.get('message', '数据未就绪')}", + ] + ) + return { + "ok": False, + "message": stats.get("message", ""), + "count": 0, + "period_label": period_label, + "markdown": md, + "items": [], + } + + items = stats.get("items") or [] + lines = [ + "## 币安 U本位 · 三日Top30交集", + "", + f"> **昨日周期**(北京时间 8:00 切日)", + f"> {period_label}", + f"> 连续三日成交额均为 Top{settings.top_n},共 **{len(items)}** 个", + "", + ] + + preview_items: list[dict] = [] + for i, row in enumerate(items, 1): + sym = row["symbol"] + t, y, b = row.get("today"), row.get("yesterday"), row.get("daybefore") + lines.append(f"### {i}. {sym}") + lines.append(_day_line("昨日", y)) + lines.append(_day_line("今日", t)) + lines.append(_day_line("前日", b)) + lines.append("") + preview_items.append( + { + "rank": i, + "symbol": sym, + "today": t, + "yesterday": y, + "daybefore": b, + "total_quote_volume": row.get("total_quote_volume"), + } + ) + + if not items: + lines.append("**暂无交集币种**(请确认今日/昨日/前日快照均已生成)") + + lines.append("---") + lines.append("> 说明:仅推送三日均为成交额 Top30 的合约;涨跌不限") + + return { + "ok": True, + "message": stats.get("criteria", ""), + "count": len(items), + "period_label": period_label, + "markdown": "\n".join(lines), + "items": preview_items, + } + + +def build_markdown(snapshot: dict | None = None) -> str: + """兼容旧调用:返回企微 Markdown 文本(忽略 snapshot,以三日交集为准)。""" + _ = snapshot + return build_push_payload()["markdown"] async def send_wecom_markdown(content: str) -> tuple[bool, str]: diff --git a/web/app.js b/web/app.js index ac61efd..8a7c5cd 100644 --- a/web/app.js +++ b/web/app.js @@ -422,6 +422,90 @@ async function runLlmInterpret() { } } +function renderWecomDayRow(label, row) { + if (!row?.rank) { + return `
${label}
`; + } + const pct = row.price_change_pct ?? 0; + return `
+ ${label} + #${row.rank} + ${row.quote_volume_fmt || row.quote_volume} + ${row.price_change_pct_fmt || pct.toFixed(2) + "%"} + ${row.funding_rate_fmt || "—"} +
`; +} + +function renderWecomPreview(payload) { + const panel = document.getElementById("wecom-preview-panel"); + const cards = document.getElementById("wecom-preview-cards"); + const meta = document.getElementById("wecom-preview-meta"); + if (!panel || !cards) return; + panel.classList.remove("hidden"); + if (!payload?.ok) { + if (meta) meta.textContent = "未就绪"; + cards.innerHTML = `

${escapeHtml(payload?.message || "无法生成预览")}

`; + return; + } + if (meta) { + meta.textContent = `${payload.period_label || "—"} · ${payload.count} 个币种`; + } + if (!payload.items?.length) { + cards.innerHTML = '

暂无三日交集币种

'; + return; + } + cards.innerHTML = payload.items + .map( + (it) => ` +
+
+ ${it.rank} + ${it.symbol} +
+ ${renderWecomDayRow("昨日", it.yesterday)} + ${renderWecomDayRow("今日", it.today)} + ${renderWecomDayRow("前日", it.daybefore)} +
` + ) + .join(""); +} + +async function loadWecomPreview() { + const cards = document.getElementById("wecom-preview-cards"); + if (cards) cards.innerHTML = '

生成预览…

'; + try { + const res = await fetch("/api/push/preview"); + const data = await res.json(); + renderWecomPreview(data); + } catch (e) { + if (cards) cards.innerHTML = `

${e.message}

`; + } +} + +async function testWecomPush() { + const el = document.getElementById("push-status"); + if (el) el.textContent = "推送中…"; + try { + const res = await fetch("/api/push/test", { method: "POST" }); + const data = await res.json(); + if (!res.ok) { + const detail = data.detail; + const msg = + typeof detail === "string" + ? detail + : Array.isArray(detail) + ? detail.map((x) => x.msg).join("; ") + : data.message || res.statusText; + throw new Error(msg); + } + if (el) el.textContent = data.message || "推送成功"; + await loadWecomPreview(); + } catch (e) { + if (el) el.textContent = "推送失败"; + alert(e.message); + } +} + async function loadStats() { document.getElementById("stats-table-wrap").innerHTML = '

统计中…

'; @@ -431,6 +515,7 @@ async function loadStats() { await loadLlmInterpretations(); renderStatsTable(); await refreshLlmStatus(); + await loadWecomPreview(); } catch (e) { document.getElementById("stats-table-wrap").innerHTML = `

${e.message}

`; } @@ -513,6 +598,8 @@ document.getElementById("btn-reload-stats")?.addEventListener("click", () => { loadStats(); }); document.getElementById("btn-export-stats")?.addEventListener("click", exportStatsCsv); +document.getElementById("btn-push-preview")?.addEventListener("click", loadWecomPreview); +document.getElementById("btn-push-test")?.addEventListener("click", testWecomPush); loadPeriod("today"); loadPeriod("yesterday"); diff --git a/web/index.html b/web/index.html index c30d6aa..2ec2ec6 100644 --- a/web/index.html +++ b/web/index.html @@ -73,9 +73,16 @@
+ +

+
@@ -95,6 +102,7 @@
+
diff --git a/web/style.css b/web/style.css index 9d89534..9fb102d 100644 --- a/web/style.css +++ b/web/style.css @@ -413,6 +413,93 @@ button:hover { color: var(--muted); } +.wecom-preview-panel { + margin: 1rem 0 0; + padding: 1rem; + background: #121a26; + border: 1px solid var(--border); + border-radius: 10px; +} + +.wecom-preview-panel.hidden { + display: none; +} + +.wecom-preview-panel h3 { + margin: 0 0 0.5rem; + font-size: 1rem; +} + +.wecom-preview-meta { + font-size: 0.8rem; + color: var(--muted); + font-weight: normal; +} + +.wecom-preview-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 0.75rem; + margin-top: 0.75rem; +} + +.wecom-card { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.65rem 0.75rem; +} + +.wecom-card-head { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; + padding-bottom: 0.4rem; + border-bottom: 1px solid var(--border); +} + +.wecom-seq { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.5rem; + height: 1.5rem; + border-radius: 4px; + background: var(--accent); + color: #0d1118; + font-size: 0.75rem; + font-weight: 700; +} + +.wecom-symbol { + font-size: 1rem; +} + +.wecom-day { + display: grid; + grid-template-columns: 2.5rem 2.5rem 1fr auto auto; + gap: 0.35rem 0.5rem; + align-items: center; + font-size: 0.82rem; + padding: 0.2rem 0; +} + +.wecom-day-label { + color: var(--muted); +} + +.wecom-fr { + color: var(--muted); + font-size: 0.78rem; +} + +.push-status { + margin-left: 1rem; + font-size: 0.85rem; + color: var(--accent); +} + .chart-modal-inner h3 { margin: 0 0 0.25rem; font-size: 1.15rem;