feat: Add OddsLineMovementChart and KellyBetSizing components

This commit is contained in:
QuantBot
2026-06-13 23:32:48 +08:00
parent 073abf98c1
commit 57bd7539ce
2 changed files with 158 additions and 62 deletions

View File

@@ -0,0 +1,90 @@
'use client';
import React, { useState, useMemo } from 'react';
interface KellyBetSizingProps {
trueProb: number; // 系統計算的真實勝率 (0.0 - 1.0)
decimalOdds: number; // 莊家賠率
}
export default function KellyBetSizing({ trueProb, decimalOdds }: KellyBetSizingProps) {
const [bankroll, setBankroll] = useState<number>(10000);
const [kellyFraction, setKellyFraction] = useState<number>(0.25); // 預設 1/4 凱利
// 核心計算邏輯 (套用凱利公式)
const suggestedStake = useMemo(() => {
const b = decimalOdds - 1.0;
const p = trueProb;
const q = 1.0 - p;
if (b <= 0) return 0;
const fullKellyPct = ((b * p) - q) / b;
if (fullKellyPct <= 0) return 0;
return Math.round(bankroll * fullKellyPct * kellyFraction);
}, [bankroll, kellyFraction, decimalOdds, trueProb]);
// 動態樣式判斷
const isHighRisk = kellyFraction > 0.5;
const highlightColor = isHighRisk ? 'text-quant-red' : 'text-quant-orange';
return (
<div className="w-full bg-stone-50 p-6 rounded-lg border border-stone-200 shadow-sm">
<div className="flex justify-between items-center border-b border-stone-200 pb-4 mb-6">
<div>
<h3 className="text-sm text-stone-500 uppercase tracking-wider font-semibold">
Kelly Criterion
</h3>
<h2 className="text-xl text-stone-900 font-bold"></h2>
</div>
<div className="text-right">
<p className="text-sm text-stone-500 mb-1"></p>
<p className={`text-4xl font-dotmatrix transition-colors duration-300 ${highlightColor}`}>
$ {suggestedStake.toLocaleString()}
</p>
</div>
</div>
{/* 控制區:總資金 */}
<div className="mb-6">
<label className="block text-sm text-stone-600 font-medium mb-2">
(Bankroll): <span className="font-dotmatrix ml-2 text-lg">${bankroll.toLocaleString()}</span>
</label>
<input
type="range"
min="1000"
max="100000"
step="1000"
value={bankroll}
onChange={(e) => setBankroll(Number(e.target.value))}
className="w-full h-2 bg-stone-200 rounded-lg appearance-none cursor-pointer accent-quant-orange"
/>
</div>
{/* 控制區:風險偏好 (分數凱利) */}
<div className="mb-2">
<label className="block text-sm text-stone-600 font-medium mb-2">
(Kelly Multiplier):
<span className={`font-dotmatrix ml-2 text-lg ${isHighRisk ? 'text-quant-red' : 'text-stone-900'}`}>
{kellyFraction}x
</span>
</label>
<input
type="range"
min="0.1"
max="1.0"
step="0.05"
value={kellyFraction}
onChange={(e) => setKellyFraction(Number(e.target.value))}
className="w-full h-2 bg-stone-200 rounded-lg appearance-none cursor-pointer accent-stone-700"
/>
<div className="flex justify-between text-xs text-stone-400 mt-2 font-dotmatrix uppercase">
<span>Conservative (0.1x)</span>
<span>Optimal (0.25x)</span>
<span className="text-quant-red">Full Risk (1.0x)</span>
</div>
</div>
</div>
);
}

View File

@@ -1,77 +1,83 @@
'use client';
import React from 'react';
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer
} 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),
);
// 定義傳入組件的歷史賠率資料介面
export interface OddsDataPoint {
time: string; // 格式化後的時間 (如: 14:30)
odds: number; // 小數賠率
}
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'];
interface OddsLineMovementChartProps {
data: OddsDataPoint[];
teamName: string;
}
export default function OddsLineMovementChart({ data, teamName }: OddsLineMovementChartProps) {
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 className="w-full h-72 bg-stone-50 p-4 rounded-lg border border-stone-200 shadow-sm">
<div className="mb-4">
<h3 className="text-sm text-stone-500 uppercase tracking-wider font-semibold">
Line Movement
</h3>
<h2 className="text-xl text-stone-900 font-dotmatrix">
{teamName}
</h2>
</div>
<ResponsiveContainer width="100%" height="80%">
<AreaChart data={data} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
<defs>
{/* 定義暖橘色漸層填充,增強視覺專業感 */}
<linearGradient id="colorOdds" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#ea580c" stopOpacity={0.3}/>
<stop offset="95%" stopColor="#ea580c" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e5e5e5" />
<XAxis
dataKey="time"
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: '#78716c' }}
dy={10}
/>
<YAxis
domain={['dataMin - 0.1', 'dataMax + 0.1']}
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: '#78716c' }}
tickFormatter={(val) => val.toFixed(2)}
/>
<Tooltip
contentStyle={{
backgroundColor: '#1A1A1A',
borderColor: '#ea580c',
color: '#FAF6F0',
fontFamily: '"DotGothic16", monospace'
}}
itemStyle={{ color: '#ea580c' }}
/>
<Area
type="monotone"
dataKey="odds"
stroke="#ea580c"
strokeWidth={3}
fillOpacity={1}
fill="url(#colorOdds)"
isAnimationActive={true}
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
}