Initial commit with 2026 World Cup Quant Platform core modules and CI/CD
This commit is contained in:
252
platform/web/app/props/page.tsx
Normal file
252
platform/web/app/props/page.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { PlayerMatchupRadar } from '@/components/PlayerMatchupRadar';
|
||||
import { PropValueCard } from '@/components/PropValueCard';
|
||||
import {
|
||||
estimatePlayerPropProbability,
|
||||
type PlayerMetricProfile,
|
||||
} from '@/lib/betting-utils';
|
||||
import {
|
||||
calculatePlayerProps,
|
||||
type PlayerPropsResponse,
|
||||
} from '@/lib/analytics-api';
|
||||
|
||||
type Metric = 'shots' | 'shots_on_target' | 'passes';
|
||||
|
||||
type RadarMetric = {
|
||||
攻擊: number;
|
||||
運球: number;
|
||||
組織: number;
|
||||
壓迫: number;
|
||||
對位完成: number;
|
||||
穩定度: number;
|
||||
};
|
||||
|
||||
const metricLabelMap: Record<Metric, string> = {
|
||||
shots: '射門',
|
||||
shots_on_target: '射正',
|
||||
passes: '傳球',
|
||||
};
|
||||
|
||||
export default function PropsPage() {
|
||||
const [playerName, setPlayerName] = useState('Kylian Mbappé');
|
||||
const [opponentName, setOpponentName] = useState('墨西哥');
|
||||
const [metric, setMetric] = useState<Metric>('shots');
|
||||
const [baselineMean, setBaselineMean] = useState(3.2);
|
||||
const [line, setLine] = useState(1.5);
|
||||
const [matchMinutes, setMatchMinutes] = useState(90);
|
||||
const [bookmakerOdds, setBookmakerOdds] = useState(2.15);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [analysis, setAnalysis] = useState<PlayerPropsResponse | null>(null);
|
||||
const [radarMetric] = useState<RadarMetric>({
|
||||
攻擊: 72,
|
||||
運球: 68,
|
||||
組織: 74,
|
||||
壓迫: 61,
|
||||
對位完成: 66,
|
||||
穩定度: 70,
|
||||
});
|
||||
|
||||
const profile = useMemo<PlayerMetricProfile>(() => ({
|
||||
playerId: playerName,
|
||||
metric,
|
||||
baselineMean,
|
||||
line,
|
||||
matchMinutes,
|
||||
teamAttackFactor: 1.15,
|
||||
opponentDefenceFactor: 0.92,
|
||||
weatherFatigueFactor: 1.02,
|
||||
}), [playerName, metric, baselineMean, line, matchMinutes]);
|
||||
|
||||
const estimate = useMemo(() => estimatePlayerPropProbability(profile), [profile]);
|
||||
|
||||
const fallback = useMemo(
|
||||
() =>
|
||||
({
|
||||
metric,
|
||||
line,
|
||||
over_probability: estimate.overProbability,
|
||||
under_probability: estimate.underProbability,
|
||||
expected_count: estimate.expectedCount,
|
||||
p5: Number((estimate.expectedCount * 0.55).toFixed(2)),
|
||||
p50: Number((estimate.expectedCount * 1.05).toFixed(2)),
|
||||
p95: Number((estimate.expectedCount * 1.65).toFixed(2)),
|
||||
simulation_runs: 12000,
|
||||
edge: null,
|
||||
top_edge: false,
|
||||
bookmaker_over_odds: bookmakerOdds,
|
||||
implied_prob: 1 / bookmakerOdds,
|
||||
}) as PlayerPropsResponse,
|
||||
[metric, line, estimate, bookmakerOdds],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const signal = new AbortController();
|
||||
|
||||
const runAnalysis = async () => {
|
||||
setLoading(true);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const result = await calculatePlayerProps({
|
||||
player_id: playerName,
|
||||
player_name: playerName,
|
||||
metric,
|
||||
baseline_mean: baselineMean,
|
||||
line,
|
||||
match_minutes: matchMinutes,
|
||||
team_attack_factor: 1.15,
|
||||
opponent_defence_factor: 0.92,
|
||||
weather_fatigue_factor: 1.02,
|
||||
bookmaker_over_odds: bookmakerOdds,
|
||||
simulations: 12000,
|
||||
});
|
||||
|
||||
if (signal.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
setAnalysis(result);
|
||||
} catch (error) {
|
||||
if (signal.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
setErrorMessage(error instanceof Error ? error.message : '道具盤分析暫時無法使用');
|
||||
setAnalysis(fallback);
|
||||
} finally {
|
||||
if (!signal.signal.aborted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
runAnalysis();
|
||||
|
||||
return () => {
|
||||
signal.abort();
|
||||
};
|
||||
}, [fallback, line, metric, baselineMean, bookmakerOdds, matchMinutes, playerName]);
|
||||
|
||||
const current = analysis ?? fallback;
|
||||
const edgePercent = current.edge === null ? (current.over_probability - (current.implied_prob ?? 0)) * 100 : current.edge * 100;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="dot-matrix text-2xl text-[#7d2a15]">球員道具盤(Player Props)專業模組</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">
|
||||
<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={playerName}
|
||||
onChange={(event) => setPlayerName(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={opponentName}
|
||||
onChange={(event) => setOpponentName(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="text-sm text-[#7a5b46]">
|
||||
道具類型
|
||||
<select
|
||||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||||
value={metric}
|
||||
onChange={(event) => setMetric(event.target.value as Metric)}
|
||||
>
|
||||
<option value="shots">射門</option>
|
||||
<option value="shots_on_target">射正</option>
|
||||
<option value="passes">傳球數</option>
|
||||
</select>
|
||||
</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.1}
|
||||
value={baselineMean}
|
||||
onChange={(event) => setBaselineMean(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="text-sm text-[#7a5b46]">
|
||||
盤口 O/U 線
|
||||
<input
|
||||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||||
type="number"
|
||||
step={0.5}
|
||||
value={line}
|
||||
onChange={(event) => setLine(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="text-sm text-[#7a5b46]">
|
||||
莊家 Over 賠率
|
||||
<input
|
||||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||||
type="number"
|
||||
step={0.01}
|
||||
value={bookmakerOdds}
|
||||
onChange={(event) => setBookmakerOdds(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 lg:grid-cols-2">
|
||||
<PropValueCard
|
||||
playerName={playerName}
|
||||
metricLabel={metricLabelMap[metric]}
|
||||
line={current.line}
|
||||
overProbability={current.over_probability}
|
||||
impliedProb={current.implied_prob ?? 1 / bookmakerOdds}
|
||||
edgePercent={Number(edgePercent.toFixed(1))}
|
||||
topEdge={current.top_edge}
|
||||
/>
|
||||
<div className="grid gap-3">
|
||||
<article className="panel-glow rounded-2xl p-4">
|
||||
<h3 className="dot-matrix text-lg text-[#7d2a15]">道具盤輸出</h3>
|
||||
<p className="mt-2 text-sm text-[#7a5b46]">預期數:{current.expected_count}</p>
|
||||
<p className="mt-1 text-sm text-[#7a5b46]">低位分位(P5):{current.p5}</p>
|
||||
<p className="mt-1 text-sm text-[#7a5b46]">中位數(P50):{current.p50}</p>
|
||||
<p className="mt-1 text-sm text-[#7a5b46]">高位分位(P95):{current.p95}</p>
|
||||
<p className="mt-1 text-xs text-[#6f4f3c]">樣本模擬:{current.simulation_runs.toLocaleString()} 次</p>
|
||||
{loading ? <p className="mt-2 text-xs text-[#a16b4f]">更新道具盤模型中…</p> : null}
|
||||
{errorMessage ? <p className="mt-2 text-xs text-[#8c2f2f]">{errorMessage}</p> : null}
|
||||
</article>
|
||||
<article className="panel-glow rounded-2xl p-4">
|
||||
<h3 className="dot-matrix text-lg text-[#7d2a15]">模擬控制</h3>
|
||||
<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="range"
|
||||
min={60}
|
||||
max={120}
|
||||
value={matchMinutes}
|
||||
onChange={(event) => setMatchMinutes(Number(event.target.value))}
|
||||
/>
|
||||
<p className="text-xs text-[#7a5b46]">{matchMinutes} 分鐘</p>
|
||||
</label>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<PlayerMatchupRadar
|
||||
playerName={playerName}
|
||||
opponentName={opponentName}
|
||||
metrics={radarMetric}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user