Files
thebet365/apps/api/src/domains/settlement/domain/settlement-calculator.ts
2026-06-13 17:38:25 +08:00

344 lines
11 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.
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';