export type BookmakerCode = 'bet365' | 'pinnacle' | 'draftkings'; export type KellyInput = { odds: number; trueProb: number; bankroll: number; fractionalKelly: number; riskTolerance: number; }; export type KellyOutput = { decimalOdds: number; impliedProb: number; edge: number; rawFraction: number; finalFraction: number; recommendedFraction: number; recommendedStake: number; }; export type PlayerMetricProfile = { playerId: string; metric: 'shots' | 'shots_on_target' | 'passes'; baselineMean: number; line: number; matchMinutes: number; teamAttackFactor: number; opponentDefenceFactor: number; weatherFatigueFactor: number; }; export type PlayerPropsEstimate = { metric: string; line: number; overProbability: number; underProbability: number; expectedCount: number; edge: number; topEdge: boolean; impliedProb?: number; }; export const TW_DOLLAR = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0, }); function poissonPmf(k: number, lambda: number): number { if (k < 0) { return 0; } if (lambda <= 0) { return k === 0 ? 1 : 0; } if (k === 0) { return Math.exp(-lambda); } let pmf = Math.exp(-lambda); for (let i = 1; i <= k; i += 1) { pmf *= lambda / i; } return pmf; } function poissonCdf(k: number, lambda: number): number { let acc = 0; const maxK = Math.max(0, Math.ceil(k)); for (let i = 0; i <= maxK; i += 1) { acc += poissonPmf(i, lambda); } return Math.min(1, Math.max(0, acc)); } export function estimatePlayerPropProbability(profile: PlayerMetricProfile): PlayerPropsEstimate { const minuteRatio = profile.matchMinutes / 90; const mean = Math.max( 0.05, profile.baselineMean * minuteRatio * Math.max(0.1, profile.teamAttackFactor) * Math.min(2.2, 1 / Math.max(0.1, profile.opponentDefenceFactor)) * Math.max(0.5, profile.weatherFatigueFactor), ); const cappedLine = Math.max(0.5, profile.line); const floorLine = Math.floor(cappedLine + 1e-9); const over = 1 - poissonCdf(floorLine, mean); const under = 1 - over; const expected = mean; return { metric: profile.metric, line: profile.line, overProbability: Number((over * 1).toFixed(6)), underProbability: Number((under * 1).toFixed(6)), expectedCount: Number(expected.toFixed(3)), edge: 0, topEdge: false, impliedProb: undefined, }; } export function calculateKellyRecommendation(input: KellyInput): KellyOutput { const decimalOdds = Number(input.odds); const trueProb = Number(input.trueProb); const bankroll = Number(input.bankroll); const fractionalKelly = Number(input.fractionalKelly); const riskTolerance = Number(input.riskTolerance); if (!Number.isFinite(decimalOdds) || decimalOdds <= 1) { throw new Error('賠率需大於 1'); } if (!Number.isFinite(trueProb) || trueProb < 0 || trueProb > 1) { throw new Error('勝率需在 0~1'); } if (!Number.isFinite(bankroll) || bankroll <= 0) { throw new Error('資金需大於 0'); } const impliedProb = 1 / decimalOdds; const edge = trueProb - impliedProb; const b = decimalOdds - 1; const rawFraction = (b * trueProb - (1 - trueProb)) / b; const finalFraction = Math.max(0, Math.min(rawFraction * fractionalKelly * riskTolerance, 1)); return { decimalOdds, impliedProb, edge: Number((edge * 100).toFixed(2)), rawFraction: Number(rawFraction.toFixed(6)), finalFraction: Number(finalFraction.toFixed(6)), recommendedFraction: Number((Math.min(finalFraction, 1) * 100).toFixed(2)), recommendedStake: Number((bankroll * Math.min(finalFraction, 1)).toFixed(2)), }; } export function calculateTopEdge( overProbability: number, bookmakerOverOdds: number, ): { edge: number; isTopEdge: boolean } { if (bookmakerOverOdds <= 1) { return { edge: 0, isTopEdge: false }; } const implied = 1 / bookmakerOverOdds; const edge = overProbability - implied; return { edge: Number((edge * 100).toFixed(2)), isTopEdge: edge > 0.08, }; } export function generateBetSlipUrl(params: { bookmakerId: BookmakerCode; matchId: string; selection: string; odds: number; trackingId?: string; }): string | null { const configuredBaseUrls: Partial> = { bet365: process.env.NEXT_PUBLIC_BET365_BETSLIP_URL, pinnacle: process.env.NEXT_PUBLIC_PINNACLE_BETSLIP_URL, draftkings: process.env.NEXT_PUBLIC_DRAFTKINGS_BETSLIP_URL, }; const baseUrl = configuredBaseUrls[params.bookmakerId]; if (!baseUrl) { return null; } const partner = encodeURIComponent(params.trackingId || process.env.NEXT_PUBLIC_AFFILIATE_ID || 'fifa2026'); const url = new URL(baseUrl); url.searchParams.set('affiliate', partner); url.searchParams.set('match', params.matchId); url.searchParams.set('selection', params.selection); url.searchParams.set('odds', String(params.odds)); return url.toString(); }