Files
Binance_Altcoin_Monitor/backend/app/main.py
T
2026-05-26 10:04:36 +08:00

253 lines
8.9 KiB
Python

import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import BackgroundTasks, FastAPI, HTTPException
from fastapi.responses import FileResponse, Response
from fastapi.staticfiles import StaticFiles
from .config import ROOT_DIR, settings
from .funding_store import get_funding_bundle
from .kline_store import get_daily_candles, sync_daily_klines
from .db import get_latest_snapshot, get_llm_interpretations, 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 .chart_image import render_daily_chart_png_async
from .llm_service import get_interpret_state, init_interpret_batch, run_interpretation_batch
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_wecom_markdown
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__)
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_wecom_markdown(payload["markdown"])
log_push(snap["period_start"], snap["period_end"], ok, msg)
if not ok:
raise HTTPException(500, f"推送失败: {msg}")
return {
"success": True,
"message": f"已推送 {payload.get('count', 0)} 个三日交集币种",
"count": payload.get("count", 0),
}
@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}/daily")
async def api_chart_daily(symbol: str, limit: int | None = None, refresh: bool = False):
"""合约日 K 线:优先读本地 SQLite,过期再拉币安入库。"""
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)
return {
"symbol": sym,
"interval": "1d",
"limit": len(candles),
"candles": candles,
"source": source,
}
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.get("/api/chart/{symbol}/daily.png")
async def api_chart_daily_png(symbol: str, limit: int | None = None):
sym = symbol.upper().strip()
if not sym.endswith("USDT"):
raise HTTPException(400, "invalid symbol")
try:
png = await render_daily_chart_png_async(sym, limit or settings.chart_kline_limit)
return Response(content=png, media_type="image/png")
except ValueError as e:
raise HTTPException(404, str(e)) from e
except Exception as e:
logger.error("chart png %s failed: %s", sym, e)
raise HTTPException(502, "图表生成失败") from e
@app.get("/api/llm/status")
async def api_llm_status():
state = get_interpret_state()
return {
**state,
"enabled": bool(settings.llm_api_key.strip()),
"model": settings.llm_model,
"base_url": settings.llm_base_url,
"interval_sec": settings.llm_symbol_interval_sec,
}
@app.get("/api/llm/interpretations")
async def api_llm_interpretations(batch_id: str | None = None, limit: int = 100):
"""返回解读列表;进行中时优先当前批次(即使尚无记录)。"""
st = get_interpret_state()
bid = batch_id or (st.get("batch_id") if st.get("running") else None)
items = get_llm_interpretations(bid, limit) if bid else get_llm_interpretations(None, limit)
if not bid and items:
bid = items[0].get("batch_id", "")
return {
"items": items,
"batch_id": bid or st.get("batch_id", ""),
"running": st.get("running", False),
"done": st.get("done", 0),
"total": st.get("total", 0),
"current_symbol": st.get("current_symbol", ""),
}
@app.post("/api/llm/interpret/run")
async def api_llm_interpret_run(background_tasks: BackgroundTasks):
if not settings.llm_api_key.strip():
raise HTTPException(400, "LLM_API_KEY 未配置")
info = init_interpret_batch()
if not info.get("ok"):
return info
bid = info.get("batch_id")
background_tasks.add_task(run_interpretation_batch, batch_id=bid)
return {
"ok": True,
"message": "已启动三日交集解读队列",
"batch_id": bid,
**get_interpret_state(),
}
@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