first commit
This commit is contained in:
@@ -0,0 +1,10 @@
|
|||||||
|
.git
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
data/*.db
|
||||||
|
.cursor
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
WECOM_WEBHOOK_URL=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY_HERE
|
||||||
|
BINANCE_FAPI_BASE=https://fapi.binance.com
|
||||||
|
TOP_N=30
|
||||||
|
VOLUME_THRESHOLD=10000000
|
||||||
|
CHANGE_THRESHOLD=5
|
||||||
|
REFRESH_MINUTES=5
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=8000
|
||||||
|
|
||||||
|
# 代理(默认关闭)。服务器无法直连币安时再开启
|
||||||
|
PROXY_ENABLED=false
|
||||||
|
PROXY_URL=socks5h://192.168.8.4:1081
|
||||||
|
# 代理范围:binance=仅币安 | wecom=仅企微 | all=全部外网请求
|
||||||
|
PROXY_FOR=binance
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
.env
|
||||||
|
logs/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.db
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
# 部署说明 — Binance Altcoin Monitor
|
||||||
|
|
||||||
|
代码仓库:[https://git.bz121.com/dekun/Binance_Altcoin_Monitor.git](https://git.bz121.com/dekun/Binance_Altcoin_Monitor.git)
|
||||||
|
|
||||||
|
生产环境推荐安装路径:**`/opt/Binance_Altcoin_Monitor`**
|
||||||
|
|
||||||
|
支持两种一键部署方式:**Docker**、**PM2**(二选一即可)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、服务器要求
|
||||||
|
|
||||||
|
| 项目 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 系统 | Linux(推荐 Ubuntu 22.04+ / Debian 12+) |
|
||||||
|
| 时区 | `Asia/Shanghai`(定时任务 08:00 / 08:10 北京时间) |
|
||||||
|
| 网络 | 能访问 `fapi.binance.com`;若不能,需开启 SOCKS5 代理(见第四节) |
|
||||||
|
| 内存 | 建议 ≥ 512MB |
|
||||||
|
| 磁盘 | ≥ 500MB(含日志与 SQLite) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、部署前准备
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 安装 git
|
||||||
|
sudo apt update && sudo apt install -y git
|
||||||
|
|
||||||
|
# 2. 克隆到 /opt(也可交给一键脚本自动完成)
|
||||||
|
sudo mkdir -p /opt
|
||||||
|
sudo git clone https://git.bz121.com/dekun/Binance_Altcoin_Monitor.git /opt/Binance_Altcoin_Monitor
|
||||||
|
sudo chown -R $USER:$USER /opt/Binance_Altcoin_Monitor
|
||||||
|
|
||||||
|
# 3. 配置环境变量
|
||||||
|
cd /opt/Binance_Altcoin_Monitor
|
||||||
|
cp .env.example .env
|
||||||
|
nano .env # 至少填写 WECOM_WEBHOOK_URL
|
||||||
|
```
|
||||||
|
|
||||||
|
`.env` 常用项:
|
||||||
|
|
||||||
|
```env
|
||||||
|
WECOM_WEBHOOK_URL=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=你的key
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=8000
|
||||||
|
|
||||||
|
# 代理默认关闭
|
||||||
|
PROXY_ENABLED=false
|
||||||
|
PROXY_URL=socks5h://192.168.8.4:1081
|
||||||
|
PROXY_FOR=binance
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、方式 A — Docker 一键部署(推荐)
|
||||||
|
|
||||||
|
### 3.1 安装 Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ubuntu 示例
|
||||||
|
curl -fsSL https://get.docker.com | sudo sh
|
||||||
|
sudo usermod -aG docker $USER
|
||||||
|
# 重新登录 shell 后生效
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 一键部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/Binance_Altcoin_Monitor
|
||||||
|
chmod +x deploy/docker-deploy.sh
|
||||||
|
./deploy/docker-deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本将:克隆/更新代码 → 生成 `.env` → `docker compose build` → `docker compose up -d`。
|
||||||
|
|
||||||
|
### 3.3 常用命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/Binance_Altcoin_Monitor
|
||||||
|
|
||||||
|
# 查看状态
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
# 查看日志
|
||||||
|
docker compose logs -f
|
||||||
|
|
||||||
|
# 重启(改 .env 后)
|
||||||
|
docker compose up -d --force-recreate
|
||||||
|
|
||||||
|
# 停止
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# 手动测试企微推送
|
||||||
|
curl -X POST http://127.0.0.1:8000/api/push/test
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 访问 Web
|
||||||
|
|
||||||
|
浏览器打开:`http://服务器IP:8000`
|
||||||
|
|
||||||
|
若前面有 Nginx,可反代到 8000 端口。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、方式 B — PM2 一键部署
|
||||||
|
|
||||||
|
### 4.1 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Python 3.10+
|
||||||
|
sudo apt install -y python3 python3-pip python3-venv
|
||||||
|
|
||||||
|
# Node.js + PM2
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||||
|
sudo apt install -y nodejs
|
||||||
|
sudo npm install -g pm2
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 一键部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/Binance_Altcoin_Monitor
|
||||||
|
chmod +x deploy/pm2-deploy.sh
|
||||||
|
./deploy/pm2-deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 常用命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 status
|
||||||
|
pm2 logs binance-altcoin-monitor
|
||||||
|
pm2 restart binance-altcoin-monitor
|
||||||
|
pm2 stop binance-altcoin-monitor
|
||||||
|
|
||||||
|
# 开机自启(按 pm2 startup 提示执行 sudo 命令后)
|
||||||
|
pm2 save
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、SOCKS5 代理(默认关闭)
|
||||||
|
|
||||||
|
当服务器**无法直连**币安 API 时,可通过内网 SOCKS5 代理访问。
|
||||||
|
|
||||||
|
| 变量 | 默认值 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| `PROXY_ENABLED` | `false` | `true` 时启用代理 |
|
||||||
|
| `PROXY_URL` | `socks5h://192.168.8.4:1081` | `socks5h` 表示由代理解析 DNS |
|
||||||
|
| `PROXY_FOR` | `binance` | `binance` 仅币安;`wecom` 仅企微;`all` 全部 HTTP |
|
||||||
|
|
||||||
|
### 如何开启
|
||||||
|
|
||||||
|
1. 编辑 `/opt/Binance_Altcoin_Monitor/.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
PROXY_ENABLED=true
|
||||||
|
PROXY_URL=socks5h://192.168.8.4:1081
|
||||||
|
PROXY_FOR=binance
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 使配置生效:
|
||||||
|
|
||||||
|
**Docker:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/Binance_Altcoin_Monitor
|
||||||
|
docker compose up -d --force-recreate
|
||||||
|
```
|
||||||
|
|
||||||
|
**PM2:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 restart binance-altcoin-monitor
|
||||||
|
```
|
||||||
|
|
||||||
|
### 如何关闭
|
||||||
|
|
||||||
|
```env
|
||||||
|
PROXY_ENABLED=false
|
||||||
|
```
|
||||||
|
|
||||||
|
同样执行上面的重启命令。
|
||||||
|
|
||||||
|
### 说明
|
||||||
|
|
||||||
|
- **默认不走代理**,直连币安与企微。
|
||||||
|
- 企微 Webhook 在国内一般可直连,建议 `PROXY_FOR=binance`,避免企微也走代理。
|
||||||
|
- Docker 容器访问宿主机局域网代理时,`.env` 中 `PROXY_URL` 可改为宿主机 IP,例如 `socks5h://192.168.8.4:1081`(需保证容器网络能访问该地址)。
|
||||||
|
- 依赖 `httpx[socks]`,Docker 镜像与 PM2 部署脚本已包含。
|
||||||
|
|
||||||
|
### 验证代理是否生效
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看日志中是否有连接错误
|
||||||
|
docker compose logs -f # Docker
|
||||||
|
pm2 logs binance-altcoin-monitor # PM2
|
||||||
|
|
||||||
|
# 触发刷新,观察是否拉取到数据
|
||||||
|
curl http://127.0.0.1:8000/api/refresh/today -X POST
|
||||||
|
curl http://127.0.0.1:8000/api/today/top30
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、防火墙与 Nginx(可选)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 开放 8000(若直接对外)
|
||||||
|
sudo ufw allow 8000/tcp
|
||||||
|
```
|
||||||
|
|
||||||
|
Nginx 反代示例:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name monitor.example.com;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、定时任务说明
|
||||||
|
|
||||||
|
| 时间(北京时间) | 行为 |
|
||||||
|
|------------------|------|
|
||||||
|
| 08:00 | 固化昨日周期数据 |
|
||||||
|
| 08:10 | 企业微信推送昨日 Top30 |
|
||||||
|
| 每 5 分钟 | 刷新今日数据(`REFRESH_MINUTES` 可改) |
|
||||||
|
|
||||||
|
**进程需常驻**(Docker `restart: unless-stopped` 或 PM2 `autorestart`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、目录与数据
|
||||||
|
|
||||||
|
```
|
||||||
|
/opt/Binance_Altcoin_Monitor/
|
||||||
|
├── .env # 配置(勿提交 git)
|
||||||
|
├── data/monitor.db # SQLite 数据
|
||||||
|
├── logs/ # PM2 日志(Docker 用 docker compose logs)
|
||||||
|
├── deploy/ # 一键脚本
|
||||||
|
├── docker-compose.yml
|
||||||
|
└── ecosystem.config.cjs
|
||||||
|
```
|
||||||
|
|
||||||
|
备份建议:定期备份 `data/monitor.db` 与 `.env`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、更新版本
|
||||||
|
|
||||||
|
**Docker:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/Binance_Altcoin_Monitor
|
||||||
|
git pull
|
||||||
|
docker compose build --no-cache
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
**PM2:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/Binance_Altcoin_Monitor
|
||||||
|
git pull
|
||||||
|
python3 -m pip install -r backend/requirements.txt -q
|
||||||
|
pm2 restart binance-altcoin-monitor
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、故障排查
|
||||||
|
|
||||||
|
| 现象 | 处理 |
|
||||||
|
|------|------|
|
||||||
|
| Web 无数据 | 检查能否访问币安;国内服务器尝试 `PROXY_ENABLED=true` |
|
||||||
|
| 企微收不到 | 检查 `WECOM_WEBHOOK_URL`;`curl -X POST .../api/push/test` |
|
||||||
|
| 08:10 未推送 | 确认容器/PM2 在 08:10 前已运行;查日志 |
|
||||||
|
| 端口占用 | `ss -tlnp \| grep 8000` 或改 `.env` 中 `PORT` |
|
||||||
|
| Docker 代理连不上 | 确认 `192.168.8.4:1081` 从容器内可达,必要时改宿主机 IP |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十一、快速命令速查
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Docker 一键
|
||||||
|
./deploy/docker-deploy.sh
|
||||||
|
|
||||||
|
# PM2 一键
|
||||||
|
./deploy/pm2-deploy.sh
|
||||||
|
|
||||||
|
# 开启代理后重启
|
||||||
|
# Docker: docker compose up -d --force-recreate
|
||||||
|
# PM2: pm2 restart binance-altcoin-monitor
|
||||||
|
```
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
TZ=Asia/Shanghai
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends tzdata \
|
||||||
|
&& ln -snf /usr/share/zoneinfo/$TZ /etc/localtime \
|
||||||
|
&& echo $TZ > /etc/timezone \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY backend/requirements.txt /app/backend/requirements.txt
|
||||||
|
RUN pip install --no-cache-dir -r /app/backend/requirements.txt
|
||||||
|
|
||||||
|
COPY . /app
|
||||||
|
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["python", "run.py"]
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# 币安 U本位合约 · 日成交额 Top30 监控
|
||||||
|
|
||||||
|
仓库:[Binance_Altcoin_Monitor](https://git.bz121.com/dekun/Binance_Altcoin_Monitor.git)
|
||||||
|
|
||||||
|
按 **北京时间 08:00** 切日,统计 U 本位永续合约成交额 Top30;每日 **08:10** 通过企业微信推送昨日完整周期数据;Web 展示昨日快照与今日实时累计。
|
||||||
|
|
||||||
|
> **Linux 生产部署(/opt、Docker、PM2、SOCKS5 代理)请参阅 [DEPLOY.md](./DEPLOY.md)**
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- 成交额排名 Top30(USDT 计价)
|
||||||
|
- 高亮标记(不改变排名):成交额 ≥ 1000万 USDT、|涨跌幅| ≥ 5%
|
||||||
|
- 昨日周期:`[D-1 08:00, D 08:00)`
|
||||||
|
- 今日周期:`[D 08:00, 当前)`,每 5 分钟后台刷新,Web 每 60 秒拉取
|
||||||
|
|
||||||
|
## 环境要求
|
||||||
|
|
||||||
|
- Python 3.10+
|
||||||
|
- 可访问 `fapi.binance.com`
|
||||||
|
- 企业微信群机器人 Webhook(可选,用于推送)
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 进入项目目录
|
||||||
|
cd 币安排名
|
||||||
|
|
||||||
|
# 2. 安装依赖
|
||||||
|
pip install -r backend/requirements.txt
|
||||||
|
|
||||||
|
# 3. 配置环境变量
|
||||||
|
copy .env.example .env
|
||||||
|
# 编辑 .env,填入 WECOM_WEBHOOK_URL
|
||||||
|
|
||||||
|
# 4. 启动服务(需保持进程常驻)
|
||||||
|
python run.py
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器打开:http://127.0.0.1:8000
|
||||||
|
|
||||||
|
## 配置说明(.env)
|
||||||
|
|
||||||
|
| 变量 | 说明 | 默认 |
|
||||||
|
|------|------|------|
|
||||||
|
| `WECOM_WEBHOOK_URL` | 企业微信机器人地址 | 空(不推送) |
|
||||||
|
| `TOP_N` | 排名数量 | 30 |
|
||||||
|
| `VOLUME_THRESHOLD` | 高亮成交额阈值 (USDT) | 10000000 |
|
||||||
|
| `CHANGE_THRESHOLD` | 高亮涨跌幅阈值 (%) | 5 |
|
||||||
|
| `REFRESH_MINUTES` | 今日数据刷新间隔 | 5 |
|
||||||
|
| `HOST` / `PORT` | 服务监听 | 127.0.0.1:8000 |
|
||||||
|
| `PROXY_ENABLED` | 是否启用 SOCKS5 代理 | false |
|
||||||
|
| `PROXY_URL` | 代理地址 | socks5h://192.168.8.4:1081 |
|
||||||
|
| `PROXY_FOR` | 代理范围 binance/wecom/all | binance |
|
||||||
|
## API
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/api/yesterday/top30` | 昨日周期 Top30 |
|
||||||
|
| GET | `/api/today/top30` | 今日周期 Top30(缓存) |
|
||||||
|
| POST | `/api/push/test` | 手动测试企业微信推送 |
|
||||||
|
| POST | `/api/refresh/today` | 立即刷新今日数据 |
|
||||||
|
| POST | `/api/refresh/yesterday` | 重新计算昨日快照 |
|
||||||
|
|
||||||
|
## 定时任务
|
||||||
|
|
||||||
|
| 时间 (北京时间) | 任务 |
|
||||||
|
|-----------------|------|
|
||||||
|
| 08:00 | 固化昨日周期数据到 SQLite |
|
||||||
|
| 08:10 | 企业微信推送昨日 Top30 |
|
||||||
|
| 每 N 分钟 | 刷新今日周期(N = REFRESH_MINUTES) |
|
||||||
|
|
||||||
|
进程重启后:若已过 08:10 且当日尚未推送成功,会自动补推一次。
|
||||||
|
|
||||||
|
## Windows 常驻运行
|
||||||
|
|
||||||
|
1. **任务计划程序**:触发器「登录时」或「计算机启动时」,操作运行 `pythonw.exe` 完整路径的 `run.py`,起始于项目目录。
|
||||||
|
2. 或使用云服务器 / VPS 用 `nssm`、pm2 等托管。
|
||||||
|
|
||||||
|
## 企业微信配置
|
||||||
|
|
||||||
|
1. 企业微信群 → 群设置 → 群机器人 → 添加
|
||||||
|
2. 复制 Webhook 地址到 `.env` 的 `WECOM_WEBHOOK_URL`
|
||||||
|
3. 启动后访问 `POST http://127.0.0.1:8000/api/push/test` 测试(可用 Postman 或 curl)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:8000/api/push/test
|
||||||
|
```
|
||||||
|
|
||||||
|
## 数据说明
|
||||||
|
|
||||||
|
- 使用币安合约 `1h` K 线按时间戳聚合 USDT 成交额(第 7 字段)
|
||||||
|
- 涨跌幅 = (周期末价 - 周期开盘价) / 开盘价 × 100%
|
||||||
|
- 今日末价优先使用实时 ticker 价格
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
币安排名/
|
||||||
|
├── backend/app/ # 后端逻辑
|
||||||
|
├── web/ # 前端静态页
|
||||||
|
├── data/ # SQLite(自动创建)
|
||||||
|
├── run.py # 启动入口
|
||||||
|
└── .env # 本地配置(勿提交)
|
||||||
|
```
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, asdict
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from .binance import binance_client
|
||||||
|
from .config import settings
|
||||||
|
from .periods import to_ms
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SymbolStats:
|
||||||
|
symbol: str
|
||||||
|
quote_volume: float
|
||||||
|
price_change_pct: float
|
||||||
|
open_price: float
|
||||||
|
last_price: float
|
||||||
|
rank: int = 0
|
||||||
|
is_high_volume: bool = False
|
||||||
|
is_high_change: bool = False
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
d = asdict(self)
|
||||||
|
d["quote_volume_fmt"] = format_volume(self.quote_volume)
|
||||||
|
d["price_change_pct_fmt"] = f"{self.price_change_pct:+.2f}%"
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def format_volume(vol: float) -> str:
|
||||||
|
if vol >= 1e8:
|
||||||
|
return f"{vol / 1e8:.2f}亿"
|
||||||
|
if vol >= 1e4:
|
||||||
|
return f"{vol / 1e4:.2f}万"
|
||||||
|
return f"{vol:.0f}"
|
||||||
|
|
||||||
|
|
||||||
|
def _aggregate_klines(klines: list, start_ms: int, end_ms: int) -> tuple[float, float, float]:
|
||||||
|
quote_vol = 0.0
|
||||||
|
open_price = 0.0
|
||||||
|
last_price = 0.0
|
||||||
|
first = True
|
||||||
|
for k in klines:
|
||||||
|
open_time = int(k[0])
|
||||||
|
if open_time < start_ms or open_time >= end_ms:
|
||||||
|
continue
|
||||||
|
if first:
|
||||||
|
open_price = float(k[1])
|
||||||
|
first = False
|
||||||
|
last_price = float(k[4])
|
||||||
|
quote_vol += float(k[7])
|
||||||
|
return quote_vol, open_price, last_price
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_symbol_stats(
|
||||||
|
symbol: str,
|
||||||
|
start_ms: int,
|
||||||
|
end_ms: int,
|
||||||
|
prices: dict[str, float],
|
||||||
|
sem: asyncio.Semaphore,
|
||||||
|
) -> SymbolStats | None:
|
||||||
|
async with sem:
|
||||||
|
try:
|
||||||
|
klines = await binance_client.get_klines(symbol, start_ms, end_ms)
|
||||||
|
quote_vol, open_price, last_price = _aggregate_klines(klines, start_ms, end_ms)
|
||||||
|
if open_price <= 0 and last_price <= 0:
|
||||||
|
return None
|
||||||
|
if open_price <= 0:
|
||||||
|
open_price = last_price
|
||||||
|
if last_price <= 0:
|
||||||
|
last_price = prices.get(symbol, open_price)
|
||||||
|
if last_price <= 0:
|
||||||
|
return None
|
||||||
|
pct = ((last_price - open_price) / open_price) * 100 if open_price > 0 else 0.0
|
||||||
|
return SymbolStats(
|
||||||
|
symbol=symbol,
|
||||||
|
quote_volume=quote_vol,
|
||||||
|
price_change_pct=pct,
|
||||||
|
open_price=open_price,
|
||||||
|
last_price=last_price,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed %s: %s", symbol, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def aggregate_period(
|
||||||
|
start: datetime,
|
||||||
|
end: datetime,
|
||||||
|
use_live_prices: bool = False,
|
||||||
|
) -> list[dict]:
|
||||||
|
symbols = await binance_client.get_usdt_perpetual_symbols()
|
||||||
|
start_ms = to_ms(start)
|
||||||
|
end_ms = to_ms(end)
|
||||||
|
|
||||||
|
prices: dict[str, float] = {}
|
||||||
|
if use_live_prices:
|
||||||
|
try:
|
||||||
|
prices = await binance_client.get_prices_batch(symbols)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Batch prices failed: %s", e)
|
||||||
|
|
||||||
|
sem = asyncio.Semaphore(settings.max_concurrency)
|
||||||
|
tasks = [
|
||||||
|
_fetch_symbol_stats(s, start_ms, end_ms, prices, sem) for s in symbols
|
||||||
|
]
|
||||||
|
results = await asyncio.gather(*tasks)
|
||||||
|
stats = [r for r in results if r is not None and r.quote_volume > 0]
|
||||||
|
stats.sort(key=lambda x: x.quote_volume, reverse=True)
|
||||||
|
top = stats[: settings.top_n]
|
||||||
|
|
||||||
|
for i, s in enumerate(top, 1):
|
||||||
|
s.rank = i
|
||||||
|
s.is_high_volume = s.quote_volume >= settings.volume_threshold
|
||||||
|
s.is_high_change = abs(s.price_change_pct) >= settings.change_threshold
|
||||||
|
|
||||||
|
return [s.to_dict() for s in top]
|
||||||
|
|
||||||
|
|
||||||
|
def enrich_snapshot_meta(
|
||||||
|
items: list[dict],
|
||||||
|
period_start: datetime,
|
||||||
|
period_end: datetime,
|
||||||
|
) -> dict:
|
||||||
|
return {
|
||||||
|
"period_start": period_start.isoformat(),
|
||||||
|
"period_end": period_end.isoformat(),
|
||||||
|
"updated_at": datetime.now().isoformat(),
|
||||||
|
"top_n": settings.top_n,
|
||||||
|
"volume_threshold": settings.volume_threshold,
|
||||||
|
"change_threshold": settings.change_threshold,
|
||||||
|
"items": items,
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
from .http_client import httpx_client_kwargs
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class BinanceFuturesClient:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.base = settings.binance_fapi_base.rstrip("/")
|
||||||
|
self._symbols_cache: list[str] | None = None
|
||||||
|
|
||||||
|
async def _get(self, path: str, params: dict | None = None) -> Any:
|
||||||
|
url = f"{self.base}{path}"
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
timeout=30.0, **httpx_client_kwargs("binance")
|
||||||
|
) as client:
|
||||||
|
resp = await client.get(url, params=params or {})
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
async def get_usdt_perpetual_symbols(self) -> list[str]:
|
||||||
|
if self._symbols_cache:
|
||||||
|
return self._symbols_cache
|
||||||
|
info = await self._get("/fapi/v1/exchangeInfo")
|
||||||
|
symbols = []
|
||||||
|
for s in info.get("symbols", []):
|
||||||
|
if (
|
||||||
|
s.get("contractType") == "PERPETUAL"
|
||||||
|
and s.get("quoteAsset") == "USDT"
|
||||||
|
and s.get("status") == "TRADING"
|
||||||
|
):
|
||||||
|
symbols.append(s["symbol"])
|
||||||
|
self._symbols_cache = sorted(symbols)
|
||||||
|
logger.info("Loaded %d USDT perpetual symbols", len(self._symbols_cache))
|
||||||
|
return self._symbols_cache
|
||||||
|
|
||||||
|
def clear_symbol_cache(self) -> None:
|
||||||
|
self._symbols_cache = None
|
||||||
|
|
||||||
|
async def get_klines(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
start_ms: int,
|
||||||
|
end_ms: int,
|
||||||
|
interval: str = "1h",
|
||||||
|
) -> list[list]:
|
||||||
|
all_klines: list[list] = []
|
||||||
|
cursor = start_ms
|
||||||
|
while cursor < end_ms:
|
||||||
|
batch = await self._get(
|
||||||
|
"/fapi/v1/klines",
|
||||||
|
{
|
||||||
|
"symbol": symbol,
|
||||||
|
"interval": interval,
|
||||||
|
"startTime": cursor,
|
||||||
|
"endTime": end_ms,
|
||||||
|
"limit": 1500,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if not batch:
|
||||||
|
break
|
||||||
|
all_klines.extend(batch)
|
||||||
|
last_open = int(batch[-1][0])
|
||||||
|
next_cursor = last_open + 3600_000
|
||||||
|
if next_cursor <= cursor:
|
||||||
|
break
|
||||||
|
cursor = next_cursor
|
||||||
|
if len(batch) < 1500:
|
||||||
|
break
|
||||||
|
return all_klines
|
||||||
|
|
||||||
|
async def get_price(self, symbol: str) -> float:
|
||||||
|
data = await self._get("/fapi/v1/ticker/price", {"symbol": symbol})
|
||||||
|
return float(data["price"])
|
||||||
|
|
||||||
|
async def get_prices_batch(self, symbols: list[str]) -> dict[str, float]:
|
||||||
|
tickers = await self._get("/fapi/v1/ticker/price")
|
||||||
|
sym_set = set(symbols)
|
||||||
|
return {t["symbol"]: float(t["price"]) for t in tickers if t["symbol"] in sym_set}
|
||||||
|
|
||||||
|
|
||||||
|
binance_client = BinanceFuturesClient()
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=str(ROOT_DIR / ".env"),
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
wecom_webhook_url: str = ""
|
||||||
|
binance_fapi_base: str = "https://fapi.binance.com"
|
||||||
|
top_n: int = 30
|
||||||
|
volume_threshold: float = 10_000_000
|
||||||
|
change_threshold: float = 5.0
|
||||||
|
refresh_minutes: int = 5
|
||||||
|
host: str = "127.0.0.1"
|
||||||
|
port: int = 8000
|
||||||
|
db_path: str = str(ROOT_DIR / "data" / "monitor.db")
|
||||||
|
max_concurrency: int = 20
|
||||||
|
# 代理默认关闭;仅当 PROXY_ENABLED=true 时生效
|
||||||
|
proxy_enabled: bool = False
|
||||||
|
proxy_url: str = "socks5h://192.168.8.4:1081"
|
||||||
|
proxy_for: str = "binance" # binance | wecom | all
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_db_dir() -> None:
|
||||||
|
Path(settings.db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_conn():
|
||||||
|
_ensure_db_dir()
|
||||||
|
conn = sqlite3.connect(settings.db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def init_db() -> None:
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS period_snapshots (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
period_type TEXT NOT NULL,
|
||||||
|
period_start TEXT NOT NULL,
|
||||||
|
period_end TEXT NOT NULL,
|
||||||
|
snapshot_json TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
UNIQUE(period_type, period_start, period_end)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS push_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
period_start TEXT NOT NULL,
|
||||||
|
period_end TEXT NOT NULL,
|
||||||
|
pushed_at TEXT NOT NULL,
|
||||||
|
success INTEGER NOT NULL,
|
||||||
|
message TEXT
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def save_snapshot(
|
||||||
|
period_type: str,
|
||||||
|
period_start: datetime,
|
||||||
|
period_end: datetime,
|
||||||
|
data: list[dict[str, Any]],
|
||||||
|
) -> None:
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO period_snapshots (period_type, period_start, period_end, snapshot_json, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(period_type, period_start, period_end) DO UPDATE SET
|
||||||
|
snapshot_json = excluded.snapshot_json,
|
||||||
|
created_at = excluded.created_at
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
period_type,
|
||||||
|
period_start.isoformat(),
|
||||||
|
period_end.isoformat(),
|
||||||
|
json.dumps(data, ensure_ascii=False),
|
||||||
|
datetime.now().isoformat(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_latest_snapshot(period_type: str) -> dict[str, Any] | None:
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT period_start, period_end, snapshot_json, created_at
|
||||||
|
FROM period_snapshots
|
||||||
|
WHERE period_type = ?
|
||||||
|
ORDER BY period_end DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(period_type,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"period_start": row["period_start"],
|
||||||
|
"period_end": row["period_end"],
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
"items": json.loads(row["snapshot_json"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def log_push(period_start: str, period_end: str, success: bool, message: str = "") -> None:
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO push_log (period_start, period_end, pushed_at, success, message)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(period_start, period_end, datetime.now().isoformat(), int(success), message),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def was_pushed_today(period_start: str, period_end: str) -> bool:
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1 FROM push_log
|
||||||
|
WHERE period_start = ? AND period_end = ? AND success = 1
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(period_start, period_end),
|
||||||
|
).fetchone()
|
||||||
|
return row is not None
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""Shared HTTP client options (optional SOCKS5 proxy)."""
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def proxy_for(target: str) -> str | None:
|
||||||
|
"""
|
||||||
|
target: 'binance' | 'wecom'
|
||||||
|
Returns proxy URL when enabled and scope matches.
|
||||||
|
"""
|
||||||
|
if not settings.proxy_enabled or not settings.proxy_url.strip():
|
||||||
|
return None
|
||||||
|
scope = settings.proxy_for.lower()
|
||||||
|
if scope == "all" or scope == target:
|
||||||
|
return settings.proxy_url.strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def httpx_client_kwargs(target: str, **extra) -> dict:
|
||||||
|
kwargs = dict(extra)
|
||||||
|
proxy = proxy_for(target)
|
||||||
|
if proxy:
|
||||||
|
kwargs["proxy"] = proxy
|
||||||
|
return kwargs
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from .aggregator import aggregate_period, enrich_snapshot_meta
|
||||||
|
from .config import ROOT_DIR, settings
|
||||||
|
from .db import get_latest_snapshot, init_db, log_push
|
||||||
|
from .periods import get_today_period, get_yesterday_period
|
||||||
|
from .scheduler import job_finalize_yesterday, job_push_wecom, job_refresh_today, start_scheduler, startup_tasks, stop_scheduler
|
||||||
|
from .state import get_today_cache
|
||||||
|
from .wecom import build_markdown, send_wecom_markdown
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
WEB_DIR = ROOT_DIR / "web"
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
init_db()
|
||||||
|
if settings.proxy_enabled:
|
||||||
|
logger.info(
|
||||||
|
"Proxy enabled: %s (scope=%s)",
|
||||||
|
settings.proxy_url,
|
||||||
|
settings.proxy_for,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info("Proxy disabled (direct connection)")
|
||||||
|
await startup_tasks()
|
||||||
|
start_scheduler()
|
||||||
|
yield
|
||||||
|
stop_scheduler()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="币安成交量排名监控", lifespan=lifespan)
|
||||||
|
|
||||||
|
if WEB_DIR.exists():
|
||||||
|
app.mount("/static", StaticFiles(directory=str(WEB_DIR)), name="static")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def index():
|
||||||
|
index_path = WEB_DIR / "index.html"
|
||||||
|
if index_path.exists():
|
||||||
|
return FileResponse(index_path)
|
||||||
|
return {"message": "Web UI not found. Place files in /web"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/yesterday/top30")
|
||||||
|
async def api_yesterday_top30():
|
||||||
|
snap = get_latest_snapshot("yesterday")
|
||||||
|
if snap:
|
||||||
|
return {
|
||||||
|
"period_start": snap["period_start"],
|
||||||
|
"period_end": snap["period_end"],
|
||||||
|
"updated_at": snap["created_at"],
|
||||||
|
"top_n": settings.top_n,
|
||||||
|
"volume_threshold": settings.volume_threshold,
|
||||||
|
"change_threshold": settings.change_threshold,
|
||||||
|
"items": snap["items"],
|
||||||
|
}
|
||||||
|
start, end = get_yesterday_period()
|
||||||
|
try:
|
||||||
|
items = await aggregate_period(start, end)
|
||||||
|
return enrich_snapshot_meta(items, start, end)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("api yesterday failed: %s", e)
|
||||||
|
meta = enrich_snapshot_meta([], start, end)
|
||||||
|
meta["error"] = "数据暂不可用,请检查网络或稍后重试"
|
||||||
|
return meta
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/today/top30")
|
||||||
|
async def api_today_top30():
|
||||||
|
cached = get_today_cache()
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
start, end = get_today_period()
|
||||||
|
try:
|
||||||
|
items = await aggregate_period(start, end, use_live_prices=True)
|
||||||
|
return enrich_snapshot_meta(items, start, end)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("api today failed: %s", e)
|
||||||
|
meta = enrich_snapshot_meta([], start, end)
|
||||||
|
meta["error"] = "数据暂不可用,请检查网络或稍后重试"
|
||||||
|
return meta
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/push/test")
|
||||||
|
async def api_push_test():
|
||||||
|
snap = get_latest_snapshot("yesterday")
|
||||||
|
if not snap:
|
||||||
|
start, end = get_yesterday_period()
|
||||||
|
items = await aggregate_period(start, end)
|
||||||
|
from .db import save_snapshot
|
||||||
|
save_snapshot("yesterday", start, end, items)
|
||||||
|
snap = get_latest_snapshot("yesterday")
|
||||||
|
if not snap:
|
||||||
|
raise HTTPException(500, "无法生成昨日数据")
|
||||||
|
content = build_markdown(snap)
|
||||||
|
ok, msg = await send_wecom_markdown(content)
|
||||||
|
log_push(snap["period_start"], snap["period_end"], ok, msg)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(500, f"推送失败: {msg}")
|
||||||
|
return {"success": True, "message": "推送成功"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/refresh/yesterday")
|
||||||
|
async def api_refresh_yesterday():
|
||||||
|
await job_finalize_yesterday()
|
||||||
|
snap = get_latest_snapshot("yesterday")
|
||||||
|
return snap or {"message": "done"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/refresh/today")
|
||||||
|
async def api_refresh_today():
|
||||||
|
await job_refresh_today()
|
||||||
|
return get_today_cache() or {"message": "done"}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
TZ = ZoneInfo("Asia/Shanghai")
|
||||||
|
DAY_CUTOFF_HOUR = 8
|
||||||
|
|
||||||
|
|
||||||
|
def now_shanghai() -> datetime:
|
||||||
|
return datetime.now(TZ)
|
||||||
|
|
||||||
|
|
||||||
|
def _align_cutoff(dt: datetime) -> datetime:
|
||||||
|
cutoff = dt.replace(hour=DAY_CUTOFF_HOUR, minute=0, second=0, microsecond=0)
|
||||||
|
if dt < cutoff:
|
||||||
|
cutoff -= timedelta(days=1)
|
||||||
|
return cutoff
|
||||||
|
|
||||||
|
|
||||||
|
def get_yesterday_period(now: datetime | None = None) -> tuple[datetime, datetime]:
|
||||||
|
"""[D-1 08:00, D 08:00) in Shanghai time."""
|
||||||
|
now = now or now_shanghai()
|
||||||
|
end = _align_cutoff(now)
|
||||||
|
start = end - timedelta(days=1)
|
||||||
|
return start, end
|
||||||
|
|
||||||
|
|
||||||
|
def get_today_period(now: datetime | None = None) -> tuple[datetime, datetime]:
|
||||||
|
"""[D 08:00, now) in Shanghai time."""
|
||||||
|
now = now or now_shanghai()
|
||||||
|
start = _align_cutoff(now)
|
||||||
|
return start, now
|
||||||
|
|
||||||
|
|
||||||
|
def to_ms(dt: datetime) -> int:
|
||||||
|
return int(dt.timestamp() * 1000)
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
|
|
||||||
|
from .aggregator import aggregate_period, enrich_snapshot_meta
|
||||||
|
from .binance import binance_client
|
||||||
|
from .config import settings
|
||||||
|
from .db import get_latest_snapshot, init_db, log_push, save_snapshot, was_pushed_today
|
||||||
|
from .periods import get_today_period, get_yesterday_period, now_shanghai
|
||||||
|
from .state import set_today_cache
|
||||||
|
from .wecom import build_markdown, send_wecom_markdown
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
scheduler = AsyncIOScheduler(timezone="Asia/Shanghai")
|
||||||
|
|
||||||
|
|
||||||
|
async def job_finalize_yesterday() -> None:
|
||||||
|
"""08:00 — compute and persist the closed yesterday period."""
|
||||||
|
logger.info("Job: finalize yesterday period")
|
||||||
|
try:
|
||||||
|
binance_client.clear_symbol_cache()
|
||||||
|
start, end = get_yesterday_period()
|
||||||
|
items = await aggregate_period(start, end, use_live_prices=False)
|
||||||
|
save_snapshot("yesterday", start, end, items)
|
||||||
|
logger.info("Yesterday snapshot saved: %s ~ %s, %d items", start, end, len(items))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Finalize yesterday failed: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
async def job_push_wecom() -> None:
|
||||||
|
"""08:10 — push yesterday Top30 to WeCom."""
|
||||||
|
logger.info("Job: WeCom push")
|
||||||
|
start, end = get_yesterday_period()
|
||||||
|
snapshot = get_latest_snapshot("yesterday")
|
||||||
|
if not snapshot:
|
||||||
|
logger.info("No yesterday snapshot, computing now")
|
||||||
|
items = await aggregate_period(start, end, use_live_prices=False)
|
||||||
|
save_snapshot("yesterday", start, end, items)
|
||||||
|
snapshot = get_latest_snapshot("yesterday")
|
||||||
|
|
||||||
|
if not snapshot:
|
||||||
|
logger.error("Failed to get yesterday snapshot for push")
|
||||||
|
return
|
||||||
|
|
||||||
|
ps, pe = snapshot["period_start"], snapshot["period_end"]
|
||||||
|
if was_pushed_today(ps, pe):
|
||||||
|
logger.info("Already pushed for period %s ~ %s", ps, pe)
|
||||||
|
return
|
||||||
|
|
||||||
|
content = build_markdown(snapshot)
|
||||||
|
ok, msg = await send_wecom_markdown(content)
|
||||||
|
log_push(ps, pe, ok, msg)
|
||||||
|
if ok:
|
||||||
|
logger.info("WeCom push succeeded")
|
||||||
|
else:
|
||||||
|
logger.error("WeCom push failed: %s", msg)
|
||||||
|
|
||||||
|
|
||||||
|
async def job_refresh_today() -> None:
|
||||||
|
"""Refresh today period cache."""
|
||||||
|
logger.info("Job: refresh today")
|
||||||
|
try:
|
||||||
|
start, end = get_today_period()
|
||||||
|
items = await aggregate_period(start, end, use_live_prices=True)
|
||||||
|
meta = enrich_snapshot_meta(items, start, end)
|
||||||
|
save_snapshot("today", start, end, items)
|
||||||
|
set_today_cache(meta)
|
||||||
|
logger.info("Today cache refreshed: %d items", len(items))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Refresh today failed: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
async def startup_tasks() -> None:
|
||||||
|
init_db()
|
||||||
|
now = now_shanghai()
|
||||||
|
start_y, end_y = get_yesterday_period(now)
|
||||||
|
|
||||||
|
snap = get_latest_snapshot("yesterday")
|
||||||
|
if not snap or snap.get("period_end") != end_y.isoformat():
|
||||||
|
try:
|
||||||
|
logger.info("Startup: computing yesterday snapshot")
|
||||||
|
items = await aggregate_period(start_y, end_y, use_live_prices=False)
|
||||||
|
save_snapshot("yesterday", start_y, end_y, items)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Startup yesterday snapshot failed (will retry on schedule): %s", e)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await job_refresh_today()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Startup today refresh failed (will retry on schedule): %s", e)
|
||||||
|
|
||||||
|
if now.hour > 8 or (now.hour == 8 and now.minute >= 10):
|
||||||
|
ps, pe = start_y.isoformat(), end_y.isoformat()
|
||||||
|
if not was_pushed_today(ps, pe) and settings.wecom_webhook_url.strip():
|
||||||
|
try:
|
||||||
|
logger.info("Startup: catch-up WeCom push")
|
||||||
|
await job_push_wecom()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Startup catch-up push failed: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
def start_scheduler() -> None:
|
||||||
|
scheduler.add_job(
|
||||||
|
job_finalize_yesterday,
|
||||||
|
CronTrigger(hour=8, minute=0, timezone="Asia/Shanghai"),
|
||||||
|
id="finalize_yesterday",
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
|
scheduler.add_job(
|
||||||
|
job_push_wecom,
|
||||||
|
CronTrigger(hour=8, minute=10, timezone="Asia/Shanghai"),
|
||||||
|
id="push_wecom",
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
|
scheduler.add_job(
|
||||||
|
job_refresh_today,
|
||||||
|
CronTrigger(minute=f"*/{settings.refresh_minutes}", timezone="Asia/Shanghai"),
|
||||||
|
id="refresh_today",
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
|
if not scheduler.running:
|
||||||
|
scheduler.start()
|
||||||
|
logger.info("Scheduler started (refresh every %d min)", settings.refresh_minutes)
|
||||||
|
|
||||||
|
|
||||||
|
def stop_scheduler() -> None:
|
||||||
|
if scheduler.running:
|
||||||
|
scheduler.shutdown(wait=False)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
_today_cache: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_today_cache() -> dict[str, Any] | None:
|
||||||
|
return _today_cache
|
||||||
|
|
||||||
|
|
||||||
|
def set_today_cache(data: dict[str, Any]) -> None:
|
||||||
|
global _today_cache
|
||||||
|
_today_cache = data
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
from .http_client import httpx_client_kwargs
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_period_label(period_start: str, period_end: str) -> str:
|
||||||
|
start = period_start[:16].replace("T", " ")
|
||||||
|
end = period_end[:16].replace("T", " ")
|
||||||
|
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}%"
|
||||||
|
lines.append(
|
||||||
|
f"| {row['rank']} | {row['symbol']} | {vol} | {pct} | {tag_str} |"
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
lines.append("> 标记说明:千万+ = 成交额≥1000万 USDT;涨跌5%+ = |涨跌幅|≥5%")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_wecom_markdown(content: str) -> tuple[bool, str]:
|
||||||
|
url = settings.wecom_webhook_url.strip()
|
||||||
|
if not url:
|
||||||
|
return False, "WECOM_WEBHOOK_URL 未配置"
|
||||||
|
payload = {"msgtype": "markdown", "markdown": {"content": content}}
|
||||||
|
last_err = ""
|
||||||
|
for attempt in range(3):
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
timeout=15.0, **httpx_client_kwargs("wecom")
|
||||||
|
) as client:
|
||||||
|
resp = await client.post(url, json=payload)
|
||||||
|
data = resp.json()
|
||||||
|
if data.get("errcode") == 0:
|
||||||
|
return True, "ok"
|
||||||
|
last_err = str(data)
|
||||||
|
except Exception as e:
|
||||||
|
last_err = str(e)
|
||||||
|
logger.warning("WeCom push attempt %d failed: %s", attempt + 1, e)
|
||||||
|
return False, last_err
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
fastapi>=0.115.0
|
||||||
|
uvicorn[standard]>=0.32.0
|
||||||
|
httpx[socks]>=0.27.0
|
||||||
|
apscheduler>=3.10.4
|
||||||
|
python-dotenv>=1.0.1
|
||||||
|
pydantic-settings>=2.6.0
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Docker 一键部署 — 安装目录 /opt/Binance_Altcoin_Monitor
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
INSTALL_DIR="/opt/Binance_Altcoin_Monitor"
|
||||||
|
REPO_URL="${REPO_URL:-https://git.bz121.com/dekun/Binance_Altcoin_Monitor.git}"
|
||||||
|
|
||||||
|
echo "==> Docker 部署 Binance Altcoin Monitor"
|
||||||
|
echo " 目录: ${INSTALL_DIR}"
|
||||||
|
|
||||||
|
if ! command -v docker &>/dev/null; then
|
||||||
|
echo "错误: 未安装 docker,请先安装 Docker Engine 与 Compose 插件"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "${INSTALL_DIR}/.git" ]; then
|
||||||
|
echo "==> 克隆仓库..."
|
||||||
|
sudo mkdir -p /opt
|
||||||
|
sudo git clone "${REPO_URL}" "${INSTALL_DIR}"
|
||||||
|
sudo chown -R "$(whoami):$(whoami)" "${INSTALL_DIR}" 2>/dev/null || true
|
||||||
|
else
|
||||||
|
echo "==> 更新代码..."
|
||||||
|
cd "${INSTALL_DIR}"
|
||||||
|
git pull --rebase
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "${INSTALL_DIR}"
|
||||||
|
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
echo "==> 创建 .env(请编辑 WECOM_WEBHOOK_URL 等)"
|
||||||
|
cp .env.example .env
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p data logs
|
||||||
|
|
||||||
|
echo "==> 构建并启动容器..."
|
||||||
|
docker compose build --no-cache
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "部署完成。"
|
||||||
|
echo " Web: http://$(hostname -I | awk '{print $1}'):${PORT:-8000}"
|
||||||
|
echo " 日志: docker compose -f ${INSTALL_DIR}/docker-compose.yml logs -f"
|
||||||
|
echo " 停止: docker compose -f ${INSTALL_DIR}/docker-compose.yml down"
|
||||||
|
echo ""
|
||||||
|
echo "开启 SOCKS5 代理: 编辑 ${INSTALL_DIR}/.env"
|
||||||
|
echo " PROXY_ENABLED=true"
|
||||||
|
echo " PROXY_URL=socks5h://192.168.8.4:1081"
|
||||||
|
echo " 然后: docker compose up -d --force-recreate"
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# PM2 一键部署 — 安装目录 /opt/Binance_Altcoin_Monitor
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
INSTALL_DIR="/opt/Binance_Altcoin_Monitor"
|
||||||
|
REPO_URL="${REPO_URL:-https://git.bz121.com/dekun/Binance_Altcoin_Monitor.git}"
|
||||||
|
|
||||||
|
echo "==> PM2 部署 Binance Altcoin Monitor"
|
||||||
|
echo " 目录: ${INSTALL_DIR}"
|
||||||
|
|
||||||
|
if ! command -v python3 &>/dev/null; then
|
||||||
|
echo "错误: 未安装 python3"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v pm2 &>/dev/null; then
|
||||||
|
echo "==> 安装 PM2..."
|
||||||
|
if command -v npm &>/dev/null; then
|
||||||
|
sudo npm install -g pm2
|
||||||
|
else
|
||||||
|
echo "错误: 请先安装 Node.js/npm,或手动: npm install -g pm2"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "${INSTALL_DIR}/.git" ]; then
|
||||||
|
echo "==> 克隆仓库..."
|
||||||
|
sudo mkdir -p /opt
|
||||||
|
sudo git clone "${REPO_URL}" "${INSTALL_DIR}"
|
||||||
|
sudo chown -R "$(whoami):$(whoami)" "${INSTALL_DIR}" 2>/dev/null || true
|
||||||
|
else
|
||||||
|
echo "==> 更新代码..."
|
||||||
|
cd "${INSTALL_DIR}"
|
||||||
|
git pull --rebase
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "${INSTALL_DIR}"
|
||||||
|
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
echo "==> 创建 .env"
|
||||||
|
cp .env.example .env
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p data logs
|
||||||
|
|
||||||
|
echo "==> 安装 Python 依赖..."
|
||||||
|
python3 -m pip install -U pip -q
|
||||||
|
python3 -m pip install -r backend/requirements.txt -q
|
||||||
|
|
||||||
|
echo "==> 启动 PM2..."
|
||||||
|
pm2 delete binance-altcoin-monitor 2>/dev/null || true
|
||||||
|
pm2 start ecosystem.config.cjs
|
||||||
|
pm2 save
|
||||||
|
|
||||||
|
# 开机自启(可选)
|
||||||
|
if command -v pm2 &>/dev/null; then
|
||||||
|
pm2 startup 2>/dev/null | tail -1 | grep -q sudo && \
|
||||||
|
echo "提示: 若需开机自启,请执行上一条 pm2 startup 输出的 sudo 命令" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "部署完成。"
|
||||||
|
echo " Web: http://$(hostname -I | awk '{print $1}'):8000"
|
||||||
|
echo " 状态: pm2 status"
|
||||||
|
echo " 日志: pm2 logs binance-altcoin-monitor"
|
||||||
|
echo ""
|
||||||
|
echo "开启 SOCKS5 代理: 编辑 ${INSTALL_DIR}/.env 后执行"
|
||||||
|
echo " pm2 restart binance-altcoin-monitor"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
services:
|
||||||
|
monitor:
|
||||||
|
build: .
|
||||||
|
image: binance-altcoin-monitor:latest
|
||||||
|
container_name: binance-altcoin-monitor
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
TZ: Asia/Shanghai
|
||||||
|
HOST: 0.0.0.0
|
||||||
|
PORT: 8000
|
||||||
|
DB_PATH: /app/data/monitor.db
|
||||||
|
ports:
|
||||||
|
- "${PORT:-8000}:8000"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
- ./.env:/app/.env:ro
|
||||||
|
# 若需走宿主机 SOCKS5(默认关闭,在 .env 设 PROXY_ENABLED=true)
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* PM2 配置 — 部署路径默认 /opt/Binance_Altcoin_Monitor
|
||||||
|
* 启动: pm2 start ecosystem.config.cjs
|
||||||
|
*/
|
||||||
|
module.exports = {
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
name: "binance-altcoin-monitor",
|
||||||
|
cwd: "/opt/Binance_Altcoin_Monitor",
|
||||||
|
script: "run.py",
|
||||||
|
interpreter: "python3",
|
||||||
|
instances: 1,
|
||||||
|
autorestart: true,
|
||||||
|
watch: false,
|
||||||
|
max_memory_restart: "512M",
|
||||||
|
env: {
|
||||||
|
NODE_ENV: "production",
|
||||||
|
TZ: "Asia/Shanghai",
|
||||||
|
HOST: "0.0.0.0",
|
||||||
|
PORT: 8000,
|
||||||
|
},
|
||||||
|
error_file: "/opt/Binance_Altcoin_Monitor/logs/pm2-error.log",
|
||||||
|
out_file: "/opt/Binance_Altcoin_Monitor/logs/pm2-out.log",
|
||||||
|
merge_logs: true,
|
||||||
|
time: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import uvicorn
|
||||||
|
|
||||||
|
from backend.app.config import settings
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
uvicorn.run(
|
||||||
|
"backend.app.main:app",
|
||||||
|
host=settings.host,
|
||||||
|
port=settings.port,
|
||||||
|
reload=False,
|
||||||
|
)
|
||||||
+91
@@ -0,0 +1,91 @@
|
|||||||
|
const REFRESH_MS = 60_000;
|
||||||
|
|
||||||
|
function formatPeriod(start, end) {
|
||||||
|
const fmt = (s) => s.replace("T", " ").slice(0, 16);
|
||||||
|
return `${fmt(start)} ~ ${fmt(end)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTags(row) {
|
||||||
|
const parts = [];
|
||||||
|
if (row.is_high_volume) {
|
||||||
|
parts.push('<span class="tag tag-vol">千万+</span>');
|
||||||
|
}
|
||||||
|
if (row.is_high_change) {
|
||||||
|
parts.push('<span class="tag tag-chg">涨跌5%+</span>');
|
||||||
|
}
|
||||||
|
return parts.length ? parts.join("") : "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
function pctClass(pct) {
|
||||||
|
if (pct > 0) return "pct-up";
|
||||||
|
if (pct < 0) return "pct-down";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable(tbody, items) {
|
||||||
|
if (!items || !items.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5" class="loading">暂无数据</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = items
|
||||||
|
.map((row) => {
|
||||||
|
const highlight =
|
||||||
|
row.is_high_volume || row.is_high_change ? " row-highlight" : "";
|
||||||
|
const pct = row.price_change_pct ?? 0;
|
||||||
|
return `<tr class="${highlight}">
|
||||||
|
<td class="rank">${row.rank}</td>
|
||||||
|
<td><strong>${row.symbol}</strong></td>
|
||||||
|
<td>${row.quote_volume_fmt || row.quote_volume}</td>
|
||||||
|
<td class="${pctClass(pct)}">${row.price_change_pct_fmt || pct.toFixed(2) + "%"}</td>
|
||||||
|
<td>${renderTags(row)}</td>
|
||||||
|
</tr>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadYesterday() {
|
||||||
|
const body = document.getElementById("yesterday-body");
|
||||||
|
body.innerHTML = '<tr><td colspan="5" class="loading">加载中…</td></tr>';
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/yesterday/top30");
|
||||||
|
const data = await res.json();
|
||||||
|
document.getElementById("yesterday-period").textContent = formatPeriod(
|
||||||
|
data.period_start,
|
||||||
|
data.period_end
|
||||||
|
);
|
||||||
|
document.getElementById("yesterday-updated").textContent =
|
||||||
|
"更新: " + (data.updated_at || "").replace("T", " ").slice(0, 19);
|
||||||
|
renderTable(body, data.items);
|
||||||
|
} catch (e) {
|
||||||
|
body.innerHTML = `<tr><td colspan="5" class="error">加载失败: ${e.message}</td></tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadToday() {
|
||||||
|
const body = document.getElementById("today-body");
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/today/top30");
|
||||||
|
const data = await res.json();
|
||||||
|
document.getElementById("today-period").textContent = formatPeriod(
|
||||||
|
data.period_start,
|
||||||
|
data.period_end
|
||||||
|
);
|
||||||
|
document.getElementById("today-updated").textContent =
|
||||||
|
"更新: " + (data.updated_at || "").replace("T", " ").slice(0, 19);
|
||||||
|
renderTable(body, data.items);
|
||||||
|
document.getElementById("status").textContent = "今日数据已刷新";
|
||||||
|
} catch (e) {
|
||||||
|
body.innerHTML = `<tr><td colspan="5" class="error">加载失败: ${e.message}</td></tr>`;
|
||||||
|
document.getElementById("status").textContent = e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("btn-refresh").addEventListener("click", async () => {
|
||||||
|
document.getElementById("status").textContent = "刷新中…";
|
||||||
|
await fetch("/api/refresh/today", { method: "POST" });
|
||||||
|
await loadToday();
|
||||||
|
});
|
||||||
|
|
||||||
|
loadYesterday();
|
||||||
|
loadToday();
|
||||||
|
setInterval(loadToday, REFRESH_MS);
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>币安 U本位 成交额 Top30</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>币安 U本位合约 · 成交额排名</h1>
|
||||||
|
<p class="subtitle">北京时间 08:00 切日 · Top30 · 高亮:≥1000万 USDT / |涨跌|≥5%</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="panel" id="panel-yesterday">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>昨日周期</h2>
|
||||||
|
<span class="period" id="yesterday-period">—</span>
|
||||||
|
<span class="updated" id="yesterday-updated"></span>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>排名</th>
|
||||||
|
<th>合约</th>
|
||||||
|
<th>成交额 (USDT)</th>
|
||||||
|
<th>涨跌幅</th>
|
||||||
|
<th>标记</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="yesterday-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" id="panel-today">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>今日周期 <span class="live">实时</span></h2>
|
||||||
|
<span class="period" id="today-period">—</span>
|
||||||
|
<span class="updated" id="today-updated"></span>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>排名</th>
|
||||||
|
<th>合约</th>
|
||||||
|
<th>成交额 (USDT)</th>
|
||||||
|
<th>涨跌幅</th>
|
||||||
|
<th>标记</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="today-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<button type="button" id="btn-refresh">立即刷新今日</button>
|
||||||
|
<span id="status"></span>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="/static/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+170
@@ -0,0 +1,170 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #0f1419;
|
||||||
|
--panel: #1a2332;
|
||||||
|
--border: #2d3a4f;
|
||||||
|
--text: #e7ecf3;
|
||||||
|
--muted: #8b9cb3;
|
||||||
|
--accent: #f0b90b;
|
||||||
|
--up: #0ecb81;
|
||||||
|
--down: #f6465d;
|
||||||
|
--tag-vol: #3d5afe;
|
||||||
|
--tag-chg: #ff6d00;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Segoe UI", system-ui, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 1100px;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.75rem 1.5rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.period {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.updated {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 0.65rem 1rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover td {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pct-up {
|
||||||
|
color: var(--up);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pct-down {
|
||||||
|
color: var(--down);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.15rem 0.45rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
margin-right: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-vol {
|
||||||
|
background: rgba(61, 90, 254, 0.25);
|
||||||
|
color: #8fa8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chg {
|
||||||
|
background: rgba(255, 109, 0, 0.25);
|
||||||
|
color: #ffb74d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-highlight td {
|
||||||
|
background: rgba(240, 185, 11, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #000;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
filter: brightness(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--down);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user