270e114658
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>
211 lines
5.5 KiB
TypeScript
211 lines
5.5 KiB
TypeScript
/** 作战地图图下明细表(由 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 = {
|
||
label: string;
|
||
n: number;
|
||
mean_abs: number | null;
|
||
mean_signed: number | null;
|
||
days?: number;
|
||
};
|
||
|
||
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)}%`;
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
/** 达标概率优先用按日口径 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 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[],
|
||
opts: {
|
||
pendingExpiry?: boolean;
|
||
pendingCount?: number;
|
||
mode: "abs" | "signed";
|
||
apiMessage?: string | null;
|
||
}
|
||
): string {
|
||
const t = moveSummaryTable(range, buckets, opts);
|
||
if (t.kind === "empty") return t.message;
|
||
const top = t.rows.slice(0, 5);
|
||
if (opts.mode === "abs") {
|
||
return `${t.note}:${top
|
||
.map((r) => `${r.label}(${fmtNum(r.meanAbs ?? 0, 2)},${r.days} 天)`)
|
||
.join("、")}。`;
|
||
}
|
||
return `${t.note}:${top
|
||
.map((r) => {
|
||
const v = r.meanSigned ?? 0;
|
||
return `${r.label}(${fmtNum(v, 2)},${r.days} 天)`;
|
||
})
|
||
.join("、")}。`;
|
||
}
|
||
|
||
export { fmtPct, fmtNum };
|