first commit

This commit is contained in:
dekun
2026-05-22 13:06:42 +08:00
commit af5c249cf8
27 changed files with 1741 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
.git
.venv
venv
__pycache__
*.pyc
.env
data/*.db
.cursor
*.md
!README.md
+14
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
.env
logs/
__pycache__/
*.py[cod]
*.db
.venv/
venv/
.idea/
.vscode/
+303
View File
@@ -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
View File
@@ -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"]
+103
View File
@@ -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)**
## 功能
- 成交额排名 Top30USDT 计价)
- 高亮标记(不改变排名):成交额 ≥ 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 # 本地配置(勿提交)
```
View File
View File
+134
View File
@@ -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,
}
+88
View File
@@ -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()
+31
View File
@@ -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()
+121
View File
@@ -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
+24
View File
@@ -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
+126
View File
@@ -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"}
+35
View File
@@ -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)
+132
View File
@@ -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)
+12
View File
@@ -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
+67
View File
@@ -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
+6
View File
@@ -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
+49
View File
@@ -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"
+68
View File
@@ -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"
+21
View File
@@ -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"
+28
View File
@@ -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,
},
],
};
+11
View File
@@ -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
View File
@@ -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);
+66
View File
@@ -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
View File
@@ -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);
}