Files
Profeto/frontend/src/admin/pages/Backtest.tsx
T
shangfangjian 78375d6e39 fix: 管理后台页面修复
- AdminLayout: 侧边栏布局和导航修复
- dal.ts: 数据访问层 API 调用修复
- Dashboard: 仪表盘统计卡片和数据加载修复
- Backtest: 回测配置和结果展示修复
- LLMConfig: LLM 配置页面修复
- Predictions: 预测管理页面修复
2026-09-20 08:45:27 +08:00

370 lines
15 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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>
)
}