diff --git a/apps/api/routes/stats.py b/apps/api/routes/stats.py index 2adf860..289d7e6 100644 --- a/apps/api/routes/stats.py +++ b/apps/api/routes/stats.py @@ -9,6 +9,7 @@ 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)]) @@ -26,14 +27,32 @@ def _range_info(range_name: str, date: str | None) -> dict: 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))) + + @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="采样时距离到期 ≥ 该小时数才计入", + ), ) -> dict: s = get_settings() + mh = _validate_min_hours(min_hours) info = _range_info(range, date) repo = Repository(s.db_path) try: @@ -43,13 +62,16 @@ def leverage_stats( side=side, underlying=s.underlying, ) - return leverage_stats_payload( + rows = filter_rows_min_hours_to_expiry(rows, min_hours=mh) + payload = leverage_stats_payload( rows, range_info=info, bucket_minutes=bucket_minutes, min_leverage=float(s.min_option_leverage), side=side, ) + payload["min_hours"] = mh + return payload finally: repo.close() @@ -60,8 +82,15 @@ def move_points_stats( 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: @@ -71,6 +100,7 @@ def move_points_stats( side=side, underlying=s.underlying, ) + rows = filter_rows_min_hours_to_expiry(rows, min_hours=mh) ymds = sorted({str(r.get("expiry_ymd")) for r in rows if r.get("expiry_ymd")}) # 懒回填:已到期但缺锚点时尽量补齐(本地指数优先,失败则跳过) try: @@ -83,13 +113,15 @@ def move_points_stats( except Exception: # noqa: BLE001 — 回填失败不阻断统计 pass settlements = repo.list_settlements(ymds) - return move_points_stats_payload( + payload = move_points_stats_payload( rows, settlements, range_info=info, bucket_minutes=bucket_minutes, side=side, ) + payload["min_hours"] = mh + return payload finally: repo.close() @@ -100,14 +132,21 @@ def ops_map( 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), ) -> dict: - lev = leverage_stats(range=range, date=date, side=side, bucket_minutes=bucket_minutes) - mov = move_points_stats(range=range, date=date, side=side, bucket_minutes=bucket_minutes) + mh = _validate_min_hours(min_hours) + lev = leverage_stats( + range=range, date=date, side=side, bucket_minutes=bucket_minutes, min_hours=mh + ) + mov = move_points_stats( + range=range, date=date, side=side, bucket_minutes=bucket_minutes, min_hours=mh + ) return { "range": range, "date": lev.get("date"), "side": side, "bucket_minutes": bucket_minutes, + "min_hours": mh, "leverage": lev, "move_points": mov, } diff --git a/packages/domain/expiry_filter.py b/packages/domain/expiry_filter.py new file mode 100644 index 0000000..b9643b6 --- /dev/null +++ b/packages/domain/expiry_filter.py @@ -0,0 +1,32 @@ +"""按采样时刻距离到期时长过滤行情样本。""" + +from __future__ import annotations + +from typing import Any, Iterable + +from packages.domain.expiry import expiry_ms_from_ymd + + +def hours_to_expiry_at(ts_ms: int, expiry_ymd: str) -> float | None: + try: + ems = expiry_ms_from_ymd(str(expiry_ymd)) + except ValueError: + return None + return (ems - int(ts_ms)) / 3_600_000.0 + + +def filter_rows_min_hours_to_expiry( + rows: Iterable[dict[str, Any]], + *, + min_hours: float, +) -> list[dict[str, Any]]: + """保留采样时距到期 ≥ min_hours 的样本。""" + out: list[dict[str, Any]] = [] + mh = float(min_hours) + for r in rows: + h = hours_to_expiry_at(int(r.get("ts_ms") or 0), str(r.get("expiry_ymd") or "")) + if h is None: + continue + if h >= mh: + out.append(r) + return out diff --git a/tests/test_expiry_filter.py b/tests/test_expiry_filter.py new file mode 100644 index 0000000..ca3e322 --- /dev/null +++ b/tests/test_expiry_filter.py @@ -0,0 +1,19 @@ +from packages.domain.expiry import expiry_ms_from_ymd +from packages.domain.expiry_filter import filter_rows_min_hours_to_expiry, hours_to_expiry_at + + +def test_filter_min_hours(): + # 到期 260803 → 2026-08-03 08:00 UTC + ymd = "260803" + ems = expiry_ms_from_ymd(ymd) + # 距到期 20h / 10h 的采样点 + ts_20h = ems - 20 * 3_600_000 + ts_10h = ems - 10 * 3_600_000 + rows = [ + {"ts_ms": ts_20h, "expiry_ymd": ymd, "side": "C"}, + {"ts_ms": ts_10h, "expiry_ymd": ymd, "side": "P"}, + ] + assert hours_to_expiry_at(ts_20h, ymd) == 20 + kept = filter_rows_min_hours_to_expiry(rows, min_hours=12) + assert len(kept) == 1 + assert kept[0]["side"] == "C" diff --git a/web/dist/index.html b/web/dist/index.html index 4faef97..aaf6757 100644 --- a/web/dist/index.html +++ b/web/dist/index.html @@ -34,7 +34,7 @@ .seg button { appearance: none; border: 0; background: transparent; color: var(--muted); padding: 0.45rem 0.85rem; cursor: pointer; } .seg button.active { background: #1e2a3a; color: var(--text); } .date-field { color: var(--muted); font-size: 0.85rem; display: inline-flex; gap: 0.5rem; align-items: center; } - .date-field input { background: var(--panel); border: 1px solid #243041; color: var(--text); border-radius: 6px; padding: 0.35rem 0.5rem; } + .date-field input, .date-field select { background: var(--panel); border: 1px solid #243041; color: var(--text); border-radius: 6px; padding: 0.35rem 0.5rem; } .chart-wrap { background: var(--panel); border: 1px solid #243041; border-radius: 10px; padding: 0.75rem 0.5rem; margin-top: 0.5rem; } .chart-title { padding: 0 0.75rem 0.25rem; color: var(--muted); font-size: 0.85rem; } .chart-svg { width: 100%; height: auto; display: block; } @@ -123,6 +123,9 @@ +
范围
@@ -211,7 +214,7 @@