344 lines
11 KiB
TypeScript
344 lines
11 KiB
TypeScript
import Decimal from 'decimal.js';
|
||
|
||
export type SelectionResult = 'WIN' | 'HALF_WIN' | 'PUSH' | 'HALF_LOSE' | 'LOSE' | 'VOID';
|
||
|
||
export interface ScoreInput {
|
||
htHome: number;
|
||
htAway: number;
|
||
ftHome: number;
|
||
ftAway: number;
|
||
}
|
||
|
||
export interface MatchStatsInput {
|
||
homeCorners?: number | null;
|
||
awayCorners?: number | null;
|
||
homeYellowCards?: number | null;
|
||
awayYellowCards?: number | null;
|
||
homeRedCards?: number | null;
|
||
awayRedCards?: number | null;
|
||
homeCards?: number | null;
|
||
awayCards?: number | null;
|
||
}
|
||
|
||
export interface SettlementInput {
|
||
marketType: string;
|
||
selectionCode: string;
|
||
handicapLine?: number | null;
|
||
totalLine?: number | null;
|
||
score: ScoreInput;
|
||
stats?: MatchStatsInput | null;
|
||
templateScores?: string[];
|
||
/** 冠军盘:获胜球队 code,如 FRA、BRA */
|
||
winnerTeamCode?: string | null;
|
||
}
|
||
|
||
export function getShScore(score: ScoreInput): { home: number; away: number } {
|
||
return {
|
||
home: score.ftHome - score.htHome,
|
||
away: score.ftAway - score.htAway,
|
||
};
|
||
}
|
||
|
||
function isQuarterLine(line: number): boolean {
|
||
const frac = Math.abs(line % 1);
|
||
return Math.abs(frac - 0.25) < 0.001 || Math.abs(frac - 0.75) < 0.001;
|
||
}
|
||
|
||
function splitQuarterLine(line: number): [number, number] {
|
||
const sign = line >= 0 ? 1 : -1;
|
||
const abs = Math.abs(line);
|
||
const lower = Math.floor(abs * 2) / 2 * sign;
|
||
const upper = (Math.floor(abs * 2) + 1) / 2 * sign;
|
||
if (Math.abs(abs % 1 - 0.25) < 0.001) {
|
||
return [lower, upper];
|
||
}
|
||
// 0.75 case: e.g. -0.75 => -0.5 and -1
|
||
const l = Math.floor(abs) * sign;
|
||
const u = (Math.floor(abs) + 0.5) * sign;
|
||
return abs % 1 > 0.5 ? [l, u] : [lower, upper];
|
||
}
|
||
|
||
function settleHandicap(
|
||
teamGoals: number,
|
||
oppGoals: number,
|
||
handicap: number,
|
||
isHome: boolean,
|
||
): SelectionResult {
|
||
const adj = teamGoals + handicap - oppGoals;
|
||
|
||
if (!isQuarterLine(handicap)) {
|
||
if (adj > 0) return 'WIN';
|
||
if (adj === 0) return 'PUSH';
|
||
return 'LOSE';
|
||
}
|
||
|
||
const [line1, line2] = splitQuarterLine(handicap);
|
||
const r1 = teamGoals + line1 - oppGoals;
|
||
const r2 = teamGoals + line2 - oppGoals;
|
||
|
||
const results: SelectionResult[] = [];
|
||
for (const r of [r1, r2]) {
|
||
if (r > 0) results.push('WIN');
|
||
else if (r === 0) results.push('PUSH');
|
||
else results.push('LOSE');
|
||
}
|
||
|
||
const winCount = results.filter((r) => r === 'WIN').length;
|
||
const loseCount = results.filter((r) => r === 'LOSE').length;
|
||
|
||
if (winCount === 2) return 'WIN';
|
||
if (loseCount === 2) return 'LOSE';
|
||
if (winCount === 1 && loseCount === 0) return 'HALF_WIN';
|
||
if (loseCount === 1 && winCount === 0) return 'HALF_LOSE';
|
||
return 'PUSH';
|
||
}
|
||
|
||
function settleOverUnder(
|
||
totalGoals: number,
|
||
line: number,
|
||
isOver: boolean,
|
||
): SelectionResult {
|
||
if (!isQuarterLine(line)) {
|
||
if (isOver) {
|
||
if (totalGoals > line) return 'WIN';
|
||
if (totalGoals === line) return 'PUSH';
|
||
return 'LOSE';
|
||
}
|
||
if (totalGoals < line) return 'WIN';
|
||
if (totalGoals === line) return 'PUSH';
|
||
return 'LOSE';
|
||
}
|
||
|
||
const [line1, line2] = splitQuarterLine(line);
|
||
const r1 = settleOverUnder(totalGoals, line1, isOver);
|
||
const r2 = settleOverUnder(totalGoals, line2, isOver);
|
||
|
||
const winCount = [r1, r2].filter((r) => r === 'WIN').length;
|
||
const loseCount = [r1, r2].filter((r) => r === 'LOSE').length;
|
||
|
||
if (winCount === 2) return 'WIN';
|
||
if (loseCount === 2) return 'LOSE';
|
||
if (winCount === 1) return 'HALF_WIN';
|
||
if (loseCount === 1) return 'HALF_LOSE';
|
||
return 'PUSH';
|
||
}
|
||
|
||
function parseScoreCode(code: string): { home: number; away: number } | null {
|
||
const match = code.match(/SCORE_(\d+)_(\d+)/);
|
||
if (match) return { home: parseInt(match[1]), away: parseInt(match[2]) };
|
||
return null;
|
||
}
|
||
|
||
function settleCorrectScore(
|
||
home: number,
|
||
away: number,
|
||
selectionCode: string,
|
||
templateScores: string[],
|
||
): SelectionResult {
|
||
const parsed = parseScoreCode(selectionCode);
|
||
if (parsed) {
|
||
return parsed.home === home && parsed.away === away ? 'WIN' : 'LOSE';
|
||
}
|
||
|
||
const actualKey = `SCORE_${home}_${away}`;
|
||
if (templateScores.includes(actualKey)) {
|
||
return 'LOSE';
|
||
}
|
||
|
||
const isDraw = home === away;
|
||
const isHomeWin = home > away;
|
||
|
||
if (selectionCode === 'OTHER_DRAW' && isDraw) return 'WIN';
|
||
if (selectionCode === 'OTHER_HOME' && isHomeWin) return 'WIN';
|
||
if (selectionCode === 'OTHER_AWAY' && !isHomeWin && !isDraw) return 'WIN';
|
||
|
||
return 'LOSE';
|
||
}
|
||
|
||
function resultSide(home: number, away: number): 'HOME' | 'DRAW' | 'AWAY' {
|
||
if (home > away) return 'HOME';
|
||
if (home < away) return 'AWAY';
|
||
return 'DRAW';
|
||
}
|
||
|
||
function settleHtFt(score: ScoreInput, selectionCode: string): SelectionResult {
|
||
const expected = `${resultSide(score.htHome, score.htAway)}_${resultSide(score.ftHome, score.ftAway)}`;
|
||
return selectionCode === expected ? 'WIN' : 'LOSE';
|
||
}
|
||
|
||
function settleTotalGoalsRange(totalGoals: number, selectionCode: string): SelectionResult {
|
||
if (selectionCode === 'TG_0_1') return totalGoals <= 1 ? 'WIN' : 'LOSE';
|
||
if (selectionCode === 'TG_2_3') return totalGoals >= 2 && totalGoals <= 3 ? 'WIN' : 'LOSE';
|
||
if (selectionCode === 'TG_4_6') return totalGoals >= 4 && totalGoals <= 6 ? 'WIN' : 'LOSE';
|
||
if (selectionCode === 'TG_7_PLUS') return totalGoals >= 7 ? 'WIN' : 'LOSE';
|
||
|
||
const exact = selectionCode.match(/^GOALS_(\d+)$/);
|
||
if (exact) return totalGoals === Number(exact[1]) ? 'WIN' : 'LOSE';
|
||
const exactPlus = selectionCode.match(/^GOALS_(\d+)_PLUS$/);
|
||
if (exactPlus) return totalGoals >= Number(exactPlus[1]) ? 'WIN' : 'LOSE';
|
||
|
||
return 'LOSE';
|
||
}
|
||
|
||
function requireStat(value: number | null | undefined, field: keyof MatchStatsInput): number {
|
||
if (value == null || Number.isNaN(value)) {
|
||
throw new Error(`SETTLEMENT_STAT_MISSING:${field}`);
|
||
}
|
||
return value;
|
||
}
|
||
|
||
export function settleSelection(input: SettlementInput): SelectionResult {
|
||
const { marketType, selectionCode, handicapLine, totalLine, score } = input;
|
||
const stats = input.stats ?? {};
|
||
const templates = input.templateScores ?? [];
|
||
|
||
switch (marketType) {
|
||
case 'FT_1X2': {
|
||
if (selectionCode === 'HOME') return score.ftHome > score.ftAway ? 'WIN' : 'LOSE';
|
||
if (selectionCode === 'DRAW') return score.ftHome === score.ftAway ? 'WIN' : 'LOSE';
|
||
if (selectionCode === 'AWAY') return score.ftHome < score.ftAway ? 'WIN' : 'LOSE';
|
||
break;
|
||
}
|
||
case 'HT_1X2': {
|
||
if (selectionCode === 'HOME') return score.htHome > score.htAway ? 'WIN' : 'LOSE';
|
||
if (selectionCode === 'DRAW') return score.htHome === score.htAway ? 'WIN' : 'LOSE';
|
||
if (selectionCode === 'AWAY') return score.htHome < score.htAway ? 'WIN' : 'LOSE';
|
||
break;
|
||
}
|
||
case 'FT_ODD_EVEN': {
|
||
const total = score.ftHome + score.ftAway;
|
||
const isOdd = total % 2 === 1;
|
||
if (selectionCode === 'ODD') return isOdd ? 'WIN' : 'LOSE';
|
||
if (selectionCode === 'EVEN') return !isOdd ? 'WIN' : 'LOSE';
|
||
break;
|
||
}
|
||
case 'FT_HANDICAP': {
|
||
const line = handicapLine ?? 0;
|
||
const isHome = selectionCode === 'HOME';
|
||
const goals = isHome ? score.ftHome : score.ftAway;
|
||
const opp = isHome ? score.ftAway : score.ftHome;
|
||
return settleHandicap(goals, opp, isHome ? line : -line, isHome);
|
||
}
|
||
case 'HT_HANDICAP': {
|
||
const line = handicapLine ?? 0;
|
||
const isHome = selectionCode === 'HOME';
|
||
const goals = isHome ? score.htHome : score.htAway;
|
||
const opp = isHome ? score.htAway : score.htHome;
|
||
return settleHandicap(goals, opp, isHome ? line : -line, isHome);
|
||
}
|
||
case 'FT_OVER_UNDER': {
|
||
const total = score.ftHome + score.ftAway;
|
||
return settleOverUnder(total, totalLine ?? 0, selectionCode === 'OVER');
|
||
}
|
||
case 'HT_OVER_UNDER': {
|
||
const total = score.htHome + score.htAway;
|
||
return settleOverUnder(total, totalLine ?? 0, selectionCode === 'OVER');
|
||
}
|
||
case 'FT_TEAM_TOTAL_HOME':
|
||
return settleOverUnder(score.ftHome, totalLine ?? 0, selectionCode === 'OVER');
|
||
case 'FT_TEAM_TOTAL_AWAY':
|
||
return settleOverUnder(score.ftAway, totalLine ?? 0, selectionCode === 'OVER');
|
||
case 'HT_FT':
|
||
return settleHtFt(score, selectionCode);
|
||
case 'FT_TOTAL_GOALS':
|
||
return settleTotalGoalsRange(score.ftHome + score.ftAway, selectionCode);
|
||
case 'FT_CORNERS_HANDICAP': {
|
||
const homeCorners = requireStat(stats.homeCorners, 'homeCorners');
|
||
const awayCorners = requireStat(stats.awayCorners, 'awayCorners');
|
||
const line = handicapLine ?? 0;
|
||
const isHome = selectionCode === 'HOME';
|
||
const corners = isHome ? homeCorners : awayCorners;
|
||
const opp = isHome ? awayCorners : homeCorners;
|
||
return settleHandicap(corners, opp, isHome ? line : -line, isHome);
|
||
}
|
||
case 'FT_CORNERS_OVER_UNDER': {
|
||
const total =
|
||
requireStat(stats.homeCorners, 'homeCorners') +
|
||
requireStat(stats.awayCorners, 'awayCorners');
|
||
return settleOverUnder(total, totalLine ?? 0, selectionCode === 'OVER');
|
||
}
|
||
case 'FT_CARDS_OVER_UNDER': {
|
||
const total =
|
||
requireStat(stats.homeCards, 'homeCards') +
|
||
requireStat(stats.awayCards, 'awayCards');
|
||
return settleOverUnder(total, totalLine ?? 0, selectionCode === 'OVER');
|
||
}
|
||
case 'FT_CORRECT_SCORE':
|
||
return settleCorrectScore(score.ftHome, score.ftAway, selectionCode, templates);
|
||
case 'HT_CORRECT_SCORE':
|
||
return settleCorrectScore(score.htHome, score.htAway, selectionCode, templates);
|
||
case 'SH_CORRECT_SCORE': {
|
||
const sh = getShScore(score);
|
||
return settleCorrectScore(sh.home, sh.away, selectionCode, templates);
|
||
}
|
||
case 'OUTRIGHT_WINNER':
|
||
if (!input.winnerTeamCode) return 'LOSE';
|
||
return selectionCode === input.winnerTeamCode ? 'WIN' : 'LOSE';
|
||
}
|
||
|
||
return 'LOSE';
|
||
}
|
||
|
||
export function calculatePayout(
|
||
stake: Decimal | number,
|
||
odds: Decimal | number,
|
||
result: SelectionResult,
|
||
): Decimal {
|
||
const s = new Decimal(stake);
|
||
const o = new Decimal(odds);
|
||
|
||
switch (result) {
|
||
case 'WIN':
|
||
return s.mul(o);
|
||
case 'HALF_WIN':
|
||
return s.div(2).mul(o).add(s.div(2));
|
||
case 'PUSH':
|
||
case 'VOID':
|
||
return s;
|
||
case 'HALF_LOSE':
|
||
return s.div(2);
|
||
case 'LOSE':
|
||
return new Decimal(0);
|
||
default:
|
||
return new Decimal(0);
|
||
}
|
||
}
|
||
|
||
export function calculateParlayPayout(
|
||
stake: Decimal | number,
|
||
selections: Array<{ odds: Decimal | number; result: SelectionResult }>,
|
||
): { betResult: 'WON' | 'LOST' | 'PUSH'; payout: Decimal; effectiveOdds: Decimal } {
|
||
const s = new Decimal(stake);
|
||
|
||
if (selections.some((sel) => sel.result === 'LOSE')) {
|
||
return { betResult: 'LOST', payout: new Decimal(0), effectiveOdds: new Decimal(0) };
|
||
}
|
||
|
||
let combinedOdds = new Decimal(1);
|
||
for (const sel of selections) {
|
||
if (sel.result === 'WIN') {
|
||
combinedOdds = combinedOdds.mul(sel.odds);
|
||
} else if (sel.result === 'HALF_WIN') {
|
||
combinedOdds = combinedOdds.mul(new Decimal(sel.odds).add(1).div(2));
|
||
} else if (sel.result === 'HALF_LOSE') {
|
||
combinedOdds = combinedOdds.mul(0.5);
|
||
}
|
||
// PUSH/VOID => odds 1.00
|
||
}
|
||
|
||
const allPush = selections.every(
|
||
(sel) => sel.result === 'PUSH' || sel.result === 'VOID',
|
||
);
|
||
if (allPush) {
|
||
return { betResult: 'PUSH', payout: s, effectiveOdds: new Decimal(1) };
|
||
}
|
||
|
||
return { betResult: 'WON', payout: s.mul(combinedOdds), effectiveOdds: combinedOdds };
|
||
}
|
||
|
||
export {
|
||
FT_CORRECT_SCORE_TEMPLATE,
|
||
HT_CORRECT_SCORE_TEMPLATE,
|
||
isQuarterHandicapOrTotal,
|
||
} from '@thebet365/shared';
|