263 lines
11 KiB
TypeScript
263 lines
11 KiB
TypeScript
'use client';
|
||
|
||
import { useState } from 'react';
|
||
import {
|
||
analyzeMlEdge,
|
||
type MlEdgeRequestPayload,
|
||
type MlEdgeResponse,
|
||
type MlTrainRequestPayload,
|
||
type MlTrainResponse,
|
||
type MlTrainingRow,
|
||
trainMlModel,
|
||
} from '@/lib/analytics-api';
|
||
|
||
const defaultTrainingRows: MlTrainingRow[] = [
|
||
{ home_rest_days: 4, away_rest_days: 3, home_travel_distance_km: 520, away_travel_distance_km: 1100, recent_5_xg_home: 1.8, recent_5_xg_away: 1.0, match_result: 'home' },
|
||
{ home_rest_days: 2, away_rest_days: 5, home_travel_distance_km: 220, away_travel_distance_km: 780, recent_5_xg_home: 1.1, recent_5_xg_away: 1.7, match_result: 'away' },
|
||
{ home_rest_days: 6, away_rest_days: 4, home_travel_distance_km: 120, away_travel_distance_km: 960, recent_5_xg_home: 2.3, recent_5_xg_away: 1.8, match_result: 'home' },
|
||
{ home_rest_days: 3, away_rest_days: 3, home_travel_distance_km: 900, away_travel_distance_km: 900, recent_5_xg_home: 1.2, recent_5_xg_away: 1.3, match_result: 'draw' },
|
||
];
|
||
|
||
export default function MlEdgePage() {
|
||
const [modelId, setModelId] = useState('default');
|
||
const [matchId, setMatchId] = useState('MATCH-2026-FINALS-07');
|
||
const [homeRest, setHomeRest] = useState(6);
|
||
const [awayRest, setAwayRest] = useState(4);
|
||
const [homeTravel, setHomeTravel] = useState(420);
|
||
const [awayTravel, setAwayTravel] = useState(970);
|
||
const [homeXg, setHomeXg] = useState(2.0);
|
||
const [awayXg, setAwayXg] = useState(1.1);
|
||
const [homeOdds, setHomeOdds] = useState(2.05);
|
||
const [drawOdds, setDrawOdds] = useState(3.35);
|
||
const [awayOdds, setAwayOdds] = useState(3.75);
|
||
const [loading, setLoading] = useState(false);
|
||
const [trainLoading, setTrainLoading] = useState(false);
|
||
const [edgeResult, setEdgeResult] = useState<MlEdgeResponse | null>(null);
|
||
const [trainResult, setTrainResult] = useState<MlTrainResponse | null>(null);
|
||
const [errorMessage, setErrorMessage] = useState('');
|
||
const [trainMessage, setTrainMessage] = useState('');
|
||
|
||
async function runEdge() {
|
||
setLoading(true);
|
||
setErrorMessage('');
|
||
try {
|
||
const payload: MlEdgeRequestPayload = {
|
||
model_id: modelId,
|
||
match_id: matchId,
|
||
home_rest_days: homeRest,
|
||
away_rest_days: awayRest,
|
||
home_travel_distance_km: homeTravel,
|
||
away_travel_distance_km: awayTravel,
|
||
recent_5_xg_home: homeXg,
|
||
recent_5_xg_away: awayXg,
|
||
home_implied_odds: homeOdds,
|
||
draw_implied_odds: drawOdds,
|
||
away_implied_odds: awayOdds,
|
||
};
|
||
const data = await analyzeMlEdge(payload);
|
||
setEdgeResult(data);
|
||
setModelId(data.model_id);
|
||
} catch (error) {
|
||
setErrorMessage(error instanceof Error ? error.message : 'ML 邊界分析暫時失敗');
|
||
setEdgeResult(null);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
async function trainModel() {
|
||
setTrainLoading(true);
|
||
setTrainMessage('');
|
||
try {
|
||
const payload: MlTrainRequestPayload = {
|
||
model_id: `model-${Date.now()}`,
|
||
rows: defaultTrainingRows,
|
||
};
|
||
const result = await trainMlModel(payload);
|
||
setTrainResult(result);
|
||
setModelId(result.model_id);
|
||
setTrainMessage(`訓練完成(${result.training_size}筆)`);
|
||
} catch (error) {
|
||
setTrainMessage(error instanceof Error ? error.message : '模型訓練失敗');
|
||
} finally {
|
||
setTrainLoading(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<h2 className="dot-matrix text-2xl text-[#7d2a15]">ML Ensemble 量化預測(第 15 階段)</h2>
|
||
<section className="panel-glow rounded-2xl p-4">
|
||
<h3 className="dot-matrix text-lg text-[#7d2a15]">模型驅動參數</h3>
|
||
<div className="mt-3 grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||
<label className="text-sm text-[#7a5b46]">
|
||
使用模型 ID
|
||
<input
|
||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||
value={modelId}
|
||
onChange={(event) => setModelId(event.target.value)}
|
||
/>
|
||
</label>
|
||
<label className="text-sm text-[#7a5b46]">
|
||
比賽編號
|
||
<input
|
||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||
value={matchId}
|
||
onChange={(event) => setMatchId(event.target.value)}
|
||
/>
|
||
</label>
|
||
<label className="text-sm text-[#7a5b46]">
|
||
主隊休息日(天)
|
||
<input
|
||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||
type="number"
|
||
value={homeRest}
|
||
onChange={(event) => setHomeRest(Number(event.target.value))}
|
||
/>
|
||
</label>
|
||
<label className="text-sm text-[#7a5b46]">
|
||
客隊休息日(天)
|
||
<input
|
||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||
type="number"
|
||
value={awayRest}
|
||
onChange={(event) => setAwayRest(Number(event.target.value))}
|
||
/>
|
||
</label>
|
||
<label className="text-sm text-[#7a5b46]">
|
||
主隊航段距離(km)
|
||
<input
|
||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||
type="number"
|
||
value={homeTravel}
|
||
onChange={(event) => setHomeTravel(Number(event.target.value))}
|
||
/>
|
||
</label>
|
||
<label className="text-sm text-[#7a5b46]">
|
||
客隊航段距離(km)
|
||
<input
|
||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||
type="number"
|
||
value={awayTravel}
|
||
onChange={(event) => setAwayTravel(Number(event.target.value))}
|
||
/>
|
||
</label>
|
||
<label className="text-sm text-[#7a5b46]">
|
||
主隊近五場 xG
|
||
<input
|
||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||
type="number"
|
||
step={0.1}
|
||
value={homeXg}
|
||
onChange={(event) => setHomeXg(Number(event.target.value))}
|
||
/>
|
||
</label>
|
||
<label className="text-sm text-[#7a5b46]">
|
||
客隊近五場 xG
|
||
<input
|
||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||
type="number"
|
||
step={0.1}
|
||
value={awayXg}
|
||
onChange={(event) => setAwayXg(Number(event.target.value))}
|
||
/>
|
||
</label>
|
||
<label className="text-sm text-[#7a5b46]">
|
||
主勝賠率(賠率)
|
||
<input
|
||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||
type="number"
|
||
step={0.01}
|
||
value={homeOdds}
|
||
onChange={(event) => setHomeOdds(Number(event.target.value))}
|
||
/>
|
||
</label>
|
||
<label className="text-sm text-[#7a5b46]">
|
||
和局賠率(賠率)
|
||
<input
|
||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||
type="number"
|
||
step={0.01}
|
||
value={drawOdds}
|
||
onChange={(event) => setDrawOdds(Number(event.target.value))}
|
||
/>
|
||
</label>
|
||
<label className="text-sm text-[#7a5b46]">
|
||
客勝賠率(賠率)
|
||
<input
|
||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||
type="number"
|
||
step={0.01}
|
||
value={awayOdds}
|
||
onChange={(event) => setAwayOdds(Number(event.target.value))}
|
||
/>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="mt-4 flex flex-wrap gap-3">
|
||
<button
|
||
className="rounded-lg bg-[#7d2a15] px-4 py-2 text-white"
|
||
type="button"
|
||
onClick={runEdge}
|
||
disabled={loading}
|
||
>
|
||
{loading ? '分析中…' : '執行 ML Edge 推論'}
|
||
</button>
|
||
<button
|
||
className="rounded-lg bg-[#d1432d] px-4 py-2 text-white"
|
||
type="button"
|
||
onClick={trainModel}
|
||
disabled={trainLoading}
|
||
>
|
||
{trainLoading ? '訓練中…' : '快速訓練演示模型'}
|
||
</button>
|
||
</div>
|
||
{errorMessage ? <p className="mt-3 text-xs text-[#8c2f2f]">{errorMessage}</p> : null}
|
||
{trainMessage ? <p className="mt-2 text-xs text-[#6f4f3c]">{trainMessage}</p> : null}
|
||
</section>
|
||
|
||
<section className="panel-glow rounded-2xl p-4">
|
||
<h3 className="dot-matrix text-lg text-[#7d2a15]">推論結果</h3>
|
||
{edgeResult ? (
|
||
<div className="mt-3 grid gap-3 md:grid-cols-2">
|
||
<article className="rounded-xl border border-[#e7c89b] bg-white/70 p-4">
|
||
<p className="text-sm text-[#7a5b46]">模型機率</p>
|
||
<p className="mt-1 text-sm text-[#5f4330]">主勝 {edgeResult.model_probs.home.toFixed(2)} / 和局 {edgeResult.model_probs.draw.toFixed(2)} / 客勝 {edgeResult.model_probs.away.toFixed(2)}</p>
|
||
<p className="mt-3 text-xs text-[#6f4f3c]">模型大小:{edgeResult.is_fallback_model ? '規則回退' : 'ML Ensemble'}</p>
|
||
</article>
|
||
<article className="rounded-xl border border-[#e7c89b] bg-white/70 p-4">
|
||
<p className="text-sm text-[#7a5b46]">強烈偏差</p>
|
||
<p className="mt-1 text-2xl font-semibold text-[#b83822]">{edgeResult.strongest_outcome}</p>
|
||
<p className="text-sm text-[#5f4330]">Edge:{edgeResult.strongest_edge_percent.toFixed(2)}%</p>
|
||
<p className="mt-2 text-xs text-[#7a5b46]">是否 Strong Buy:{edgeResult.strong_buy ? '是' : '否'}</p>
|
||
</article>
|
||
<article className="rounded-xl border border-[#e7c89b] bg-white/70 p-4 md:col-span-2">
|
||
<p className="text-sm text-[#7a5b46]">各結果 Edge 估計</p>
|
||
<ul className="mt-2 space-y-1 text-sm text-[#5f4330]">
|
||
<li>主勝:{(edgeResult.edges.home.edge * 100).toFixed(2)}% {edgeResult.edges.home.strong_buy ? '(Strong Buy)' : ''}</li>
|
||
<li>和局:{(edgeResult.edges.draw.edge * 100).toFixed(2)}% {edgeResult.edges.draw.strong_buy ? '(Strong Buy)' : ''}</li>
|
||
<li>客勝:{(edgeResult.edges.away.edge * 100).toFixed(2)}% {edgeResult.edges.away.strong_buy ? '(Strong Buy)' : ''}</li>
|
||
</ul>
|
||
</article>
|
||
</div>
|
||
) : (
|
||
<p className="mt-3 text-sm text-[#7a5b46]">尚未提交分析,請先點擊「執行 ML Edge 推論」。</p>
|
||
)}
|
||
</section>
|
||
|
||
<section className="panel-glow rounded-2xl p-4">
|
||
<h3 className="dot-matrix text-lg text-[#7d2a15]">模型訓練摘要</h3>
|
||
{trainResult ? (
|
||
<p className="mt-2 text-sm text-[#7a5b46]">
|
||
目前模型:{trainResult.model_id} | 樣本數:{trainResult.training_size} | 準確率:
|
||
{trainResult.accuracy === null ? 'N/A' : trainResult.accuracy.toFixed(4)} | 回退:{trainResult.is_fallback ? '是' : '否'}
|
||
</p>
|
||
) : (
|
||
<p className="mt-2 text-sm text-[#7a5b46]">尚未執行訓練。</p>
|
||
)}
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
|