169 lines
5.4 KiB
TypeScript
169 lines
5.4 KiB
TypeScript
'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>
|
||
);
|
||
}
|