feat: add ops-map text summaries for leverage and move periods

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-02 09:43:56 +08:00
parent 74021dae08
commit f798c11cf9
5 changed files with 327 additions and 9 deletions
+32
View File
@@ -0,0 +1,32 @@
"""对齐前端 opsSummary 规则的轻量逻辑测试(Python 镜像)。"""
MIN_N = 3
TOP_K = 5
def leverage_summary_week(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 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 不足
]
ranked = leverage_summary_week(buckets)
assert [b["label"] for b in ranked] == ["14:00", "09:00"]
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
+116 -4
View File
@@ -38,6 +38,16 @@
.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; }
.chart-summary {
margin: 0.65rem 0 0;
padding: 0.75rem 0.9rem;
background: #0c1117;
border: 1px solid #243041;
border-radius: 8px;
color: var(--muted);
font-size: 0.88rem;
line-height: 1.55;
}
.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; }
@@ -123,6 +133,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="toolbar" style="margin-top:1rem">
<div class="seg" id="moveModeSeg">
<button type="button" data-mode="abs" class="active">绝对波动</button>
@@ -134,7 +145,7 @@
<div class="chart-title" id="moveTitle">下图 · 时段→到期波动</div>
<svg id="moveChart" class="chart-svg" viewBox="0 0 880 260" role="img"></svg>
</div>
<p id="moveHint" style="color:var(--muted);font-size:0.85rem;margin-top:0.75rem"></p>
<p class="chart-summary" id="moveSummary"></p>
</section>
<section id="page-settings" class="hidden">
<div class="center-page">
@@ -517,7 +528,16 @@
document.querySelectorAll("#moveModeSeg button").forEach(b => b.addEventListener("click", () => {
state.moveMode = b.dataset.mode;
document.querySelectorAll("#moveModeSeg button").forEach(x => x.classList.toggle("active", x === b));
if (state.lastMov) drawMoveChart(state.lastMov.buckets || [], state.moveMode);
if (state.lastMov) {
drawMoveChart(state.lastMov.buckets || [], state.moveMode);
document.getElementById("moveSummary").textContent = buildMoveSummary(
state.range, state.lastMov.buckets || [], {
pendingExpiry: !!state.lastMov.pending_expiry,
mode: state.moveMode,
apiMessage: state.lastMov.message || null,
}
);
}
}));
document.getElementById("anchorDate").addEventListener("change", loadOps);
@@ -577,6 +597,88 @@
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 buildMoveSummary(range, buckets, opts) {
opts = opts || {};
if (opts.pendingExpiry) {
const extra = opts.apiMessage ? (" " + opts.apiMessage) : "";
return ("未到期,暂无法统计时段→到期波动;结算后将按日/周/月给出高波动钟点。" + extra).trim();
}
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 "所选范围内暂无已结算波动样本。";
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("、") + "。";
}
async function loadOps() {
const date = document.getElementById("anchorDate").value;
const q = new URLSearchParams({ range: state.range, side: state.side, bucket_minutes: "60", date });
@@ -592,6 +694,9 @@
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(
state.range, lev.buckets || [], lev.min_leverage || 100
);
const mov = d.move_points || {};
state.lastMov = mov;
document.getElementById("opsMoveN").textContent = mov.settled_count ?? 0;
@@ -602,10 +707,17 @@
document.getElementById("moveTitle").textContent =
"下图 · 时段→到期波动(" + (titleMap[state.range]||"") + " · " + state.moveMode + "";
drawMoveChart(mov.buckets || [], state.moveMode);
document.getElementById("moveHint").textContent = mov.message || "";
document.getElementById("moveSummary").textContent = buildMoveSummary(
state.range, mov.buckets || [], {
pendingExpiry: !!mov.pending_expiry,
mode: state.moveMode,
apiMessage: mov.message || null,
}
);
} catch (e) {
document.getElementById("opsRange").textContent = "加载失败";
document.getElementById("moveHint").textContent = String(e);
document.getElementById("levSummary").textContent = String(e);
document.getElementById("moveSummary").textContent = String(e);
}
}
+157
View File
@@ -0,0 +1,157 @@
/** 作战地图图下文字结论(由 buckets 生成)。 */
export type LevBucket = {
label: string;
n: number;
mean: number | null;
pct_ge_min: number | null;
};
export type MoveBucket = {
label: string;
n: number;
mean_abs: number | null;
mean_signed: number | null;
};
const MIN_N = 3;
const TOP_K = 5;
function fmtPct(p: number): string {
return `${Math.round(p * 100)}%`;
}
function fmtNum(n: number, d = 1): string {
return n.toFixed(d);
}
function withData<T extends { n: number }>(buckets: T[]): T[] {
return buckets.filter((b) => b.n > 0);
}
function enoughSamples<T extends { n: number }>(buckets: T[]): T[] {
return buckets.filter((b) => b.n >= MIN_N);
}
/** 上图杠杆结论 */
export function leverageSummary(
range: "day" | "week" | "month",
buckets: LevBucket[],
minLeverage: number
): string {
const active = withData(buckets);
if (!active.length) {
return "所选范围内暂无杠杆样本,采集后将显示各时段分布。";
}
const bestMean = [...active].sort((a, b) => (b.mean ?? -1) - (a.mean ?? -1))[0];
const minL = minLeverage;
if (range === "day") {
const labels = active.map((b) => b.label).join("、");
const parts: string[] = [];
parts.push(`今日杠杆分布:有样本时段 ${labels}`);
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(
range: "day" | "week" | "month",
buckets: MoveBucket[],
opts: {
pendingExpiry?: boolean;
pendingCount?: number;
mode: "abs" | "signed";
apiMessage?: string | null;
}
): string {
if (opts.pendingExpiry) {
const extra = opts.apiMessage ? ` ${opts.apiMessage}` : "";
return `未到期,暂无法统计时段→到期波动;结算后将按日/周/月给出高波动钟点。${extra}`.trim();
}
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") {
const list = ranked
.map((b) => `${b.label}${fmtNum(b.mean_abs ?? 0, 2)}`)
.join("、");
return `${windowName}波动较高时段:${list}`;
}
const list = ranked
.map((b) => {
const v = b.mean_signed ?? 0;
const dir = v >= 0 ? "偏多" : "偏空";
return `${b.label}${fmtNum(v, 2)}${dir}`;
})
.join("、");
return `${windowName}带符号波动较大时段:${list}`;
}
+12 -5
View File
@@ -10,6 +10,7 @@ import AppNav from "../components/AppNav";
import LeverageChart from "../components/LeverageChart";
import LoginGate from "../components/LoginGate";
import MovePointsChart from "../components/MovePointsChart";
import { leverageSummary, moveSummary } from "../lib/opsSummary";
type RangeKey = "day" | "week" | "month";
type SideKey = "both" | "C" | "P";
@@ -157,6 +158,9 @@ export default function OpsMapPage() {
minLeverage={lev.min_leverage}
title={`上图 · 时段杠杆均值(${rangeLabel}`}
/>
<p className="chart-summary">
{leverageSummary(range, lev.buckets, lev.min_leverage)}
</p>
<div className="toolbar" style={{ marginTop: "1.25rem" }}>
<div className="seg">
@@ -189,11 +193,14 @@ export default function OpsMapPage() {
moveMode === "abs" ? "abs" : "signed"
}`}
/>
{mov?.message && (
<p style={{ color: "var(--muted)", fontSize: "0.85rem", marginTop: "0.75rem" }}>
{mov.message}
</p>
)}
<p className="chart-summary">
{moveSummary(range, mov?.buckets ?? [], {
pendingExpiry: mov?.pending_expiry,
pendingCount: mov?.pending_count,
mode: moveMode,
apiMessage: mov?.message,
})}
</p>
</>
)}
</div>
+10
View File
@@ -179,6 +179,16 @@ select.field-input { cursor: pointer; }
font-size: 0.85rem;
}
.chart-svg { width: 100%; height: auto; display: block; }
.chart-summary {
margin: 0.65rem 0 0;
padding: 0.75rem 0.9rem;
background: #0c1117;
border: 1px solid #243041;
border-radius: 8px;
color: var(--muted);
font-size: 0.88rem;
line-height: 1.55;
}
.chart-legend {
display: flex;
flex-wrap: wrap;