- AdminLayout: 侧边栏布局和导航修复 - dal.ts: 数据访问层 API 调用修复 - Dashboard: 仪表盘统计卡片和数据加载修复 - Backtest: 回测配置和结果展示修复 - LLMConfig: LLM 配置页面修复 - Predictions: 预测管理页面修复
370 lines
15 KiB
TypeScript
370 lines
15 KiB
TypeScript
/**
|
||
* 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<string, string> = { '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<League[]>([])
|
||
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<string | null>(null)
|
||
const [result, setResult] = useState<BacktestResponse | null>(null)
|
||
const [evalSummary, setEvalSummary] = useState<EvalSummary | null>(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 (
|
||
<div className="space-y-6">
|
||
<SectionHeader
|
||
title="回测管理"
|
||
description="在历史数据上运行预测并评估准确率。逐场调用 LLM,成本高,建议先小场次试跑。"
|
||
/>
|
||
|
||
<div className="grid gap-6 lg:grid-cols-2">
|
||
{/* 回测配置 */}
|
||
<Card>
|
||
<CardHeader title="回测配置" />
|
||
<CardBody>
|
||
<form onSubmit={handleBacktest} className="space-y-4">
|
||
<div>
|
||
<label className="mb-1.5 block text-xs text-ink-500">联赛</label>
|
||
<select
|
||
value={leagueId}
|
||
onChange={e => setLeagueId(e.target.value)}
|
||
className="field w-full"
|
||
>
|
||
<option value="">全部联赛</option>
|
||
{leagues.map(l => (
|
||
<option key={l.id ?? l.code} value={String(l.id)}>
|
||
{l.name_zh || l.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||
<div>
|
||
<label className="mb-1.5 block text-xs text-ink-500">起始日期</label>
|
||
<input
|
||
type="date"
|
||
value={dateFrom}
|
||
onChange={e => setDateFrom(e.target.value)}
|
||
className="field w-full"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="mb-1.5 block text-xs text-ink-500">结束日期</label>
|
||
<input
|
||
type="date"
|
||
value={dateTo}
|
||
onChange={e => setDateTo(e.target.value)}
|
||
className="field w-full"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="mb-1.5 block text-xs text-ink-500">场数限制</label>
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={200}
|
||
value={limit}
|
||
onChange={e => setLimit(parseInt(e.target.value) || 20)}
|
||
className="field w-full"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="mb-1.5 block text-xs text-ink-500">模式</label>
|
||
<input
|
||
type="text"
|
||
value="多专家 (5 路 + 终裁)"
|
||
readOnly
|
||
className="field w-full bg-paper-100 text-ink-500"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="mb-1.5 block text-xs text-ink-500">
|
||
指定模型(可选,空=默认 <code className="font-mono">gpt-4o</code>)
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={model}
|
||
onChange={e => setModel(e.target.value)}
|
||
placeholder="如 deepseek-chat / 留空使用默认"
|
||
className="field w-full"
|
||
/>
|
||
</div>
|
||
|
||
{error && <Alert kind="error" title="回测失败" message={error} onClose={() => setError(null)} />}
|
||
|
||
<button type="submit" disabled={loading} className="btn btn-solid w-full">
|
||
{loading ? (<><Spinner /> 回测中,逐场预测耗时较长</>) : '开始回测'}
|
||
</button>
|
||
</form>
|
||
</CardBody>
|
||
</Card>
|
||
|
||
{/* 结果汇总 */}
|
||
<div className="space-y-6">
|
||
{summary && (
|
||
<Card>
|
||
<CardHeader
|
||
title="回测结果"
|
||
description={`模式: ${mode}${model ? ` · 模型: ${model}` : ''} · 限 ${limit} 场`}
|
||
action={
|
||
result?.results?.length
|
||
? (<button onClick={() => exportCsv(result.results)} className="btn btn-sm">导出 CSV</button>)
|
||
: undefined
|
||
}
|
||
/>
|
||
<CardBody>
|
||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||
<div>
|
||
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">
|
||
{summary.scored}/{summary.total}
|
||
</div>
|
||
<div className="mt-1 text-2xs text-ink-400">已评分 / 总场数</div>
|
||
</div>
|
||
<div>
|
||
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">
|
||
{summary.success}/{summary.degraded}
|
||
</div>
|
||
<div className="mt-1 text-2xs text-ink-400">
|
||
成功 / 降级<span className="text-ink-300"> (degraded)</span>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="font-serif text-2xl font-bold tabular-nums text-press">
|
||
{summary.accuracy_1x2 !== undefined && summary.accuracy_1x2 !== null
|
||
? `${summary.accuracy_1x2.toFixed(1)}%`
|
||
: '—'}
|
||
</div>
|
||
<div className="mt-1 text-2xs text-ink-400">1X2 准确率</div>
|
||
</div>
|
||
<div>
|
||
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">
|
||
{summary.avg_score_rmse !== undefined && summary.avg_score_rmse !== null
|
||
? summary.avg_score_rmse.toFixed(2)
|
||
: '—'}
|
||
</div>
|
||
<div className="mt-1 text-2xs text-ink-400">比分 RMSE</div>
|
||
</div>
|
||
<div>
|
||
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">
|
||
{summary.avg_subjective_confidence !== undefined && summary.avg_subjective_confidence !== null
|
||
? `${Math.round(summary.avg_subjective_confidence * 100)}%`
|
||
: '—'}
|
||
</div>
|
||
<div className="mt-1 text-2xs text-ink-400">平均置信度</div>
|
||
</div>
|
||
{summary.degraded > 0 && (
|
||
<div className="col-span-full border-l-2 border-press bg-press-wash/40 px-3 py-2 text-2xs leading-relaxed text-press-dark">
|
||
有 {summary.degraded} 场预测降级(专家无有效结论),未计入准确率分子。建议检查该时段数据完整性。
|
||
</div>
|
||
)}
|
||
</div>
|
||
</CardBody>
|
||
</Card>
|
||
)}
|
||
|
||
{/* 模型评估 */}
|
||
<Card>
|
||
<CardHeader
|
||
title="模型评估"
|
||
description="已结算预测的准确率统计"
|
||
action={
|
||
<button onClick={loadEval} disabled={evalLoading} className="btn btn-sm">
|
||
{evalLoading ? (<><Spinner /> 加载中</>) : '刷新'}
|
||
</button>
|
||
}
|
||
/>
|
||
<CardBody>
|
||
{evalSummary && evalSummary.summary?.length > 0 ? (
|
||
<div className="space-y-2.5">
|
||
{evalSummary.summary.map((s, i) => (
|
||
<div
|
||
key={i}
|
||
className="flex flex-col gap-1 border-b border-ink-200 pb-2.5 last:border-b-0 last:pb-0 sm:flex-row sm:items-center sm:justify-between"
|
||
>
|
||
<span className="font-mono text-xs text-ink-700">{s.provider}/{s.model}</span>
|
||
<span className="text-2xs tabular-nums text-ink-500">
|
||
准确率 <span className="font-serif text-sm font-bold text-ink-900">
|
||
{s.accuracy_1x2 !== undefined && s.accuracy_1x2 !== null ? `${s.accuracy_1x2.toFixed(1)}%` : '—'}
|
||
</span>
|
||
<span className="ml-2">{s.total} 场</span>
|
||
{s.avg_score_rmse != null && <span className="ml-2">RMSE {s.avg_score_rmse.toFixed(2)}</span>}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="py-4 text-center text-xs text-ink-400">
|
||
暂无评估数据。到「预测管理」完成结算后,这里会给出各模型准确率。
|
||
</p>
|
||
)}
|
||
</CardBody>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 逐场明细 */}
|
||
{result && result.results?.length > 0 && (
|
||
<Card>
|
||
<CardHeader title="逐场明细" />
|
||
<CardBody className="px-0 sm:px-0">
|
||
<div>
|
||
{result.results.map(r => (
|
||
<div
|
||
key={r.match_id}
|
||
className="flex flex-col gap-1.5 border-b border-ink-200 px-4 py-3 last:border-b-0 sm:flex-row sm:items-center sm:gap-3 sm:px-5"
|
||
>
|
||
<span className="w-24 flex-shrink-0 text-2xs tabular-nums text-ink-400">
|
||
{fmtDate(r.match_date)}
|
||
</span>
|
||
<span className="flex min-w-0 flex-1 items-center gap-1.5 text-sm text-ink-800">
|
||
<TeamSideTag side="home" />
|
||
<span className="truncate">{r.home_team_zh || r.home_team}</span>
|
||
<span className="flex-shrink-0 text-ink-300">vs</span>
|
||
<TeamSideTag side="away" />
|
||
<span className="truncate">{r.away_team_zh || r.away_team}</span>
|
||
</span>
|
||
<span className="flex-shrink-0 text-xs tabular-nums text-ink-500">
|
||
实际 <span className="font-serif font-bold text-ink-900">{r.actual_score}</span>
|
||
<span className="mx-2 text-ink-200">|</span>
|
||
预测 <span className={`font-serif font-bold ${r.correct_1x2 ? 'text-ink-900' : 'text-ink-400'}`}>
|
||
{r.pred_home ?? '-'}:{r.pred_away ?? '-'}
|
||
</span>
|
||
</span>
|
||
<span className="flex-shrink-0 sm:w-16 sm:text-right">
|
||
{r.correct_1x2 ? <Badge status="success">命中</Badge> : <Badge status="loss">未中</Badge>}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardBody>
|
||
</Card>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|