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
+109 -71
View File
@@ -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 @@
<div class="chart-title" id="chartTitle">上图 · 时段杠杆均值</div>
<svg id="levChart" class="chart-svg" viewBox="0 0 880 260" role="img"></svg>
</div>
<p class="chart-summary" id="levSummary"></p>
<div class="chart-summary" id="levSummary"></div>
<div class="toolbar" style="margin-top:1rem">
<div class="seg" id="moveModeSeg">
<button type="button" data-mode="abs" class="active">绝对波动</button>
@@ -151,7 +162,7 @@
<div class="chart-title" id="moveTitle">下图 · 时段→到期波动</div>
<svg id="moveChart" class="chart-svg" viewBox="0 0 880 260" role="img"></svg>
</div>
<p class="chart-summary" id="moveSummary"></p>
<div class="chart-summary" id="moveSummary"></div>
</section>
<section id="page-settings" class="hidden">
<div class="center-page">
@@ -556,7 +567,8 @@
document.querySelectorAll("#moveModeSeg button").forEach(x => x.classList.toggle("active", x === b));
if (state.lastMov) {
drawMoveChart(state.lastMov.buckets || [], state.moveMode);
document.getElementById("moveSummary").textContent = buildMoveSummary(
renderMoveTable(
document.getElementById("moveSummary"),
state.range, state.lastMov.buckets || [], {
pendingExpiry: !!state.lastMov.pending_expiry,
mode: state.moveMode,
@@ -631,86 +643,110 @@
svg.innerHTML = html;
}
const SUMMARY_MIN_N = 3;
const SUMMARY_TOP_K = 5;
function fmtSummaryPct(p) { return Math.round(p * 100) + "%"; }
function fmtSummaryNum(n, d) {
d = d == null ? 1 : d;
return Number(n).toFixed(d);
}
function buildLevSummary(range, buckets, minLeverage) {
const active = (buckets || []).filter(b => (b.n || 0) > 0);
if (!active.length) return "所选范围内暂无杠杆样本,采集后将显示各时段分布。";
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 hitRate(b) {
if (b.pct_days_hit != null) return b.pct_days_hit;
return b.pct_ge_min;
}
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 || {};
if (opts.pendingExpiry) {
const extra = opts.apiMessage ? (" " + opts.apiMessage) : "";
return ("未到期,暂无法统计时段→到期波动;结算后将按日/周/月给出高波动钟点。" + extra).trim();
renderEmptySummary(el, ("未到期,暂无法统计时段→到期波动;结算后将按日/周/月给出各钟点明细。" + extra).trim());
return;
}
const mode = opts.mode || "abs";
const active = (buckets || []).filter(b => {
if ((b.n || 0) <= 0) return false;
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 pool = range === "day" ? active : active.filter(b => b.n >= SUMMARY_MIN_N);
const ranked = pool.slice().sort((a, b) => {
if (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, SUMMARY_TOP_K);
if (!ranked.length) {
return range === "day"
? (windowName + "暂无可用波动样本。")
: (windowName + "波动样本偏少(单时段不足 " + SUMMARY_MIN_N + " 条),暂不排名。");
}
if (mode === "abs") {
return windowName + "波动较高时段:" +
ranked.map(b => b.label + "" + fmtSummaryNum(b.mean_abs || 0, 2) + "").join("、") + "。";
}
return windowName + "带符号波动较大时段:" +
ranked.map(b => {
const v = b.mean_signed || 0;
return b.label + "" + fmtSummaryNum(v, 2) + "" + (v >= 0 ? "偏多" : "偏空") + "";
}).join("、") + "。";
const rows = active.slice().map(b => ({
label: b.label,
days: typeof b.days === "number" ? b.days : ((b.n || 0) > 0 ? 1 : 0),
n: b.n,
meanAbs: b.mean_abs,
meanSigned: b.mean_signed,
})).sort((a, b) => {
if (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 = mode === "abs"
? (windowName + "各时段→到期绝对波动明细")
: (windowName + "各时段→到期带符号波动明细");
const colA = mode === "abs" ? "绝对波动均值" : "带符号均值";
const colB = mode === "abs" ? "带符号均值" : "绝对波动均值";
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>" + colA + "</th><th>" + colB + "</th>";
html += "</tr></thead><tbody>";
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() {
@@ -751,7 +787,8 @@
const titleMap = { day: "日", week: "近7日", month: "近30日" };
document.getElementById("chartTitle").textContent = "上图 · 时段杠杆均值(" + (titleMap[state.range]||"") + "";
drawChart(lev.buckets || [], lev.min_leverage || 100);
document.getElementById("levSummary").textContent = buildLevSummary(
renderLevTable(
document.getElementById("levSummary"),
state.range, lev.buckets || [], lev.min_leverage || 100
);
const mov = d.move_points || {};
@@ -764,7 +801,8 @@
document.getElementById("moveTitle").textContent =
"下图 · 时段→到期波动(" + (titleMap[state.range]||"") + " · " + state.moveMode + "";
drawMoveChart(mov.buckets || [], state.moveMode);
document.getElementById("moveSummary").textContent = buildMoveSummary(
renderMoveTable(
document.getElementById("moveSummary"),
state.range, mov.buckets || [], {
pendingExpiry: !!mov.pending_expiry,
mode: state.moveMode,
@@ -773,8 +811,8 @@
);
} catch (e) {
document.getElementById("opsRange").textContent = "加载失败";
document.getElementById("levSummary").textContent = String(e);
document.getElementById("moveSummary").textContent = String(e);
renderEmptySummary(document.getElementById("levSummary"), String(e));
renderEmptySummary(document.getElementById("moveSummary"), String(e));
}
}