feat(admin,api,player): smart score solver, disable settlement UI, misc fixes
This commit is contained in:
@@ -354,6 +354,22 @@ class ScoreDto {
|
||||
ftAway!: number;
|
||||
}
|
||||
|
||||
/* 智能比分推荐已关闭
|
||||
class SmartScoreSuggestDto {
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
strategies?: Array<'MIN_PAYOUT' | 'MAX_PAYOUT' | 'BALANCED' | 'TARGET_HOLD'>;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
targetHoldPct?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
maxGoals?: number;
|
||||
}
|
||||
*/
|
||||
|
||||
class MarketTemplatesDto {
|
||||
@IsArray()
|
||||
marketTypes!: string[];
|
||||
@@ -1195,6 +1211,10 @@ export class AdminController {
|
||||
return jsonResponse(data);
|
||||
}
|
||||
|
||||
// 智能比分推荐已关闭
|
||||
// @Post('matches/:id/settlement/smart-score')
|
||||
// async suggestSmartScore(...) { ... }
|
||||
|
||||
@Post('matches/:id/settlement/score')
|
||||
async recordScore(
|
||||
@CurrentUser('id') operatorId: bigint,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -86,6 +86,7 @@ export class CashbackService {
|
||||
operatorId,
|
||||
`Cashback batch ${batch.batchNo}`,
|
||||
batch.batchNo,
|
||||
'CASHBACK_DEPOSIT',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 } } },
|
||||
|
||||
235
apps/api/src/domains/settlement/smart-score.solver.ts
Normal file
235
apps/api/src/domains/settlement/smart-score.solver.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user