236 lines
6.3 KiB
TypeScript
236 lines
6.3 KiB
TypeScript
import Decimal from 'decimal.js';
|
|
import {
|
|
calculatePayout,
|
|
settleSelection,
|
|
FT_CORRECT_SCORE_TEMPLATE,
|
|
HT_CORRECT_SCORE_TEMPLATE,
|
|
type ScoreInput,
|
|
} from './domain/settlement-calculator';
|
|
|
|
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<ScoreEvaluation, 'htHome' | 'htAway' | 'ftHome' | 'ftAway'> & {
|
|
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<typeof evaluateScoreForSingleBets>,
|
|
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<typeof evaluateScoreForSingleBets>[] = [];
|
|
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<string>();
|
|
|
|
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 };
|
|
}
|