180 lines
5.1 KiB
TypeScript
180 lines
5.1 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 {
|
||
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}`;
|
||
}
|