307 lines
16 KiB
TypeScript
307 lines
16 KiB
TypeScript
'use client';
|
||
|
||
import type { DailyCardItem } from '@/lib/analytics-api';
|
||
|
||
type ActionableBetCardProps = {
|
||
item: DailyCardItem;
|
||
onAddToSlip: (item: DailyCardItem) => void;
|
||
isInSlip?: boolean;
|
||
className?: string;
|
||
};
|
||
|
||
function isConditionalPick(item: DailyCardItem): boolean {
|
||
return (
|
||
item.has_market_odds === false ||
|
||
item.selection.includes('預掛條件') ||
|
||
item.selection.includes('參考盤監控') ||
|
||
item.rationale.includes('尚未取得可用即時盤口') ||
|
||
item.rationale.includes('尚未取得完整實盤賠率') ||
|
||
Boolean(item.legs?.some((leg) => leg.selection.includes('預掛條件')))
|
||
);
|
||
}
|
||
|
||
function isReferenceMarket(item: DailyCardItem): boolean {
|
||
return item.odds_source_kind === 'reference_market';
|
||
}
|
||
|
||
function recommendationLabel(item: DailyCardItem): string {
|
||
const isConditional = isConditionalPick(item);
|
||
|
||
if (item.recommendation === 'SAFE_SINGLE') return isConditional ? '觀察單關' : '核心單關';
|
||
if (item.recommendation === 'HIGH_RISK_SINGLE') return '小注高賠';
|
||
if (item.recommendation === 'SAFE_PARLAY') return isConditional ? '觀察串關' : '跨場串關';
|
||
if (item.recommendation === 'SGP_LOTTERY') return '同場小注';
|
||
return '研究候選';
|
||
}
|
||
|
||
function riskLabel(value?: string): string {
|
||
if (value === 'core') return '核心候選';
|
||
if (value === 'speculative') return '小注高波動';
|
||
if (value === 'parlay') return '串關組合';
|
||
if (value === 'sgp') return '同場高波動';
|
||
return '研究候選';
|
||
}
|
||
|
||
function betMode(item: DailyCardItem): string {
|
||
if (item.recommendation === 'SAFE_PARLAY') return '到投注平台選「串關」,依下方每一腿逐一加入。';
|
||
if (item.recommendation === 'SGP_LOTTERY') return '到投注平台選「同場串關」,只用很小的注碼。';
|
||
return '到投注平台選「單關」,找到同一場比賽與同一個市場後下注。';
|
||
}
|
||
|
||
function executionSteps(item: DailyCardItem): string[] {
|
||
if (item.recommendation === 'SAFE_PARLAY') {
|
||
return [
|
||
'在投注平台選擇「串關」,不要用單關賠率硬湊。',
|
||
'逐一加入下方每一腿,任一腿的場次、玩法、分線或選項不同就整組放棄。',
|
||
`總賠率與每腿賠率都要達到門檻,參考上限不要超過 ${formatTwd(stakeAmountTwd(item))}。`,
|
||
];
|
||
}
|
||
|
||
if (item.recommendation === 'SGP_LOTTERY') {
|
||
return [
|
||
'在投注平台選擇「同場串關」,確認所有選項都來自同一場比賽。',
|
||
'平台若沒有完全相同的同場組合或分線,不要用相似選項替代。',
|
||
`同場串關波動較高,只能小注,參考上限不要超過 ${formatTwd(stakeAmountTwd(item))}。`,
|
||
];
|
||
}
|
||
|
||
return [
|
||
'在投注平台選擇「單關」,找到同一場比賽。',
|
||
`選擇「${item.market_type}」裡的「${item.selection}」,並確認分線一致。`,
|
||
`只有賠率大於或等於 ${item.target_odds.toFixed(2)} 才考慮,參考上限不要超過 ${formatTwd(stakeAmountTwd(item))}。`,
|
||
];
|
||
}
|
||
|
||
function formatTwd(value: number): string {
|
||
return new Intl.NumberFormat('zh-TW', {
|
||
style: 'currency',
|
||
currency: 'TWD',
|
||
maximumFractionDigits: 0,
|
||
}).format(value);
|
||
}
|
||
|
||
function stakeAmountTwd(item: DailyCardItem): number {
|
||
const unitSize = item.unit_size_twd ?? 1000;
|
||
return item.stake_amount_twd ?? Math.round(item.stake_units * unitSize);
|
||
}
|
||
|
||
function stakeGuide(item: DailyCardItem): string {
|
||
const unitSize = item.unit_size_twd ?? 1000;
|
||
return `建議參考上限 ${formatTwd(stakeAmountTwd(item))},約 ${item.stake_units.toFixed(2)}u,系統目前以 1u 等於 ${formatTwd(unitSize)} 換算。`;
|
||
}
|
||
|
||
function oddsRule(item: DailyCardItem): string {
|
||
const source = item.odds_source_label ? `目前來源:${item.odds_source_label}。` : '';
|
||
return `${source}只有在你拿到的賠率大於或等於 ${item.target_odds.toFixed(2)} 時才考慮下注;如果平台賠率低於這個數字,期望值會被吃掉,直接跳過。`;
|
||
}
|
||
|
||
function executionStatusLabel(item: DailyCardItem): string {
|
||
if (isReferenceMarket(item)) return '台灣盤參考';
|
||
return isConditionalPick(item) ? '預掛條件' : '實盤可檢查';
|
||
}
|
||
|
||
function executionStatusDetail(item: DailyCardItem): string {
|
||
if (isReferenceMarket(item)) {
|
||
return '這張已有台灣運彩公開盤作為參考價,可以用來比對最低可接受賠率;但它仍不是多莊家正式盤,下注前要再確認同一玩法、同一分線與最新盤口。';
|
||
}
|
||
|
||
if (isConditionalPick(item)) {
|
||
return '目前尚未取得完整實盤或分線盤口,這張不是叫你立刻下注,而是先設定最低賠率門檻。等平台開盤後,只有賠率達標才進場。';
|
||
}
|
||
|
||
return '這張已有可比對的盤口資料,仍需在下注前確認同一市場、同一選項、同一分線與最低可接受賠率都一致。';
|
||
}
|
||
|
||
function dataQualityLabel(item: DailyCardItem): string {
|
||
if (isReferenceMarket(item)) return '資料品質:台灣運彩參考盤,已和模型門檻比對';
|
||
if (item.data_quality === 'rank_elo_prior') return '資料品質:國際排名與實力分數估計,信心已降低';
|
||
if (item.data_quality === 'fallback_prior') return '資料品質:基礎估計,僅能觀察';
|
||
if (item.data_quality === 'mixed') return '資料品質:串關腿數混合來源';
|
||
if (isConditionalPick(item)) return '資料品質:等待實盤確認';
|
||
if (item.data_checks?.some((check) => check.includes('去水') || check.includes('莊家'))) return '資料品質:已檢查平台抽成影響';
|
||
return '資料品質:基礎盤口檢查';
|
||
}
|
||
|
||
function plainCheckLabel(check: string): string {
|
||
return check
|
||
.replaceAll('Poisson', '進球分布')
|
||
.replaceAll('xG', '預期進球')
|
||
.replaceAll('EV', '期望值')
|
||
.replaceAll('edge', '模型優勢')
|
||
.replaceAll('CLV', '收盤價差')
|
||
.replaceAll('FIFA 排名/Elo', '國際排名與實力分數')
|
||
.replaceAll('FIFA/Elo', '國際排名與實力分數')
|
||
.replaceAll('市場隱含機率', '市場估計機率')
|
||
.replaceAll('正 期望值', '期望值為正')
|
||
.replaceAll('正期望值', '期望值為正')
|
||
.replaceAll('小倉位', '小注碼')
|
||
.replaceAll('預掛總賠率門檻', '等待總賠率達標')
|
||
.replaceAll('串關 EV 重新計算', '串關期望值重新計算')
|
||
.replaceAll('高 期望值 門檻', '高期望值門檻');
|
||
}
|
||
|
||
function plainLanguage(item: DailyCardItem): string {
|
||
const implied = typeof item.market_implied_prob === 'number' ? item.market_implied_prob.toFixed(2) : null;
|
||
const edge = typeof item.edge_percent === 'number' ? item.edge_percent.toFixed(2) : null;
|
||
const isConditional = isConditionalPick(item);
|
||
|
||
if (implied && edge) {
|
||
const quality = item.data_quality && item.data_quality !== 'observed' ? ' 但此項目前資料品質不是完整即時盤,信心與倉位已被系統折扣。' : '';
|
||
const sourceNote = isReferenceMarket(item) ? ' 這裡的市場機率來自台灣運彩參考盤,不是多莊家共識。' : '';
|
||
if (isConditional) {
|
||
return `模型估這個選項有 ${item.win_prob.toFixed(2)}% 機率打出;目前還沒有完整可驗證賠率,所以 ${implied}% 代表「最低可接受賠率」換算出的門檻,不是真實市場共識。模型多看好 ${edge} 個百分點,期望值 ${item.ev_percent.toFixed(2)}% 代表長期同類型條件可能有優勢,不代表這場一定會中。${quality}`;
|
||
}
|
||
return `模型估這個選項有 ${item.win_prob.toFixed(2)}% 機率打出,市場目前大約只反映 ${implied}% 的機率,等於模型多看好 ${edge} 個百分點。期望值 ${item.ev_percent.toFixed(2)}% 代表長期同類型下注的平均報酬為正,不代表這場一定會中。${sourceNote}${quality}`;
|
||
}
|
||
|
||
return `模型估這個選項有 ${item.win_prob.toFixed(2)}% 機率打出,期望值 ${item.ev_percent.toFixed(2)}%。這是長期平均報酬判斷,不是單場保證;若資料品質不是完整即時盤,請只列入觀察。`;
|
||
}
|
||
|
||
export function ActionableBetCard({ item, onAddToSlip, isInSlip = false, className = '' }: ActionableBetCardProps) {
|
||
const confidence = typeof item.confidence_score === 'number' ? item.confidence_score : null;
|
||
const checks = item.data_checks ?? [];
|
||
const confidenceFactors = item.confidence_factors ?? [];
|
||
const isCombo = item.legs?.length;
|
||
const isConditional = isConditionalPick(item);
|
||
const referenceMarket = isReferenceMarket(item);
|
||
const actionLabel = isInSlip
|
||
? isConditional ? '已在賠率監控清單' : '已在下注前檢查清單'
|
||
: isConditional ? '加入賠率監控清單' : '加入下注前檢查清單';
|
||
|
||
return (
|
||
<article className={`group relative overflow-hidden rounded-2xl border border-[#e7c89b] bg-[#fff8e6] p-5 shadow-[0_18px_48px_rgba(125,42,21,0.12)] transition hover:border-[#d1432d] ${className}`}>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<p className="dot-matrix text-xs font-semibold tracking-wider text-[#b83822]">{recommendationLabel(item)}</p>
|
||
<span className="rounded-full border border-[#d8b58c] bg-white/70 px-2 py-1 text-[11px] font-semibold text-[#7d2a15]">{riskLabel(item.risk_level)}</span>
|
||
<span className={`rounded-full border px-2 py-1 text-[11px] font-semibold ${
|
||
isConditional
|
||
? 'border-[#b88700]/40 bg-[#fff7d6] text-[#8a6400]'
|
||
: referenceMarket
|
||
? 'border-[#d8b58c] bg-[#fff0e2] text-[#7d2a15]'
|
||
: 'border-[#1a9a57]/30 bg-[#e9f8ef] text-[#167a47]'
|
||
}`}>
|
||
{executionStatusLabel(item)}
|
||
</span>
|
||
{item.odds_source_label ? (
|
||
<span className="rounded-full border border-[#d8b58c] bg-white/70 px-2 py-1 text-[11px] font-semibold text-[#5f4330]">
|
||
{item.odds_source_label}
|
||
</span>
|
||
) : null}
|
||
{confidence !== null ? (
|
||
<span className="rounded-full border border-[#1a9a57]/30 bg-[#e9f8ef] px-2 py-1 text-[11px] font-semibold text-[#167a47]">
|
||
信心 {confidence.toFixed(1)}
|
||
</span>
|
||
) : null}
|
||
{item.confidence_band ? (
|
||
<span className="rounded-full border border-[#d8b58c] bg-white/70 px-2 py-1 text-[11px] font-semibold text-[#7d2a15]">
|
||
{item.confidence_band}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
|
||
<h3 className="mt-3 text-xl font-black text-[#3f2f25]">{item.match_label}</h3>
|
||
<p className="mt-1 text-sm text-[#7a5b46]">投注市場:{item.market_type}</p>
|
||
<p className="mt-1 text-base font-bold text-[#b83822]">投注選項:{item.selection}</p>
|
||
|
||
<section className={`mt-4 rounded-2xl border p-4 ${
|
||
isConditional ? 'border-[#e7c462] bg-[#fff7d6]' : 'border-[#9bd8b0] bg-[#e9f8ef]'
|
||
}`}>
|
||
<p className="dot-matrix text-sm font-bold text-[#7d2a15]">推薦狀態</p>
|
||
<p className="mt-2 text-sm leading-6 text-[#5f4330]">{executionStatusDetail(item)}</p>
|
||
<p className="mt-2 text-xs font-bold text-[#7a5b46]">{dataQualityLabel(item)}</p>
|
||
</section>
|
||
|
||
<section className="mt-4 rounded-2xl border border-[#eadcb9] bg-white/75 p-4">
|
||
<p className="dot-matrix text-sm font-bold text-[#7d2a15]">下注方式</p>
|
||
<p className="mt-2 text-sm leading-6 text-[#5f4330]">{betMode(item)}</p>
|
||
<div className="mt-3 grid gap-2 text-sm text-[#5f4330] md:grid-cols-2">
|
||
<p>市場:<span className="font-bold text-[#3f2f25]">{item.market_type}</span></p>
|
||
<p>選項:<span className="font-bold text-[#3f2f25]">{item.selection}</span></p>
|
||
<p>最低可接受賠率:<span className="font-bold text-[#b83822]">{item.target_odds.toFixed(2)}</span></p>
|
||
<p>建議上限:<span className="font-bold text-[#167a47]">{formatTwd(stakeAmountTwd(item))}</span></p>
|
||
</div>
|
||
<div className="mt-3 rounded-xl border border-[#eadcb9] bg-[#fff8e6] p-3">
|
||
<p className="text-xs font-black text-[#7d2a15]">實際操作三步驟</p>
|
||
<ol className="mt-2 space-y-1 text-sm leading-6 text-[#5f4330]">
|
||
{executionSteps(item).map((step, index) => (
|
||
<li key={step}>第 {index + 1} 步:{step}</li>
|
||
))}
|
||
</ol>
|
||
</div>
|
||
<p className="mt-3 rounded-xl bg-[#fff0e2] p-3 text-sm leading-6 text-[#7d2a15]">{oddsRule(item)}</p>
|
||
<p className="mt-2 text-xs leading-5 text-[#8a6b58]">{stakeGuide(item)}</p>
|
||
</section>
|
||
|
||
{isCombo ? (
|
||
<section className="mt-4 rounded-2xl border border-[#eadcb9] bg-white/75 p-4">
|
||
<p className="dot-matrix text-sm font-bold text-[#7d2a15]">串關組成</p>
|
||
<div className="mt-2 space-y-2 text-sm text-[#5f4330]">
|
||
{item.legs?.map((leg, index) => (
|
||
<p key={`${leg.match_id}-${leg.selection}`}>
|
||
第 {index + 1} 腿:<span className="font-bold text-[#3f2f25]">{leg.selection}</span>,賠率至少 {leg.odds.toFixed(2)}
|
||
</p>
|
||
))}
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
<section className="mt-4 rounded-2xl border border-[#eadcb9] bg-white/75 p-4">
|
||
<p className="dot-matrix text-sm font-bold text-[#7d2a15]">白話分析</p>
|
||
<p className="mt-2 text-sm leading-7 text-[#5f4330]">{plainLanguage(item)}</p>
|
||
<div className="mt-3 grid gap-2 text-sm text-[#5f4330] md:grid-cols-2">
|
||
<p>模型勝率:<span className="font-bold text-[#167a47]">{item.win_prob.toFixed(2)}%</span></p>
|
||
{typeof item.market_implied_prob === 'number' ? (
|
||
<p>{isConditional ? '賠率門檻換算機率' : '市場估計機率'}:<span className="font-bold text-[#3f2f25]">{item.market_implied_prob.toFixed(2)}%</span></p>
|
||
) : null}
|
||
{typeof item.edge_percent === 'number' ? <p>模型優勢:<span className="font-bold text-[#b88700]">{item.edge_percent.toFixed(2)} 百分點</span></p> : null}
|
||
<p>預期期望值:<span className="font-bold text-[#167a47]">{item.ev_percent.toFixed(2)}%</span></p>
|
||
</div>
|
||
</section>
|
||
|
||
{confidenceFactors.length || checks.length ? (
|
||
<section className="mt-4 rounded-2xl border border-[#eadcb9] bg-white/75 p-4">
|
||
<p className="dot-matrix text-sm font-bold text-[#7d2a15]">信心與計算依據</p>
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
{confidenceFactors.slice(0, 5).map((factor) => (
|
||
<span key={factor} className="rounded-full bg-[#e9f8ef] px-3 py-1 text-xs font-semibold text-[#167a47]">
|
||
{plainCheckLabel(factor)}
|
||
</span>
|
||
))}
|
||
{checks.slice(0, 6).map((check) => (
|
||
<span key={check} className="rounded-full bg-[#fff0e2] px-3 py-1 text-xs font-semibold text-[#7a5b46]">
|
||
{plainCheckLabel(check)}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
<section className="mt-4 rounded-2xl border border-[#d8b58c] bg-[#fff0e2] p-4">
|
||
<p className="dot-matrix text-sm font-bold text-[#7d2a15]">風險提醒</p>
|
||
<p className="mt-2 text-sm leading-6 text-[#7a5b46]">
|
||
這是研究候選,不是結果承諾。若賠率低於最低可接受賠率、先發名單異常、傷停新聞改變、或資料源延遲,就不要下注,等待下一次刷新。
|
||
</p>
|
||
<p className="mt-2 text-sm leading-6 text-[#7a5b46]">
|
||
下注前最後確認:同一場比賽、同一市場、同一分線、同一選項、賠率達標、倉位不超過建議新台幣上限。若你找不到完全相同的玩法,就不要改買相似玩法。
|
||
</p>
|
||
</section>
|
||
|
||
<button
|
||
type="button"
|
||
aria-pressed={isInSlip}
|
||
className={`mt-4 inline-block w-full rounded-full px-4 py-3 text-sm font-black text-white transition ${
|
||
isInSlip ? 'bg-[#167a47] hover:bg-[#11613a]' : 'bg-[#d1432d] hover:bg-[#b83822]'
|
||
}`}
|
||
onClick={() => onAddToSlip(item)}
|
||
>
|
||
{actionLabel}
|
||
</button>
|
||
{isInSlip ? (
|
||
<p role="status" className="mt-3 rounded-xl border border-[#9bd8b0] bg-[#e9f8ef] p-3 text-center text-xs font-bold text-[#167a47]">
|
||
已加入清單,請到本頁上方「賠率監控清單」集中檢查。
|
||
</p>
|
||
) : null}
|
||
</article>
|
||
);
|
||
}
|