commit af5c249cf8cc0d2c493cce50e005ee7aa23977c4 Author: dekun Date: Fri May 22 13:06:42 2026 +0800 first commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..177f4a3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.venv +venv +__pycache__ +*.pyc +.env +data/*.db +.cursor +*.md +!README.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8f61b13 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96aabdf --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.env +logs/ +__pycache__/ +*.py[cod] +*.db +.venv/ +venv/ +.idea/ +.vscode/ diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..becd4dc --- /dev/null +++ b/DEPLOY.md @@ -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 +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9c8525b --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..3cbf272 --- /dev/null +++ b/README.md @@ -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 # 本地配置(勿提交) +``` diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/aggregator.py b/backend/app/aggregator.py new file mode 100644 index 0000000..d6d4aa8 --- /dev/null +++ b/backend/app/aggregator.py @@ -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, + } diff --git a/backend/app/binance.py b/backend/app/binance.py new file mode 100644 index 0000000..c0d2c80 --- /dev/null +++ b/backend/app/binance.py @@ -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() diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..abc02cb --- /dev/null +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/db.py b/backend/app/db.py new file mode 100644 index 0000000..77049a9 --- /dev/null +++ b/backend/app/db.py @@ -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 diff --git a/backend/app/http_client.py b/backend/app/http_client.py new file mode 100644 index 0000000..96fad90 --- /dev/null +++ b/backend/app/http_client.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..361b612 --- /dev/null +++ b/backend/app/main.py @@ -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"} diff --git a/backend/app/periods.py b/backend/app/periods.py new file mode 100644 index 0000000..646586d --- /dev/null +++ b/backend/app/periods.py @@ -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) diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py new file mode 100644 index 0000000..08063a7 --- /dev/null +++ b/backend/app/scheduler.py @@ -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) diff --git a/backend/app/state.py b/backend/app/state.py new file mode 100644 index 0000000..b5b5d69 --- /dev/null +++ b/backend/app/state.py @@ -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 diff --git a/backend/app/wecom.py b/backend/app/wecom.py new file mode 100644 index 0000000..9cdffd3 --- /dev/null +++ b/backend/app/wecom.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..a66c0f3 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/deploy/docker-deploy.sh b/deploy/docker-deploy.sh new file mode 100644 index 0000000..87818b0 --- /dev/null +++ b/deploy/docker-deploy.sh @@ -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" diff --git a/deploy/pm2-deploy.sh b/deploy/pm2-deploy.sh new file mode 100644 index 0000000..04c334d --- /dev/null +++ b/deploy/pm2-deploy.sh @@ -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" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1448056 --- /dev/null +++ b/docker-compose.yml @@ -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" diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs new file mode 100644 index 0000000..97766a5 --- /dev/null +++ b/ecosystem.config.cjs @@ -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, + }, + ], +}; diff --git a/run.py b/run.py new file mode 100644 index 0000000..9a26316 --- /dev/null +++ b/run.py @@ -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, + ) diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..c010308 --- /dev/null +++ b/web/app.js @@ -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('千万+'); + } + if (row.is_high_change) { + parts.push('涨跌5%+'); + } + 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 = '暂无数据'; + 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 ` + ${row.rank} + ${row.symbol} + ${row.quote_volume_fmt || row.quote_volume} + ${row.price_change_pct_fmt || pct.toFixed(2) + "%"} + ${renderTags(row)} + `; + }) + .join(""); +} + +async function loadYesterday() { + const body = document.getElementById("yesterday-body"); + body.innerHTML = '加载中…'; + 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 = `加载失败: ${e.message}`; + } +} + +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 = `加载失败: ${e.message}`; + 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); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..4c96193 --- /dev/null +++ b/web/index.html @@ -0,0 +1,66 @@ + + + + + + 币安 U本位 成交额 Top30 + + + +
+

币安 U本位合约 · 成交额排名

+

北京时间 08:00 切日 · Top30 · 高亮:≥1000万 USDT / |涨跌|≥5%

+
+ +
+
+

昨日周期

+ + +
+
+ + + + + + + + + + + +
排名合约成交额 (USDT)涨跌幅标记
+
+
+ +
+
+

今日周期 实时

+ + +
+
+ + + + + + + + + + + +
排名合约成交额 (USDT)涨跌幅标记
+
+
+ + + + + + diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..d0f0740 --- /dev/null +++ b/web/style.css @@ -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); +}