"""日/周/月统计 API。""" from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, Query from apps.api.auth import require_auth from apps.worker.settle import ensure_settlements_for_ymds from packages.config import get_settings from packages.db import Repository from packages.domain.aggregate import leverage_stats_payload, move_points_stats_payload from packages.domain.expiry_filter import filter_rows_min_hours_to_expiry from packages.domain.range import resolve_range router = APIRouter(prefix="/stats", tags=["stats"], dependencies=[Depends(require_auth)]) def _range_info(range_name: str, date: str | None) -> dict: s = get_settings() try: return resolve_range( range_name, date, month_mode=s.month_range_mode, ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) from e def _validate_min_hours(min_hours: float) -> float: # 8–48,步进 2(与前端一致) h = float(min_hours) if h < 8 or h > 48 or abs(h - round(h)) > 1e-9 or int(round(h)) % 2 != 0: raise HTTPException( status_code=400, detail="min_hours must be even integer in [8, 48]", ) return float(int(round(h))) def _validate_min_leverage(min_leverage: float) -> float: # 90–300,步进 10 v = float(min_leverage) if v < 90 or v > 300 or abs(v - round(v)) > 1e-9 or int(round(v)) % 10 != 0: raise HTTPException( status_code=400, detail="min_leverage must be multiple of 10 in [90, 300]", ) return float(int(round(v))) @router.get("/leverage") def leverage_stats( range: str = Query(default="day", pattern="^(day|week|month)$"), date: str | None = Query(default=None, description="锚点日 YYYY-MM-DD(上海)"), side: str = Query(default="both", pattern="^(C|P|both)$"), bucket_minutes: int = Query(default=60, ge=15, le=120), min_hours: float = Query( default=12, ge=8, le=48, description="采样时距离到期 ≥ 该小时数才计入", ), min_leverage: float = Query( default=100, ge=90, le=300, description="杠杆达标线(≥ 该值计入达标占比)", ), ) -> dict: s = get_settings() mh = _validate_min_hours(min_hours) ml = _validate_min_leverage(min_leverage) info = _range_info(range, date) repo = Repository(s.db_path) try: raw = repo.fetch_option_quotes( start_ms=info["start_ms"], end_ms=info["end_ms"], side=side, underlying=s.underlying, ) rows, filt = filter_rows_min_hours_to_expiry(raw, min_hours=mh) payload = leverage_stats_payload( rows, range_info=info, bucket_minutes=bucket_minutes, min_leverage=ml, side=side, ) payload["min_hours"] = mh payload["expiry_filter"] = filt return payload finally: repo.close() @router.get("/move_points") def move_points_stats( range: str = Query(default="day", pattern="^(day|week|month)$"), date: str | None = Query(default=None, description="锚点日 YYYY-MM-DD"), side: str = Query(default="both", pattern="^(C|P|both)$"), bucket_minutes: int = Query(default=60, ge=15, le=120), min_hours: float = Query( default=12, ge=8, le=48, description="采样时距离到期 ≥ 该小时数才计入", ), ) -> dict: s = get_settings() mh = _validate_min_hours(min_hours) info = _range_info(range, date) repo = Repository(s.db_path) try: raw = repo.fetch_option_quotes( start_ms=info["start_ms"], end_ms=info["end_ms"], side=side, underlying=s.underlying, ) rows, filt = filter_rows_min_hours_to_expiry(raw, min_hours=mh) ymds = sorted({str(r.get("expiry_ymd")) for r in rows if r.get("expiry_ymd")}) # 懒回填:已到期但缺锚点时尽量补齐(本地指数优先,失败则跳过) try: ensure_settlements_for_ymds( repo, ymds, underlying=s.underlying, index_inst_id=s.index_inst_id, ) except Exception: # noqa: BLE001 — 回填失败不阻断统计 pass settlements = repo.list_settlements(ymds) payload = move_points_stats_payload( rows, settlements, range_info=info, bucket_minutes=bucket_minutes, side=side, ) payload["min_hours"] = mh payload["expiry_filter"] = filt return payload finally: repo.close() @router.get("/ops-map") def ops_map( range: str = Query(default="day", pattern="^(day|week|month)$"), date: str | None = Query(default=None), side: str = Query(default="both", pattern="^(C|P|both)$"), bucket_minutes: int = Query(default=60, ge=15, le=120), min_hours: float = Query(default=12, ge=8, le=48), min_leverage: float = Query(default=100, ge=90, le=300), ) -> dict: # 直接算一遍,避免路由函数互相调用时 Query 默认值问题 s = get_settings() mh = _validate_min_hours(min_hours) ml = _validate_min_leverage(min_leverage) info = _range_info(range, date) repo = Repository(s.db_path) try: raw = repo.fetch_option_quotes( start_ms=info["start_ms"], end_ms=info["end_ms"], side=side, underlying=s.underlying, ) rows, filt = filter_rows_min_hours_to_expiry(raw, min_hours=mh) lev = leverage_stats_payload( rows, range_info=info, bucket_minutes=bucket_minutes, min_leverage=ml, side=side, ) lev["min_hours"] = mh lev["expiry_filter"] = filt ymds = sorted({str(r.get("expiry_ymd")) for r in rows if r.get("expiry_ymd")}) try: ensure_settlements_for_ymds( repo, ymds, underlying=s.underlying, index_inst_id=s.index_inst_id, ) except Exception: # noqa: BLE001 pass settlements = repo.list_settlements(ymds) mov = move_points_stats_payload( rows, settlements, range_info=info, bucket_minutes=bucket_minutes, side=side, ) mov["min_hours"] = mh mov["expiry_filter"] = filt return { "range": range, "date": lev.get("date"), "side": side, "bucket_minutes": bucket_minutes, "min_hours": mh, "min_leverage": ml, "expiry_filter": filt, "leverage": lev, "move_points": mov, } finally: repo.close()