211 lines
8.2 KiB
TypeScript
211 lines
8.2 KiB
TypeScript
'use client';
|
||
|
||
import { useMemo, useState } from 'react';
|
||
import {
|
||
Area,
|
||
AreaChart,
|
||
CartesianGrid,
|
||
ResponsiveContainer,
|
||
Tooltip,
|
||
XAxis,
|
||
YAxis,
|
||
} from 'recharts';
|
||
|
||
import type {
|
||
PortfolioLeakCluster,
|
||
PortfolioLeaksResponse,
|
||
} from '@/lib/analytics-api';
|
||
|
||
type BettingLeaksDashboardProps = {
|
||
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 }: BettingLeaksDashboardProps) {
|
||
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]">整體報酬率</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]'
|
||
}`}
|
||
>
|
||
僅看「正 期望值 且個人勝率高」
|
||
</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">報酬率</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>
|
||
);
|
||
}
|