The dashboard had several oversized orchestration, component, and CSS files that made product-copy changes and mobile/performance work risky. This refactor preserves behavior while splitting scan terminal CSS, opportunity helpers, future forecast panels, history/detail charts, and probability/model sections into smaller ownership boundaries. Constraint: No user-visible version bump because this batch is architecture and performance cleanup, not a release announcement. Rejected: Rewrite dashboard state management in the same batch | too broad for a safe upload after CSS and component splitting. Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep new component/CSS boundaries instead of moving product copy back into the large dashboard files. Tested: npm run build; npm run test:business; git diff --check Not-tested: Browser visual smoke test after push
214 lines
6.9 KiB
TypeScript
214 lines
6.9 KiB
TypeScript
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
|
||
import { formatQuoteCents } from "./opportunity-format";
|
||
import { getLocalizedRowText } from "./opportunity-copy";
|
||
import { getModelSourceSummary } from "./opportunity-model-summary";
|
||
|
||
export function getOpportunityStrength(edgePercent?: number | null, locale = "zh-CN") {
|
||
const edge = Number(edgePercent);
|
||
const normalized = Number.isFinite(edge) ? edge : 0;
|
||
if (normalized >= 20) {
|
||
return {
|
||
label: locale === "en-US" ? "High confidence" : "高胜率",
|
||
tone: "strong",
|
||
};
|
||
}
|
||
if (normalized >= 10) {
|
||
return {
|
||
label: locale === "en-US" ? "Medium confidence" : "中等胜率",
|
||
tone: "medium",
|
||
};
|
||
}
|
||
return {
|
||
label: locale === "en-US" ? "Watch" : "观察",
|
||
tone: "watch",
|
||
};
|
||
}
|
||
|
||
export function getShortAiConclusion(
|
||
row: ScanOpportunityRow,
|
||
locale: string,
|
||
_edgePercent?: number | null,
|
||
strengthLabel?: string,
|
||
) {
|
||
const directReason =
|
||
getLocalizedRowText(row, locale, row.ai_reason_zh, row.ai_reason_en) ||
|
||
getLocalizedRowText(
|
||
row,
|
||
locale,
|
||
row.ai_watchlist_reason_zh,
|
||
row.ai_watchlist_reason_en,
|
||
);
|
||
if (directReason) return directReason;
|
||
const cityThesis = getLocalizedRowText(
|
||
row,
|
||
locale,
|
||
row.ai_city_thesis_zh,
|
||
row.ai_city_thesis_en,
|
||
);
|
||
if (cityThesis) return cityThesis;
|
||
|
||
const modelBasis = getModelSourceSummary(row, locale, row.target_unit || row.temp_symbol);
|
||
if (locale === "en-US") {
|
||
return `${strengthLabel || "Watch"}: AI should validate against ${modelBasis}.`;
|
||
}
|
||
return `${strengthLabel || "观察"}:AI 需结合${modelBasis}确认。`;
|
||
}
|
||
|
||
export function getRiskHints(
|
||
row: ScanOpportunityRow,
|
||
locale: string,
|
||
modelProbability?: number | null,
|
||
) {
|
||
const hints: string[] = [];
|
||
const spread = Number(row.spread);
|
||
if (Number.isFinite(spread) && spread > 0.03) {
|
||
hints.push(
|
||
locale === "en-US"
|
||
? `Wide spread ${formatQuoteCents(spread)} may distort the displayed market price.`
|
||
: `盘口价差 ${formatQuoteCents(spread)} 偏宽,可能扭曲市场价格参考。`,
|
||
);
|
||
}
|
||
const quoteAgeSeconds =
|
||
row.quote_age_ms != null && Number.isFinite(Number(row.quote_age_ms))
|
||
? Math.round(Number(row.quote_age_ms) / 1000)
|
||
: null;
|
||
if (quoteAgeSeconds != null && quoteAgeSeconds > 60) {
|
||
hints.push(
|
||
locale === "en-US"
|
||
? `Quote age ${quoteAgeSeconds}s; refresh before acting.`
|
||
: `报价已 ${quoteAgeSeconds}s,执行前需要刷新。`,
|
||
);
|
||
}
|
||
if (row.trend_alignment === false) {
|
||
hints.push(
|
||
locale === "en-US"
|
||
? "Intraday trend does not fully support this direction."
|
||
: "日内趋势未完全支持该方向。",
|
||
);
|
||
}
|
||
if (row.cluster_adjusted) {
|
||
hints.push(
|
||
locale === "en-US"
|
||
? "Tail bucket was cluster-adjusted; bucket confidence may be overstated."
|
||
: "尾部桶已做模型集群折扣,温度桶信心可能偏乐观。",
|
||
);
|
||
}
|
||
if (modelProbability != null && modelProbability < 10) {
|
||
hints.push(
|
||
locale === "en-US"
|
||
? "Low model probability makes the setup sensitive to calibration error."
|
||
: "模型概率偏低,校准误差会显著影响判断。",
|
||
);
|
||
}
|
||
if (!hints.length) {
|
||
hints.push(
|
||
locale === "en-US"
|
||
? "Main residual risk is late observation updates or a shifted peak window."
|
||
: "主要残余风险是后续实测升温或峰值窗口漂移。",
|
||
);
|
||
}
|
||
return hints;
|
||
}
|
||
|
||
export function getRecommendationReasons(
|
||
row: ScanOpportunityRow,
|
||
locale: string,
|
||
_edgePercent?: number | null,
|
||
price?: number | null,
|
||
) {
|
||
const reasons: string[] = [];
|
||
const aiReason = getLocalizedRowText(row, locale, row.ai_reason_zh, row.ai_reason_en);
|
||
if (aiReason && String(row.ai_decision || "").toLowerCase() === "approve") {
|
||
reasons.push(aiReason);
|
||
}
|
||
const modelBasis = getModelSourceSummary(row, locale, row.target_unit || row.temp_symbol);
|
||
reasons.push(
|
||
locale === "en-US"
|
||
? `AI uses ${modelBasis} with market ask ${formatQuoteCents(price)} only as downstream bucket context.`
|
||
: `AI 以${modelBasis}为主,市场买价 ${formatQuoteCents(price)} 只作下游温度桶参考。`,
|
||
);
|
||
if (row.peak_alignment_score != null) {
|
||
reasons.push(
|
||
locale === "en-US"
|
||
? `Peak alignment score ${Number(row.peak_alignment_score).toFixed(2)} supports checking this bucket.`
|
||
: `峰值对齐分 ${Number(row.peak_alignment_score).toFixed(2)},支持把该桶纳入检查。`,
|
||
);
|
||
}
|
||
return reasons.slice(0, 3);
|
||
}
|
||
|
||
export function getExclusionReasons(
|
||
row: ScanOpportunityRow,
|
||
locale: string,
|
||
edgePercent?: number | null,
|
||
) {
|
||
const decision = String(row.ai_decision || "").toLowerCase();
|
||
const aiReason =
|
||
getLocalizedRowText(row, locale, row.ai_reason_zh, row.ai_reason_en) ||
|
||
getLocalizedRowText(
|
||
row,
|
||
locale,
|
||
row.ai_watchlist_reason_zh,
|
||
row.ai_watchlist_reason_en,
|
||
);
|
||
if (decision === "veto" || decision === "downgrade" || decision === "watchlist") {
|
||
return [
|
||
aiReason ||
|
||
(locale === "en-US"
|
||
? "AI did not classify this row as the primary forecast bucket."
|
||
: "AI 未把该合约列为主预测桶。"),
|
||
];
|
||
}
|
||
if (edgePercent != null && Number(edgePercent) < 10) {
|
||
return [
|
||
locale === "en-US"
|
||
? "This bucket is not the current forecast center."
|
||
: "该桶不是当前预测中枢。",
|
||
];
|
||
}
|
||
return [
|
||
locale === "en-US"
|
||
? "No hard veto in the current AI/rule snapshot."
|
||
: "当前 AI/规则快照没有硬性排除项。",
|
||
];
|
||
}
|
||
|
||
export function getAiMeta(row: ScanOpportunityRow, locale: string) {
|
||
const decision = String(row.ai_decision || "").toLowerCase();
|
||
if (decision === "veto") {
|
||
return {
|
||
label: locale === "en-US" ? "AI veto" : "AI 排除",
|
||
tone: "veto",
|
||
reason: locale === "en-US" ? row.ai_reason_en || row.ai_reason_zh : row.ai_reason_zh || row.ai_reason_en,
|
||
};
|
||
}
|
||
if (decision === "downgrade") {
|
||
return {
|
||
label: locale === "en-US" ? "AI downgrade" : "AI 降级",
|
||
tone: "downgrade",
|
||
reason: locale === "en-US" ? row.ai_reason_en || row.ai_reason_zh : row.ai_reason_zh || row.ai_reason_en,
|
||
};
|
||
}
|
||
if (row.ai_rank != null || decision === "approve") {
|
||
return {
|
||
label: locale === "en-US" ? `AI pick ${row.ai_rank || ""}`.trim() : `AI 推荐 ${row.ai_rank || ""}`.trim(),
|
||
tone: "approve",
|
||
reason:
|
||
(locale === "en-US" ? row.ai_reason_en || row.ai_reason_zh : row.ai_reason_zh || row.ai_reason_en) ||
|
||
row.ai_model_cluster_note ||
|
||
null,
|
||
};
|
||
}
|
||
if (decision === "watchlist") {
|
||
return {
|
||
label: locale === "en-US" ? "AI watch" : "AI 观察",
|
||
tone: "downgrade",
|
||
reason:
|
||
locale === "en-US"
|
||
? row.ai_watchlist_reason_en || row.ai_watchlist_reason_zh
|
||
: row.ai_watchlist_reason_zh || row.ai_watchlist_reason_en,
|
||
};
|
||
}
|
||
return null;
|
||
}
|