234 lines
8.2 KiB
Python
234 lines
8.2 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, 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
|