Files
thebet365/apps/api/src/settlement/settlement-calculator.ts
Mars 14e49374ac 初始化足球投注平台 MVP Monorepo
包含 NestJS 后端、三端前端、Prisma 数据模型、结算引擎测试与 PRD 文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 14:35:48 +08:00

280 lines
8.7 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 SettlementInput {
marketType: string;
selectionCode: string;
handicapLine?: number | null;
totalLine?: number | null;
score: ScoreInput;
templateScores?: string[];
}
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';
}
export function settleSelection(input: SettlementInput): SelectionResult {
const { marketType, selectionCode, handicapLine, totalLine, score } = input;
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_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':
return selectionCode === `TEAM_${input.score.ftHome}` ? '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 function isQuarterHandicapOrTotal(line: number | null | undefined): boolean {
if (line == null) return false;
return isQuarterLine(line);
}
export const FT_CORRECT_SCORE_TEMPLATE = [
'SCORE_0_0', 'SCORE_1_1', 'SCORE_2_2', 'SCORE_3_3', 'SCORE_4_4', 'OTHER_DRAW',
'SCORE_1_0', 'SCORE_2_0', 'SCORE_2_1', 'SCORE_3_0', 'SCORE_3_1', 'SCORE_3_2',
'SCORE_4_0', 'SCORE_4_1', 'SCORE_4_2', 'SCORE_4_3', 'OTHER_HOME',
'SCORE_0_1', 'SCORE_0_2', 'SCORE_1_2', 'SCORE_0_3', 'SCORE_1_3', 'SCORE_2_3',
'SCORE_0_4', 'SCORE_1_4', 'SCORE_2_4', 'SCORE_3_4', 'OTHER_AWAY',
];
export const HT_CORRECT_SCORE_TEMPLATE = [
'SCORE_0_0', 'SCORE_1_1', 'SCORE_2_2', 'OTHER_DRAW',
'SCORE_1_0', 'SCORE_2_0', 'SCORE_2_1', 'SCORE_3_0', 'OTHER_HOME',
'SCORE_0_1', 'SCORE_0_2', 'SCORE_1_2', 'SCORE_0_3', 'OTHER_AWAY',
];