feat: show ops-map summaries as detailed period tables

Replace paragraph text with day-hit stats (days/days_hit/pct) so leverage and move charts list each hour with counts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-05 17:12:23 +08:00
parent e213db8d2d
commit 270e114658
8 changed files with 513 additions and 210 deletions
+26 -2
View File
@@ -6,7 +6,7 @@ import math
from collections import defaultdict from collections import defaultdict
from typing import Any, Iterable, Sequence 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 from packages.domain.leverage import LEVERAGE_FORMULA_VERSION
@@ -87,9 +87,15 @@ def aggregate_leverage(
""" """
rows: 需含 ts_ms, leverage, side。 rows: 需含 ts_ms, leverage, side。
返回按桶排序的聚合列表(含空桶)。 返回按桶排序的聚合列表(含空桶)。
额外字段:
days — 该时段有样本的上海自然日数
days_hit — 当日该时段均值 ≥ min_leverage 的天数
pct_days_hit — days_hit / days
""" """
want = (side or "both").upper() want = (side or "both").upper()
by_bucket: dict[int, list[float]] = defaultdict(list) 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: for r in rows:
lev = r.get("leverage") lev = r.get("leverage")
@@ -106,18 +112,32 @@ def aggregate_leverage(
continue continue
if want == "BOTH" and s not in ("C", "P"): if want == "BOTH" and s not in ("C", "P"):
continue 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[b].append(lev_f)
by_bucket_day[b][day].append(lev_f)
out: list[dict[str, Any]] = [] out: list[dict[str, Any]] = []
thr = float(min_leverage)
for b in all_bucket_starts(bucket_minutes): for b in all_bucket_starts(bucket_minutes):
stats = summarize_values(by_bucket.get(b, []), min_leverage=min_leverage) 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( out.append(
{ {
"bucket_start_min": b, "bucket_start_min": b,
"bucket_hour": b // 60 if bucket_minutes >= 60 else None, "bucket_hour": b // 60 if bucket_minutes >= 60 else None,
"label": bucket_label(b, bucket_minutes), "label": bucket_label(b, bucket_minutes),
**stats, **stats,
"days": days,
"days_hit": days_hit,
"pct_days_hit": pct_days,
} }
) )
return out return out
@@ -156,9 +176,11 @@ def aggregate_move_points(
""" """
samples: {ts_ms, move_signed, move_abs} samples: {ts_ms, move_signed, move_abs}
桶内同时给出 signed / abs 分布。 桶内同时给出 signed / abs 分布。
额外:days — 该时段有结算样本的自然日数。
""" """
by_signed: dict[int, list[float]] = defaultdict(list) by_signed: dict[int, list[float]] = defaultdict(list)
by_abs: 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: for s in samples:
ts = s.get("ts_ms") ts = s.get("ts_ms")
signed = s.get("move_signed") signed = s.get("move_signed")
@@ -174,6 +196,7 @@ def aggregate_move_points(
b = shanghai_bucket(int(ts), bucket_minutes) b = shanghai_bucket(int(ts), bucket_minutes)
by_signed[b].append(signed_f) by_signed[b].append(signed_f)
by_abs[b].append(abs_f) by_abs[b].append(abs_f)
by_bucket_days[b].add(shanghai_day(int(ts)))
out: list[dict[str, Any]] = [] out: list[dict[str, Any]] = []
for b in all_bucket_starts(bucket_minutes): 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, "bucket_hour": b // 60 if bucket_minutes >= 60 else None,
"label": bucket_label(b, bucket_minutes), "label": bucket_label(b, bucket_minutes),
"n": signed_stats["n"], "n": signed_stats["n"],
"days": len(by_bucket_days.get(b, set())),
"signed": signed_stats, "signed": signed_stats,
"abs": abs_stats, "abs": abs_stats,
# 便捷字段(看板默认用 abs 均值) # 便捷字段(看板默认用 abs 均值)
+24
View File
@@ -61,5 +61,29 @@ def test_aggregate_leverage_buckets():
assert b14["n"] == 2 assert b14["n"] == 2
assert b14["mean"] == 150 assert b14["mean"] == 150
assert b14["label"] == "14:00" 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) empty = next(b for b in buckets if b["bucket_start_min"] == 0)
assert empty["n"] == 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
+45 -23
View File
@@ -1,32 +1,54 @@
"""对齐前端 opsSummary 规则的轻量逻辑测试Python 镜像)""" """对齐前端 opsSummary 规则的轻量逻辑测试。"""
MIN_N = 3 def leverage_rows(buckets, min_leverage=100):
TOP_K = 5
def leverage_summary_week(buckets, min_leverage=100):
active = [b for b in buckets if b["n"] > 0] 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(): def days(b):
buckets = [ return b["days"] if "days" in b else (1 if b["n"] > 0 else 0)
{"label": "09:00", "n": 10, "mean": 110, "pct_ge_min": 0.5},
{"label": "14:00", "n": 10, "mean": 105, "pct_ge_min": 0.8}, def days_hit(b):
{"label": "21:00", "n": 2, "mean": 200, "pct_ge_min": 1.0}, # n 不足 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) rows.sort(
assert [b["label"] for b in ranked] == ["14:00", "09:00"] 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(): def test_day_keeps_small_n():
active = [b for b in [ rows = leverage_rows(
{"label": "09:00", "n": 1, "mean": 118, "pct_ge_min": 1.0}, [{"label": "09:00", "n": 1, "mean": 118, "pct_ge_min": 1.0, "days": 1, "days_hit": 1, "pct_days_hit": 1.0}]
] if b["n"] > 0] )
assert len(active) == 1 assert len(rows) == 1
assert rows[0]["days"] == 1 and rows[0]["days_hit"] == 1
+109 -71
View File
@@ -48,6 +48,17 @@
font-size: 0.88rem; font-size: 0.88rem;
line-height: 1.55; 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; } .hidden { display: none; }
pre { background: var(--panel); border: 1px solid #243041; border-radius: 10px; padding: 1rem; overflow: auto; font-size: 0.78rem; color: #b7c5d4; } 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; } .center-page { display: flex; justify-content: center; align-items: flex-start; min-height: 50vh; padding: 1.5rem 0 3rem; }
@@ -139,7 +150,7 @@
<div class="chart-title" id="chartTitle">上图 · 时段杠杆均值</div> <div class="chart-title" id="chartTitle">上图 · 时段杠杆均值</div>
<svg id="levChart" class="chart-svg" viewBox="0 0 880 260" role="img"></svg> <svg id="levChart" class="chart-svg" viewBox="0 0 880 260" role="img"></svg>
</div> </div>
<p class="chart-summary" id="levSummary"></p> <div class="chart-summary" id="levSummary"></div>
<div class="toolbar" style="margin-top:1rem"> <div class="toolbar" style="margin-top:1rem">
<div class="seg" id="moveModeSeg"> <div class="seg" id="moveModeSeg">
<button type="button" data-mode="abs" class="active">绝对波动</button> <button type="button" data-mode="abs" class="active">绝对波动</button>
@@ -151,7 +162,7 @@
<div class="chart-title" id="moveTitle">下图 · 时段→到期波动</div> <div class="chart-title" id="moveTitle">下图 · 时段→到期波动</div>
<svg id="moveChart" class="chart-svg" viewBox="0 0 880 260" role="img"></svg> <svg id="moveChart" class="chart-svg" viewBox="0 0 880 260" role="img"></svg>
</div> </div>
<p class="chart-summary" id="moveSummary"></p> <div class="chart-summary" id="moveSummary"></div>
</section> </section>
<section id="page-settings" class="hidden"> <section id="page-settings" class="hidden">
<div class="center-page"> <div class="center-page">
@@ -556,7 +567,8 @@
document.querySelectorAll("#moveModeSeg button").forEach(x => x.classList.toggle("active", x === b)); document.querySelectorAll("#moveModeSeg button").forEach(x => x.classList.toggle("active", x === b));
if (state.lastMov) { if (state.lastMov) {
drawMoveChart(state.lastMov.buckets || [], state.moveMode); drawMoveChart(state.lastMov.buckets || [], state.moveMode);
document.getElementById("moveSummary").textContent = buildMoveSummary( renderMoveTable(
document.getElementById("moveSummary"),
state.range, state.lastMov.buckets || [], { state.range, state.lastMov.buckets || [], {
pendingExpiry: !!state.lastMov.pending_expiry, pendingExpiry: !!state.lastMov.pending_expiry,
mode: state.moveMode, mode: state.moveMode,
@@ -631,86 +643,110 @@
svg.innerHTML = html; svg.innerHTML = html;
} }
const SUMMARY_MIN_N = 3;
const SUMMARY_TOP_K = 5;
function fmtSummaryPct(p) { return Math.round(p * 100) + "%"; } function fmtSummaryPct(p) { return Math.round(p * 100) + "%"; }
function fmtSummaryNum(n, d) { function fmtSummaryNum(n, d) {
d = d == null ? 1 : d; d = d == null ? 1 : d;
return Number(n).toFixed(d); return Number(n).toFixed(d);
} }
function buildLevSummary(range, buckets, minLeverage) { function hitRate(b) {
const active = (buckets || []).filter(b => (b.n || 0) > 0); if (b.pct_days_hit != null) return b.pct_days_hit;
if (!active.length) return "所选范围内暂无杠杆样本,采集后将显示各时段分布。"; return b.pct_ge_min;
const bestMean = active.slice().sort((a, b) => (b.mean ?? -1) - (a.mean ?? -1))[0];
if (range === "day") {
const labels = active.map(b => b.label).join("、");
let text = "今日杠杆分布:有样本时段 " + labels + "。";
if (bestMean && bestMean.mean != null) {
const ge = bestMean.mean >= minLeverage ? "已超过" : "未达到";
text += " " + bestMean.label + " 均值约 " + fmtSummaryNum(bestMean.mean) + "" + ge + "达标线 " + minLeverage + "。";
}
if (active.length <= 2) {
text += " 采集初期,今日仅 " + active.length + " 个时段有样本,结论仅供参考。";
}
return text;
}
const windowName = range === "week" ? "本周" : "本月";
const ranked = active
.filter(b => b.n >= SUMMARY_MIN_N && b.pct_ge_min != null)
.sort((a, b) => {
const dp = (b.pct_ge_min || 0) - (a.pct_ge_min || 0);
if (dp !== 0) return dp;
const dn = (b.n || 0) - (a.n || 0);
if (dn !== 0) return dn;
return (b.mean || 0) - (a.mean || 0);
})
.slice(0, SUMMARY_TOP_K);
let text = "";
if (!ranked.length) {
text = windowName + "各时段样本仍偏少(单时段不足 " + SUMMARY_MIN_N + " 条),暂不给出高概率名单;持续采集后会更稳定。";
} else {
text = windowName + "≥" + minLeverage + " 出现概率较高的时段:" +
ranked.map(b => b.label + "" + fmtSummaryPct(b.pct_ge_min || 0) + "").join("、") + "。";
}
if (bestMean && bestMean.mean != null && bestMean.n >= SUMMARY_MIN_N) {
text += " 杠杆均值最高时段:" + bestMean.label + "(约 " + fmtSummaryNum(bestMean.mean) + ")。";
} else if (bestMean && bestMean.mean != null) {
text += " 当前均值最高:" + bestMean.label + "(约 " + fmtSummaryNum(bestMean.mean) + ",样本 " + bestMean.n + ",偏少)。";
}
return text;
} }
function buildMoveSummary(range, buckets, opts) { function dayCount(b) {
if (typeof b.days === "number") return b.days;
return (b.n || 0) > 0 ? 1 : 0;
}
function daysHitCount(b, minLeverage) {
if (typeof b.days_hit === "number") return b.days_hit;
return (b.mean != null && b.mean >= minLeverage && (b.n || 0) > 0) ? 1 : 0;
}
function renderEmptySummary(el, message) {
el.className = "chart-summary";
el.textContent = message;
}
function renderLevTable(el, range, buckets, minLeverage) {
const active = (buckets || []).filter(b => (b.n || 0) > 0);
if (!active.length) {
renderEmptySummary(el, "所选范围内暂无杠杆样本,采集后将显示各时段分布。");
return;
}
const rows = active.slice().map(b => ({
label: b.label,
days: dayCount(b),
daysHit: daysHitCount(b, minLeverage),
pct: hitRate(b),
mean: b.mean,
n: b.n,
})).sort((a, b) => {
const dp = (b.pct ?? -1) - (a.pct ?? -1);
if (dp !== 0) return dp;
const dh = b.daysHit - a.daysHit;
if (dh !== 0) return dh;
return (b.mean || 0) - (a.mean || 0);
});
const windowName = range === "day" ? "今日" : (range === "week" ? "本周" : "本月");
const note = range === "day"
? (windowName + "各时段明细(达标 = 该时段均值 ≥ " + minLeverage + "")
: (windowName + "各时段明细(达标 = 当日该时段均值 ≥ " + minLeverage + ";概率 = 达标天数 ÷ 一共几天)");
let html = '<div class="summary-note">' + note + '</div><div class="summary-table-wrap"><table class="summary-table"><thead><tr>';
html += "<th>时段</th><th>一共几天</th><th>达标几天</th><th>达标概率</th><th>杠杆均值</th><th>样本数</th>";
html += "</tr></thead><tbody>";
rows.forEach(r => {
html += "<tr><td>" + r.label + "</td><td>" + r.days + "</td><td>" + r.daysHit + "</td><td>" +
(r.pct != null ? fmtSummaryPct(r.pct) : "—") + "</td><td>" +
(r.mean != null ? fmtSummaryNum(r.mean) : "—") + "</td><td>" + r.n + "</td></tr>";
});
html += "</tbody></table></div>";
el.className = "chart-summary chart-summary-table";
el.innerHTML = html;
}
function renderMoveTable(el, range, buckets, opts) {
opts = opts || {}; opts = opts || {};
if (opts.pendingExpiry) { if (opts.pendingExpiry) {
const extra = opts.apiMessage ? (" " + opts.apiMessage) : ""; const extra = opts.apiMessage ? (" " + opts.apiMessage) : "";
return ("未到期,暂无法统计时段→到期波动;结算后将按日/周/月给出高波动钟点。" + extra).trim(); renderEmptySummary(el, ("未到期,暂无法统计时段→到期波动;结算后将按日/周/月给出各钟点明细。" + extra).trim());
return;
} }
const mode = opts.mode || "abs"; const mode = opts.mode || "abs";
const active = (buckets || []).filter(b => { const active = (buckets || []).filter(b => {
if ((b.n || 0) <= 0) return false; if ((b.n || 0) <= 0) return false;
return mode === "abs" ? b.mean_abs != null : b.mean_signed != null; return mode === "abs" ? b.mean_abs != null : b.mean_signed != null;
}); });
if (!active.length) return "所选范围内暂无已结算波动样本。"; if (!active.length) {
renderEmptySummary(el, "所选范围内暂无已结算波动样本。");
return;
}
const windowName = range === "day" ? "今日" : (range === "week" ? "本周" : "本月"); const windowName = range === "day" ? "今日" : (range === "week" ? "本周" : "本月");
const pool = range === "day" ? active : active.filter(b => b.n >= SUMMARY_MIN_N); const rows = active.slice().map(b => ({
const ranked = pool.slice().sort((a, b) => { label: b.label,
if (mode === "abs") return Math.abs(b.mean_abs || 0) - Math.abs(a.mean_abs || 0); days: typeof b.days === "number" ? b.days : ((b.n || 0) > 0 ? 1 : 0),
return Math.abs(b.mean_signed || 0) - Math.abs(a.mean_signed || 0); n: b.n,
}).slice(0, SUMMARY_TOP_K); meanAbs: b.mean_abs,
if (!ranked.length) { meanSigned: b.mean_signed,
return range === "day" })).sort((a, b) => {
? (windowName + "暂无可用波动样本。") if (mode === "abs") return Math.abs(b.meanAbs || 0) - Math.abs(a.meanAbs || 0);
: (windowName + "波动样本偏少(单时段不足 " + SUMMARY_MIN_N + " 条),暂不排名。"); return Math.abs(b.meanSigned || 0) - Math.abs(a.meanSigned || 0);
} });
if (mode === "abs") { const note = mode === "abs"
return windowName + "波动较高时段:" + ? (windowName + "各时段→到期绝对波动明细")
ranked.map(b => b.label + "" + fmtSummaryNum(b.mean_abs || 0, 2) + "").join("、") + "。"; : (windowName + "各时段→到期带符号波动明细");
} const colA = mode === "abs" ? "绝对波动均值" : "带符号均值";
return windowName + "带符号波动较大时段:" + const colB = mode === "abs" ? "带符号均值" : "绝对波动均值";
ranked.map(b => { let html = '<div class="summary-note">' + note + '</div><div class="summary-table-wrap"><table class="summary-table"><thead><tr>';
const v = b.mean_signed || 0; html += "<th>时段</th><th>一共几天</th><th>样本数</th><th>" + colA + "</th><th>" + colB + "</th>";
return b.label + "" + fmtSummaryNum(v, 2) + "" + (v >= 0 ? "偏多" : "偏空") + ""; html += "</tr></thead><tbody>";
}).join("、") + "。"; rows.forEach(r => {
const a = mode === "abs"
? (r.meanAbs != null ? fmtSummaryNum(r.meanAbs, 2) : "—")
: (r.meanSigned != null ? fmtSummaryNum(r.meanSigned, 2) : "—");
const b = mode === "abs"
? (r.meanSigned != null ? fmtSummaryNum(r.meanSigned, 2) : "—")
: (r.meanAbs != null ? fmtSummaryNum(r.meanAbs, 2) : "—");
html += "<tr><td>" + r.label + "</td><td>" + r.days + "</td><td>" + r.n + "</td><td>" + a + "</td><td>" + b + "</td></tr>";
});
html += "</tbody></table></div>";
el.className = "chart-summary chart-summary-table";
el.innerHTML = html;
} }
async function loadOps() { async function loadOps() {
@@ -751,7 +787,8 @@
const titleMap = { day: "日", week: "近7日", month: "近30日" }; const titleMap = { day: "日", week: "近7日", month: "近30日" };
document.getElementById("chartTitle").textContent = "上图 · 时段杠杆均值(" + (titleMap[state.range]||"") + ""; document.getElementById("chartTitle").textContent = "上图 · 时段杠杆均值(" + (titleMap[state.range]||"") + "";
drawChart(lev.buckets || [], lev.min_leverage || 100); drawChart(lev.buckets || [], lev.min_leverage || 100);
document.getElementById("levSummary").textContent = buildLevSummary( renderLevTable(
document.getElementById("levSummary"),
state.range, lev.buckets || [], lev.min_leverage || 100 state.range, lev.buckets || [], lev.min_leverage || 100
); );
const mov = d.move_points || {}; const mov = d.move_points || {};
@@ -764,7 +801,8 @@
document.getElementById("moveTitle").textContent = document.getElementById("moveTitle").textContent =
"下图 · 时段→到期波动(" + (titleMap[state.range]||"") + " · " + state.moveMode + ""; "下图 · 时段→到期波动(" + (titleMap[state.range]||"") + " · " + state.moveMode + "";
drawMoveChart(mov.buckets || [], state.moveMode); drawMoveChart(mov.buckets || [], state.moveMode);
document.getElementById("moveSummary").textContent = buildMoveSummary( renderMoveTable(
document.getElementById("moveSummary"),
state.range, mov.buckets || [], { state.range, mov.buckets || [], {
pendingExpiry: !!mov.pending_expiry, pendingExpiry: !!mov.pending_expiry,
mode: state.moveMode, mode: state.moveMode,
@@ -773,8 +811,8 @@
); );
} catch (e) { } catch (e) {
document.getElementById("opsRange").textContent = "加载失败"; document.getElementById("opsRange").textContent = "加载失败";
document.getElementById("levSummary").textContent = String(e); renderEmptySummary(document.getElementById("levSummary"), String(e));
document.getElementById("moveSummary").textContent = String(e); renderEmptySummary(document.getElementById("moveSummary"), String(e));
} }
} }
+4
View File
@@ -195,6 +195,9 @@ export type LeverageBucket = {
p25: number | null; p25: number | null;
p75: number | null; p75: number | null;
pct_ge_min: number | null; pct_ge_min: number | null;
days?: number;
days_hit?: number;
pct_days_hit?: number | null;
}; };
export type LeverageStats = { export type LeverageStats = {
@@ -213,6 +216,7 @@ export type MoveBucket = {
bucket_start_min: number; bucket_start_min: number;
label: string; label: string;
n: number; n: number;
days?: number;
mean_abs: number | null; mean_abs: number | null;
median_abs: number | null; median_abs: number | null;
mean_signed: number | null; mean_signed: number | null;
+159 -106
View File
@@ -1,10 +1,13 @@
/** 作战地图图下文字结论(由 buckets 生成)。 */ /** 作战地图图下明细表(由 buckets 生成)。 */
export type LevBucket = { export type LevBucket = {
label: string; label: string;
n: number; n: number;
mean: number | null; mean: number | null;
pct_ge_min: number | null; pct_ge_min: number | null;
days?: number;
days_hit?: number;
pct_days_hit?: number | null;
}; };
export type MoveBucket = { export type MoveBucket = {
@@ -12,10 +15,29 @@ export type MoveBucket = {
n: number; n: number;
mean_abs: number | null; mean_abs: number | null;
mean_signed: number | null; mean_signed: number | null;
days?: number;
}; };
const MIN_N = 3; export type LevTableRow = {
const TOP_K = 5; label: string;
days: number;
daysHit: number;
pct: number | null;
mean: number | null;
n: number;
};
export type MoveTableRow = {
label: string;
days: number;
n: number;
meanAbs: number | null;
meanSigned: number | null;
};
export type SummaryTable<T> =
| { kind: "empty"; message: string }
| { kind: "table"; note?: string; rows: T[] };
function fmtPct(p: number): string { function fmtPct(p: number): string {
return `${Math.round(p * 100)}%`; return `${Math.round(p * 100)}%`;
@@ -29,74 +51,136 @@ function withData<T extends { n: number }>(buckets: T[]): T[] {
return buckets.filter((b) => b.n > 0); return buckets.filter((b) => b.n > 0);
} }
function enoughSamples<T extends { n: number }>(buckets: T[]): T[] { /** 达标概率优先用按日口径 pct_days_hit,兼容旧数据回退到样本口径。 */
return buckets.filter((b) => b.n >= MIN_N); function hitRate(b: LevBucket): number | null {
if (b.pct_days_hit != null) return b.pct_days_hit;
return b.pct_ge_min;
} }
/** 上图杠杆结论 */ function dayCount(b: LevBucket): number {
if (typeof b.days === "number") return b.days;
return b.n > 0 ? 1 : 0;
}
function daysHitCount(b: LevBucket, minLeverage: number): number {
if (typeof b.days_hit === "number") return b.days_hit;
if (b.mean != null && b.mean >= minLeverage && b.n > 0) return 1;
return 0;
}
/** 上图杠杆明细表 */
export function leverageSummaryTable(
range: "day" | "week" | "month",
buckets: LevBucket[],
minLeverage: number
): SummaryTable<LevTableRow> {
const active = withData(buckets);
if (!active.length) {
return {
kind: "empty",
message: "所选范围内暂无杠杆样本,采集后将显示各时段分布。",
};
}
const rows: LevTableRow[] = [...active]
.map((b) => ({
label: b.label,
days: dayCount(b),
daysHit: daysHitCount(b, minLeverage),
pct: hitRate(b),
mean: b.mean,
n: b.n,
}))
.sort((a, b) => {
const dp = (b.pct ?? -1) - (a.pct ?? -1);
if (dp !== 0) return dp;
const dh = b.daysHit - a.daysHit;
if (dh !== 0) return dh;
return (b.mean ?? 0) - (a.mean ?? 0);
});
const windowName = range === "day" ? "今日" : range === "week" ? "本周" : "本月";
const note =
range === "day"
? `${windowName}各时段明细(达标 = 该时段均值 ≥ ${minLeverage}`
: `${windowName}各时段明细(达标 = 当日该时段均值 ≥ ${minLeverage};概率 = 达标天数 ÷ 一共几天)`;
return { kind: "table", note, rows };
}
/** 下图波动明细表 */
export function moveSummaryTable(
range: "day" | "week" | "month",
buckets: MoveBucket[],
opts: {
pendingExpiry?: boolean;
pendingCount?: number;
mode: "abs" | "signed";
apiMessage?: string | null;
}
): SummaryTable<MoveTableRow> {
if (opts.pendingExpiry) {
const extra = opts.apiMessage ? ` ${opts.apiMessage}` : "";
return {
kind: "empty",
message:
`未到期,暂无法统计时段→到期波动;结算后将按日/周/月给出各钟点明细。${extra}`.trim(),
};
}
const active = withData(buckets).filter((b) =>
opts.mode === "abs" ? b.mean_abs != null : b.mean_signed != null
);
if (!active.length) {
return { kind: "empty", message: "所选范围内暂无已结算波动样本。" };
}
const windowName =
range === "day" ? "今日" : range === "week" ? "本周" : "本月";
const rows: MoveTableRow[] = [...active]
.map((b) => ({
label: b.label,
days: typeof b.days === "number" ? b.days : b.n > 0 ? 1 : 0,
n: b.n,
meanAbs: b.mean_abs,
meanSigned: b.mean_signed,
}))
.sort((a, b) => {
if (opts.mode === "abs") {
return Math.abs(b.meanAbs ?? 0) - Math.abs(a.meanAbs ?? 0);
}
return Math.abs(b.meanSigned ?? 0) - Math.abs(a.meanSigned ?? 0);
});
const note =
opts.mode === "abs"
? `${windowName}各时段→到期绝对波动明细`
: `${windowName}各时段→到期带符号波动明细`;
return { kind: "table", note, rows };
}
/** @deprecated 保留短句接口给旧调用;新 UI 用表格。 */
export function leverageSummary( export function leverageSummary(
range: "day" | "week" | "month", range: "day" | "week" | "month",
buckets: LevBucket[], buckets: LevBucket[],
minLeverage: number minLeverage: number
): string { ): string {
const active = withData(buckets); const t = leverageSummaryTable(range, buckets, minLeverage);
if (!active.length) { if (t.kind === "empty") return t.message;
return "所选范围内暂无杠杆样本,采集后将显示各时段分布。"; const top = t.rows.slice(0, 5);
} const list = top
.map(
const bestMean = [...active].sort((a, b) => (b.mean ?? -1) - (a.mean ?? -1))[0]; (r) =>
const minL = minLeverage; `${r.label}(一共 ${r.days} 天,达标 ${r.daysHit}${
r.pct != null ? `${fmtPct(r.pct)}` : ""
if (range === "day") { }`
const labels = active.map((b) => b.label).join("、"); )
const parts: string[] = []; .join("、");
parts.push(`今日杠杆分布:有样本时段 ${labels}`); return `${t.note}${list}`;
if (bestMean?.mean != null) {
const ge = bestMean.mean >= minL ? "已超过" : "未达到";
parts.push(
`${bestMean.label} 均值约 ${fmtNum(bestMean.mean)}${ge}达标线 ${minL}`
);
}
if (active.length <= 2) {
parts.push(`采集初期,今日仅 ${active.length} 个时段有样本,结论仅供参考。`);
}
return parts.join(" ");
}
const windowName = range === "week" ? "本周" : "本月";
const ranked = enoughSamples(active)
.filter((b) => b.pct_ge_min != null)
.sort((a, b) => {
const dp = (b.pct_ge_min ?? 0) - (a.pct_ge_min ?? 0);
if (dp !== 0) return dp;
const dn = b.n - a.n;
if (dn !== 0) return dn;
return (b.mean ?? 0) - (a.mean ?? 0);
})
.slice(0, TOP_K);
const parts: string[] = [];
if (!ranked.length) {
parts.push(
`${windowName}各时段样本仍偏少(单时段不足 ${MIN_N} 条),暂不给出高概率名单;持续采集后会更稳定。`
);
} else {
const list = ranked
.map((b) => `${b.label}${fmtPct(b.pct_ge_min ?? 0)}`)
.join("、");
parts.push(`${windowName}${minL} 出现概率较高的时段:${list}`);
}
if (bestMean?.mean != null && bestMean.n >= MIN_N) {
parts.push(`杠杆均值最高时段:${bestMean.label}(约 ${fmtNum(bestMean.mean)})。`);
} else if (bestMean?.mean != null) {
parts.push(
`当前均值最高:${bestMean.label}(约 ${fmtNum(bestMean.mean)},样本 ${bestMean.n},偏少)。`
);
}
return parts.join(" ");
} }
/** 下图波动结论 */
export function moveSummary( export function moveSummary(
range: "day" | "week" | "month", range: "day" | "week" | "month",
buckets: MoveBucket[], buckets: MoveBucket[],
@@ -107,51 +191,20 @@ export function moveSummary(
apiMessage?: string | null; apiMessage?: string | null;
} }
): string { ): string {
if (opts.pendingExpiry) { const t = moveSummaryTable(range, buckets, opts);
const extra = opts.apiMessage ? ` ${opts.apiMessage}` : ""; if (t.kind === "empty") return t.message;
return `未到期,暂无法统计时段→到期波动;结算后将按日/周/月给出高波动钟点。${extra}`.trim(); const top = t.rows.slice(0, 5);
}
const active = withData(buckets).filter((b) =>
opts.mode === "abs" ? b.mean_abs != null : b.mean_signed != null
);
if (!active.length) {
return "所选范围内暂无已结算波动样本。";
}
const windowName =
range === "day" ? "今日" : range === "week" ? "本周" : "本月";
// 日视图样本少时放宽;周/月仍要求单时段样本足够
const pool = range === "day" ? active : enoughSamples(active);
const ranked = [...pool]
.sort((a, b) => {
if (opts.mode === "abs") {
return Math.abs(b.mean_abs ?? 0) - Math.abs(a.mean_abs ?? 0);
}
return Math.abs(b.mean_signed ?? 0) - Math.abs(a.mean_signed ?? 0);
})
.slice(0, TOP_K);
if (!ranked.length) {
return range === "day"
? `${windowName}暂无可用波动样本。`
: `${windowName}波动样本偏少(单时段不足 ${MIN_N} 条),暂不排名。`;
}
if (opts.mode === "abs") { if (opts.mode === "abs") {
const list = ranked return `${t.note}${top
.map((b) => `${b.label}${fmtNum(b.mean_abs ?? 0, 2)}`) .map((r) => `${r.label}${fmtNum(r.meanAbs ?? 0, 2)}${r.days}`)
.join("、"); .join("、")}`;
return `${windowName}波动较高时段:${list}`;
} }
return `${t.note}${top
const list = ranked .map((r) => {
.map((b) => { const v = r.meanSigned ?? 0;
const v = b.mean_signed ?? 0; return `${r.label}${fmtNum(v, 2)}${r.days} 天)`;
const dir = v >= 0 ? "偏多" : "偏空";
return `${b.label}${fmtNum(v, 2)}${dir}`;
}) })
.join("、"); .join("、")}。`;
return `${windowName}带符号波动较大时段:${list}`;
} }
export { fmtPct, fmtNum };
+107 -8
View File
@@ -10,7 +10,12 @@ import AppNav from "../components/AppNav";
import LeverageChart from "../components/LeverageChart"; import LeverageChart from "../components/LeverageChart";
import LoginGate from "../components/LoginGate"; import LoginGate from "../components/LoginGate";
import MovePointsChart from "../components/MovePointsChart"; import MovePointsChart from "../components/MovePointsChart";
import { leverageSummary, moveSummary } from "../lib/opsSummary"; import {
fmtNum,
fmtPct,
leverageSummaryTable,
moveSummaryTable,
} from "../lib/opsSummary";
type RangeKey = "day" | "week" | "month"; type RangeKey = "day" | "week" | "month";
type SideKey = "both" | "C" | "P"; type SideKey = "both" | "C" | "P";
@@ -215,9 +220,49 @@ export default function OpsMapPage() {
minLeverage={lev.min_leverage} minLeverage={lev.min_leverage}
title={`上图 · 时段杠杆均值(${rangeLabel}`} title={`上图 · 时段杠杆均值(${rangeLabel}`}
/> />
<p className="chart-summary"> {(() => {
{leverageSummary(range, lev.buckets, lev.min_leverage)} const levTable = leverageSummaryTable(
</p> range,
lev.buckets,
lev.min_leverage
);
if (levTable.kind === "empty") {
return <p className="chart-summary">{levTable.message}</p>;
}
return (
<div className="chart-summary chart-summary-table">
{levTable.note && (
<div className="summary-note">{levTable.note}</div>
)}
<div className="summary-table-wrap">
<table className="summary-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{levTable.rows.map((r) => (
<tr key={r.label}>
<td>{r.label}</td>
<td>{r.days}</td>
<td>{r.daysHit}</td>
<td>{r.pct != null ? fmtPct(r.pct) : "—"}</td>
<td>{r.mean != null ? fmtNum(r.mean) : "—"}</td>
<td>{r.n}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
})()}
<div className="toolbar" style={{ marginTop: "1.25rem" }}> <div className="toolbar" style={{ marginTop: "1.25rem" }}>
<div className="seg"> <div className="seg">
@@ -250,14 +295,68 @@ export default function OpsMapPage() {
moveMode === "abs" ? "abs" : "signed" moveMode === "abs" ? "abs" : "signed"
}`} }`}
/> />
<p className="chart-summary"> {(() => {
{moveSummary(range, mov?.buckets ?? [], { const movTable = moveSummaryTable(range, mov?.buckets ?? [], {
pendingExpiry: mov?.pending_expiry, pendingExpiry: mov?.pending_expiry,
pendingCount: mov?.pending_count, pendingCount: mov?.pending_count,
mode: moveMode, mode: moveMode,
apiMessage: mov?.message, apiMessage: mov?.message,
})} });
</p> if (movTable.kind === "empty") {
return <p className="chart-summary">{movTable.message}</p>;
}
return (
<div className="chart-summary chart-summary-table">
{movTable.note && (
<div className="summary-note">{movTable.note}</div>
)}
<div className="summary-table-wrap">
<table className="summary-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th>
{moveMode === "abs" ? "绝对波动均值" : "带符号均值"}
</th>
<th>
{moveMode === "abs" ? "带符号均值" : "绝对波动均值"}
</th>
</tr>
</thead>
<tbody>
{movTable.rows.map((r) => (
<tr key={r.label}>
<td>{r.label}</td>
<td>{r.days}</td>
<td>{r.n}</td>
<td>
{moveMode === "abs"
? r.meanAbs != null
? fmtNum(r.meanAbs, 2)
: "—"
: r.meanSigned != null
? fmtNum(r.meanSigned, 2)
: "—"}
</td>
<td>
{moveMode === "abs"
? r.meanSigned != null
? fmtNum(r.meanSigned, 2)
: "—"
: r.meanAbs != null
? fmtNum(r.meanAbs, 2)
: "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
})()}
</> </>
)} )}
</div> </div>
+39
View File
@@ -190,6 +190,45 @@ select.field-input { cursor: pointer; }
font-size: 0.88rem; font-size: 0.88rem;
line-height: 1.55; 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);
}
.chart-legend { .chart-legend {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;