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, 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 = 50): return {"items": get_llm_interpretations(batch_id, limit)} @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 未配置") state = get_interpret_state() if state.get("running"): return {"ok": False, "message": "解读任务进行中", **state} background_tasks.add_task(run_interpretation_batch) return {"ok": True, "message": "已启动三日交集解读队列", **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