Files
Binance_Altcoin_Monitor/backend/app/main.py
T
2026-05-30 10:48:57 +08:00

259 lines
8.9 KiB
Python

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 .config import ROOT_DIR, settings
from .funding_store import get_funding_bundle
from .kline_store import get_candles, get_daily_candles, sync_daily_klines, sync_klines
from .chart_intervals import CHART_INTERVALS, limit_for_interval, validate_interval
from .binance import binance_client
from .db import get_latest_snapshot, init_db, log_push, save_snapshot
from .exceptions import BinanceRateLimitedError
from .period_api import get_period_top30
from .periods import get_daybefore_period, 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 .stats import compute_three_day_stats
from .aggregator import aggregate_period
from .wecom import build_markdown, build_push_payload, send_push_payload
from .state import get_today_cache
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
logger = logging.getLogger(__name__)
async def _chart_price_meta(sym: str) -> dict:
try:
return await binance_client.get_symbol_price_meta(sym)
except Exception as e:
logger.warning("price meta %s fallback: %s", sym, e)
return {"tick_size": "0.01", "price_precision": 2}
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/today/top30")
async def api_today_top30():
from .state import get_today_cache
cached = get_today_cache()
if cached:
from .funding_store import enrich_items_with_funding
cached["items"] = await enrich_items_with_funding(cached.get("items", []))
return cached
return await get_period_top30(
"today", get_today_period, use_live_prices=True, data_mode=settings.today_data_mode
)
@app.get("/api/yesterday/top30")
async def api_yesterday_top30():
return await get_period_top30("yesterday", get_yesterday_period)
@app.get("/api/daybefore/top30")
async def api_daybefore_top30():
return await get_period_top30("daybefore", get_daybefore_period)
@app.get("/api/stats/three-day")
async def api_stats_three_day():
return compute_three_day_stats()
@app.get("/api/push/preview")
async def api_push_preview():
"""预览企微推送内容(三日交集,列表排版)。"""
return build_push_payload()
@app.post("/api/push/test")
async def api_push_test():
payload = build_push_payload()
if not payload.get("ok"):
raise HTTPException(400, payload.get("message") or "三日交集数据未就绪")
snap = get_latest_snapshot("yesterday")
if not snap:
start, end = get_yesterday_period()
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, "无法生成昨日数据")
ok, msg = await send_push_payload(payload)
log_push(snap["period_start"], snap["period_end"], ok, msg)
if not ok:
raise HTTPException(500, f"推送失败: {msg}")
parts = payload.get("parts", 1)
return {
"success": True,
"message": f"已推送 {payload.get('count', 0)} 个币种"
+ (f"(分 {parts} 条消息)" if parts > 1 else ""),
"count": payload.get("count", 0),
"parts": parts,
}
@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"}
@app.post("/api/refresh/daybefore")
async def api_refresh_daybefore():
await job_finalize_yesterday()
return get_latest_snapshot("daybefore") or {"message": "done"}
@app.get("/api/chart/{symbol}")
async def api_chart(
symbol: str,
interval: str = "1d",
limit: int | None = None,
refresh: bool = False,
):
"""合约 K 线:优先读本地 SQLite,过期再拉币安入库。"""
sym = symbol.upper().strip()
if not sym.endswith("USDT"):
raise HTTPException(400, "invalid symbol")
try:
iv = validate_interval(interval)
except ValueError as e:
raise HTTPException(400, str(e)) from e
default_limit = limit_for_interval(iv)
try:
candles, source = await get_candles(
sym, iv, limit or default_limit, force_refresh=refresh
)
price_meta = await _chart_price_meta(sym)
return {
"symbol": sym,
"interval": iv,
"limit": len(candles),
"candles": candles,
"source": source,
"intervals": list(CHART_INTERVALS),
**price_meta,
}
except BinanceRateLimitedError as e:
raise HTTPException(503, f"币安限流,请 {e.retry_after_sec} 秒后再试") from e
except Exception as e:
logger.error("chart %s %s failed: %s", sym, iv, e)
raise HTTPException(502, "K线获取失败") from e
@app.get("/api/chart/{symbol}/daily")
async def api_chart_daily(symbol: str, limit: int | None = None, refresh: bool = False):
"""合约日 K 线(兼容旧路径)。"""
sym = symbol.upper().strip()
if not sym.endswith("USDT"):
raise HTTPException(400, "invalid symbol")
try:
candles, source = await get_daily_candles(sym, limit, force_refresh=refresh)
price_meta = await _chart_price_meta(sym)
return {
"symbol": sym,
"interval": "1d",
"limit": len(candles),
"candles": candles,
"source": source,
"intervals": list(CHART_INTERVALS),
**price_meta,
}
except BinanceRateLimitedError as e:
raise HTTPException(503, f"币安限流,请 {e.retry_after_sec} 秒后再试") from e
except Exception as e:
logger.error("chart %s failed: %s", sym, e)
raise HTTPException(502, "K线获取失败") from e
@app.get("/api/funding/{symbol}/history")
async def api_funding_history(symbol: str, limit: int | None = None, refresh: bool = False):
sym = symbol.upper().strip()
if not sym.endswith("USDT"):
raise HTTPException(400, "invalid symbol")
try:
return await get_funding_bundle(sym, limit, force_refresh=refresh)
except BinanceRateLimitedError as e:
raise HTTPException(503, f"币安限流,请 {e.retry_after_sec} 秒后再试") from e
except Exception as e:
logger.error("funding %s failed: %s", sym, e)
raise HTTPException(502, "资金费率获取失败") from e
@app.post("/api/chart/{symbol}/refresh")
async def api_chart_refresh(
symbol: str, interval: str = "1d", limit: int | None = None
):
"""强制从币安同步 K 线到本地库。"""
sym = symbol.upper().strip()
try:
iv = validate_interval(interval)
except ValueError as e:
raise HTTPException(400, str(e)) from e
try:
candles = await sync_klines(sym, iv, limit)
return {"symbol": sym, "interval": iv, "saved": len(candles), "source": "binance"}
except BinanceRateLimitedError as e:
raise HTTPException(503, f"币安限流,请 {e.retry_after_sec} 秒后再试") from e
@app.post("/api/chart/{symbol}/daily/refresh")
async def api_chart_daily_refresh(symbol: str, limit: int | None = None):
"""强制从币安同步日 K 到本地库。"""
sym = symbol.upper().strip()
try:
candles = await sync_daily_klines(sym, limit)
return {"symbol": sym, "saved": len(candles), "source": "binance"}
except BinanceRateLimitedError as e:
raise HTTPException(503, f"币安限流,请 {e.retry_after_sec} 秒后再试") from e