first commit
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user