feat: Add OddsLineMovementChart and KellyBetSizing components
This commit is contained in:
90
platform/web/components/KellyBetSizing.tsx
Normal file
90
platform/web/components/KellyBetSizing.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user