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', ];