diff --git a/platform/web/app/page.tsx b/platform/web/app/page.tsx index 244c8c6..11fe60f 100644 --- a/platform/web/app/page.tsx +++ b/platform/web/app/page.tsx @@ -107,6 +107,49 @@ function nextStepText(item: DailyCardItem): string { return '可進入下注前檢查。下單前仍要確認同一場、同一玩法、同一選項與賠率門檻。'; } +function pickMatchKey(item: DailyCardItem): string { + return String(item.match_id ?? item.match_label).trim().toLowerCase(); +} + +function pickMarketGroup(item: DailyCardItem): string { + if (item.recommendation === 'SAFE_PARLAY') return '跨場串關'; + if (item.recommendation === 'SGP_LOTTERY') return '同場串關'; + if (item.recommendation === 'HIGH_RISK_SINGLE') return '小注高賠'; + return item.market_type || '單關'; +} + +function diversifyPicks(items: DailyCardItem[], limit: number): DailyCardItem[] { + const selected: DailyCardItem[] = []; + const seen = new Set(); + const matchCounts = new Map(); + const matchMarketSeen = new Set(); + + const tryAdd = (item: DailyCardItem, maxPerMatch: number, enforceMarketSpread: boolean) => { + if (selected.length >= limit) return; + const key = pickKey(item); + if (seen.has(key)) return; + const matchKey = pickMatchKey(item); + const marketKey = `${matchKey}|${pickMarketGroup(item)}`; + if ((matchCounts.get(matchKey) ?? 0) >= maxPerMatch) return; + if (enforceMarketSpread && matchMarketSeen.has(marketKey)) return; + + selected.push(item); + seen.add(key); + matchCounts.set(matchKey, (matchCounts.get(matchKey) ?? 0) + 1); + matchMarketSeen.add(marketKey); + }; + + items.forEach((item) => tryAdd(item, 2, true)); + items.forEach((item) => tryAdd(item, 3, false)); + return selected.slice(0, limit); +} + +function shortBetInstruction(item: DailyCardItem): string { + if (item.recommendation === 'SAFE_PARLAY') return '到投注平台選「串關」,把每一腿分開加入;任一腿賠率不達標就整組跳過。'; + if (item.recommendation === 'SGP_LOTTERY') return '到投注平台選「同場串關」,只用小注碼;若平台沒有同樣分線就不要替代。'; + return '到投注平台選「單關」,確認同一場、同一市場、同一選項,再比對最低可接受賠率。'; +} + function buildGateState( sourceHealth: SourceHealthResponse | null, errors: LoadErrors, @@ -407,7 +450,7 @@ export default function Home() { const balanced = [...singles.slice(0, 3), ...parlays.slice(0, 2), ...sameGame.slice(0, 1), ...highRisk.slice(0, 2)]; const used = new Set(balanced.map(pickKey)); const remaining = rank(recommendationItems).filter((item) => !used.has(pickKey(item))); - return [...balanced, ...remaining].slice(0, 10); + return diversifyPicks([...balanced, ...remaining], 10); }, [recommendationItems]); const categorySummary = useMemo(() => { @@ -478,7 +521,7 @@ export default function Home() { } const next = [item, ...watchlist].slice(0, 20); setWatchlist(next); - setFeedbackMessage(`已加入${isConditionalPick(item) || !item.has_market_odds ? '賠率監控清單' : '下注前檢查清單'}:${item.match_label} | ${item.selection}`); + setFeedbackMessage(`已加入${isConditionalPick(item) || !item.has_market_odds ? '賠率監控清單' : '下注前檢查清單'}:${item.match_label} | ${item.selection}。最低賠率 ${item.target_odds.toFixed(2)},參考上限 ${formatTwd(stakeAmountTwd(item))}。`); try { window.localStorage.setItem(WATCHLIST_KEY, JSON.stringify(next)); } catch { @@ -506,7 +549,7 @@ export default function Home() {

首頁第一屏 / {dateWindow.today} - {dateWindow.tomorrow}

下一批可檢查投注候選

- 一打開首頁先看這裡:系統會先看今天,若今天已無可下注賽事,就自動切到明天有資料的場次。若資料不足,卡片會標成「預掛觀察」,只代表先盯賠率門檻,不是叫你立刻下注。 + 一打開首頁先看這裡:系統會優先顯示今天與明天,再依日期列出後續可分析賽事。清單會自動分散場次與玩法,避免同一場比賽洗版;若資料不足,卡片會標成「預掛觀察」,只代表先盯賠率門檻,不是叫你立刻下注。

目前主顯示日期:{activeRecommendationDate} @@ -517,6 +560,7 @@ export default function Home() { 可檢查 {liveRecommendationCount} 組 預掛監控 {watchOnlyRecommendationCount} 組 60 秒刷新 + 首頁最多每場優先顯示 2 組 @@ -630,6 +674,7 @@ export default function Home() {

台北日期:{activeDateLabel}

{item.market_type}|{item.selection}

同一場比賽可能有不同玩法;不同玩法的賠率不能互相比大小,只能和該玩法自己的最低門檻比較。

+

{shortBetInstruction(item)}

最低可接受賠率:{item.target_odds.toFixed(2)}

模型勝率:{item.win_prob.toFixed(2)}%

@@ -779,6 +824,7 @@ export default function Home() {

{item.match_label}

{item.market_type} | {item.selection}

+

{shortBetInstruction(item)}

模型勝率 {item.win_prob.toFixed(2)}%

最低賠率 {item.target_odds.toFixed(2)}

diff --git a/platform/web/components/ActionableBetCard.tsx b/platform/web/components/ActionableBetCard.tsx index 40b4373..f569180 100644 --- a/platform/web/components/ActionableBetCard.tsx +++ b/platform/web/components/ActionableBetCard.tsx @@ -48,6 +48,30 @@ function betMode(item: DailyCardItem): string { 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', @@ -196,6 +220,14 @@ export function ActionableBetCard({ item, onAddToSlip, isInSlip = false, classNa

最低可接受賠率:{item.target_odds.toFixed(2)}

建議上限:{formatTwd(stakeAmountTwd(item))}

+
+

實際操作三步驟

+
    + {executionSteps(item).map((step, index) => ( +
  1. 第 {index + 1} 步:{step}
  2. + ))} +
+

{oddsRule(item)}

{stakeGuide(item)}

@@ -250,7 +282,7 @@ export function ActionableBetCard({ item, onAddToSlip, isInSlip = false, classNa 這是研究候選,不是結果承諾。若賠率低於最低可接受賠率、先發名單異常、傷停新聞改變、或資料源延遲,就不要下注,等待下一次刷新。

- 下注前最後確認:同一場比賽、同一市場、同一分線、同一選項、賠率達標、倉位不超過建議單位。 + 下注前最後確認:同一場比賽、同一市場、同一分線、同一選項、賠率達標、倉位不超過建議新台幣上限。若你找不到完全相同的玩法,就不要改買相似玩法。