From 56f58a12bd29773ce736cd1d7820316d49fac62f Mon Sep 17 00:00:00 2001 From: dekun Date: Sun, 2 Aug 2026 10:48:40 +0800 Subject: [PATCH] fix: show expiry-filter sample stats so ops map filter effect is visible Co-authored-by: Cursor --- apps/api/routes/stats.py | 79 ++++++++++++++++++++++++-------- packages/domain/expiry_filter.py | 38 +++++++++++++-- tests/test_expiry_filter.py | 4 +- web/dist/index.html | 18 +++++++- web/src/api/client.ts | 12 +++++ web/src/pages/OpsMap.tsx | 19 +++++++- 6 files changed, 143 insertions(+), 27 deletions(-) diff --git a/apps/api/routes/stats.py b/apps/api/routes/stats.py index 289d7e6..368d452 100644 --- a/apps/api/routes/stats.py +++ b/apps/api/routes/stats.py @@ -56,13 +56,13 @@ def leverage_stats( info = _range_info(range, date) repo = Repository(s.db_path) try: - rows = repo.fetch_option_quotes( + raw = repo.fetch_option_quotes( start_ms=info["start_ms"], end_ms=info["end_ms"], side=side, underlying=s.underlying, ) - rows = filter_rows_min_hours_to_expiry(rows, min_hours=mh) + rows, filt = filter_rows_min_hours_to_expiry(raw, min_hours=mh) payload = leverage_stats_payload( rows, range_info=info, @@ -71,6 +71,7 @@ def leverage_stats( side=side, ) payload["min_hours"] = mh + payload["expiry_filter"] = filt return payload finally: repo.close() @@ -94,13 +95,13 @@ def move_points_stats( info = _range_info(range, date) repo = Repository(s.db_path) try: - rows = repo.fetch_option_quotes( + raw = repo.fetch_option_quotes( start_ms=info["start_ms"], end_ms=info["end_ms"], side=side, underlying=s.underlying, ) - rows = filter_rows_min_hours_to_expiry(rows, min_hours=mh) + 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: @@ -121,6 +122,7 @@ def move_points_stats( side=side, ) payload["min_hours"] = mh + payload["expiry_filter"] = filt return payload finally: repo.close() @@ -134,19 +136,58 @@ def ops_map( bucket_minutes: int = Query(default=60, ge=15, le=120), min_hours: float = Query(default=12, ge=8, le=48), ) -> dict: + # 直接算一遍,避免路由函数互相调用时 Query 默认值问题 + s = get_settings() 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, - } + 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=float(s.min_option_leverage), + 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, + "expiry_filter": filt, + "leverage": lev, + "move_points": mov, + } + finally: + repo.close() diff --git a/packages/domain/expiry_filter.py b/packages/domain/expiry_filter.py index b9643b6..cb287cb 100644 --- a/packages/domain/expiry_filter.py +++ b/packages/domain/expiry_filter.py @@ -15,18 +15,46 @@ def hours_to_expiry_at(ts_ms: int, expiry_ymd: str) -> float | None: return (ems - int(ts_ms)) / 3_600_000.0 +def hours_stats(rows: Iterable[dict[str, Any]]) -> dict[str, Any]: + """样本在采样时刻的距到期小时分布。""" + vals: list[float] = [] + 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 not None: + vals.append(float(h)) + if not vals: + return {"n": 0, "min_hours": None, "max_hours": None} + return { + "n": len(vals), + "min_hours": min(vals), + "max_hours": max(vals), + } + + 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]] = [] +) -> tuple[list[dict[str, Any]], dict[str, Any]]: + """保留采样时距到期 ≥ min_hours 的样本;附带过滤前后统计。""" + raw = list(rows) + before = hours_stats(raw) mh = float(min_hours) - for r in rows: + out: list[dict[str, Any]] = [] + for r in raw: 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 + after = hours_stats(out) + meta = { + "min_hours_filter": mh, + "raw_sample_count": before["n"], + "filtered_sample_count": after["n"], + "raw_hours_min": before["min_hours"], + "raw_hours_max": before["max_hours"], + "filtered_hours_min": after["min_hours"], + "filtered_hours_max": after["max_hours"], + } + return out, meta diff --git a/tests/test_expiry_filter.py b/tests/test_expiry_filter.py index ca3e322..4f4aa37 100644 --- a/tests/test_expiry_filter.py +++ b/tests/test_expiry_filter.py @@ -14,6 +14,8 @@ def test_filter_min_hours(): {"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) + kept, meta = filter_rows_min_hours_to_expiry(rows, min_hours=12) assert len(kept) == 1 assert kept[0]["side"] == "C" + assert meta["raw_sample_count"] == 2 + assert meta["filtered_sample_count"] == 1 diff --git a/web/dist/index.html b/web/dist/index.html index aaf6757..ff25258 100644 --- a/web/dist/index.html +++ b/web/dist/index.html @@ -712,7 +712,23 @@ }); const lev = d.leverage || {}; document.getElementById("opsRange").textContent = (lev.start_ymd || "—") + " → " + (lev.end_ymd || "—"); - document.getElementById("opsSamples").textContent = "杠杆样本 " + (lev.sample_count ?? 0); + const filt = d.expiry_filter || lev.expiry_filter || {}; + const rawN = filt.raw_sample_count; + const keptN = filt.filtered_sample_count != null ? filt.filtered_sample_count : (lev.sample_count ?? 0); + let sampleHint = "杠杆样本 " + keptN; + if (rawN != null) { + const hMin = filt.raw_hours_min; + const hMax = filt.raw_hours_max; + const span = (hMin != null && hMax != null) + ? (" · 样本距到期约 " + Math.floor(hMin) + "–" + Math.ceil(hMax) + "h") + : ""; + if (rawN === keptN) { + sampleHint = "杠杆样本 " + keptN + "(≥" + (state.minHours || 12) + "h 未减少)" + span; + } else { + sampleHint = "杠杆样本 " + keptN + "/" + rawN + "(≥" + (state.minHours || 12) + "h)" + span; + } + } + document.getElementById("opsSamples").textContent = sampleHint; document.getElementById("opsMin").textContent = lev.min_leverage ?? "—"; const titleMap = { day: "日", week: "近7日", month: "近30日" }; document.getElementById("chartTitle").textContent = "上图 · 时段杠杆均值(" + (titleMap[state.range]||"") + ")"; diff --git a/web/src/api/client.ts b/web/src/api/client.ts index f144c95..1805877 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -229,9 +229,21 @@ export type MovePointsStats = { buckets: MoveBucket[]; }; +export type ExpiryFilterMeta = { + min_hours_filter: number; + raw_sample_count: number; + filtered_sample_count: number; + raw_hours_min: number | null; + raw_hours_max: number | null; + filtered_hours_min?: number | null; + filtered_hours_max?: number | null; +}; + export type OpsMap = { leverage: LeverageStats; move_points: MovePointsStats; + min_hours?: number; + expiry_filter?: ExpiryFilterMeta; }; export async function fetchHealth(): Promise { diff --git a/web/src/pages/OpsMap.tsx b/web/src/pages/OpsMap.tsx index 8aaf65f..95814bb 100644 --- a/web/src/pages/OpsMap.tsx +++ b/web/src/pages/OpsMap.tsx @@ -88,8 +88,25 @@ export default function OpsMapPage() { const lev: LeverageStats | undefined = data?.leverage; const mov: MovePointsStats | undefined = data?.move_points; + const filt = data?.expiry_filter; const rangeLabel = range === "day" ? "日" : range === "week" ? "近7日" : "近30日"; + const sampleHint = (() => { + if (!filt) return `杠杆样本 ${lev?.sample_count ?? 0}`; + const raw = filt.raw_sample_count; + const kept = filt.filtered_sample_count; + const hMin = filt.raw_hours_min; + const hMax = filt.raw_hours_max; + const span = + hMin != null && hMax != null + ? ` · 样本距到期约 ${Math.floor(hMin)}–${Math.ceil(hMax)}h` + : ""; + if (raw === kept) { + return `杠杆样本 ${kept}(≥${minHours}h 未减少)${span}`; + } + return `杠杆样本 ${kept}/${raw}(≥${minHours}h)${span}`; + })(); + return (
比特骆驼行情采集分析
@@ -152,7 +169,7 @@ export default function OpsMapPage() {
{lev.start_ymd} → {lev.end_ymd}
-
杠杆样本 {lev.sample_count}
+
{sampleHint}
达标线