包含 NestJS 后端、三端前端、Prisma 数据模型、结算引擎测试与 PRD 文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
280 lines
8.7 KiB
TypeScript
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',
|
|
];
|