Files
2026FIFAWorldCup/platform/web/lib/betting-utils.ts
QuantBot aa7e3bba76
Some checks failed
2026 World Cup Quant Platform - Production Deployment / Code Quality & Testing (push) Failing after 1m49s
2026 World Cup Quant Platform - Production Deployment / Deploy to Production VM via Rsync (push) Has been skipped
chore: migrate deployment to Gitea Actions with zero-trust rsync
2026-06-16 19:06:50 +08:00

180 lines
4.9 KiB
TypeScript

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<Record<BookmakerCode, string | undefined>> = {
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();
}