/** * Admin 后台 - 回测管理页面(报刊风) * * 功能: * - 回测配置: 联赛(下拉)、日期范围、场数、模式 * - 结果汇总: 已评分数、1X2 准确率、比分 RMSE、平均置信度 * - 逐场明细: 实际比分 vs 预测比分,正误标记 * - 模型评估: 各模型历史准确率(/eval/summary) * * 注意: 回测会对每场完赛比赛各发起一次 LLM 预测,成本高,需管理员密钥。 */ import { useEffect, useState } from 'react' import { triggerBacktest, fetchEvalSummary, fetchLeagues } from '../dal' import type { BacktestRequest, BacktestSummary, EvalSummary, League } from '../types' import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components' import TeamSideTag from '../../components/TeamSideTag' interface BacktestResultRow { match_id: number league_code?: string | null home_team: string away_team: string home_team_zh?: string | null away_team_zh?: string | null match_date?: string | null actual_score: string actual_1x2?: string pred_home?: number | null pred_away?: number | null pred_1x2?: string | null subjective_confidence?: number | null correct_1x2: boolean } interface BacktestResponse { summary: BacktestSummary results: BacktestResultRow[] } const OUTCOME_LABEL: Record = { '1': '主胜', X: '平局', '2': '客胜' } /** 导出回测明细为 CSV(UTF-8 BOM,Excel 可直接打开) */ function exportCsv(rows: BacktestResultRow[]) { const header = [ "比赛日期", "联赛", "主队", "客队", "实际比分", "实际1X2", "预测主球", "预测客球", "预测1X2", "主观置信度", "1X2命中", ] const lines = [header.join(",")] for (const r of rows) { lines.push([ fmtDate(r.match_date), r.league_code ?? "", csvCell(r.home_team_zh || r.home_team), csvCell(r.away_team_zh || r.away_team), r.actual_score, r.actual_1x2 ?? "", r.pred_home ?? "", r.pred_away ?? "", r.pred_1x2 ?? "", r.subjective_confidence != null ? String(Math.round(r.subjective_confidence * 100)) : "", r.correct_1x2 ? "是" : "否", ].join(",")) } const blob = new Blob(["" + lines.join("\n")], { type: "text/csv;charset=utf-8" }) const url = URL.createObjectURL(blob) const a = document.createElement("a") a.href = url a.download = `backtest_${new Date().toLocaleDateString('sv-SE')}.csv` a.click() URL.revokeObjectURL(url) } /** CSV 字段转义:含逗号/引号/换行时加引号 */ function csvCell(v: string): string { return /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v } function fmtDate(s?: string | null): string { if (!s) return '—' return new Date(s).toLocaleDateString('sv-SE') } export default function BacktestPage() { const [leagues, setLeagues] = useState([]) const [leagueId, setLeagueId] = useState('') const [dateFrom, setDateFrom] = useState('') const [dateTo, setDateTo] = useState('') const [limit, setLimit] = useState(20) const [mode] = useState<'multi'>('multi') const [model, setModel] = useState('') const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [result, setResult] = useState(null) const [evalSummary, setEvalSummary] = useState(null) const [evalLoading, setEvalLoading] = useState(false) useEffect(() => { fetchLeagues().then(setLeagues) loadEval() }, []) async function loadEval() { setEvalLoading(true) try { setEvalSummary(await fetchEvalSummary()) } finally { setEvalLoading(false) } } async function handleBacktest(e: React.FormEvent) { e.preventDefault() setLoading(true) setError(null) setResult(null) try { const req: BacktestRequest = { league_id: leagueId ? parseInt(leagueId) : undefined, date_from: dateFrom || undefined, date_to: dateTo || undefined, limit, mode, model: model.trim() || undefined, } const res = await triggerBacktest(req as BacktestRequest) setResult(res as unknown as BacktestResponse) } catch (err: unknown) { setError(err instanceof Error ? err.message : '回测失败') } finally { setLoading(false) } } const summary = result?.summary return (
{/* 回测配置 */}
setDateFrom(e.target.value)} className="field w-full" />
setDateTo(e.target.value)} className="field w-full" />
setLimit(parseInt(e.target.value) || 20)} className="field w-full" />
setModel(e.target.value)} placeholder="如 deepseek-chat / 留空使用默认" className="field w-full" />
{error && setError(null)} />}
{/* 结果汇总 */}
{summary && ( exportCsv(result.results)} className="btn btn-sm">导出 CSV) : undefined } />
{summary.scored}/{summary.total}
已评分 / 总场数
{summary.success}/{summary.degraded}
成功 / 降级 (degraded)
{summary.accuracy_1x2 !== undefined && summary.accuracy_1x2 !== null ? `${summary.accuracy_1x2.toFixed(1)}%` : '—'}
1X2 准确率
{summary.avg_score_rmse !== undefined && summary.avg_score_rmse !== null ? summary.avg_score_rmse.toFixed(2) : '—'}
比分 RMSE
{summary.avg_subjective_confidence !== undefined && summary.avg_subjective_confidence !== null ? `${Math.round(summary.avg_subjective_confidence * 100)}%` : '—'}
平均置信度
{summary.degraded > 0 && (
有 {summary.degraded} 场预测降级(专家无有效结论),未计入准确率分子。建议检查该时段数据完整性。
)}
)} {/* 模型评估 */} {evalLoading ? (<> 加载中) : '刷新'} } /> {evalSummary && evalSummary.summary?.length > 0 ? (
{evalSummary.summary.map((s, i) => (
{s.provider}/{s.model} 准确率 {s.accuracy_1x2 !== undefined && s.accuracy_1x2 !== null ? `${s.accuracy_1x2.toFixed(1)}%` : '—'} {s.total} 场 {s.avg_score_rmse != null && RMSE {s.avg_score_rmse.toFixed(2)}}
))}
) : (

暂无评估数据。到「预测管理」完成结算后,这里会给出各模型准确率。

)}
{/* 逐场明细 */} {result && result.results?.length > 0 && (
{result.results.map(r => (
{fmtDate(r.match_date)} {r.home_team_zh || r.home_team} vs {r.away_team_zh || r.away_team} 实际 {r.actual_score} | 预测 {r.pred_home ?? '-'}:{r.pred_away ?? '-'} {r.correct_1x2 ? 命中 : 未中}
))}
)}
) }