Initial commit with 2026 World Cup Quant Platform core modules and CI/CD
This commit is contained in:
37
platform/web/components/ActionableBetCard.tsx
Normal file
37
platform/web/components/ActionableBetCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
168
platform/web/components/BetSizingSlider.tsx
Normal file
168
platform/web/components/BetSizingSlider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
211
platform/web/components/BettingLeaksDashboard.tsx
Normal file
211
platform/web/components/BettingLeaksDashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
platform/web/components/EquityCurveChart.tsx
Normal file
57
platform/web/components/EquityCurveChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
platform/web/components/HedgeAlert.tsx
Normal file
40
platform/web/components/HedgeAlert.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
platform/web/components/LeaderboardBoard.tsx
Normal file
72
platform/web/components/LeaderboardBoard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
platform/web/components/LiveMatchCenter.tsx
Normal file
85
platform/web/components/LiveMatchCenter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
61
platform/web/components/MatchConditionsCard.tsx
Normal file
61
platform/web/components/MatchConditionsCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
41
platform/web/components/MobileBottomNav.tsx
Normal file
41
platform/web/components/MobileBottomNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
platform/web/components/MoneyFlowBar.tsx
Normal file
43
platform/web/components/MoneyFlowBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
77
platform/web/components/OddsLineMovementChart.tsx
Normal file
77
platform/web/components/OddsLineMovementChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
156
platform/web/components/PerformanceLedger.tsx
Normal file
156
platform/web/components/PerformanceLedger.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
78
platform/web/components/PlayerMatchupRadar.tsx
Normal file
78
platform/web/components/PlayerMatchupRadar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
platform/web/components/PropValueCard.tsx
Normal file
44
platform/web/components/PropValueCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
platform/web/components/PwaBootstrap.tsx
Normal file
60
platform/web/components/PwaBootstrap.tsx
Normal 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;
|
||||
}
|
||||
64
platform/web/components/QuickBetButton.tsx
Normal file
64
platform/web/components/QuickBetButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
platform/web/components/RLMRadarBoard.tsx
Normal file
85
platform/web/components/RLMRadarBoard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
platform/web/components/TransparentImage.tsx
Normal file
72
platform/web/components/TransparentImage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user