diff --git a/packages/domain/aggregate.py b/packages/domain/aggregate.py index 6a2e5d3..6a29995 100644 --- a/packages/domain/aggregate.py +++ b/packages/domain/aggregate.py @@ -6,7 +6,7 @@ import math from collections import defaultdict from typing import Any, Iterable, Sequence -from packages.domain.buckets import shanghai_bucket +from packages.domain.buckets import shanghai_bucket, shanghai_day from packages.domain.leverage import LEVERAGE_FORMULA_VERSION @@ -87,9 +87,15 @@ def aggregate_leverage( """ rows: 需含 ts_ms, leverage, side。 返回按桶排序的聚合列表(含空桶)。 + 额外字段: + days — 该时段有样本的上海自然日数 + days_hit — 当日该时段均值 ≥ min_leverage 的天数 + pct_days_hit — days_hit / days """ want = (side or "both").upper() by_bucket: dict[int, list[float]] = defaultdict(list) + # bucket -> day_ymd -> leverages + by_bucket_day: dict[int, dict[str, list[float]]] = defaultdict(lambda: defaultdict(list)) for r in rows: lev = r.get("leverage") @@ -106,18 +112,32 @@ def aggregate_leverage( continue if want == "BOTH" and s not in ("C", "P"): continue - b = shanghai_bucket(int(r["ts_ms"]), bucket_minutes) + ts = int(r["ts_ms"]) + b = shanghai_bucket(ts, bucket_minutes) + day = shanghai_day(ts) by_bucket[b].append(lev_f) + by_bucket_day[b][day].append(lev_f) out: list[dict[str, Any]] = [] + thr = float(min_leverage) for b in all_bucket_starts(bucket_minutes): stats = summarize_values(by_bucket.get(b, []), min_leverage=min_leverage) + day_map = by_bucket_day.get(b, {}) + days = len(day_map) + days_hit = 0 + for vals in day_map.values(): + if vals and (sum(vals) / len(vals)) >= thr: + days_hit += 1 + pct_days = (days_hit / days) if days else None out.append( { "bucket_start_min": b, "bucket_hour": b // 60 if bucket_minutes >= 60 else None, "label": bucket_label(b, bucket_minutes), **stats, + "days": days, + "days_hit": days_hit, + "pct_days_hit": pct_days, } ) return out @@ -156,9 +176,11 @@ def aggregate_move_points( """ samples: {ts_ms, move_signed, move_abs} 桶内同时给出 signed / abs 分布。 + 额外:days — 该时段有结算样本的自然日数。 """ by_signed: dict[int, list[float]] = defaultdict(list) by_abs: dict[int, list[float]] = defaultdict(list) + by_bucket_days: dict[int, set[str]] = defaultdict(set) for s in samples: ts = s.get("ts_ms") signed = s.get("move_signed") @@ -174,6 +196,7 @@ def aggregate_move_points( b = shanghai_bucket(int(ts), bucket_minutes) by_signed[b].append(signed_f) by_abs[b].append(abs_f) + by_bucket_days[b].add(shanghai_day(int(ts))) out: list[dict[str, Any]] = [] for b in all_bucket_starts(bucket_minutes): @@ -185,6 +208,7 @@ def aggregate_move_points( "bucket_hour": b // 60 if bucket_minutes >= 60 else None, "label": bucket_label(b, bucket_minutes), "n": signed_stats["n"], + "days": len(by_bucket_days.get(b, set())), "signed": signed_stats, "abs": abs_stats, # 便捷字段(看板默认用 abs 均值) diff --git a/tests/test_aggregate.py b/tests/test_aggregate.py index 01622c9..da5d4fc 100644 --- a/tests/test_aggregate.py +++ b/tests/test_aggregate.py @@ -61,5 +61,29 @@ def test_aggregate_leverage_buckets(): assert b14["n"] == 2 assert b14["mean"] == 150 assert b14["label"] == "14:00" + assert b14["days"] == 1 + assert b14["days_hit"] == 1 + assert b14["pct_days_hit"] == 1.0 empty = next(b for b in buckets if b["bucket_start_min"] == 0) assert empty["n"] == 0 + assert empty["days"] == 0 + assert empty["days_hit"] == 0 + + +def test_aggregate_leverage_days_hit(): + from datetime import datetime + from zoneinfo import ZoneInfo + + sh = ZoneInfo("Asia/Shanghai") + ts1 = int(datetime(2026, 7, 30, 11, 10, tzinfo=sh).timestamp() * 1000) + ts2 = int(datetime(2026, 7, 31, 11, 20, tzinfo=sh).timestamp() * 1000) + rows = [ + {"ts_ms": ts1, "side": "C", "leverage": 120}, # day1 hit + {"ts_ms": ts2, "side": "C", "leverage": 80}, # day2 miss + ] + buckets = aggregate_leverage(rows, bucket_minutes=60, min_leverage=100, side="C") + b11 = next(b for b in buckets if b["bucket_start_min"] == 11 * 60) + assert b11["days"] == 2 + assert b11["days_hit"] == 1 + assert b11["pct_days_hit"] == 0.5 + assert b11["n"] == 2 diff --git a/tests/test_ops_summary_logic.py b/tests/test_ops_summary_logic.py index d470055..65ecbf4 100644 --- a/tests/test_ops_summary_logic.py +++ b/tests/test_ops_summary_logic.py @@ -1,32 +1,54 @@ -"""对齐前端 opsSummary 规则的轻量逻辑测试(Python 镜像)。""" +"""对齐前端 opsSummary 表规则的轻量逻辑测试。""" -MIN_N = 3 -TOP_K = 5 - - -def leverage_summary_week(buckets, min_leverage=100): +def leverage_rows(buckets, min_leverage=100): active = [b for b in buckets if b["n"] > 0] - ranked = sorted( - [b for b in active if b["n"] >= MIN_N and b.get("pct_ge_min") is not None], - key=lambda b: (b["pct_ge_min"], b["n"], b.get("mean") or 0), - reverse=True, - )[:TOP_K] - return ranked + def hit_rate(b): + if b.get("pct_days_hit") is not None: + return b["pct_days_hit"] + return b.get("pct_ge_min") -def test_week_ranks_by_pct_ge_min(): - buckets = [ - {"label": "09:00", "n": 10, "mean": 110, "pct_ge_min": 0.5}, - {"label": "14:00", "n": 10, "mean": 105, "pct_ge_min": 0.8}, - {"label": "21:00", "n": 2, "mean": 200, "pct_ge_min": 1.0}, # n 不足 + def days(b): + return b["days"] if "days" in b else (1 if b["n"] > 0 else 0) + + def days_hit(b): + if "days_hit" in b: + return b["days_hit"] + return 1 if (b.get("mean") or 0) >= min_leverage and b["n"] > 0 else 0 + + rows = [ + { + "label": b["label"], + "days": days(b), + "days_hit": days_hit(b), + "pct": hit_rate(b), + "mean": b.get("mean"), + "n": b["n"], + } + for b in active ] - ranked = leverage_summary_week(buckets) - assert [b["label"] for b in ranked] == ["14:00", "09:00"] + rows.sort( + key=lambda r: (r["pct"] if r["pct"] is not None else -1, r["days_hit"], r["mean"] or 0), + reverse=True, + ) + return rows + + +def test_week_table_uses_days_hit(): + buckets = [ + {"label": "09:00", "n": 10, "mean": 110, "pct_ge_min": 0.9, "days": 5, "days_hit": 2, "pct_days_hit": 0.4}, + {"label": "14:00", "n": 10, "mean": 105, "pct_ge_min": 0.5, "days": 5, "days_hit": 4, "pct_days_hit": 0.8}, + {"label": "21:00", "n": 2, "mean": 200, "pct_ge_min": 1.0, "days": 1, "days_hit": 1, "pct_days_hit": 1.0}, + ] + rows = leverage_rows(buckets) + assert [r["label"] for r in rows] == ["21:00", "14:00", "09:00"] + assert rows[1]["days"] == 5 and rows[1]["days_hit"] == 4 def test_day_keeps_small_n(): - active = [b for b in [ - {"label": "09:00", "n": 1, "mean": 118, "pct_ge_min": 1.0}, - ] if b["n"] > 0] - assert len(active) == 1 + rows = leverage_rows( + [{"label": "09:00", "n": 1, "mean": 118, "pct_ge_min": 1.0, "days": 1, "days_hit": 1, "pct_days_hit": 1.0}] + ) + assert len(rows) == 1 + assert rows[0]["days"] == 1 and rows[0]["days_hit"] == 1 diff --git a/web/dist/index.html b/web/dist/index.html index 6fa72b8..9eccb20 100644 --- a/web/dist/index.html +++ b/web/dist/index.html @@ -48,6 +48,17 @@ font-size: 0.88rem; line-height: 1.55; } + .chart-summary-table { padding: 0.65rem 0.75rem 0.75rem; } + .summary-note { margin-bottom: 0.55rem; color: var(--muted); font-size: 0.82rem; } + .summary-table-wrap { overflow-x: auto; } + .summary-table { width: 100%; border-collapse: collapse; font-size: 0.84rem; color: var(--text); } + .summary-table th, .summary-table td { + padding: 0.4rem 0.55rem; text-align: right; border-bottom: 1px solid #1c2734; white-space: nowrap; + } + .summary-table th:first-child, .summary-table td:first-child { text-align: left; } + .summary-table thead th { color: var(--muted); font-weight: 600; border-bottom-color: #2a3a4d; } + .summary-table tbody tr:last-child td { border-bottom: none; } + .summary-table tbody tr:hover td { background: rgba(255,255,255,0.03); } .hidden { display: none; } pre { background: var(--panel); border: 1px solid #243041; border-radius: 10px; padding: 1rem; overflow: auto; font-size: 0.78rem; color: #b7c5d4; } .center-page { display: flex; justify-content: center; align-items: flex-start; min-height: 50vh; padding: 1.5rem 0 3rem; } @@ -139,7 +150,7 @@
—
+