feat: add A-card operations frontend and backend foundation
This commit is contained in:
@@ -0,0 +1,385 @@
|
||||
export const MODEL_CATEGORIES = [
|
||||
{ id: "std", name: "标准A卡" },
|
||||
{ id: "bai", name: "白户A卡" },
|
||||
{ id: "big", name: "大额A卡" },
|
||||
{ id: "afd", name: "反欺诈评分" },
|
||||
] as const;
|
||||
|
||||
export type ModelCategoryId = (typeof MODEL_CATEGORIES)[number]["id"];
|
||||
export type ModelGrade = "A" | "B" | "C";
|
||||
export type AbnormalLevel = "三级" | "二级" | "一级" | "正常";
|
||||
export type ModelStatus = "正常" | "陪跑" | "陪跑结束" | "下线";
|
||||
export type RankingStatus = "相符" | "不符" | "—";
|
||||
|
||||
export type ModelRecord = {
|
||||
bank: string;
|
||||
category: ModelCategoryId;
|
||||
name: string;
|
||||
modelId: string;
|
||||
version: string;
|
||||
status: ModelStatus;
|
||||
iteratedAt: string;
|
||||
ranking: RankingStatus;
|
||||
ks: number;
|
||||
psi: number;
|
||||
ksDrop: number;
|
||||
secondaryHits: number;
|
||||
wuji: boolean;
|
||||
processedAt: string | null;
|
||||
previousAdvice: string;
|
||||
commonModel: string | null;
|
||||
};
|
||||
|
||||
export type MonitoringRow = ModelRecord & {
|
||||
monitorMonth: string;
|
||||
};
|
||||
|
||||
export const MONITOR_MONTHS = [
|
||||
"2026-02",
|
||||
"2026-03",
|
||||
"2026-04",
|
||||
"2026-05",
|
||||
"2026-06",
|
||||
"2026-07",
|
||||
] as const;
|
||||
|
||||
export const MODELS: ModelRecord[] = [
|
||||
{
|
||||
bank: "江城银行", category: "std", name: "标准A卡", modelId: "JC-STD-001", version: "v2.3",
|
||||
status: "正常", iteratedAt: "2026-06-18", ranking: "不符", ks: 36.84, psi: 27.4,
|
||||
ksDrop: 24.1, secondaryHits: 3, wuji: true, processedAt: "2026-06-18",
|
||||
previousAdvice: "建议启动模型微调或重构评估", commonModel: "标准A卡通用版 v2.3",
|
||||
},
|
||||
{
|
||||
bank: "滨海银行", category: "std", name: "标准A卡", modelId: "BH-STD-001", version: "v2.3",
|
||||
status: "陪跑", iteratedAt: "2026-07-28", ranking: "相符", ks: 44.1, psi: 6.2,
|
||||
ksDrop: 3.4, secondaryHits: 0, wuji: false, processedAt: "2026-06-03",
|
||||
previousAdvice: "维持现状,继续监控", commonModel: "标准A卡通用版 v2.3",
|
||||
},
|
||||
{
|
||||
bank: "华东银行", category: "std", name: "标准A卡", modelId: "HD-STD-001", version: "v2.2",
|
||||
status: "正常", iteratedAt: "2026-03-20", ranking: "相符", ks: 43.5, psi: 8.8,
|
||||
ksDrop: 6.1, secondaryHits: 0, wuji: false, processedAt: "2026-05-20",
|
||||
previousAdvice: "维持现状,继续监控", commonModel: "标准A卡通用版 v2.3",
|
||||
},
|
||||
{
|
||||
bank: "南岭银行", category: "std", name: "标准A卡", modelId: "NL-STD-001", version: "v1.6",
|
||||
status: "正常", iteratedAt: "2025-08-22", ranking: "相符", ks: 41.12, psi: 13.6,
|
||||
ksDrop: 22.5, secondaryHits: 1, wuji: false, processedAt: "2026-06-12",
|
||||
previousAdvice: "建议重点关注 PSI 与 KS 环比变化", commonModel: "标准A卡通用版 v2.3",
|
||||
},
|
||||
{
|
||||
bank: "云岭银行", category: "std", name: "标准A卡", modelId: "YL-STD-001", version: "v2.1",
|
||||
status: "正常", iteratedAt: "2026-01-09", ranking: "相符", ks: 45.8, psi: 5.1,
|
||||
ksDrop: 2.2, secondaryHits: 0, wuji: false, processedAt: "2026-04-28",
|
||||
previousAdvice: "维持现状,继续监控", commonModel: "标准A卡通用版 v2.3",
|
||||
},
|
||||
{
|
||||
bank: "通汇银行", category: "std", name: "标准A卡", modelId: "TH-STD-001", version: "v2.0",
|
||||
status: "正常", iteratedAt: "2025-11-30", ranking: "不符", ks: 38.73, psi: 9.4,
|
||||
ksDrop: 11.8, secondaryHits: 4, wuji: false, processedAt: "2026-05-16",
|
||||
previousAdvice: "建议开展模型微调可行性评估", commonModel: "标准A卡通用版 v2.3",
|
||||
},
|
||||
{
|
||||
bank: "北岸银行", category: "std", name: "标准A卡", modelId: "BA-STD-001", version: "v1.9",
|
||||
status: "下线", iteratedAt: "2024-12-11", ranking: "—", ks: 0, psi: 0,
|
||||
ksDrop: 0, secondaryHits: 0, wuji: false, processedAt: "2026-03-02",
|
||||
previousAdvice: "模型已下线,不再参与月度监控", commonModel: null,
|
||||
},
|
||||
{
|
||||
bank: "江城银行", category: "big", name: "大额A卡", modelId: "JC-BIG-001", version: "v1.4",
|
||||
status: "正常", iteratedAt: "2026-07-03", ranking: "相符", ks: 46.3, psi: 4.8,
|
||||
ksDrop: 1.5, secondaryHits: 0, wuji: false, processedAt: "2026-06-24",
|
||||
previousAdvice: "维持现状,继续监控", commonModel: "大额A卡通用版 v1.4",
|
||||
},
|
||||
{
|
||||
bank: "华东银行", category: "big", name: "大额A卡", modelId: "HD-BIG-001", version: "v1.3",
|
||||
status: "正常", iteratedAt: "2026-04-16", ranking: "相符", ks: 44.9, psi: 7.3,
|
||||
ksDrop: 4.9, secondaryHits: 0, wuji: false, processedAt: "2026-05-30",
|
||||
previousAdvice: "维持现状,继续监控", commonModel: "大额A卡通用版 v1.4",
|
||||
},
|
||||
{
|
||||
bank: "云岭银行", category: "big", name: "大额A卡", modelId: "YL-BIG-001", version: "v1.2",
|
||||
status: "陪跑结束", iteratedAt: "2026-08-05", ranking: "相符", ks: 42.7, psi: 9.6,
|
||||
ksDrop: 8.2, secondaryHits: 0, wuji: false, processedAt: null,
|
||||
previousAdvice: "暂无历史处理建议", commonModel: "大额A卡通用版 v1.4",
|
||||
},
|
||||
{
|
||||
bank: "滨海银行", category: "big", name: "大额A卡", modelId: "BH-BIG-001", version: "v1.1",
|
||||
status: "正常", iteratedAt: "2025-09-25", ranking: "相符", ks: 39.4, psi: 11.2,
|
||||
ksDrop: 9.7, secondaryHits: 2, wuji: false, processedAt: "2026-04-19",
|
||||
previousAdvice: "建议持续跟踪,下期复核", commonModel: "大额A卡通用版 v1.4",
|
||||
},
|
||||
{
|
||||
bank: "南岭银行", category: "bai", name: "白户A卡", modelId: "NL-BAI-001", version: "v1.5",
|
||||
status: "正常", iteratedAt: "2026-05-22", ranking: "不符", ks: 34.2, psi: 18.7,
|
||||
ksDrop: 15.3, secondaryHits: 2, wuji: true, processedAt: "2026-06-09",
|
||||
previousAdvice: "建议启动模型微调或重构评估", commonModel: null,
|
||||
},
|
||||
{
|
||||
bank: "通汇银行", category: "bai", name: "白户A卡", modelId: "TH-BAI-001", version: "v1.4",
|
||||
status: "正常", iteratedAt: "2026-02-11", ranking: "相符", ks: 37.6, psi: 12.1,
|
||||
ksDrop: 7.4, secondaryHits: 1, wuji: false, processedAt: "2026-05-11",
|
||||
previousAdvice: "建议重点关注并持续跟踪", commonModel: "白户A卡通用版 v1.4",
|
||||
},
|
||||
{
|
||||
bank: "江城银行", category: "bai", name: "白户A卡", modelId: "JC-BAI-001", version: "v1.3",
|
||||
status: "正常", iteratedAt: "2025-12-05", ranking: "相符", ks: 40.8, psi: 9.9,
|
||||
ksDrop: 5.6, secondaryHits: 0, wuji: false, processedAt: "2026-03-26",
|
||||
previousAdvice: "维持现状,继续监控", commonModel: "白户A卡通用版 v1.4",
|
||||
},
|
||||
{
|
||||
bank: "华东银行", category: "afd", name: "反欺诈评分", modelId: "HD-AFD-001", version: "v2.0",
|
||||
status: "正常", iteratedAt: "2026-04-10", ranking: "不符", ks: 28.6, psi: 31.5,
|
||||
ksDrop: 18.9, secondaryHits: 3, wuji: false, processedAt: "2026-06-21",
|
||||
previousAdvice: "建议启动模型微调或重构评估", commonModel: null,
|
||||
},
|
||||
{
|
||||
bank: "滨海银行", category: "afd", name: "反欺诈评分", modelId: "BH-AFD-001", version: "v1.8",
|
||||
status: "正常", iteratedAt: "2025-10-30", ranking: "相符", ks: 36.4, psi: 8.1,
|
||||
ksDrop: 4.2, secondaryHits: 3, wuji: true, processedAt: "2026-05-25",
|
||||
previousAdvice: "建议重点关注并持续跟踪", commonModel: "反欺诈评分通用版 v1.8",
|
||||
},
|
||||
{
|
||||
bank: "云岭银行", category: "afd", name: "反欺诈评分", modelId: "YL-AFD-001", version: "v1.7",
|
||||
status: "正常", iteratedAt: "2025-08-19", ranking: "相符", ks: 38.2, psi: 14.6,
|
||||
ksDrop: 12.7, secondaryHits: 2, wuji: false, processedAt: "2026-04-15",
|
||||
previousAdvice: "建议持续跟踪,下期复核", commonModel: null,
|
||||
},
|
||||
];
|
||||
|
||||
export const FEATURE_METRICS = [
|
||||
{ key: "age", name: "申请人年龄", iv: 0.284, ivDrop: 6.8, csi: 0.041, csiRise: 0.8, ksContribution: -0.7, psiContribution: 1.2 },
|
||||
{ key: "income", name: "月均收入", iv: 0.251, ivDrop: 12.4, csi: 0.058, csiRise: 1.7, ksContribution: -1.2, psiContribution: 2.4 },
|
||||
{ key: "debt_ratio", name: "负债收入比", iv: 0.219, ivDrop: 18.6, csi: 0.076, csiRise: 2.9, ksContribution: -2.1, psiContribution: 4.1 },
|
||||
{ key: "credit_age", name: "信贷账龄", iv: 0.193, ivDrop: 21.3, csi: 0.083, csiRise: 3.5, ksContribution: -2.8, psiContribution: 5.2 },
|
||||
{ key: "query_3m", name: "近三月查询次数", iv: 0.171, ivDrop: 24.7, csi: 0.091, csiRise: 4.2, ksContribution: -3.4, psiContribution: 6.8 },
|
||||
] as const;
|
||||
|
||||
export const SORTING_DISTRIBUTION = {
|
||||
bins: ["(0,580]", "[580,600)", "[600,620)", "[620,640)", "[640,660)", "[660,680)", "[680,+)"],
|
||||
counts: [307, 418, 730, 1100, 1545, 1895, 6104],
|
||||
badRates: [10.42, 5.98, 5.48, 3.64, 2.85, 1.64, 0.31],
|
||||
} as const;
|
||||
|
||||
export const BANK_AVERAGE_CYCLE: Record<string, number> = {
|
||||
江城银行: 8.6,
|
||||
滨海银行: 11.2,
|
||||
华东银行: 9.8,
|
||||
南岭银行: 12.4,
|
||||
云岭银行: 10.1,
|
||||
通汇银行: 13.6,
|
||||
北岸银行: 16.0,
|
||||
};
|
||||
|
||||
export type ModelLifecycle = {
|
||||
onlineAt: string | null;
|
||||
escortStartAt: string | null;
|
||||
escortEndAt: string | null;
|
||||
offlineAt: string | null;
|
||||
developer: string;
|
||||
developmentKs: number;
|
||||
developmentPsi: number;
|
||||
maxLift: number;
|
||||
};
|
||||
|
||||
export const MODEL_LIFECYCLE: Record<string, ModelLifecycle> = {
|
||||
"JC-STD-001": { onlineAt: "2025-03-12", escortStartAt: "2025-01-20", escortEndAt: "2025-03-10", offlineAt: null, developer: "李伟", developmentKs: 46.0, developmentPsi: 3.92, maxLift: 3.24 },
|
||||
"BH-STD-001": { onlineAt: null, escortStartAt: "2026-07-28", escortEndAt: null, offlineAt: null, developer: "王芳", developmentKs: 47.5, developmentPsi: 3.48, maxLift: 3.37 },
|
||||
"HD-STD-001": { onlineAt: "2024-11-05", escortStartAt: "2024-09-18", escortEndAt: "2024-11-01", offlineAt: null, developer: "李伟", developmentKs: 46.9, developmentPsi: 3.71, maxLift: 3.18 },
|
||||
"NL-STD-001": { onlineAt: "2025-08-22", escortStartAt: "2025-06-30", escortEndAt: "2025-08-18", offlineAt: null, developer: "王芳", developmentKs: 45.4, developmentPsi: 4.06, maxLift: 3.09 },
|
||||
"YL-STD-001": { onlineAt: "2025-05-14", escortStartAt: "2025-03-22", escortEndAt: "2025-05-10", offlineAt: null, developer: "李伟", developmentKs: 48.2, developmentPsi: 3.26, maxLift: 3.42 },
|
||||
"TH-STD-001": { onlineAt: "2024-06-18", escortStartAt: "2024-04-25", escortEndAt: "2024-06-14", offlineAt: null, developer: "王芳", developmentKs: 44.8, developmentPsi: 4.31, maxLift: 2.96 },
|
||||
"BA-STD-001": { onlineAt: "2023-09-01", escortStartAt: null, escortEndAt: null, offlineAt: "2026-02-28", developer: "李伟", developmentKs: 42.7, developmentPsi: 4.62, maxLift: 2.81 },
|
||||
"JC-BIG-001": { onlineAt: "2025-10-08", escortStartAt: "2025-08-15", escortEndAt: "2025-10-05", offlineAt: null, developer: "王芳", developmentKs: 49.1, developmentPsi: 3.05, maxLift: 3.55 },
|
||||
"HD-BIG-001": { onlineAt: "2025-02-19", escortStartAt: "2024-12-20", escortEndAt: "2025-02-15", offlineAt: null, developer: "李伟", developmentKs: 47.8, developmentPsi: 3.37, maxLift: 3.31 },
|
||||
"YL-BIG-001": { onlineAt: "2026-08-05", escortStartAt: "2026-06-10", escortEndAt: "2026-08-01", offlineAt: null, developer: "王芳", developmentKs: 46.6, developmentPsi: 3.68, maxLift: 3.12 },
|
||||
"BH-BIG-001": { onlineAt: "2024-12-03", escortStartAt: "2024-10-11", escortEndAt: "2024-11-29", offlineAt: null, developer: "李伟", developmentKs: 43.9, developmentPsi: 4.25, maxLift: 2.91 },
|
||||
"NL-BAI-001": { onlineAt: "2025-07-16", escortStartAt: "2025-05-20", escortEndAt: "2025-07-12", offlineAt: null, developer: "王芳", developmentKs: 42.6, developmentPsi: 4.74, maxLift: 2.73 },
|
||||
"TH-BAI-001": { onlineAt: "2025-04-09", escortStartAt: "2025-02-14", escortEndAt: "2025-04-05", offlineAt: null, developer: "李伟", developmentKs: 43.7, developmentPsi: 4.18, maxLift: 2.88 },
|
||||
"JC-BAI-001": { onlineAt: "2024-08-27", escortStartAt: "2024-07-01", escortEndAt: "2024-08-23", offlineAt: null, developer: "王芳", developmentKs: 45.1, developmentPsi: 3.89, maxLift: 3.03 },
|
||||
"HD-AFD-001": { onlineAt: "2025-01-22", escortStartAt: "2024-11-28", escortEndAt: "2025-01-18", offlineAt: null, developer: "李伟", developmentKs: 40.8, developmentPsi: 5.16, maxLift: 2.54 },
|
||||
"BH-AFD-001": { onlineAt: "2024-10-14", escortStartAt: "2024-08-20", escortEndAt: "2024-10-10", offlineAt: null, developer: "王芳", developmentKs: 42.3, developmentPsi: 4.69, maxLift: 2.68 },
|
||||
"YL-AFD-001": { onlineAt: "2024-05-08", escortStartAt: "2024-03-15", escortEndAt: "2024-05-04", offlineAt: null, developer: "李伟", developmentKs: 43.5, developmentPsi: 4.42, maxLift: 2.79 },
|
||||
};
|
||||
|
||||
export function categoryName(category: ModelCategoryId): string {
|
||||
return MODEL_CATEGORIES.find((item) => item.id === category)?.name ?? category;
|
||||
}
|
||||
|
||||
export function abnormalLevelOf(model: Pick<ModelRecord, "ranking" | "ks" | "psi" | "ksDrop">): AbnormalLevel {
|
||||
const { ranking, ks, psi, ksDrop } = model;
|
||||
if (ranking === "—") return "正常";
|
||||
if (ranking === "不符" && ks < 40 && psi > 10) return "三级";
|
||||
if (ranking === "相符" && ks < 40 && psi > 25) return "三级";
|
||||
if (ranking === "不符" && ks >= 40 && psi > 25) return "二级";
|
||||
if (ranking === "不符" && ks >= 40 && psi > 10 && psi <= 25 && ksDrop > 20) return "二级";
|
||||
if (ranking === "不符" && ks < 40 && psi <= 10) return "二级";
|
||||
if (ranking === "相符" && ks < 40 && psi <= 25) return "二级";
|
||||
if (ranking === "相符" && ks >= 40 && psi > 25 && ksDrop > 20) return "二级";
|
||||
if (ranking === "不符" && ks >= 40 && psi > 10 && psi <= 25 && ksDrop <= 20) return "一级";
|
||||
if (ranking === "不符" && ks >= 40 && psi <= 10) return "一级";
|
||||
if (ranking === "相符" && ks >= 40 && psi > 25 && ksDrop <= 20) return "一级";
|
||||
if (ranking === "相符" && ks >= 40 && psi > 10 && psi <= 25) return "一级";
|
||||
if (ranking === "相符" && ks >= 40 && psi <= 10 && ksDrop > 20) return "一级";
|
||||
return "正常";
|
||||
}
|
||||
|
||||
export function gradeOf(model: Pick<ModelRecord, "ranking" | "ks" | "psi" | "ksDrop" | "secondaryHits" | "status">): ModelGrade {
|
||||
if (model.status === "下线") return "A";
|
||||
const abnormal = abnormalLevelOf(model);
|
||||
if (abnormal === "三级") return "C";
|
||||
if (abnormal === "二级" && model.secondaryHits >= 4) return "C";
|
||||
if (abnormal === "正常") return "A";
|
||||
return "B";
|
||||
}
|
||||
|
||||
export function abnormalReasonOf(model: ModelRecord | MonitoringRow): string {
|
||||
const abnormal = abnormalLevelOf(model);
|
||||
if (abnormal === "正常") return "各项指标均在阈值内";
|
||||
|
||||
const reasons: string[] = [];
|
||||
if (model.ranking === "不符") reasons.push("排序性不符");
|
||||
if (model.ks < 40) reasons.push("KS 低于 40%");
|
||||
if (model.psi > 25) reasons.push("PSI 高于 25%");
|
||||
else if (model.psi > 10) reasons.push("PSI 处于 10%–25%");
|
||||
if (model.ksDrop > 20) reasons.push("KS 环比降幅高于 20%");
|
||||
|
||||
if (abnormal === "二级") {
|
||||
const result = model.secondaryHits >= 4 ? ",已触发监控结果升级为 C" : ",未达到累计升级条件";
|
||||
return `${reasons.join("、")};近 6 个月累计 ${model.secondaryHits} 次二级异常${result}`;
|
||||
}
|
||||
return reasons.join("、");
|
||||
}
|
||||
|
||||
function hash(value: string): number {
|
||||
let result = 0;
|
||||
for (const character of value) result = (result * 31 + character.charCodeAt(0)) >>> 0;
|
||||
return result;
|
||||
}
|
||||
|
||||
export function modelTrend(
|
||||
model: ModelRecord,
|
||||
metric: "ks" | "psi",
|
||||
months: readonly string[] = MONITOR_MONTHS,
|
||||
): number[] {
|
||||
const end = model[metric];
|
||||
const amplitude = metric === "ks" ? 2.6 : 4.4;
|
||||
const seed = hash(`${model.modelId}-${metric}`);
|
||||
const waveAt = (index: number) => (((seed >> (index % 12)) % 9) - 4) * (amplitude / 12);
|
||||
const lastWave = waveAt(Math.max(0, months.length - 1));
|
||||
return months.map((_, index) => {
|
||||
const distance = months.length - 1 - index;
|
||||
const drift = metric === "ks" ? distance * 0.32 : -distance * 0.74;
|
||||
const wave = waveAt(index) - lastWave;
|
||||
return Math.max(0, Number((end + drift + wave).toFixed(2)));
|
||||
});
|
||||
}
|
||||
|
||||
export function monitoringRows(source: ModelRecord[] = MODELS): MonitoringRow[] {
|
||||
const rows: MonitoringRow[] = [];
|
||||
for (const model of source) {
|
||||
if (model.status === "下线") {
|
||||
rows.push({ ...model, monitorMonth: "2026-02", ranking: "相符", ks: 39.8, psi: 9.1, ksDrop: 6.2, secondaryHits: 1 });
|
||||
continue;
|
||||
}
|
||||
const ksTrend = modelTrend(model, "ks");
|
||||
const psiTrend = modelTrend(model, "psi");
|
||||
MONITOR_MONTHS.forEach((monitorMonth, index) => {
|
||||
rows.push({
|
||||
...model,
|
||||
monitorMonth,
|
||||
ks: ksTrend[index] ?? model.ks,
|
||||
psi: psiTrend[index] ?? model.psi,
|
||||
ksDrop: Math.max(0, Number((model.ksDrop - (MONITOR_MONTHS.length - 1 - index) * 0.9).toFixed(2))),
|
||||
secondaryHits: Math.max(0, model.secondaryHits - (MONITOR_MONTHS.length - 1 - index)),
|
||||
});
|
||||
});
|
||||
}
|
||||
return rows.sort((left, right) => (
|
||||
right.monitorMonth.localeCompare(left.monitorMonth)
|
||||
|| left.bank.localeCompare(right.bank, "zh-CN")
|
||||
|| left.modelId.localeCompare(right.modelId)
|
||||
));
|
||||
}
|
||||
|
||||
export function monthsBetween(from: string, to: string): string[] {
|
||||
const [fromYear, fromMonth] = from.split("-").map(Number);
|
||||
const [toYear, toMonth] = to.split("-").map(Number);
|
||||
if (!fromYear || !fromMonth || !toYear || !toMonth) return [...MONITOR_MONTHS];
|
||||
const start = fromYear * 12 + fromMonth - 1;
|
||||
const end = toYear * 12 + toMonth - 1;
|
||||
if (start > end || end - start > 23) return [...MONITOR_MONTHS];
|
||||
return Array.from({ length: end - start + 1 }, (_, index) => {
|
||||
const value = start + index;
|
||||
const year = Math.floor(value / 12);
|
||||
const month = value % 12 + 1;
|
||||
return `${year}-${String(month).padStart(2, "0")}`;
|
||||
});
|
||||
}
|
||||
|
||||
export function average(values: number[]): number {
|
||||
return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0;
|
||||
}
|
||||
|
||||
export function categoryTrend(
|
||||
category: ModelCategoryId,
|
||||
metric: "ks" | "psi",
|
||||
months: string[],
|
||||
source: ModelRecord[] = MODELS,
|
||||
): number[] {
|
||||
const models = source.filter((model) => model.category === category && model.status !== "下线");
|
||||
const series = models.map((model) => modelTrend(model, metric, months));
|
||||
return months.map((_, index) => Number(average(series.map((values) => values[index] ?? 0)).toFixed(2)));
|
||||
}
|
||||
|
||||
export function bankTrend(
|
||||
bank: string,
|
||||
metric: "ks" | "psi",
|
||||
months: string[],
|
||||
source: ModelRecord[] = MODELS,
|
||||
): number[] {
|
||||
const models = source.filter((model) => model.bank === bank && model.status !== "下线");
|
||||
const series = models.map((model) => modelTrend(model, metric, months));
|
||||
return months.map((_, index) => Number(average(series.map((values) => values[index] ?? 0)).toFixed(2)));
|
||||
}
|
||||
|
||||
export function modelsTrend(
|
||||
models: ModelRecord[],
|
||||
metric: "ks" | "psi",
|
||||
months: string[],
|
||||
): number[] {
|
||||
const activeModels = models.filter((model) => model.status !== "下线");
|
||||
const series = activeModels.map((model) => modelTrend(model, metric, months));
|
||||
return months.map((_, index) => Number(average(series.map((values) => values[index] ?? 0)).toFixed(2)));
|
||||
}
|
||||
|
||||
export function latestIterationDate(models: ModelRecord[]): string {
|
||||
return models.map((model) => model.iteratedAt).filter(Boolean).sort().at(-1) ?? "—";
|
||||
}
|
||||
|
||||
export function modelIterationCycleMonths(model: ModelRecord): number | null {
|
||||
const lifecycle = MODEL_LIFECYCLE[model.modelId];
|
||||
const start = lifecycle?.onlineAt ?? lifecycle?.escortStartAt;
|
||||
if (!start || !model.iteratedAt) return null;
|
||||
const [startYear, startMonth] = start.slice(0, 7).split("-").map(Number);
|
||||
const [endYear, endMonth] = model.iteratedAt.slice(0, 7).split("-").map(Number);
|
||||
if (![startYear, startMonth, endYear, endMonth].every(Number.isFinite)) return null;
|
||||
return Math.max(0, (endYear * 12 + endMonth) - (startYear * 12 + startMonth));
|
||||
}
|
||||
|
||||
export function averageIterationCycle(models: ModelRecord[]): number {
|
||||
const values = models.map(modelIterationCycleMonths).filter((value): value is number => value !== null);
|
||||
return Number(average(values).toFixed(1));
|
||||
}
|
||||
|
||||
export function averageIterationMonths(models: ModelRecord[]): number {
|
||||
const months = models
|
||||
.filter((model) => model.status !== "下线")
|
||||
.map((model) => {
|
||||
const [year, month] = model.iteratedAt.slice(0, 7).split("-").map(Number);
|
||||
return (2026 * 12 + 7) - (year * 12 + month);
|
||||
})
|
||||
.filter(Number.isFinite);
|
||||
return Number(average(months).toFixed(1));
|
||||
}
|
||||
Reference in New Issue
Block a user