@@ -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);
}
}
diff --git a/web/src/lib/opsSummary.ts b/web/src/lib/opsSummary.ts
new file mode 100644
index 0000000..9208b4f
--- /dev/null
+++ b/web/src/lib/opsSummary.ts
@@ -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
(buckets: T[]): T[] {
+ return buckets.filter((b) => b.n > 0);
+}
+
+function enoughSamples(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}。`;
+}
diff --git a/web/src/pages/OpsMap.tsx b/web/src/pages/OpsMap.tsx
index 2ff8b83..7715d7a 100644
--- a/web/src/pages/OpsMap.tsx
+++ b/web/src/pages/OpsMap.tsx
@@ -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})`}
/>
+
+ {leverageSummary(range, lev.buckets, lev.min_leverage)}
+
@@ -189,11 +193,14 @@ export default function OpsMapPage() {
moveMode === "abs" ? "abs" : "signed"
})`}
/>
- {mov?.message && (
-
- {mov.message}
-
- )}
+
+ {moveSummary(range, mov?.buckets ?? [], {
+ pendingExpiry: mov?.pending_expiry,
+ pendingCount: mov?.pending_count,
+ mode: moveMode,
+ apiMessage: mov?.message,
+ })}
+
>
)}
diff --git a/web/src/styles.css b/web/src/styles.css
index c0d09c5..e468bd4 100644
--- a/web/src/styles.css
+++ b/web/src/styles.css
@@ -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;