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
+159 -106
View File
@@ -1,10 +1,13 @@
/** 作战地图图下文字结论(由 buckets 生成)。 */
/** 作战地图图下明细表(由 buckets 生成)。 */
export type LevBucket = {
label: string;
n: number;
mean: number | null;
pct_ge_min: number | null;
days?: number;
days_hit?: number;
pct_days_hit?: number | null;
};
export type MoveBucket = {
@@ -12,10 +15,29 @@ export type MoveBucket = {
n: number;
mean_abs: number | null;
mean_signed: number | null;
days?: number;
};
const MIN_N = 3;
const TOP_K = 5;
export type LevTableRow = {
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 {
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);
}
function enoughSamples<T extends { n: number }>(buckets: T[]): T[] {
return buckets.filter((b) => b.n >= MIN_N);
/** 达标概率优先用按日口径 pct_days_hit,兼容旧数据回退到样本口径。 */
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(
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(" ");
const t = leverageSummaryTable(range, buckets, minLeverage);
if (t.kind === "empty") return t.message;
const top = t.rows.slice(0, 5);
const list = top
.map(
(r) =>
`${r.label}(一共 ${r.days} 天,达标 ${r.daysHit}${
r.pct != null ? `${fmtPct(r.pct)}` : ""
}`
)
.join("、");
return `${t.note}${list}`;
}
/** 下图波动结论 */
export function moveSummary(
range: "day" | "week" | "month",
buckets: MoveBucket[],
@@ -107,51 +191,20 @@ export function moveSummary(
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} 条),暂不排名。`;
}
const t = moveSummaryTable(range, buckets, opts);
if (t.kind === "empty") return t.message;
const top = t.rows.slice(0, 5);
if (opts.mode === "abs") {
const list = ranked
.map((b) => `${b.label}${fmtNum(b.mean_abs ?? 0, 2)}`)
.join("、");
return `${windowName}波动较高时段:${list}`;
return `${t.note}${top
.map((r) => `${r.label}${fmtNum(r.meanAbs ?? 0, 2)}${r.days}`)
.join("、")}`;
}
const list = ranked
.map((b) => {
const v = b.mean_signed ?? 0;
const dir = v >= 0 ? "偏多" : "偏空";
return `${b.label}${fmtNum(v, 2)}${dir}`;
return `${t.note}${top
.map((r) => {
const v = r.meanSigned ?? 0;
return `${r.label}${fmtNum(v, 2)}${r.days} 天)`;
})
.join("、");
return `${windowName}带符号波动较大时段:${list}`;
.join("、")}。`;
}
export { fmtPct, fmtNum };