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,37 @@
'use client';
import type { DailyCardItem } from '@/lib/analytics-api';
type Props = {
item: DailyCardItem;
onAddToSlip: (item: DailyCardItem) => void;
className?: string;
};
export function ActionableBetCard({ item, onAddToSlip, className = '' }: Props) {
return (
<article className={`group relative overflow-hidden rounded-2xl border border-[#dfc091] bg-[#fff8e6] p-4 transition ${className}`}>
<p className="dot-matrix text-sm font-semibold text-[#7d2a15]">{item.recommendation}</p>
<h3 className="mt-1 text-lg text-[#6b3f2d]">{item.match_label}</h3>
<p className="mt-1 text-sm text-[#7a5b46]">{item.market_type}</p>
<p className="mt-1 text-sm text-[#7d2a15]">{item.selection}</p>
<div className="mt-2 grid gap-1 text-sm text-[#6d4d39]">
<p><span className="font-semibold text-[#8c2f2f]">{item.target_odds.toFixed(2)}</span></p>
<p><span className="font-semibold text-[#7d2a15]">{item.win_prob.toFixed(2)}%</span></p>
<p>EV<span className="font-semibold text-[#7d2a15]">{item.ev_percent.toFixed(2)}%</span></p>
<p><span className="font-semibold text-[#7d2a15]">{item.stake_units.toFixed(2)} Units</span></p>
</div>
<p className="mt-2 text-sm text-[#6a4f3a]">{item.rationale}</p>
<button
type="button"
className="mt-3 inline-block rounded-full bg-[#7d2a15] px-4 py-2 text-sm text-white opacity-0 transition hover:bg-[#5f1f11] group-hover:opacity-100"
onClick={() => onAddToSlip(item)}
>
Add to Slip
</button>
<div className="absolute right-[-24px] top-1/2 h-24 w-2/5 -translate-y-1/2 rotate-6 rounded-full bg-gradient-to-r from-[#d1432d]/35 to-transparent opacity-0 blur-[12px] transition group-hover:opacity-100" />
</article>
);
}

View File

@@ -0,0 +1,168 @@
'use client';
import { FormEvent, useEffect, useMemo, useState } from 'react';
import { calculateKellyRecommendation } from '@/lib/betting-utils';
import { TW_DOLLAR } from '@/lib/betting-utils';
import { calculateKelly, type KellyResponse } from '@/lib/analytics-api';
export function BetSizingSlider() {
const [bankroll, setBankroll] = useState(5000);
const [odds, setOdds] = useState(2.4);
const [probability, setProbability] = useState(0.55);
const [fractional, setFractional] = useState(0.25);
const [riskTolerance, setRiskTolerance] = useState(1);
const [kellyProfile, setKellyProfile] = useState<KellyResponse | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const profile = useMemo(() => {
try {
return calculateKellyRecommendation({
odds,
trueProb: probability,
bankroll,
fractionalKelly: fractional,
riskTolerance,
});
} catch {
return {
recommendedStake: 0,
recommendedFraction: 0,
};
}
}, [odds, probability, bankroll, fractional, riskTolerance]);
const backendProfile = useMemo(
() => ({
odds,
true_prob: probability,
bankroll,
fractional_kelly_factor: fractional,
risk_tolerance_factor: riskTolerance,
}),
[bankroll, fractional, odds, probability, riskTolerance],
);
const showFractionPercent = kellyProfile ? kellyProfile.recommended_fraction : profile.recommendedFraction;
const showStake = kellyProfile ? kellyProfile.recommended_stake : profile.recommendedStake;
function onBankrollSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
}
useEffect(() => {
const handle = new AbortController();
setIsLoading(true);
setErrorMessage('');
const runCalculation = async () => {
try {
const data = await calculateKelly(backendProfile);
if (!handle.signal.aborted) {
setKellyProfile(data);
}
} catch (error) {
if (handle.signal.aborted) {
return;
}
setErrorMessage(error instanceof Error ? error.message : '凱利計算暫時失敗,使用本機備援值');
} finally {
if (!handle.signal.aborted) {
setIsLoading(false);
}
}
};
runCalculation();
return () => {
handle.abort();
};
}, [backendProfile]);
return (
<section className="panel-glow rounded-2xl p-4">
<h3 className="dot-matrix text-xl text-[#7d2a15]"></h3>
<form onSubmit={onBankrollSubmit} className="mt-3 space-y-4">
<label className="block text-sm text-[#7a5b46]">
USD
<input
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
type="number"
value={bankroll}
min={100}
step={100}
onChange={(e) => setBankroll(Math.max(100, Number(e.target.value)))}
/>
</label>
<label className="block text-sm text-[#7a5b46]">
Decimal
<input
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
type="number"
value={odds}
min={1.01}
step={0.05}
onChange={(e) => setOdds(Math.max(1.01, Number(e.target.value)))}
/>
</label>
<label className="block text-sm text-[#7a5b46]">
<input
className="mt-2 w-full"
type="range"
min={0}
max={1}
step={0.01}
value={probability}
onChange={(e) => setProbability(Number(e.target.value))}
/>
<p className="text-xs text-[#7a5b46]">{(probability * 100).toFixed(1)}%</p>
</label>
<label className="block text-sm text-[#7a5b46]">
Fractional
<input
className="mt-2 w-full"
type="range"
min={0.1}
max={1}
step={0.05}
value={fractional}
onChange={(e) => setFractional(Number(e.target.value))}
/>
<p className="text-xs text-[#7a5b46]">{(fractional * 100).toFixed(0)}%</p>
</label>
<label className="block text-sm text-[#7a5b46]">
<input
className="mt-2 w-full"
type="range"
min={0.5}
max={1.8}
step={0.05}
value={riskTolerance}
onChange={(e) => setRiskTolerance(Number(e.target.value))}
/>
<p className="text-xs text-[#7a5b46]">{riskTolerance.toFixed(2)}x</p>
</label>
</form>
<div className="mt-4 rounded-xl bg-[#fff0d9] p-4">
<p className="dot-matrix text-lg text-[#7d2a15]"></p>
<p className="mt-2 text-3xl text-[#b83822]">{TW_DOLLAR.format(profile.recommendedStake)}</p>
<p className="mt-1 text-sm text-[#7a5b46]">
{showFractionPercent.toFixed(1)}%
</p>
<p className="mt-1 text-sm text-[#7a5b46]">
{TW_DOLLAR.format(showStake)}
</p>
{isLoading ? <p className="mt-2 text-xs text-[#a16b4f]"></p> : null}
{errorMessage ? <p className="mt-2 text-xs text-[#8c2f2f]">{errorMessage}</p> : null}
</div>
</section>
);
}

View File

@@ -0,0 +1,211 @@
'use client';
import { useMemo, useState } from 'react';
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type {
PortfolioHardTruth,
PortfolioLeakCluster,
PortfolioLeaksResponse,
} from '@/lib/analytics-api';
type Props = {
data: PortfolioLeaksResponse;
};
function calculateEquity(clusters: PortfolioLeakCluster[]) {
let balance = 0;
const points = clusters
.slice()
.sort((a, b) => b.bet_count - a.bet_count)
.map((cluster) => {
balance += Number(cluster.total_pnl);
return {
category: `${cluster.market_type}/${cluster.bet_type}/${cluster.match_stage}`,
pnl: Number(cluster.total_pnl.toFixed(2)),
balance: Number(balance.toFixed(2)),
roi: Number(cluster.roi_percent.toFixed(2)),
};
});
return points;
}
function computeMaxDrawdown(points: { balance: number }[]) {
let peak = -Infinity;
let maxDD = 0;
for (const point of points) {
if (point.balance > peak) {
peak = point.balance;
continue;
}
const drop = ((peak - point.balance) / Math.max(Math.abs(peak), 1)) * 100;
if (drop > maxDD) {
maxDD = drop;
}
}
return Number(maxDD.toFixed(2));
}
export function BettingLeaksDashboard({ data }: Props) {
const [onlyPositiveAndHigh, setOnlyPositiveAndHigh] = useState(false);
const clusters = useMemo(() => {
const all = data.clusters;
if (!onlyPositiveAndHigh) {
return all;
}
return all.filter((cluster) => cluster.roi_percent > 0 && cluster.hit_rate_percent >= 60);
}, [data.clusters, onlyPositiveAndHigh]);
const equityPoints = useMemo(() => calculateEquity(data.clusters), [data.clusters]);
const maxDrawdown = useMemo(() => computeMaxDrawdown(equityPoints), [equityPoints]);
return (
<section className="space-y-4">
<section className="grid gap-3 md:grid-cols-4">
<article className="panel-glow rounded-xl p-3">
<p className="text-xs text-[#7a5b46]"></p>
<p className="mt-2 text-2xl font-semibold text-[#7d2a15]">{data.total_bet_count}</p>
</article>
<article className="panel-glow rounded-xl p-3">
<p className="text-xs text-[#7a5b46]"> ROI</p>
<p className={`mt-2 text-2xl font-semibold ${data.overall_roi_percent >= 0 ? 'text-[#1a9a57]' : 'text-[#8c2f2f]'}`}>
{data.overall_roi_percent.toFixed(2)}%
</p>
</article>
<article className="panel-glow rounded-xl p-3">
<p className="text-xs text-[#7a5b46]"></p>
<p className="mt-2 text-2xl font-semibold text-[#7d2a15]">{data.overall_hit_rate_percent.toFixed(2)}%</p>
</article>
<article className="panel-glow rounded-xl p-3">
<p className="text-xs text-[#7a5b46]"></p>
<p className={`mt-2 dot-matrix text-2xl font-semibold ${data.total_pnl >= 0 ? 'text-[#1a9a57]' : 'text-[#8c2f2f]'}`}>
${data.total_pnl.toFixed(0)}
</p>
</article>
</section>
<section className="flex flex-wrap gap-3">
<button
type="button"
onClick={() => setOnlyPositiveAndHigh((prev) => !prev)}
className={`rounded-full px-4 py-2 text-sm transition ${
onlyPositiveAndHigh ? 'bg-[#7d2a15] text-white' : 'bg-white/80 text-[#5f4330]'
}`}
>
EV
</button>
</section>
<article className="panel-glow rounded-2xl p-4">
<h3 className="dot-matrix text-lg text-[#7d2a15]"></h3>
<div className="mt-3 h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={equityPoints} margin={{ top: 6, right: 12, left: 6, bottom: 6 }}>
<defs>
<linearGradient id="leakGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#d1432d" stopOpacity={0.45} />
<stop offset="100%" stopColor="#d1432d" stopOpacity={0.03} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="4 4" stroke="#eadcb9" />
<XAxis dataKey="category" tick={{ fill: '#6d4d39', fontSize: 10 }} />
<YAxis tick={{ fill: '#6d4d39', fontSize: 10 }} />
<Tooltip
contentStyle={{ background: '#fff8e6', borderColor: '#d8b58c' }}
itemStyle={{ color: '#5f4031' }}
/>
<Area
type="monotone"
dataKey="balance"
stroke="#b83822"
fill="url(#leakGradient)"
strokeWidth={2.4}
/>
</AreaChart>
</ResponsiveContainer>
</div>
<p className="mt-2 text-xs text-[#7d4d39]">{maxDrawdown.toFixed(2)}%</p>
</article>
<article className="panel-glow rounded-2xl p-4">
<h3 className="dot-matrix text-lg text-[#7d2a15]"></h3>
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#e6cfad] text-[#7a5b46]">
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left">ROI</th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
</tr>
</thead>
<tbody>
{clusters.map((cluster) => (
<tr key={`${cluster.market_type}-${cluster.bet_type}-${cluster.odds_bucket}-${cluster.match_stage}`} className="border-b border-[#f2e4cb]">
<td className="py-2 text-[#5f4330]">{cluster.market_type}</td>
<td className="py-2 text-[#5f4330]">{cluster.bet_type}</td>
<td className="py-2 text-[#5f4330]">{cluster.odds_bucket}</td>
<td className="py-2 text-[#5f4330]">{cluster.match_stage}</td>
<td className="py-2 text-[#5f4330]">{cluster.bet_count}</td>
<td className="py-2 text-[#5f4330]">${cluster.total_stake.toFixed(0)}</td>
<td className={`py-2 font-semibold ${cluster.roi_percent >= 0 ? 'text-[#1a9a57]' : 'text-[#8c2f2f]'}`}>
{cluster.roi_percent.toFixed(2)}%
</td>
<td className="py-2 text-[#5f4330]">{cluster.hit_rate_percent.toFixed(1)}%</td>
<td className={`py-2 ${cluster.status === 'CRITICAL_LEAK' ? 'text-[#8c2f2f]' : 'text-[#7a5b46]'}`}>
{cluster.status}
</td>
</tr>
))}
{clusters.length === 0 ? (
<tr>
<td className="py-3 text-[#7a5b46]" colSpan={9}>
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</article>
<article className="panel-glow rounded-2xl p-4">
<h3 className="dot-matrix text-lg text-[#7d2a15]">Hard Truths</h3>
<div className="mt-3 space-y-2">
{data.hard_truths.length === 0 ? (
<p className="text-sm text-[#6d4d39]"></p>
) : null}
{data.hard_truths.map((truth, index) => (
<div
key={`${truth.title}-${index}`}
className="rounded-xl border border-[#d1432d] bg-[#2f0f18] p-3 text-[#ffd8d8]"
>
<p className="dot-matrix text-sm">{truth.title}</p>
<p className="mt-1 text-sm">{truth.message}</p>
<p className="mt-1 text-xs text-[#ffd4a3]">{JSON.stringify(truth.cluster)}</p>
</div>
))}
</div>
</article>
</section>
);
}

View File

@@ -0,0 +1,57 @@
'use client';
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
type Point = {
ts: string;
capital: number;
};
type Props = {
title: string;
points: Point[];
maxDrawdown: number;
};
export function EquityCurveChart({ title, points, maxDrawdown }: Props) {
return (
<article className="panel-glow rounded-2xl p-4">
<h3 className="dot-matrix text-lg text-[#7d2a15]">{title}</h3>
<div className="mt-3 h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={points} margin={{ top: 6, right: 18, bottom: 6, left: 6 }}>
<defs>
<linearGradient id="equityGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#d1432d" stopOpacity={0.45} />
<stop offset="100%" stopColor="#d1432d" stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="4 4" stroke="#eadcb9" />
<XAxis dataKey="ts" tick={{ fill: '#6d4d39', fontSize: 11 }} />
<YAxis tick={{ fill: '#6d4d39', fontSize: 11 }} />
<Tooltip
contentStyle={{ background: '#fff8e6', borderColor: '#d8b58c' }}
itemStyle={{ color: '#5f4031' }}
/>
<Area
type="monotone"
dataKey="capital"
stroke="#b83822"
fill="url(#equityGradient)"
strokeWidth={2.4}
/>
</AreaChart>
</ResponsiveContainer>
</div>
<p className="mt-3 text-sm font-semibold text-[#8c2f2f]">{maxDrawdown.toFixed(2)}%</p>
</article>
);
}

View File

@@ -0,0 +1,40 @@
'use client';
type Props = {
isVisible: boolean;
parlayLabel: string;
counterSelection: string;
hedgeStake: number;
lockedProfit: number;
};
export function HedgeAlert({ isVisible, parlayLabel, counterSelection, hedgeStake, lockedProfit }: Props) {
if (!isVisible) {
return null;
}
return (
<aside className="fixed bottom-28 right-4 z-50 w-[min(340px,calc(100vw-2rem))] rounded-2xl border-2 border-[#fbbf24] bg-[#032f26] p-4 text-white shadow-2xl shadow-[#f59e0b]/35">
<p className="dot-matrix text-sm animate-pulse text-[#fef3c7]">🔒 </p>
<p className="mt-2 text-xs text-[#f3e6c6]">
{parlayLabel}
</p>
<p className="mt-3 text-sm text-[#ffd26d]">
<span className="font-semibold">{counterSelection}</span>
</p>
<p className="mt-2 text-sm">
<span className="font-bold text-[#fca5a5]">${hedgeStake.toFixed(2)}</span>
</p>
<p className="mt-1 text-sm">
<span className="font-bold text-[#86efac]">${lockedProfit.toFixed(2)}</span>
</p>
<button
type="button"
className="dot-matrix mt-3 rounded-full bg-[#16a34a] px-3 py-2 text-xs text-black transition hover:bg-[#22c55e]"
onClick={() => window.alert('請先至下注頁面進行對沖下單。')}
>
</button>
</aside>
);
}

View File

@@ -0,0 +1,72 @@
'use client';
import React from 'react';
// 模擬的大神資料
const leaderboardData = [
{ rank: 1, name: 'QuantKing99', clv: '+8.4%', roi: '+65.2%', isSharp: true },
{ rank: 2, name: 'RLM_Hunter', clv: '+7.1%', roi: '+52.8%', isSharp: true },
{ rank: 3, name: 'AlphaBet', clv: '+6.5%', roi: '+48.1%', isSharp: true },
{ rank: 4, name: 'NormalGuy', clv: '+1.2%', roi: '+5.4%', isSharp: false },
{ rank: 5, name: 'LuckyBettor', clv: '-2.1%', roi: '+12.5%', isSharp: false },
];
export default function LeaderboardBoard() {
const handleCopyBet = (bettorName: string) => {
alert(`[系統提示] 已啟動一鍵跟單 ${bettorName}\n系統將自動按凱利比例配置您的資金。`);
// 實務上這裡會呼叫後端建立跟單關係,並計算注碼
};
return (
<div className="w-full bg-stone-900 p-6 rounded-lg border border-stone-700 shadow-xl">
<div className="mb-6">
<h2 className="text-2xl text-stone-100 font-dotmatrix uppercase font-bold tracking-wider border-b border-stone-700 pb-2">
🏆 (Social Trading)
</h2>
<p className="text-stone-400 text-sm mt-1"> CLV () 30 ROI </p>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-stone-800 text-stone-400 text-sm font-dotmatrix uppercase">
<th className="p-3 font-semibold">Rank</th>
<th className="p-3 font-semibold">Bettor</th>
<th className="p-3 font-semibold">Avg CLV</th>
<th className="p-3 font-semibold">30D ROI</th>
<th className="p-3 font-semibold text-right">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-stone-800 text-stone-200">
{leaderboardData.map((bettor) => (
<tr key={bettor.rank} className="hover:bg-stone-800 transition-colors">
<td className="p-3 font-bold font-dotmatrix text-lg">
{bettor.rank <= 3 ? <span className="text-quant-orange">#{bettor.rank}</span> : `#${bettor.rank}`}
</td>
<td className="p-3">
<div className="flex items-center space-x-2">
<span className={`font-semibold ${bettor.isSharp ? 'text-quant-red drop-shadow-[0_0_8px_rgba(234,88,12,0.8)]' : 'text-stone-300'}`}>
{bettor.name}
</span>
{bettor.isSharp && <span className="text-xs bg-quant-orange/20 text-quant-orange px-2 py-0.5 rounded uppercase font-dotmatrix">Sharp</span>}
</div>
</td>
<td className="p-3 font-dotmatrix text-quant-orange">{bettor.clv}</td>
<td className="p-3 font-dotmatrix text-green-500">{bettor.roi}</td>
<td className="p-3 text-right">
<button
onClick={() => handleCopyBet(bettor.name)}
className="bg-stone-700 hover:bg-quant-orange hover:text-stone-900 text-stone-300 font-dotmatrix uppercase text-sm px-4 py-2 rounded transition-all font-bold"
>
Copy Bet
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,85 @@
'use client';
import {
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
type XGPoint = {
minute: number;
xgHome: number;
xgAway: number;
};
type ZonePoint = {
zone: string;
pct: number;
};
type Props = {
timeline: {
minute: number;
label: string;
}[];
xgSeries: XGPoint[];
heatZones: ZonePoint[];
};
export function LiveMatchCenter({ timeline, xgSeries, heatZones }: Props) {
return (
<section className="space-y-4">
<article className="panel-glow rounded-2xl p-4">
<h3 className="dot-matrix text-xl text-[#7d2a15]"></h3>
<ul className="mt-2 space-y-1 text-sm text-[#7a5b46]">
{timeline.map((item) => (
<li key={item.minute}>
{item.minute} {item.label}
</li>
))}
</ul>
</article>
<article className="panel-glow rounded-2xl p-4">
<h3 className="dot-matrix text-xl text-[#7d2a15]">xG </h3>
<div className="mt-2 h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={xgSeries}>
<CartesianGrid strokeDasharray="4 4" stroke="#eadcb9" />
<XAxis dataKey="minute" tick={{ fill: '#6d4d39' }} />
<YAxis tick={{ fill: '#6d4d39' }} />
<Tooltip
contentStyle={{ background: '#fff8e6', borderColor: '#d8b58c' }}
itemStyle={{ color: '#5f4031' }}
/>
<Bar dataKey="xgHome" fill="#b83822" name="主隊xG" />
<Bar dataKey="xgAway" fill="#7a4a2c" name="客隊xG" />
</BarChart>
</ResponsiveContainer>
</div>
</article>
<article className="panel-glow rounded-2xl p-4">
<h3 className="dot-matrix text-xl text-[#7d2a15]"></h3>
<div className="mt-3 space-y-2">
{heatZones.map((zone) => (
<div key={zone.zone}>
<p className="text-xs text-[#8a6b58]">{zone.zone}</p>
<div className="mt-1 h-3 overflow-hidden rounded-full bg-[#ece0ca]">
<div
className="h-full bg-[#b83822]"
style={{ width: `${Math.max(0, Math.min(zone.pct, 100))}%` }}
/>
</div>
</div>
))}
</div>
</article>
</section>
);
}

View File

@@ -0,0 +1,61 @@
type MatchConditionsCardProps = {
matchId: string;
strictnessIndex: number;
heatIndex: number;
cardsPressureAlert: boolean;
secondHalfHomeAttack: number;
secondHalfAwayAttack: number;
secondHalfUnderRecommendation: boolean;
attackerDirection: string;
};
export function MatchConditionsCard({
matchId,
strictnessIndex,
heatIndex,
cardsPressureAlert,
secondHalfHomeAttack,
secondHalfAwayAttack,
secondHalfUnderRecommendation,
attackerDirection,
}: MatchConditionsCardProps) {
const isHeatCritical = heatIndex >= 32;
const isStrict = strictnessIndex >= 80;
const underSignal = secondHalfUnderRecommendation || cardsPressureAlert;
return (
<article className="panel-glow rounded-2xl p-4">
<h3 className="dot-matrix text-lg text-[#7d2a15]">{matchId}</h3>
<div className="mt-3 space-y-2 text-sm text-[#6f4f3c]">
<p>{strictnessIndex.toFixed(1)}</p>
<p>Heat Index{heatIndex.toFixed(1)} </p>
<p>調{secondHalfHomeAttack.toFixed(2)}</p>
<p>調{secondHalfAwayAttack.toFixed(2)}</p>
<p>{attackerDirection}</p>
</div>
<div
className={`mt-4 rounded-xl border p-3 ${underSignal ? 'border-[#d1432d] bg-[#fff0e2]' : 'border-[#dcb53b] bg-[#fff8e6]'}`}
>
<p className="dot-matrix text-sm text-[#7d2a15]"></p>
{cardsPressureAlert ? (
<p className="mt-1 text-sm text-[#8c2f2f]">
</p>
) : null}
{isHeatCritical ? (
<p className="mt-1 text-sm text-[#8c2f2f]">
2H Under
</p>
) : null}
{isStrict ? (
<p className="mt-1 text-sm text-[#8c2f2f]"></p>
) : null}
{!cardsPressureAlert && !isHeatCritical ? (
<p className="mt-1 text-sm text-[#7a5b46]"></p>
) : null}
</div>
</article>
);
}

View File

@@ -0,0 +1,41 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
type NavItem = {
href: string;
label: string;
};
const navItems: NavItem[] = [
{ href: '/daily-card', label: '每日作戰室' },
{ href: '/matches', label: '賽事' },
{ href: '/portfolio', label: '投資組合' },
{ href: '/proof-of-yield', label: '帳本' },
];
export function MobileBottomNav() {
const path = usePathname() || '/';
return (
<nav className="fixed inset-x-0 bottom-0 z-40 border-t border-[#e7cfa7] bg-[#f6f0e1]/95 shadow-[0_-8px_24px_rgba(98,58,34,0.12)] backdrop-blur md:hidden">
<div className="mx-auto flex max-w-7xl justify-between px-2 py-2">
{navItems.map((item) => {
const isActive = path === item.href;
return (
<Link
key={item.href}
href={item.href}
className={`dot-matrix flex-1 rounded-xl px-3 py-2 text-center text-xs font-semibold transition ${
isActive ? 'bg-[#7d2a15] text-white' : 'text-[#6a4935]'
}`}
>
{item.label}
</Link>
);
})}
</div>
</nav>
);
}

View File

@@ -0,0 +1,43 @@
'use client';
type MoneyFlowProps = {
label: string;
ticketPct: number;
handlePct: number;
};
export function MoneyFlowBar({ label, ticketPct, handlePct }: MoneyFlowProps) {
const alert = handlePct - ticketPct >= 20;
return (
<article className="panel-glow rounded-2xl p-4">
<p className="dot-matrix text-sm text-[#7d2a15]">{label}</p>
<div className="mt-2">
<p className="text-xs text-[#8a6b58]">{ticketPct.toFixed(1)}%</p>
<div className="mt-1 h-3 w-full overflow-hidden rounded-full bg-[#ece0ca]">
<div
className="h-full rounded-full bg-[#b68a65]"
style={{ width: `${Math.min(ticketPct, 100)}%` }}
/>
</div>
</div>
<div className="mt-3">
<p className="text-xs text-[#8a6b58]">{handlePct.toFixed(1)}%</p>
<div className="mt-1 h-3 w-full overflow-hidden rounded-full bg-[#ece0ca]">
<div
className={`h-full rounded-full ${alert ? 'bg-[#d1432d]' : 'bg-[#dcb53b]'}`}
style={{ width: `${Math.min(handlePct, 100)}%` }}
/>
</div>
</div>
{alert ? (
<p className="mt-2 text-sm text-[#8c2f2f]"> 20%</p>
) : (
<p className="mt-2 text-sm text-[#6f4f3c]"></p>
)}
</article>
);
}

View File

@@ -0,0 +1,77 @@
'use client';
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
type ChartPoint = {
time: string;
odds: number;
bookmaker: string;
};
type GroupedData = {
time: string;
[bookmaker: string]: string | number;
};
function normalizeForChart(points: ChartPoint[]): GroupedData[] {
const map = new Map<string, GroupedData>();
for (const row of points) {
const current = map.get(row.time) || { time: row.time };
current[row.bookmaker] = row.odds;
map.set(row.time, current);
}
return Array.from(map.values()).sort((a, b) =>
a.time.localeCompare(b.time),
);
}
type Props = {
data: ChartPoint[];
};
export function OddsLineMovementChart({ data }: Props) {
const bookmakers = Array.from(new Set(data.map((row) => row.bookmaker)));
const lines = normalizeForChart(data);
const palette = ['#b83822', '#7a4a2c', '#dcb53b', '#5f4031', '#8c5b38'];
return (
<div className="panel-glow rounded-2xl p-4">
<h3 className="dot-matrix text-xl text-[#7d2a15]"></h3>
<div className="mt-3 h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={lines} margin={{ top: 12, right: 24, bottom: 12, left: 6 }}>
<CartesianGrid strokeDasharray="4 4" stroke="#eadcb9" />
<XAxis dataKey="time" tick={{ fill: '#6d4d39' }} />
<YAxis tick={{ fill: '#6d4d39' }} />
<Tooltip
contentStyle={{ background: '#fff8e6', borderColor: '#d8b58c' }}
itemStyle={{ color: '#5f4031' }}
/>
{bookmakers.map((bookmaker, idx) => (
<Line
key={bookmaker}
type="monotone"
dataKey={bookmaker}
stroke={palette[idx % palette.length]}
strokeWidth={2.3}
dot={false}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
</div>
);
}

View File

@@ -0,0 +1,156 @@
import { useMemo } from 'react';
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { ProofOfYieldRecord, ProofOfYieldSummary } from '@/lib/analytics-api';
type Point = {
ts: string;
cumulative: number;
pnl: number;
};
type Props = {
summary: ProofOfYieldSummary;
records: ProofOfYieldRecord[];
};
export function PerformanceLedger({ summary, records }: Props) {
const sorted = useMemo(
() => [...records].sort((a, b) => new Date(a.settled_at).getTime() - new Date(b.settled_at).getTime()),
[records],
);
const curve: Point[] = useMemo(() => {
let sum = 0;
return sorted.map((record) => {
sum += record.pnl;
return {
ts: record.settled_at.slice(0, 16).replace('T', ' '),
cumulative: Number(sum.toFixed(4)),
pnl: record.pnl,
};
});
}, [sorted]);
const maxClv = useMemo(() => {
const values = records.map((record) => record.clv_percent).filter((value) => value !== null) as number[];
if (values.length === 0) {
return 0;
}
return Math.max(...values);
}, [records]);
return (
<section className="space-y-4">
<section className="grid gap-3 md:grid-cols-4">
<article className="panel-glow rounded-xl p-3">
<p className="text-xs text-[#7a5b46]"></p>
<p className="mt-2 text-2xl font-semibold text-[#7d2a15]">{summary.total_recommendations}</p>
</article>
<article className="panel-glow rounded-xl p-3">
<p className="text-xs text-[#7a5b46]"></p>
<p className="mt-2 text-2xl font-semibold text-[#7d2a15]">{summary.win_rate_percent.toFixed(1)}%</p>
</article>
<article className="panel-glow rounded-xl p-3">
<p className="text-xs text-[#7a5b46]">ROI</p>
<p className={`mt-2 text-2xl font-semibold ${summary.roi_percent >= 10 ? 'text-[#d1432d]' : 'text-[#7d2a15]'}`}>
{summary.roi_percent.toFixed(2)}%
</p>
</article>
<article className="panel-glow rounded-xl p-3">
<p className="text-xs text-[#7a5b46]"> CLV</p>
<p className="mt-2 dot-matrix text-2xl font-semibold text-[#7d2a15]">
{summary.avg_clv_percent.toFixed(2)}%
</p>
</article>
</section>
<article className="panel-glow rounded-2xl p-4">
<h3 className="dot-matrix text-lg text-[#7d2a15]"></h3>
<div className="mt-3 h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={curve} margin={{ top: 6, right: 18, bottom: 6, left: 8 }}>
<defs>
<linearGradient id="ledgerGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#d1432d" stopOpacity={0.45} />
<stop offset="100%" stopColor="#d1432d" stopOpacity={0.03} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="4 4" stroke="#eadcb9" />
<XAxis dataKey="ts" tick={{ fill: '#6d4d39', fontSize: 11 }} />
<YAxis tick={{ fill: '#6d4d39', fontSize: 11 }} />
<Tooltip
contentStyle={{ background: '#fff8e6', borderColor: '#d8b58c' }}
itemStyle={{ color: '#5f4031' }}
/>
<Area
type="monotone"
dataKey="cumulative"
stroke="#b83822"
fill="url(#ledgerGradient)"
strokeWidth={2.4}
/>
</AreaChart>
</ResponsiveContainer>
</div>
<p className="mt-2 text-xs text-[#8c2f2f]"> CLV{maxClv.toFixed(2)}%</p>
</article>
<article className="panel-glow rounded-2xl p-4">
<h3 className="dot-matrix text-lg text-[#7d2a15]"></h3>
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#e6cfad] text-[#7a5b46]">
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left">CLV</th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left">P/L</th>
</tr>
</thead>
<tbody>
{records.map((record) => (
<tr key={record.recommendation_id} className="border-b border-[#f2e4cb]">
<td className="py-2 text-[#5f4330]">{record.match_id}</td>
<td className="py-2 text-[#5f4330]">{record.market_type}</td>
<td className="py-2 text-[#5f4330]">{record.selection}</td>
<td className="py-2 text-[#5f4330]">{record.stake}</td>
<td className="py-2 text-[#5f4330]">{record.recommended_odds}</td>
<td className="py-2 text-[#5f4330]">{record.closing_odds}</td>
<td className="py-2 text-[#5f4330]">
{record.clv_percent === null ? '-' : `${record.clv_percent.toFixed(2)}%`}
</td>
<td className="py-2 text-[#5f4330]">{record.is_win ? '命中' : '未中'}</td>
<td className={`py-2 ${record.pnl >= 0 ? 'text-[#1a9a57]' : 'text-[#8c2f2f]'}`}>
{record.pnl >= 0 ? '+' : ''}{record.pnl.toFixed(2)}
</td>
</tr>
))}
{records.length === 0 ? (
<tr>
<td className="py-3 text-[#7a5b46]" colSpan={9}>
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</article>
</section>
);
}

View File

@@ -0,0 +1,78 @@
'use client';
import {
Legend,
PolarAngleAxis,
PolarGrid,
PolarRadiusAxis,
Radar,
RadarChart,
ResponsiveContainer,
} from 'recharts';
type MetricPoint = {
metric: string;
player: number;
opponent: number;
};
type Props = {
playerName: string;
opponentName: string;
metrics: {
攻擊: number;
運球: number;
組織: number;
壓迫: number;
對位完成: number;
穩定度: number;
};
};
export function PlayerMatchupRadar({ playerName, opponentName, metrics }: Props) {
const data: MetricPoint[] = [
{ metric: '攻擊', player: metrics.攻擊, opponent: Math.max(0, 100 - metrics.) },
{ metric: '運球', player: metrics.運球, opponent: Math.max(0, 100 - metrics.) },
{ metric: '組織', player: metrics.組織, opponent: Math.max(0, 100 - metrics.) },
{ metric: '壓迫', player: metrics.壓迫, opponent: Math.max(0, 100 - metrics.) },
{ metric: '對位完成', player: metrics.對位完成, opponent: Math.max(0, 100 - metrics.) },
{ metric: '穩定度', player: metrics.穩定度, opponent: Math.max(0, 100 - metrics.) },
];
return (
<article className="panel-glow rounded-2xl p-4">
<h3 className="dot-matrix text-xl text-[#7d2a15]">
{playerName} {opponentName}
</h3>
<div className="mt-3 h-80 w-full">
<ResponsiveContainer width="100%" height="100%">
<RadarChart data={data} outerRadius={110} cx="50%" cy="50%">
<PolarGrid stroke="#e0c6a8" />
<PolarAngleAxis dataKey="metric" tick={{ fill: '#6f4b36', fontSize: 12 }} />
<PolarRadiusAxis angle={30} domain={[0, 100]} />
<Radar
name={playerName}
dataKey="player"
stroke="#d1432d"
fill="#d1432d"
fillOpacity={0.35}
fillRule="evenodd"
/>
<Radar
name={opponentName}
dataKey="opponent"
stroke="#7a4a2c"
fill="#7a4a2c"
fillOpacity={0.2}
fillRule="evenodd"
/>
<Legend wrapperStyle={{ color: '#6f4f3c' }} />
</RadarChart>
</ResponsiveContainer>
</div>
<p className="mt-3 text-sm text-[#7a5b46]">
</p>
</article>
);
}

View File

@@ -0,0 +1,44 @@
'use client';
type Props = {
playerName: string;
metricLabel: string;
line: number;
overProbability: number;
impliedProb: number;
edgePercent: number;
topEdge: boolean;
};
function percent(value: number): string {
return `${(value * 100).toFixed(1)}%`;
}
export function PropValueCard({
playerName,
metricLabel,
line,
overProbability,
impliedProb,
edgePercent,
topEdge,
}: Props) {
return (
<article className={`panel-glow rounded-2xl border p-4 ${topEdge ? 'prop-top-edge' : ''}`}>
<p className="dot-matrix text-sm text-[#7d2a15]">{playerName} · {metricLabel}</p>
<h4 className="mt-1 text-2xl font-semibold text-[#7d2a15]">{line.toFixed(1)} </h4>
<div className="mt-3 space-y-1 text-sm text-[#7a5b46]">
<p> Over {percent(overProbability)}</p>
<p>{percent(impliedProb)}</p>
<p>{edgePercent > 0 ? '+' : ''}{edgePercent.toFixed(1)}%</p>
</div>
{topEdge ? (
<p className="mt-3 rounded-lg bg-[#f5c6b4]/60 p-2 text-xs text-[#8a2e17] dot-matrix">
Top Edge
</p>
) : (
<p className="mt-3 text-xs text-[#6f4f3c]"></p>
)}
</article>
);
}

View File

@@ -0,0 +1,60 @@
'use client';
import { useEffect } from 'react';
function toUint8Array(base64: string): Uint8Array {
const padding = '='.repeat((4 - (base64.length % 4)) % 4);
const base64Safe = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(base64Safe);
const buffer = new ArrayBuffer(raw.length);
const out = new Uint8Array(buffer);
for (let i = 0; i < raw.length; i += 1) {
out[i] = raw.charCodeAt(i);
}
return out;
}
export function PwaBootstrap() {
useEffect(() => {
if (typeof window === 'undefined' || !('serviceWorker' in navigator)) {
return;
}
const setup = async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js');
await registration.update();
if (Notification.permission === 'granted') {
const vapid = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
if (vapid) {
const existing = await registration.pushManager.getSubscription();
if (!existing) {
await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: toUint8Array(vapid),
});
}
}
}
navigator.serviceWorker.addEventListener('message', (event) => {
const payload = event.data;
if (payload?.type === 'push-trigger') {
// eslint-disable-next-line no-console
console.log('PWA push message', payload);
}
});
} catch (error) {
// eslint-disable-next-line no-console
console.warn('PWA bootstrap 失敗', error);
}
};
void setup();
}, []);
return null;
}

View File

@@ -0,0 +1,64 @@
'use client';
import { useMemo, useState } from 'react';
import { BookmakerCode, generateBetSlipUrl } from '@/lib/betting-utils';
const logoMap: Record<BookmakerCode, string> = {
bet365: 'Bet365',
pinnacle: 'Pinnacle',
draftkings: 'DraftKings',
};
type Props = {
bookmakerId: BookmakerCode;
matchId: string;
selection: string;
odds: number;
disabled?: boolean;
};
export function QuickBetButton({ bookmakerId, matchId, selection, odds, disabled = false }: Props) {
const [isRedirecting, setIsRedirecting] = useState(false);
const label = logoMap[bookmakerId];
const oddsText = odds.toFixed(2);
const href = useMemo(
() =>
generateBetSlipUrl({
bookmakerId,
matchId,
selection,
odds,
}),
[bookmakerId, matchId, selection, odds],
);
const handleClick = () => {
if (disabled) {
return;
}
setIsRedirecting(true);
window.open(href, '_blank', 'noopener,noreferrer');
window.setTimeout(() => setIsRedirecting(false), 900);
};
return (
<button
type="button"
onClick={handleClick}
disabled={disabled}
className={`group relative inline-flex min-w-40 items-center justify-center rounded-full bg-[#7d2a15] px-4 py-2 text-sm text-white transition ${
disabled ? 'cursor-not-allowed opacity-60' : 'hover:bg-[#5f1f11]'
}`}
>
<span className="dot-matrix text-sm">{isRedirecting ? '輸入中...' : `${label} ${oddsText}`}</span>
{!isRedirecting ? (
<span className="absolute -top-1 -right-2 hidden rounded-full bg-[#d1432d] px-2 py-0.5 text-[10px] text-white transition group-hover:inline">{oddsText}</span>
) : null}
{isRedirecting ? (
<span className="dot-matrix ml-2 inline-block dot-matrix-loading text-[10px]">10110</span>
) : null}
</button>
);
}

View File

@@ -0,0 +1,85 @@
'use client';
import React from 'react';
export interface RlmAlertData {
matchName: string;
market: string;
selection: string;
ticketPct: number; // 散戶注單佔比 (例如 82%)
handlePct: number; // 大戶資金佔比 (例如 15%)
openingOdds: number;
currentOdds: number;
}
interface RlmRadarBoardProps {
alerts: RlmAlertData[];
}
export default function RlmRadarBoard({ alerts }: RlmRadarBoardProps) {
if (!alerts || alerts.length === 0) {
return (
<div className="w-full bg-stone-900 p-6 rounded-lg border border-stone-700 shadow-xl flex items-center justify-center min-h-[200px]">
<p className="text-stone-500 font-dotmatrix text-lg tracking-widest uppercase">
[ ]
</p>
</div>
);
}
return (
<div className="w-full bg-stone-900 p-6 rounded-lg border border-quant-red shadow-2xl relative overflow-hidden">
{/* 背景警示條紋 */}
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-quant-red via-quant-orange to-quant-red animate-pulse-fast"></div>
<div className="mb-6 flex items-center justify-between">
<div>
<h2 className="text-2xl text-quant-red font-dotmatrix animate-blink uppercase font-bold tracking-wider">
🚨 (RLM)
</h2>
<p className="text-stone-400 text-sm mt-1"></p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{alerts.map((alert, index) => (
<div key={index} className="bg-stone-800 p-4 rounded border border-stone-700 hover:border-quant-orange transition-colors">
<h3 className="text-stone-200 font-bold mb-3">{alert.matchName} | {alert.market}</h3>
<div className="space-y-4">
{/* 資金流向對比 */}
<div>
<div className="flex justify-between text-xs font-dotmatrix uppercase text-stone-400 mb-1">
<span> (Tickets)</span>
<span className="text-stone-200">{alert.ticketPct}%</span>
</div>
<div className="w-full h-2 bg-stone-700 rounded overflow-hidden">
<div className="h-full bg-stone-500" style={{ width: `${alert.ticketPct}%` }}></div>
</div>
</div>
<div>
<div className="flex justify-between text-xs font-dotmatrix uppercase text-quant-orange mb-1">
<span> (Handle)</span>
<span>{alert.handlePct}%</span>
</div>
<div className="w-full h-2 bg-stone-700 rounded overflow-hidden">
<div className="h-full bg-quant-orange" style={{ width: `${alert.handlePct}%` }}></div>
</div>
</div>
{/* 賠率變動對比 */}
<div className="mt-4 pt-3 border-t border-stone-700 flex justify-between items-center">
<span className="text-sm text-stone-400"> [{alert.selection}]</span>
<div className="flex items-center space-x-3 font-dotmatrix text-lg">
<span className="text-stone-500 line-through">{alert.openingOdds.toFixed(2)}</span>
<span className="text-quant-red animate-pulse-fast font-bold"> {alert.currentOdds.toFixed(2)}</span>
</div>
</div>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,72 @@
'use client';
import React, { useState, useEffect } from 'react';
import Image, { ImageProps } from 'next/image';
interface TransparentImageProps extends Omit<ImageProps, 'src'> {
src: string;
alt: string;
teamOrBrandName?: string;
theme?: 'light' | 'dark'; // bg-stone-50 vs bg-stone-900
}
/**
* 字串過濾器 (String Formatter)
* 確保專有名詞的大小寫與字元間距嚴格對齊
*/
export const formatBrandName = (name: string) => {
return name.toUpperCase().replace(/\s+/g, ' ').trim();
};
export default function TransparentImage({
src,
alt,
teamOrBrandName,
theme = 'dark',
className = '',
...props
}: TransparentImageProps) {
const [isValidAlpha, setIsValidAlpha] = useState<boolean | null>(null);
useEffect(() => {
// 實務上這裡可以加上 Canvas 檢查,掃描圖片角落是否有典型的實體灰白棋盤格特徵
// 或者只允許載入已經過後端白名單校驗的 CDN 圖片
// 這裡我們假設 src 來自信任的 CDN 或本地 public
setIsValidAlpha(true);
}, [src]);
// 防呆機制:若檢測到無效透明度,顯示佔位符
if (isValidAlpha === false) {
return (
<div className={`flex flex-col items-center justify-center border border-quant-red border-dashed rounded ${className} p-2`}>
<span className="text-quant-red font-dotmatrix text-[10px] uppercase">Alpha Error</span>
</div>
);
}
// Anti-aliasing 最佳化樣式
const edgeSmoothing = theme === 'dark' ? 'mix-blend-plus-lighter' : 'mix-blend-multiply';
return (
<div className={`flex flex-col items-center ${className}`}>
<div className={`relative ${props.width ? `w-[${props.width}px]` : 'w-full'} ${props.height ? `h-[${props.height}px]` : 'h-full'}`}>
<Image
src={src}
alt={alt}
className={`object-contain ${edgeSmoothing}`}
{...props}
/>
</div>
{teamOrBrandName && (
<span className="mt-2 text-sm font-dotmatrix tracking-widest uppercase">
{theme === 'dark' ? (
<span className="text-stone-300">{formatBrandName(teamOrBrandName)}</span>
) : (
<span className="text-stone-800">{formatBrandName(teamOrBrandName)}</span>
)}
</span>
)}
</div>
);
}