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:
@@ -195,6 +195,9 @@ export type LeverageBucket = {
|
||||
p25: number | null;
|
||||
p75: number | null;
|
||||
pct_ge_min: number | null;
|
||||
days?: number;
|
||||
days_hit?: number;
|
||||
pct_days_hit?: number | null;
|
||||
};
|
||||
|
||||
export type LeverageStats = {
|
||||
@@ -213,6 +216,7 @@ export type MoveBucket = {
|
||||
bucket_start_min: number;
|
||||
label: string;
|
||||
n: number;
|
||||
days?: number;
|
||||
mean_abs: number | null;
|
||||
median_abs: number | null;
|
||||
mean_signed: number | null;
|
||||
|
||||
+159
-106
@@ -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 };
|
||||
|
||||
+107
-8
@@ -10,7 +10,12 @@ 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";
|
||||
import {
|
||||
fmtNum,
|
||||
fmtPct,
|
||||
leverageSummaryTable,
|
||||
moveSummaryTable,
|
||||
} from "../lib/opsSummary";
|
||||
|
||||
type RangeKey = "day" | "week" | "month";
|
||||
type SideKey = "both" | "C" | "P";
|
||||
@@ -215,9 +220,49 @@ export default function OpsMapPage() {
|
||||
minLeverage={lev.min_leverage}
|
||||
title={`上图 · 时段杠杆均值(${rangeLabel})`}
|
||||
/>
|
||||
<p className="chart-summary">
|
||||
{leverageSummary(range, lev.buckets, lev.min_leverage)}
|
||||
</p>
|
||||
{(() => {
|
||||
const levTable = leverageSummaryTable(
|
||||
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="seg">
|
||||
@@ -250,14 +295,68 @@ export default function OpsMapPage() {
|
||||
moveMode === "abs" ? "abs" : "signed"
|
||||
})`}
|
||||
/>
|
||||
<p className="chart-summary">
|
||||
{moveSummary(range, mov?.buckets ?? [], {
|
||||
{(() => {
|
||||
const movTable = moveSummaryTable(range, mov?.buckets ?? [], {
|
||||
pendingExpiry: mov?.pending_expiry,
|
||||
pendingCount: mov?.pending_count,
|
||||
mode: moveMode,
|
||||
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>
|
||||
|
||||
@@ -190,6 +190,45 @@ select.field-input { cursor: pointer; }
|
||||
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);
|
||||
}
|
||||
.chart-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
Reference in New Issue
Block a user