Files
2026FIFAWorldCup/platform/web/lib/betting-utils.ts

180 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
const partner = encodeURIComponent(params.trackingId || process.env.NEXT_PUBLIC_AFFILIATE_ID || 'fifa2026');
const odds = encodeURIComponent(String(params.odds));
const selection = encodeURIComponent(params.selection);
const matchId = encodeURIComponent(params.matchId);
if (params.bookmakerId === 'bet365') {
// Bet365以 hash path + query 的混合方式組成,對應其「快速下單」導流機制。
return `https://www.bet365.com/#/AC/B1/C1/D13/Ep1/F2/?affiliate=${partner}&match=${matchId}&selection=${selection}&odds=${odds}`;
}
if (params.bookmakerId === 'pinnacle') {
// Pinnacle通常以 REST 風格 query string 直達預填。
return `https://www.pinnacle.com/en/betting/football/${matchId}?selection=${selection}&odds=${odds}&aff=${partner}`;
}
// fallback to DraftKings pattern
return `https://www.draftkings.com/sportsbook/odds?event=${matchId}&selection=${selection}&odds=${odds}&affid=${partner}`;
}