180 lines
4.9 KiB
TypeScript
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();
|
|
}
|