feat(admin,api,player): smart score solver, disable settlement UI, misc fixes

This commit is contained in:
2026-06-04 17:56:32 +08:00
parent 9fcee31a9a
commit 6264b8806c
12 changed files with 356 additions and 16 deletions

View File

@@ -354,7 +354,10 @@ export class MatchesService {
const matchStats = await this.betStatsForMatches(
leagueMatches.map((m) => m.id),
);
const leagueBetRollup = new Map<string, MatchBetStatsSummary>();
const leagueBetRollup = new Map<
string,
{ betCount: number; totalStake: Decimal; pendingCount: number }
>();
for (const lm of leagueMatches) {
const lid = lm.leagueId.toString();
const cur = leagueBetRollup.get(lid) ?? {
@@ -365,7 +368,7 @@ export class MatchesService {
const ms = matchStats.get(lm.id.toString());
if (ms) {
cur.betCount += ms.betCount;
cur.totalStake = cur.totalStake.add(ms.totalStake);
cur.totalStake = cur.totalStake.add(new Decimal(ms.totalStake));
cur.pendingCount += ms.pendingCount;
}
leagueBetRollup.set(lid, cur);
@@ -417,7 +420,7 @@ export class MatchesService {
]);
const raw = betStatsMap.get(m.id.toString());
const betCount = raw?.betCount ?? 0;
const totalStake = raw?.totalStake.toString() ?? '0';
const totalStake = raw?.totalStake ?? '0';
const pendingBets = raw?.pendingCount ?? 0;
return {
id: m.id.toString(),
@@ -442,11 +445,8 @@ export class MatchesService {
/** 批量汇总多场关联注单(按 bet 去重计注单数) */
async betStatsForMatches(
matchIds: bigint[],
): Promise<Map<string, MatchBetStatsSummary & { totalStake: Decimal }>> {
const result = new Map<
string,
MatchBetStatsSummary & { totalStake: Decimal }
>();
): Promise<Map<string, MatchBetStatsSummary>> {
const result = new Map<string, MatchBetStatsSummary>();
if (!matchIds.length) return result;
const legs = await this.prisma.betSelection.findMany({
@@ -481,7 +481,7 @@ export class MatchesService {
if (!bets) {
result.set(mid, {
betCount: 0,
totalStake: new Decimal(0),
totalStake: '0',
pendingCount: 0,
});
continue;
@@ -494,7 +494,7 @@ export class MatchesService {
}
result.set(mid, {
betCount: bets.size,
totalStake,
totalStake: totalStake.toString(),
pendingCount,
});
}

View File

@@ -33,6 +33,7 @@ export class WalletService {
operatorId: bigint,
remark?: string,
referenceId?: string,
transactionType = 'MANUAL_DEPOSIT',
) {
const amt = new Decimal(amount);
if (amt.lte(0)) throw new BadRequestException('Amount must be positive');
@@ -55,7 +56,7 @@ export class WalletService {
transactionId: generateTransactionId(),
userId,
walletId: w.id,
transactionType: 'MANUAL_DEPOSIT',
transactionType,
amount: amt,
balanceBefore,
balanceAfter,

View File

@@ -86,6 +86,7 @@ export class CashbackService {
operatorId,
`Cashback batch ${batch.batchNo}`,
batch.batchNo,
'CASHBACK_DEPOSIT',
);
}
}

View File

@@ -12,6 +12,19 @@ import {
FT_CORRECT_SCORE_TEMPLATE,
HT_CORRECT_SCORE_TEMPLATE,
} from './domain/settlement-calculator';
// 智能比分推荐已关闭
// import { suggestScoresForBets, type SmartScoreSimBet, type SmartScoreStrategy } from './smart-score.solver';
function resolveSelectionCodeFromLeg(
selectionCode: string | null | undefined,
nameSnapshot: string,
): string {
if (selectionCode?.trim()) return selectionCode.trim();
if (nameSnapshot.includes('-')) {
return `SCORE_${nameSnapshot.replace('-', '_')}`;
}
return nameSnapshot;
}
@Injectable()
export class SettlementService {
@@ -429,6 +442,10 @@ export class SettlementService {
};
}
/* 智能比分推荐已关闭 — 恢复时取消注释并恢复 smart-score.solver import
async suggestSmartScores(...) { ... }
*/
async voidMatchBets(matchId: bigint) {
const bets = await this.prisma.bet.findMany({
where: { status: 'PENDING', selections: { some: { matchId } } },

View File

@@ -0,0 +1,235 @@
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 };
}