import Decimal from 'decimal.js'; import { calculatePayout, settleSelection, type ScoreInput, } from './domain/settlement-calculator'; import { FT_CORRECT_SCORE_TEMPLATE, HT_CORRECT_SCORE_TEMPLATE, } from '@thebet365/shared'; export type SmartScoreStrategy = | 'MIN_PAYOUT' | 'MAX_PAYOUT' | 'BALANCED' | 'TARGET_HOLD'; export type SmartScoreSimBet = { stake: Decimal; odds: Decimal; marketType: string; selectionCode: string; handicapLine: number | null; totalLine: number | null; }; export type ScoreEvaluation = { htHome: number; htAway: number; ftHome: number; ftAway: number; totalStake: string; totalPayout: string; totalRefund: string; houseProfit: string; houseHoldPct: number; playerWinStakePct: number; winBets: number; loseBets: number; pushBets: number; }; function templateForMarket(marketType: string): string[] { if (marketType === 'FT_CORRECT_SCORE') return [...FT_CORRECT_SCORE_TEMPLATE]; if (marketType.includes('CORRECT_SCORE')) return [...HT_CORRECT_SCORE_TEMPLATE]; return []; } export function evaluateScoreForSingleBets( bets: SmartScoreSimBet[], score: ScoreInput, ): Omit & { score: ScoreInput; } { let totalStake = new Decimal(0); let totalPayout = new Decimal(0); let totalRefund = new Decimal(0); let winStake = new Decimal(0); let loseStake = new Decimal(0); let winBets = 0; let loseBets = 0; let pushBets = 0; for (const bet of bets) { totalStake = totalStake.add(bet.stake); const result = settleSelection({ marketType: bet.marketType, selectionCode: bet.selectionCode, handicapLine: bet.handicapLine, totalLine: bet.totalLine, score, templateScores: templateForMarket(bet.marketType), }); const payout = calculatePayout(bet.stake, bet.odds, result); if (result === 'LOSE') { loseBets += 1; loseStake = loseStake.add(bet.stake); } else if (result === 'PUSH' || result === 'VOID') { pushBets += 1; totalRefund = totalRefund.add(bet.stake); } else { winBets += 1; winStake = winStake.add(bet.stake); totalPayout = totalPayout.add(payout); } } const houseProfit = totalStake.sub(totalPayout).sub(totalRefund); const houseHoldPct = totalStake.gt(0) ? houseProfit.div(totalStake).mul(100).toNumber() : 0; const playerWinStakePct = totalStake.gt(0) ? winStake.div(totalStake).mul(100).toNumber() : 0; return { score, totalStake: totalStake.toString(), totalPayout: totalPayout.toString(), totalRefund: totalRefund.toString(), houseProfit: houseProfit.toString(), houseHoldPct: Math.round(houseHoldPct * 100) / 100, playerWinStakePct: Math.round(playerWinStakePct * 100) / 100, winBets, loseBets, pushBets, }; } function strategyScore( evalResult: ReturnType, strategy: SmartScoreStrategy, targetHoldPct: number, ): number { const payout = new Decimal(evalResult.totalPayout).add(evalResult.totalRefund); const hold = evalResult.houseHoldPct; const winPct = evalResult.playerWinStakePct; switch (strategy) { case 'MIN_PAYOUT': return payout.toNumber(); case 'MAX_PAYOUT': return -payout.toNumber(); case 'BALANCED': return Math.abs(winPct - 50); case 'TARGET_HOLD': return Math.abs(hold - targetHoldPct); default: return payout.toNumber(); } } function scoreKey(s: ScoreInput): string { return `${s.htHome}-${s.htAway}-${s.ftHome}-${s.ftAway}`; } export function suggestScoresForBets( bets: SmartScoreSimBet[], options: { strategies?: SmartScoreStrategy[]; targetHoldPct?: number; maxGoals?: number; alternativesPerStrategy?: number; } = {}, ): { suggestions: Array<{ strategy: SmartScoreStrategy; rank: number; evaluation: ScoreEvaluation; }>; searchedCandidates: number; } { const strategies = options.strategies ?? [ 'MIN_PAYOUT', 'MAX_PAYOUT', 'BALANCED', 'TARGET_HOLD', ]; const targetHoldPct = Math.min(100, Math.max(0, options.targetHoldPct ?? 30)); const maxGoals = Math.min(8, Math.max(2, options.maxGoals ?? 5)); if (!bets.length) { const zero: ScoreInput = { htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 }; const ev = evaluateScoreForSingleBets(bets, zero); return { suggestions: strategies.map((strategy, i) => ({ strategy, rank: 1, evaluation: { htHome: 0, htAway: 0, ftHome: 0, ftAway: 0, ...ev, }, })), searchedCandidates: 1, }; } const allEvals: ReturnType[] = []; for (let htHome = 0; htHome <= maxGoals; htHome++) { for (let htAway = 0; htAway <= maxGoals; htAway++) { for (let ftHome = htHome; ftHome <= maxGoals; ftHome++) { for (let ftAway = htAway; ftAway <= maxGoals; ftAway++) { const score: ScoreInput = { htHome, htAway, ftHome, ftAway }; allEvals.push(evaluateScoreForSingleBets(bets, score)); } } } } const suggestions: Array<{ strategy: SmartScoreStrategy; rank: number; evaluation: ScoreEvaluation; }> = []; const usedKeys = new Set(); for (const strategy of strategies) { const sorted = [...allEvals].sort( (a, b) => strategyScore(a, strategy, targetHoldPct) - strategyScore(b, strategy, targetHoldPct), ); let rank = 0; for (const ev of sorted) { const key = scoreKey(ev.score); if (usedKeys.has(`${strategy}:${key}`)) continue; usedKeys.add(`${strategy}:${key}`); rank += 1; suggestions.push({ strategy, rank, evaluation: { htHome: ev.score.htHome, htAway: ev.score.htAway, ftHome: ev.score.ftHome, ftAway: ev.score.ftAway, totalStake: ev.totalStake, totalPayout: ev.totalPayout, totalRefund: ev.totalRefund, houseProfit: ev.houseProfit, houseHoldPct: ev.houseHoldPct, playerWinStakePct: ev.playerWinStakePct, winBets: ev.winBets, loseBets: ev.loseBets, pushBets: ev.pushBets, }, }); if (rank >= (options.alternativesPerStrategy ?? 1)) break; } } return { suggestions, searchedCandidates: allEvals.length }; }