Initial commit with 2026 World Cup Quant Platform core modules and CI/CD

This commit is contained in:
QuantBot
2026-06-13 23:18:18 +08:00
commit 073abf98c1
155 changed files with 19539 additions and 0 deletions

View 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>
);
}